From 59af3f379b29baea704c9fcb6c51b81dfcad0a50 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Tue, 11 Aug 2026 14:03:44 -0700 Subject: [PATCH 01/17] initial checkin --- .../lib/_sass/base/_breakpoints.scss | 31 ++ .../lib/_sass/components/_ide-explorer.scss | 434 +++++++++++++++++ .../lib/components/common/ide_explorer.dart | 444 ++++++++++++++++++ sites/docs/lib/_sass/_site.scss | 1 + sites/docs/lib/main.server.dart | 2 + sites/docs/lib/src/client/global_scripts.dart | 123 +++++ sites/docs/src/content/ai/evals.md | 2 + .../src/data/flutter_bench_task_example.yml | 186 ++++++++ 8 files changed, 1223 insertions(+) create mode 100644 packages/site_shared/lib/_sass/base/_breakpoints.scss create mode 100644 packages/site_shared/lib/_sass/components/_ide-explorer.scss create mode 100644 packages/site_shared/lib/components/common/ide_explorer.dart create mode 100644 sites/docs/src/data/flutter_bench_task_example.yml 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 00000000000..d101c202e6f --- /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-above($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 00000000000..81f144c7c69 --- /dev/null +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -0,0 +1,434 @@ +@use '../base/mixins'; +@use '../base/breakpoints'; + +.ide-explorer { + display: flex; + align-items: stretch; + overflow: hidden; + height: 600px; + 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 { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.375rem; + border-bottom: 1px solid var(--site-outline-variant); + + &.ide-root-tabs-single { + justify-content: flex-end; + } +} + +.ide-root-tab { + flex: 1; + padding: 0.3rem 0; + border: none; + border-radius: 0.2rem; + background: transparent; + color: var(--site-base-fgColor-alt); + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + + &:hover { + @include mixins.interaction-style(4%); + } + + &.active { + background-color: var(--site-base-bgColor); + color: var(--site-primary-color); + } +} + +.ide-toggle-all { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border: none; + border-radius: 0.2rem; + background: transparent; + color: var(--site-base-fgColor-alt); + cursor: pointer; + + .material-symbols { + font-size: 1.1rem; + } + + &:hover { + @include mixins.interaction-style(4%); + } +} + +.ide-tree { + display: none; + flex: 1; + overflow-y: auto; + padding: 0.375rem 0; + + &.active { + display: block; + } + + ul { + list-style: none; + margin: 0; + padding-inline-start: 0.9rem; + } + + > ul { + padding-inline-start: 0; + } + + li.ide-node { + margin: 0; + } + + summary { + list-style: none; + + &::-webkit-details-marker { + display: none; + } + + &::before { + content: ''; + display: inline-block; + flex-shrink: 0; + width: 0.6rem; + height: 0.6rem; + margin-inline-end: 0.3rem; + background-color: var(--site-base-fgColor-alt); + clip-path: polygon(15% 0%, 100% 50%, 15% 100%); + transition: transform 0.1s ease; + } + } + + details[open] > summary::before { + transform: rotate(90deg); + } + + .ide-node-row, + summary { + display: flex; + align-items: center; + width: 100%; + padding: 0.25rem 0.6rem; + border: none; + background: transparent; + color: inherit; + font-family: var(--site-code-fontFamily); + font-size: 0.8125rem; + text-align: left; + cursor: pointer; + outline-offset: -2px; + + &:hover { + @include mixins.interaction-style(4%); + } + + &.active { + background-color: var(--site-primary-color-highlight); + color: var(--site-primary-color); + font-weight: 500; + } + + svg { + flex-shrink: 0; + width: 0.9rem; + height: 0.9rem; + margin-inline-end: 0.4rem; + } + } + + .ide-node-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.ide-badge-dot { + flex-shrink: 0; + width: 0.4rem; + height: 0.4rem; + margin-inline-start: 0.4rem; + border-radius: 50%; + background-color: var(--site-base-fgColor-alt); +} + +.ide-badge { + flex-shrink: 0; + padding: 0.1rem 0.5rem; + border-radius: 1rem; + background-color: var(--site-raised-bgColor); + color: var(--site-base-fgColor-alt); + font-family: var(--site-ui-fontFamily); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.ide-tone-info { + &.ide-badge-dot, + &.ide-badge { + background-color: var(--site-alert-info-color); + } + + &.ide-badge { + background-color: color-mix(in srgb, var(--site-alert-info-color) 12%, transparent); + color: var(--site-alert-info-color); + } +} + +.ide-tone-success { + &.ide-badge-dot, + &.ide-badge { + background-color: var(--site-alert-tip-color); + } + + &.ide-badge { + background-color: color-mix(in srgb, var(--site-alert-tip-color) 12%, transparent); + color: var(--site-alert-tip-color); + } +} + +.ide-tone-warning { + &.ide-badge-dot, + &.ide-badge { + background-color: var(--site-alert-warning-color); + } + + &.ide-badge { + background-color: color-mix(in srgb, var(--site-alert-warning-color) 12%, transparent); + color: var(--site-alert-warning-color); + } +} + +.ide-detail { + flex: 1; + min-width: 0; + padding: 1.25rem 1.5rem; + overflow-y: auto; + + @include breakpoints.screen-below('md') { + padding: 1rem; + } +} + +.ide-detail-panel { + display: none; + + &.active { + display: block; + } + + > :last-child { + margin-block-end: 0; + } +} + +.ide-path { + margin-block-end: 0.5rem; + color: var(--site-base-fgColor-alt); + font-family: var(--site-code-fontFamily); + font-size: 0.75rem; +} + +.ide-path-sep { + margin-inline: 0.3rem; + color: var(--site-outline); +} + +.ide-detail-header { + display: flex; + align-items: flex-start; + gap: 0.6rem; + margin-block-end: 0.6rem; + + svg { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + margin-block-start: 0.15rem; + } +} + +.ide-detail-heading { + flex: 1; + min-width: 0; +} + +.ide-detail-title { + font-family: var(--site-code-fontFamily); + font-size: 1.25rem; + font-weight: 600; +} + +.ide-detail-one-liner { + margin-block-start: 0.15rem; + color: var(--site-base-fgColor-alt); + font-size: 0.9375rem; +} + +.ide-note { + padding: 0.6rem 0.75rem; + margin-block-end: 1rem; + border-radius: 0.3rem; + background-color: var(--site-inset-bgColor); + color: var(--site-inset-fgColor); + font-size: 0.9rem; + + p { + margin: 0; + } +} + +.ide-note-title { + margin-block-end: 0.2rem; + color: var(--site-base-fgColor-alt); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.ide-description { + margin-block-end: 1rem; +} + +.ide-tips { + padding: 0.6rem 0.85rem; + margin-block-end: 1rem; + border: 1px solid var(--site-outline-variant); + border-radius: 0.3rem; + + ul { + margin: 0; + padding-inline-start: 1.1rem; + } + + li { + margin-block-end: 0.25rem; + + &:last-child { + margin-block-end: 0; + } + } +} + +.ide-tips-title { + margin-block-end: 0.4rem; + color: var(--site-primary-color); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.ide-example { + margin-block-end: 1rem; +} + +.ide-docs-link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.75rem; + margin-block-end: 1rem; + border-radius: 1rem; + background-color: var(--site-primary-color-highlight); + color: var(--site-primary-color); + font-size: 0.8125rem; + font-weight: 600; + text-decoration: none; + + .material-symbols { + font-size: 1rem; + } + + &:hover { + text-decoration: underline; + } +} + +.ide-contents-title { + margin-block-end: 0.5rem; + color: var(--site-base-fgColor-alt); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.ide-contents-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.ide-content-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + border: none; + border-radius: 0.3rem; + background-color: var(--site-raised-bgColor-translucent); + color: inherit; + font-family: var(--site-code-fontFamily); + font-size: 0.8125rem; + text-align: left; + cursor: pointer; + + &:hover { + @include mixins.interaction-style(4%); + } + + svg { + flex-shrink: 0; + width: 0.9rem; + height: 0.9rem; + } + + .ide-content-one-liner { + overflow: hidden; + color: var(--site-base-fgColor-alt); + font-family: var(--site-ui-fontFamily); + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart new file mode 100644 index 00000000000..b9086852a8b --- /dev/null +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -0,0 +1,444 @@ +// 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:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/components/file_icon.dart'; +import 'package:jaspr_content/jaspr_content.dart'; + +import '../../src/extensions/code_block_processor.dart'; +import '../../src/markdown/markdown_parser.dart'; +import '../../util.dart'; +import 'material_icon.dart'; +import 'wrapped_code_block.dart'; + +/// 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: a description, tips, an example snippet, +/// and a link to learn more. Clicking an entry in the tree (or in a +/// folder's "Contents" list) swaps which detail pane is shown. +/// +/// The tree is authored as data, referenced by the required `data` +/// attribute, which names a top-level key in the page's data (loaded from +/// a YAML/JSON file in the site's `src/data` directory). The data can +/// either be a single list of nodes, or a map with a `roots` list if the +/// explorer should offer more than one top-level tree (e.g. "Project" and +/// "Global", shown as tabs above the sidebar): +/// +/// ```yaml +/// # A single implicit root: +/// - id: main-dart +/// label: main.dart +/// oneLiner: The app's entry point. +/// example: | +/// void main() => runApp(const MyApp()); +/// exampleLanguage: dart +/// +/// # Or, multiple named roots shown as tabs: +/// roots: +/// - id: project +/// label: my_app/ +/// children: [ ... ] +/// - id: global +/// label: "~/" +/// children: [ ... ] +/// ``` +/// +/// Each node supports: `id` (required, unique), `label` (required), +/// `type` (`file` or `folder`; inferred from `children`/a trailing `/` +/// when omitted), `closed` (folders are expanded by default), `badge` +/// (a short string, or a map with `label` and `tone` of `info`, `success`, +/// `warning`, or `neutral`), `oneLiner`, `note`/`noteTitle` (a short +/// highlighted callout), `description` (Markdown), `tips` (a list of +/// Markdown strings), `example`/`exampleLanguage`/`exampleTitle` (a +/// syntax-highlighted, copyable snippet), `docsLink`/`docsLinkLabel`, and +/// nested `children`. +/// +/// Usage from Markdown: ``. +class IdeExplorer extends CustomComponentBase { + const IdeExplorer(); + + static int _nextInstanceId = 0; + + @override + Pattern get pattern => 'IdeExplorer'; + + @override + Component apply( + String name, + Map attributes, + Component? child, + ) { + final dataKey = attributes['data']; + if (dataKey == null) { + throw ArgumentError( + 'The element requires a "data" attribute naming a key ' + 'in the page data.', + ); + } + + return Builder( + builder: (context) { + final rawData = context.page.data[dataKey]; + if (rawData == null) { + throw ArgumentError('No page data found for "$dataKey".'); + } + + final roots = _parseRoots(rawData, rootLabel: attributes['rootLabel']); + + return _IdeExplorer( + instanceId: '${_nextInstanceId++}', + roots: roots, + ); + }, + ); + } + + static List _parseRoots( + Object? rawData, { + String? rootLabel, + }) { + if (rawData is List) { + return [ + IdeExplorerRoot._({ + 'id': 'root', + 'label': rootLabel ?? '', + 'children': rawData, + }), + ]; + } + + if (rawData is Map) { + final data = rawData.cast(); + final rawRoots = data['roots']; + if (rawRoots is List) { + return rawRoots + .cast>() + .map((root) => IdeExplorerRoot._(root.cast())) + .toList(growable: false); + } + } + + throw ArgumentError( + 'Invalid data: expected a list of nodes, or a map with ' + 'a "roots" list.', + ); + } +} + +/// A single top-level tree shown by an [IdeExplorer], such as "Project" or +/// "Global". +extension type IdeExplorerRoot._(Map _data) { + String get id => _data['id'] as String; + String get label => _data['label'] as String? ?? ''; + + List get children => + (_data['children'] as List? ?? const []) + .cast>() + .map((node) => IdeTreeNode._(node.cast())) + .toList(growable: false); +} + +/// A single file or folder entry in an [IdeExplorer] tree. +extension type IdeTreeNode._(Map _data) { + String get id => _data['id'] as String; + String get label => _data['label'] as String; + + List get children => + (_data['children'] as List? ?? const []) + .cast>() + .map((node) => IdeTreeNode._(node.cast())) + .toList(growable: false); + + bool get isFolder => + (_data['type'] as String?) == 'folder' || + children.isNotEmpty || + label.endsWith('/'); + + /// Whether a folder should render collapsed by default. + bool get startsClosed => _data['closed'] == true; + + IdeBadge? get badge => switch (_data['badge']) { + final String label => IdeBadge(label: label), + final Map map => IdeBadge( + label: map['label'] as String, + tone: switch (map['tone']) { + 'info' => .info, + 'success' => .success, + 'warning' => .warning, + _ => .neutral, + }, + ), + _ => null, + }; + + String? get oneLiner => _data['oneLiner'] as String?; + String? get note => _data['note'] as String?; + String? get noteTitle => _data['noteTitle'] as String?; + String? get description => _data['description'] as String?; + + List get tips => + (_data['tips'] as List? ?? const []).cast(); + + String? get example => _data['example'] as String?; + String get exampleLanguage => + _data['exampleLanguage'] as String? ?? 'plaintext'; + String get exampleTitle => _data['exampleTitle'] as String? ?? label; + + String? get docsLink => _data['docsLink'] as String?; + String get docsLinkLabel => _data['docsLinkLabel'] as String? ?? 'Learn more'; +} + +/// A short highlighted label attached to an [IdeTreeNode], such as +/// "committed" or "gitignored". +final class IdeBadge { + const IdeBadge({required this.label, this.tone = IdeBadgeTone.neutral}); + + final String label; + final IdeBadgeTone tone; +} + +enum IdeBadgeTone { neutral, info, success, warning } + +/// A flattened [IdeTreeNode] paired with the path of ancestor labels +/// leading to it, used to render the breadcrumb in its detail pane. +typedef _FlatNode = ({IdeTreeNode node, String domId, List path}); + +class _IdeExplorer extends StatelessComponent { + const _IdeExplorer({required this.instanceId, required this.roots}); + + final String instanceId; + final List roots; + + String _domId(String nodeId) => 'ide-$instanceId-$nodeId'; + + @override + Component build(BuildContext context) { + final flatNodesByRoot = { + for (final root in roots) + root.id: _flatten( + root.children, + path: root.label.isEmpty ? [] : [root.label], + ), + }; + final allFlatNodes = flatNodesByRoot.values + .expand((nodes) => nodes) + .toList(); + final firstNodeDomId = flatNodesByRoot[roots.first.id]?.firstOrNull?.domId; + + return div(classes: 'ide-explorer not-content', [ + div(classes: 'ide-sidebar', [ + if (roots.length > 1) + div( + classes: 'ide-root-tabs', + attributes: {'role': 'tablist'}, + [ + for (final (rootIndex, root) in roots.indexed) + button( + classes: [ + 'ide-root-tab', + if (rootIndex == 0) 'active', + ].toClasses, + attributes: {'data-ide-root': root.id}, + [.text(root.label.isEmpty ? root.id : root.label)], + ), + _buildToggleAllButton(), + ], + ) + else + div(classes: 'ide-root-tabs ide-root-tabs-single', [ + _buildToggleAllButton(), + ]), + for (final (rootIndex, root) in roots.indexed) + div( + classes: [ + 'ide-tree', + if (rootIndex == 0) 'active', + ].toClasses, + attributes: {'data-ide-root': root.id}, + [ + ul([ + for (final node in root.children) + _buildTreeNode(node, selectedDomId: firstNodeDomId), + ]), + ], + ), + ]), + div(classes: 'ide-detail', [ + for (final flat in allFlatNodes) + _buildDetailPanel(flat, isActive: flat.domId == firstNodeDomId), + ]), + ]); + } + + Component _buildToggleAllButton() { + return const button( + classes: 'ide-toggle-all', + type: ButtonType.button, + attributes: { + 'data-ide-toggle-all': '', + 'title': 'Expand or collapse all folders', + }, + [ + MaterialIcon('unfold_more', label: 'Expand or collapse all folders'), + ], + ); + } + + List<_FlatNode> _flatten( + List nodes, { + required List path, + }) { + final result = <_FlatNode>[]; + for (final node in nodes) { + final nodePath = [...path, node.label]; + result.add((node: node, domId: _domId(node.id), path: path)); + result.addAll(_flatten(node.children, path: nodePath)); + } + return result; + } + + Component _buildTreeNode(IdeTreeNode node, {required String? selectedDomId}) { + final domId = _domId(node.id); + final isSelected = domId == selectedDomId; + final icon = node.isFolder + ? FileIcon.folderIcon + : FileIcon.forFile(node.label); + + if (!node.isFolder) { + return li(classes: 'ide-node ide-node-file', [ + button( + classes: ['ide-node-row', if (isSelected) 'active'].toClasses, + type: ButtonType.button, + attributes: {'data-ide-select': domId}, + [ + icon, + span(classes: 'ide-node-label', [.text(node.label)]), + _buildBadgeDot(node.badge), + ], + ), + ]); + } + + return li(classes: 'ide-node ide-node-folder', [ + details( + open: !node.startsClosed, + [ + summary( + classes: isSelected ? 'active' : null, + attributes: {'data-ide-select': domId, 'tabindex': '0'}, + [ + icon, + span(classes: 'ide-node-label', [.text(node.label)]), + _buildBadgeDot(node.badge), + ], + ), + ul([ + for (final child in node.children) + _buildTreeNode(child, selectedDomId: selectedDomId), + ]), + ], + ), + ]); + } + + Component _buildBadgeDot(IdeBadge? badge) { + if (badge == null) return const Component.empty(); + return span( + classes: ['ide-badge-dot', 'ide-tone-${badge.tone.name}'].toClasses, + attributes: {'title': badge.label}, + [], + ); + } + + Component _buildDetailPanel(_FlatNode flat, {required bool isActive}) { + final node = flat.node; + + return div( + classes: ['ide-detail-panel', if (isActive) 'active'].toClasses, + attributes: {'data-ide-panel': flat.domId}, + [ + if (flat.path.isNotEmpty) + div( + classes: 'ide-path', + [ + for (final (i, segment) in flat.path.indexed) ...[ + if (i > 0) const span(classes: 'ide-path-sep', [.text('/')]), + span([.text(segment.replaceFirst(RegExp(r'/$'), ''))]), + ], + ], + ), + div(classes: 'ide-detail-header', [ + node.isFolder ? FileIcon.folderIcon : FileIcon.forFile(node.label), + div(classes: 'ide-detail-heading', [ + div(classes: 'ide-detail-title', [.text(node.label)]), + if (node.oneLiner case final oneLiner?) + div(classes: 'ide-detail-one-liner', [.text(oneLiner)]), + ]), + if (node.badge case final badge?) + span( + classes: ['ide-badge', 'ide-tone-${badge.tone.name}'].toClasses, + [.text(badge.label)], + ), + ]), + if (node.note case final note?) + div(classes: 'ide-note', [ + if (node.noteTitle case final title?) + div(classes: 'ide-note-title', [.text(title)]), + DashMarkdown(content: note, inline: true), + ]), + if (node.description case final description?) + div(classes: 'ide-description', [DashMarkdown(content: description)]), + if (node.tips.isNotEmpty) + div(classes: 'ide-tips', [ + const div(classes: 'ide-tips-title', [.text('Tips')]), + ul([ + for (final tip in node.tips) + li([DashMarkdown(content: tip, inline: true)]), + ]), + ]), + if (node.example case final example?) + div(classes: 'ide-example', [ + WrappedCodeBlock( + content: CodeBlockProcessor.highlightCode( + [ + for (final line in example.split('\n')) + CodeLine(content: line, highlights: const []), + ], + language: node.exampleLanguage, + ), + language: node.exampleLanguage, + title: node.exampleTitle, + ), + ]), + if (node.docsLink case final docsLink?) + a(href: docsLink, classes: 'ide-docs-link', [ + .text(node.docsLinkLabel), + const MaterialIcon('arrow_forward'), + ]), + if (node.children.isNotEmpty) + div(classes: 'ide-contents', [ + const div(classes: 'ide-contents-title', [.text('Contents')]), + div(classes: 'ide-contents-list', [ + for (final child in node.children) + button( + classes: 'ide-content-link', + type: ButtonType.button, + attributes: {'data-ide-select': _domId(child.id)}, + [ + child.isFolder + ? FileIcon.folderIcon + : FileIcon.forFile(child.label), + span(classes: 'ide-node-label', [.text(child.label)]), + if (child.oneLiner case final oneLiner?) + span(classes: 'ide-content-one-liner', [.text(oneLiner)]), + ], + ), + ]), + ]), + ], + ); + } +} diff --git a/sites/docs/lib/_sass/_site.scss b/sites/docs/lib/_sass/_site.scss index 99092c6a676..8945534d0f4 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 2738c1fd439..2e9e144fa6c 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.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 IdeExplorer(), 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 caa9bf379de..cc4e0e07787 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() { @@ -461,6 +462,128 @@ void _setUpSteppers() { } } +/// 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. + // Skip the clicked node's own
(when a folder's summary was + // clicked directly) so the browser's native open/close toggle on that + // element isn't fought by also forcing it open here. + final ownDetails = sidebarTarget?.tagName.toLowerCase() == 'summary' + ? sidebarTarget!.parentElement + : null; + var current = sidebarTarget; + while (current != null) { + final ancestorDetails = current.closest('details'); + if (ancestorDetails == null) break; + if (ancestorDetails != ownDetails) { + (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); + 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); +} + void _scrollTo(web.Element element, {required bool smooth}) { // Scroll the next step into view, accounting for the fixed header and toc. final headerOffset = diff --git a/sites/docs/src/content/ai/evals.md b/sites/docs/src/content/ai/evals.md index e4985cc52b3..deec5a53dfb 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. + + diff --git a/sites/docs/src/data/flutter_bench_task_example.yml b/sites/docs/src/data/flutter_bench_task_example.yml new file mode 100644 index 00000000000..15f97c892eb --- /dev/null +++ b/sites/docs/src/data/flutter_bench_task_example.yml @@ -0,0 +1,186 @@ +roots: + - id: task + label: theme_from_design_file/ + children: + - id: instruction + label: instruction.md + oneLiner: The prompt the agent receives + badge: input + description: | + A realistic prompt written the way developers talk to agents: + typically one or two sentences, behavior-focused rather than + prescriptive. + tips: + - Prompts avoid naming exact APIs so the agent has to make + reasonable implementation choices, just like a real request. + - Some tasks include a follow-up instruction to simulate a + second turn of human feedback. + example: | + Create a Material theme data in `./lib` folder in a file called + `theme.dart`. Include both light and dark versions named + `lightTheme` and `darkTheme`. + exampleLanguage: markdown + exampleTitle: instruction.md + docsLink: https://docs.flutter.dev/ai/evals + docsLinkLabel: How prompts are authored + + - id: target-codebase + label: target_codebase/ + oneLiner: A containerized Flutter or Dart project + badge: + label: input + tone: info + description: | + A containerized environment that can be preseeded with a Flutter + or Dart project. + + Some more ambitious evals start from scratch, while other tasks + start from existing codebases and add features, fix bugs, or + refactor code. These codebases are large projects, but are not + known open-source apps, to avoid contaminating the evaluation + process. + children: + - id: lib + label: lib/ + oneLiner: Application source code + children: + - id: main-dart + label: main.dart + oneLiner: App entry point + example: | + void main() => runApp(const MyApp()); + exampleLanguage: dart + - id: theme-dart + label: theme.dart + oneLiner: Created by the agent during the eval + badge: + label: agent-authored + tone: warning + note: | + This file doesn't exist when the task starts. It's the + file the agent is expected to create. + noteTitle: Doesn't exist yet + - id: widgets + label: widgets/ + closed: true + oneLiner: Shared widgets used across the sample app + children: + - id: app-bar-dart + label: app_bar.dart + - id: card-dart + label: card.dart + - id: test + label: test/ + oneLiner: Existing and agent-authored tests + closed: true + children: + - id: theme-test-dart + label: theme_test.dart + oneLiner: Golden test the agent's code must satisfy + badge: hidden + - id: widget-test-dart + label: widget_test.dart + - id: pubspec + label: pubspec.yaml + oneLiner: Declares the project's dependencies + example: | + name: sample_app + environment: + sdk: ^3.6.0 + dependencies: + flutter: + sdk: flutter + exampleLanguage: yaml + - id: analysis-options + label: analysis_options.yaml + oneLiner: Lint rules the agent's code is checked against + + - id: verification + label: verification/ + oneLiner: Automated tests and scoring guidelines + badge: + label: hidden from agent + tone: warning + description: | + Automated tests and scoring guidelines, discussed in depth later + on this page. The agent never sees this directory; it's mounted + only when the grading harness runs. + tips: + - Rubrics are graded by a separate model judge, not the agent + being evaluated, to avoid the agent grading its own work. + children: + - id: rubric + label: rubric.yaml + oneLiner: Grading criteria for this task + example: | + - criterion: Uses ThemeData.light() and ThemeData.dark() + weight: 0.4 + - criterion: Exposes lightTheme and darkTheme top-level getters + weight: 0.4 + - criterion: No analyzer warnings introduced + weight: 0.2 + exampleLanguage: yaml + - id: verification-tests + label: tests/ + oneLiner: Scripts that check the resulting codebase + children: + - id: check-analyze-sh + label: check_analyze.sh + oneLiner: Fails the task if `flutter analyze` reports issues + - id: check-theme-py + label: check_theme.py + oneLiner: Statically inspects theme.dart for required symbols + + - id: metadata + label: metadata.yaml + oneLiner: Slicing and re-targeting info for this task + badge: config + description: | + Each task has a related metadata file that allows us to + customize the task to test with different inputs and slice the + output data. + example: | + cuj_id: theme-from-design-file + task_type: feature-add + priority_tier: p0 + expected_tools: [Read, Write, Edit, Bash] + exampleLanguage: yaml + docsLink: https://docs.flutter.dev/ai/evals + docsLinkLabel: How tasks are sliced and prioritized + + - id: global + label: Shared config + children: + - id: harness-config + label: harness_config.yaml + oneLiner: Settings shared by every task in the benchmark + badge: + label: shared + tone: success + description: | + Model, timeout, and sandboxing defaults that apply across the + whole suite unless a task's own `metadata.yaml` overrides them. + example: | + default_model: claude-sonnet-5 + timeout_seconds: 900 + sandbox: docker + exampleLanguage: yaml + - id: judge-prompts + label: judge_prompts/ + oneLiner: Prompt templates used by the automated grading model + closed: true + children: + - id: reasoning-judge-md + label: reasoning_judge.md + oneLiner: Scores whether the agent's approach was sound + - id: safety-judge-md + label: safety_judge.md + oneLiner: Flags destructive or unsafe actions + - id: conciseness-judge-md + label: conciseness_judge.md + oneLiner: Penalizes unnecessary steps or verbosity + - id: readme + label: README.md + oneLiner: How to add a new task to the benchmark + docsLink: https://docs.flutter.dev/ai/evals + docsLinkLabel: Read the full evals methodology \ No newline at end of file From 78a396dbda8c1e459a9ac37a43fef08d8e07f53c Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Tue, 11 Aug 2026 14:14:46 -0700 Subject: [PATCH 02/17] fix filename --- packages/site_shared/lib/_sass/base/_breakpoints.scss | 2 +- sites/docs/src/content/ai/evals.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/site_shared/lib/_sass/base/_breakpoints.scss b/packages/site_shared/lib/_sass/base/_breakpoints.scss index d101c202e6f..c1005c03959 100644 --- a/packages/site_shared/lib/_sass/base/_breakpoints.scss +++ b/packages/site_shared/lib/_sass/base/_breakpoints.scss @@ -10,7 +10,7 @@ $breakpoints: ( '3xl': 1920px, ); -@mixin screen-above($name) { +@mixin screen($name) { $value: map.get($breakpoints, $name); @if $value == null { @error 'Unknown breakpoint: #{$name}'; diff --git a/sites/docs/src/content/ai/evals.md b/sites/docs/src/content/ai/evals.md index deec5a53dfb..c6fd16e84a5 100644 --- a/sites/docs/src/content/ai/evals.md +++ b/sites/docs/src/content/ai/evals.md @@ -29,4 +29,4 @@ Evals measure both deterministic code correctness (reasoning, safety, and conciseness) using automated model judges and expert human grading. - + From 66d2e4059ec52cda3db03495d61dc86b97d386d9 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Tue, 11 Aug 2026 14:53:02 -0700 Subject: [PATCH 03/17] checkin some refactoring --- .../lib/_sass/components/_ide-explorer.scss | 2 +- .../lib/components/common/ide_explorer.dart | 575 ++++++++++++------ sites/docs/lib/main.server.dart | 2 +- .../flutter_bench_task_ide_explorer.dart | 101 +++ 4 files changed, 480 insertions(+), 200 deletions(-) create mode 100644 sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss index 81f144c7c69..695bb9aed02 100644 --- a/packages/site_shared/lib/_sass/components/_ide-explorer.scss +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -5,7 +5,7 @@ display: flex; align-items: stretch; overflow: hidden; - height: 600px; + height: min(600px, 80vh); margin-block: 1.71em; border: 1px solid var(--site-outline); border-radius: var(--site-radius); diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart index b9086852a8b..4b1caca9e9c 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -13,219 +13,242 @@ import '../../util.dart'; import 'material_icon.dart'; import 'wrapped_code_block.dart'; -/// 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: a description, tips, an example snippet, -/// and a link to learn more. Clicking an entry in the tree (or in a -/// folder's "Contents" list) swaps which detail pane is shown. -/// -/// The tree is authored as data, referenced by the required `data` -/// attribute, which names a top-level key in the page's data (loaded from -/// a YAML/JSON file in the site's `src/data` directory). The data can -/// either be a single list of nodes, or a map with a `roots` list if the -/// explorer should offer more than one top-level tree (e.g. "Project" and -/// "Global", shown as tabs above the sidebar): -/// -/// ```yaml -/// # A single implicit root: -/// - id: main-dart -/// label: main.dart -/// oneLiner: The app's entry point. -/// example: | -/// void main() => runApp(const MyApp()); -/// exampleLanguage: dart -/// -/// # Or, multiple named roots shown as tabs: -/// roots: -/// - id: project -/// label: my_app/ -/// children: [ ... ] -/// - id: global -/// label: "~/" -/// children: [ ... ] -/// ``` -/// -/// Each node supports: `id` (required, unique), `label` (required), -/// `type` (`file` or `folder`; inferred from `children`/a trailing `/` -/// when omitted), `closed` (folders are expanded by default), `badge` -/// (a short string, or a map with `label` and `tone` of `info`, `success`, -/// `warning`, or `neutral`), `oneLiner`, `note`/`noteTitle` (a short -/// highlighted callout), `description` (Markdown), `tips` (a list of -/// Markdown strings), `example`/`exampleLanguage`/`exampleTitle` (a -/// syntax-highlighted, copyable snippet), `docsLink`/`docsLinkLabel`, and -/// nested `children`. -/// -/// Usage from Markdown: ``. -class IdeExplorer extends CustomComponentBase { - const IdeExplorer(); - - static int _nextInstanceId = 0; - - @override - Pattern get pattern => 'IdeExplorer'; - - @override - Component apply( - String name, - Map attributes, - Component? child, - ) { - final dataKey = attributes['data']; - if (dataKey == null) { - throw ArgumentError( - 'The element requires a "data" attribute naming a key ' - 'in the page data.', - ); - } - - return Builder( - builder: (context) { - final rawData = context.page.data[dataKey]; - if (rawData == null) { - throw ArgumentError('No page data found for "$dataKey".'); - } - - final roots = _parseRoots(rawData, rootLabel: attributes['rootLabel']); - - return _IdeExplorer( - instanceId: '${_nextInstanceId++}', - roots: roots, - ); - }, - ); - } - - static List _parseRoots( - Object? rawData, { - String? rootLabel, - }) { - if (rawData is List) { - return [ - IdeExplorerRoot._({ - 'id': 'root', - 'label': rootLabel ?? '', - 'children': rawData, - }), - ]; - } - - if (rawData is Map) { - final data = rawData.cast(); - final rawRoots = data['roots']; - if (rawRoots is List) { - return rawRoots - .cast>() - .map((root) => IdeExplorerRoot._(root.cast())) - .toList(growable: false); - } - } +/// A single top-level tree shown by an [IdeExplorer], such as +/// "Project" or "Global". It contains all of the 'files' (IdeTreeNodes) +/// inside the project thats displayed in the IDE. +/// +/// When you have multiple projects to display, they're displayed as +/// tabs that you can switch between. +/// +class IdeExplorerProjectRoot { + const IdeExplorerProjectRoot({ + required this.id, + this.label = '', + this.children = const [], + }); + + final String id; + final String label; + final List children; + + factory IdeExplorerProjectRoot.fromMap(Map map) { + final id = map['id']?.toString() ?? 'root'; + final label = map['label']?.toString() ?? ''; + final rawChildren = map['children']; + final children = switch (rawChildren) { + final List list => + list + .whereType>() + .map(IdeTreeNode.fromMap) + .toList(growable: false), + _ => const [], + }; - throw ArgumentError( - 'Invalid data: expected a list of nodes, or a map with ' - 'a "roots" list.', + return IdeExplorerProjectRoot( + id: id, + label: label, + children: children, ); } } -/// A single top-level tree shown by an [IdeExplorer], such as "Project" or -/// "Global". -extension type IdeExplorerRoot._(Map _data) { - String get id => _data['id'] as String; - String get label => _data['label'] as String? ?? ''; - - List get children => - (_data['children'] as List? ?? const []) - .cast>() - .map((node) => IdeTreeNode._(node.cast())) - .toList(growable: false); -} - /// A single file or folder entry in an [IdeExplorer] tree. -extension type IdeTreeNode._(Map _data) { - String get id => _data['id'] as String; - String get label => _data['label'] as String; - - List get children => - (_data['children'] as List? ?? const []) - .cast>() - .map((node) => IdeTreeNode._(node.cast())) - .toList(growable: false); +class IdeTreeNode { + const IdeTreeNode({ + required this.id, + required this.label, + bool? isFolder, + this.startsClosed = false, + this.badge, + this.oneLiner, + this.note, + this.noteTitle, + this.description, + this.tips = const [], + this.example, + this.exampleLanguage = 'plaintext', + this.exampleTitle, + this.docsLink, + this.docsLinkLabel = 'Learn more', + this.children = const [], + }) : _explicitIsFolder = isFolder; + + final String id; + final String label; + final bool? _explicitIsFolder; + final bool startsClosed; + final IdeBadge? badge; + final String? oneLiner; + final String? note; + final String? noteTitle; + final String? description; + final List tips; + final String? example; + final String exampleLanguage; + final String? exampleTitle; + final String? docsLink; + final String docsLinkLabel; + final List children; bool get isFolder => - (_data['type'] as String?) == 'folder' || - children.isNotEmpty || - label.endsWith('/'); - - /// Whether a folder should render collapsed by default. - bool get startsClosed => _data['closed'] == true; - - IdeBadge? get badge => switch (_data['badge']) { - final String label => IdeBadge(label: label), - final Map map => IdeBadge( - label: map['label'] as String, - tone: switch (map['tone']) { - 'info' => .info, - 'success' => .success, - 'warning' => .warning, - _ => .neutral, - }, - ), - _ => null, - }; - - String? get oneLiner => _data['oneLiner'] as String?; - String? get note => _data['note'] as String?; - String? get noteTitle => _data['noteTitle'] as String?; - String? get description => _data['description'] as String?; - - List get tips => - (_data['tips'] as List? ?? const []).cast(); - - String? get example => _data['example'] as String?; - String get exampleLanguage => - _data['exampleLanguage'] as String? ?? 'plaintext'; - String get exampleTitle => _data['exampleTitle'] as String? ?? label; + _explicitIsFolder ?? (children.isNotEmpty || label.endsWith('/')); + + factory IdeTreeNode.fromMap(Map map) { + final id = map['id']?.toString() ?? ''; + final label = map['label']?.toString() ?? ''; + final type = map['type']?.toString(); + final isFolder = type != null ? type == 'folder' : null; + final startsClosed = map['closed'] == true; + final badge = map['badge'] != null ? IdeBadge.from(map['badge']) : null; + final oneLiner = map['oneLiner']?.toString(); + final note = map['note']?.toString(); + final noteTitle = map['noteTitle']?.toString(); + final description = map['description']?.toString(); + final rawTips = map['tips']; + final tips = switch (rawTips) { + final List list => + list + .map((e) => e?.toString() ?? '') + .where((tip) => tip.isNotEmpty) + .toList(growable: false), + _ => const [], + }; + final example = map['example']?.toString(); + final exampleLanguage = map['exampleLanguage']?.toString() ?? 'plaintext'; + final exampleTitle = map['exampleTitle']?.toString(); + final docsLink = map['docsLink']?.toString(); + final docsLinkLabel = map['docsLinkLabel']?.toString() ?? 'Learn more'; + final rawChildren = map['children']; + final children = switch (rawChildren) { + final List list => + list + .whereType>() + .map(IdeTreeNode.fromMap) + .toList(growable: false), + _ => const [], + }; - String? get docsLink => _data['docsLink'] as String?; - String get docsLinkLabel => _data['docsLinkLabel'] as String? ?? 'Learn more'; + return IdeTreeNode( + id: id, + label: label, + isFolder: isFolder, + startsClosed: startsClosed, + badge: badge, + oneLiner: oneLiner, + note: note, + noteTitle: noteTitle, + description: description, + tips: tips, + example: example, + exampleLanguage: exampleLanguage, + exampleTitle: exampleTitle, + docsLink: docsLink, + docsLinkLabel: docsLinkLabel, + children: children, + ); + } } -/// A short highlighted label attached to an [IdeTreeNode], such as -/// "committed" or "gitignored". +/// Corresponds to colors used for the [IdeBadge] +enum IdeBadgeTone { neutral, info, success, warning } + +/// A highlighted pill-style label displayed next to an [IdeTreeNode] title. +/// Intended to be used to display a single word of crucial metadata, +/// e.g. if you were including pubspec.lock in the component, +/// you could add "gitignored". final class IdeBadge { - const IdeBadge({required this.label, this.tone = IdeBadgeTone.neutral}); + const IdeBadge({ + required this.label, + this.tone = IdeBadgeTone.neutral, + }); final String label; final IdeBadgeTone tone; -} -enum IdeBadgeTone { neutral, info, success, warning } + factory IdeBadge.from(Object? data) { + if (data is String) { + return IdeBadge(label: data); + } + if (data is Map) { + final label = data['label']?.toString() ?? ''; + final toneStr = data['tone']?.toString(); + final tone = switch (toneStr) { + 'info' => IdeBadgeTone.info, + 'success' => IdeBadgeTone.success, + 'warning' => IdeBadgeTone.warning, + _ => IdeBadgeTone.neutral, + }; + return IdeBadge(label: label, tone: tone); + } + throw ArgumentError('Invalid badge data: $data'); + } +} /// A flattened [IdeTreeNode] paired with the path of ancestor labels /// leading to it, used to render the breadcrumb in its detail pane. -typedef _FlatNode = ({IdeTreeNode node, String domId, List path}); +typedef _BreadcrumbNode = ({IdeTreeNode node, String domId, List path}); -class _IdeExplorer extends StatelessComponent { - const _IdeExplorer({required this.instanceId, required this.roots}); - final String instanceId; - final List roots; +/// 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: a description, tips, an example snippet, +/// and a link to learn more. Clicking an entry in the tree (or in a +/// folder's "Contents" list) swaps which detail pane is shown. +/// +/// Can be constructed directly in Dart with [IdeExplorer.new] or +/// [IdeExplorer.fromNodes], or used in Markdown via `` +/// powered by [DashIdeExplorer]. +class IdeExplorer extends StatelessComponent { + const IdeExplorer({ + super.key, + required this.roots, + this.instanceId, + }); + + /// Creates an [IdeExplorer] with a single root from a list of [children]. + IdeExplorer.fromNodes({ + super.key, + required List children, + String rootLabel = '', + this.instanceId, + }) : roots = [ + IdeExplorerProjectRoot( + id: 'root', + label: rootLabel, + children: children, + ), + ]; + + // TODO: What is this used for? Is it not always 0 given that its static? + static int _nextInstanceId = 0; + + final List roots; - String _domId(String nodeId) => 'ide-$instanceId-$nodeId'; + final String? instanceId; + + String _domId(String effectiveInstanceId, String nodeId) => + 'ide-$effectiveInstanceId-$nodeId'; @override Component build(BuildContext context) { + if (roots.isEmpty) { + return const Component.empty(); + } + + final effectiveInstanceId = instanceId ?? '${_nextInstanceId++}'; + final flatNodesByRoot = { for (final root in roots) root.id: _flatten( root.children, + instanceId: effectiveInstanceId, path: root.label.isEmpty ? [] : [root.label], ), }; final allFlatNodes = flatNodesByRoot.values .expand((nodes) => nodes) - .toList(); + .toList(growable: false); final firstNodeDomId = flatNodesByRoot[roots.first.id]?.firstOrNull?.domId; return div(classes: 'ide-explorer not-content', [ @@ -241,7 +264,11 @@ class _IdeExplorer extends StatelessComponent { 'ide-root-tab', if (rootIndex == 0) 'active', ].toClasses, - attributes: {'data-ide-root': root.id}, + attributes: { + 'data-ide-root': root.id, + 'role': 'tab', + 'aria-selected': '${rootIndex == 0}', + }, [.text(root.label.isEmpty ? root.id : root.label)], ), _buildToggleAllButton(), @@ -261,14 +288,22 @@ class _IdeExplorer extends StatelessComponent { [ ul([ for (final node in root.children) - _buildTreeNode(node, selectedDomId: firstNodeDomId), + _buildTreeNode( + node, + instanceId: effectiveInstanceId, + selectedDomId: firstNodeDomId, + ), ]), ], ), ]), div(classes: 'ide-detail', [ for (final flat in allFlatNodes) - _buildDetailPanel(flat, isActive: flat.domId == firstNodeDomId), + _buildDetailPanel( + flat, + instanceId: effectiveInstanceId, + isActive: flat.domId == firstNodeDomId, + ), ]), ]); } @@ -280,6 +315,7 @@ class _IdeExplorer extends StatelessComponent { 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'), @@ -287,21 +323,37 @@ class _IdeExplorer extends StatelessComponent { ); } - List<_FlatNode> _flatten( + // TODO: The _BreadcumbNode class can just be a record + List<_BreadcrumbNode> _flatten( List nodes, { + required String instanceId, required List path, }) { - final result = <_FlatNode>[]; + final result = <_BreadcrumbNode>[]; for (final node in nodes) { final nodePath = [...path, node.label]; - result.add((node: node, domId: _domId(node.id), path: path)); - result.addAll(_flatten(node.children, path: nodePath)); + result.add(( + node: node, + domId: _domId(instanceId, node.id), + path: path, + )); + result.addAll( + _flatten( + node.children, + instanceId: instanceId, + path: nodePath, + ), + ); } return result; } - Component _buildTreeNode(IdeTreeNode node, {required String? selectedDomId}) { - final domId = _domId(node.id); + Component _buildTreeNode( + IdeTreeNode node, { + required String instanceId, + required String? selectedDomId, + }) { + final domId = _domId(instanceId, node.id); final isSelected = domId == selectedDomId; final icon = node.isFolder ? FileIcon.folderIcon @@ -312,7 +364,10 @@ class _IdeExplorer extends StatelessComponent { button( classes: ['ide-node-row', if (isSelected) 'active'].toClasses, type: ButtonType.button, - attributes: {'data-ide-select': domId}, + attributes: { + 'data-ide-select': domId, + if (isSelected) 'aria-current': 'true', + }, [ icon, span(classes: 'ide-node-label', [.text(node.label)]), @@ -328,7 +383,10 @@ class _IdeExplorer extends StatelessComponent { [ summary( classes: isSelected ? 'active' : null, - attributes: {'data-ide-select': domId, 'tabindex': '0'}, + attributes: { + 'data-ide-select': domId, + if (isSelected) 'aria-current': 'true', + }, [ icon, span(classes: 'ide-node-label', [.text(node.label)]), @@ -337,7 +395,11 @@ class _IdeExplorer extends StatelessComponent { ), ul([ for (final child in node.children) - _buildTreeNode(child, selectedDomId: selectedDomId), + _buildTreeNode( + child, + instanceId: instanceId, + selectedDomId: selectedDomId, + ), ]), ], ), @@ -348,12 +410,20 @@ class _IdeExplorer extends StatelessComponent { if (badge == null) return const Component.empty(); return span( classes: ['ide-badge-dot', 'ide-tone-${badge.tone.name}'].toClasses, - attributes: {'title': badge.label}, + attributes: { + 'role': 'img', + 'aria-label': badge.label, + 'title': badge.label, + }, [], ); } - Component _buildDetailPanel(_FlatNode flat, {required bool isActive}) { + Component _buildDetailPanel( + _BreadcrumbNode flat, { + required String instanceId, + required bool isActive, + }) { final node = flat.node; return div( @@ -379,7 +449,10 @@ class _IdeExplorer extends StatelessComponent { ]), if (node.badge case final badge?) span( - classes: ['ide-badge', 'ide-tone-${badge.tone.name}'].toClasses, + classes: [ + 'ide-badge', + 'ide-tone-${badge.tone.name}', + ].toClasses, [.text(badge.label)], ), ]), @@ -390,7 +463,10 @@ class _IdeExplorer extends StatelessComponent { DashMarkdown(content: note, inline: true), ]), if (node.description case final description?) - div(classes: 'ide-description', [DashMarkdown(content: description)]), + div( + classes: 'ide-description', + [DashMarkdown(content: description)], + ), if (node.tips.isNotEmpty) div(classes: 'ide-tips', [ const div(classes: 'ide-tips-title', [.text('Tips')]), @@ -404,7 +480,7 @@ class _IdeExplorer extends StatelessComponent { WrappedCodeBlock( content: CodeBlockProcessor.highlightCode( [ - for (final line in example.split('\n')) + for (final line in example.trimRight().split('\n')) CodeLine(content: line, highlights: const []), ], language: node.exampleLanguage, @@ -426,14 +502,19 @@ class _IdeExplorer extends StatelessComponent { button( classes: 'ide-content-link', type: ButtonType.button, - attributes: {'data-ide-select': _domId(child.id)}, + attributes: { + 'data-ide-select': _domId(instanceId, child.id), + }, [ child.isFolder ? FileIcon.folderIcon : FileIcon.forFile(child.label), span(classes: 'ide-node-label', [.text(child.label)]), if (child.oneLiner case final oneLiner?) - span(classes: 'ide-content-one-liner', [.text(oneLiner)]), + span( + classes: 'ide-content-one-liner', + [.text(oneLiner)], + ), ], ), ]), @@ -442,3 +523,101 @@ class _IdeExplorer extends StatelessComponent { ); } } + +/// A custom markdown component wrapper for [IdeExplorer]. +/// +/// The tree is authored as data, referenced by the required `data` +/// attribute, which names a top-level key in the page's data (loaded from +/// a YAML/JSON file in the site's `src/data` directory). The data can +/// either be a single list of nodes, or a map with a `roots` list if the +/// explorer should offer more than one top-level tree (e.g. "Project" and +/// "Global", shown as tabs above the sidebar): +/// +/// ```yaml +/// # A single implicit root: +/// - id: main-dart +/// label: main.dart +/// oneLiner: The app's entry point. +/// example: | +/// void main() => runApp(const MyApp()); +/// exampleLanguage: dart +/// +/// # Or, multiple named roots shown as tabs: +/// roots: +/// - id: project +/// label: my_app/ +/// children: [ ... ] +/// - id: global +/// label: "~/" +/// children: [ ... ] +/// ``` +/// +/// Usage from Markdown: ``. +class DashIdeExplorer extends CustomComponentBase { + const DashIdeExplorer(); + + @override + Pattern get pattern => RegExp(r'^(Dash)?IdeExplorer$', caseSensitive: false); + + @override + Component apply( + String name, + Map attributes, + Component? child, + ) { + final dataKey = attributes['data']; + if (dataKey == null) { + throw ArgumentError( + 'The element requires a "data" attribute naming a key ' + 'in the page data.', + ); + } + + return Builder( + builder: (context) { + final rawData = context.page.data[dataKey]; + if (rawData == null) { + throw ArgumentError('No page data found for "$dataKey".'); + } + + final roots = parseRoots(rawData, rootLabel: attributes['rootLabel']); + + return IdeExplorer(roots: roots); + }, + ); + } + + /// Parses raw page data (from YAML/JSON) into a list of [IdeExplorerProjectRoot]s. + static List parseRoots( + Object? rawData, { + String? rootLabel, + }) { + if (rawData is List) { + return [ + IdeExplorerProjectRoot( + id: 'root', + label: rootLabel ?? '', + children: rawData + .whereType>() + .map(IdeTreeNode.fromMap) + .toList(growable: false), + ), + ]; + } + + if (rawData is Map) { + final rawRoots = rawData['roots']; + if (rawRoots is List) { + return rawRoots + .whereType>() + .map(IdeExplorerProjectRoot.fromMap) + .toList(growable: false); + } + } + + throw ArgumentError( + 'Invalid data: expected a list of nodes, or a map with ' + 'a "roots" list.', + ); + } +} diff --git a/sites/docs/lib/main.server.dart b/sites/docs/lib/main.server.dart index 2e9e144fa6c..91dd9eb6710 100644 --- a/sites/docs/lib/main.server.dart +++ b/sites/docs/lib/main.server.dart @@ -100,7 +100,7 @@ List get _embeddableComponents => [ const CodePreview(), const YoutubeEmbed(), const FileTree(), - const IdeExplorer(), + const DashIdeExplorer(), const Quiz(), const ProgressRing(), const SummaryCard(), diff --git a/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart b/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart new file mode 100644 index 00000000000..727d5009303 --- /dev/null +++ b/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart @@ -0,0 +1,101 @@ +import 'package:jaspr/client.dart'; +import 'package:jaspr_content/jaspr_content.dart'; +import 'package:site_shared/components/common/ide_explorer.dart'; + +/// A custom markdown component wrapper for [IdeExplorer]. +/// +/// The tree is authored as data, referenced by the required `data` +/// attribute, which names a top-level key in the page's data (loaded from +/// a YAML/JSON file in the site's `src/data` directory). The data can +/// either be a single list of nodes, or a map with a `roots` list if the +/// explorer should offer more than one top-level tree (e.g. "Project" and +/// "Global", shown as tabs above the sidebar): +/// +/// ```yaml +/// # A single implicit root: +/// - id: main-dart +/// label: main.dart +/// oneLiner: The app's entry point. +/// example: | +/// void main() => runApp(const MyApp()); +/// exampleLanguage: dart +/// +/// # Or, multiple named roots shown as tabs: +/// roots: +/// - id: project +/// label: my_app/ +/// children: [ ... ] +/// - id: global +/// label: "~/" +/// children: [ ... ] +/// ``` +/// +/// Usage from Markdown: ``. +class DashIdeExplorer extends CustomComponentBase { + const DashIdeExplorer(); + + @override + Pattern get pattern => RegExp(r'^(Dash)?IdeExplorer$', caseSensitive: false); + + @override + Component apply( + String name, + Map attributes, + Component? child, + ) { + final dataKey = attributes['data']; + if (dataKey == null) { + throw ArgumentError( + 'The element requires a "data" attribute naming a key ' + 'in the page data.', + ); + } + + return Builder( + builder: (context) { + final rawData = context.page.data[dataKey]; + if (rawData == null) { + throw ArgumentError('No page data found for "$dataKey".'); + } + + final roots = parseRoots(rawData, rootLabel: attributes['rootLabel']); + + return IdeExplorer(roots: roots); + }, + ); + } + + /// Parses raw page data (from YAML/JSON) into a list of [IdeExplorerProjectRoot]s. + static List parseRoots( + Object? rawData, { + String? rootLabel, + }) { + if (rawData is List) { + return [ + IdeExplorerProjectRoot( + id: 'root', + label: rootLabel ?? '', + children: rawData + .whereType>() + .map(IdeTreeNode.fromMap) + .toList(growable: false), + ), + ]; + } + + if (rawData is Map) { + final rawRoots = rawData['roots']; + if (rawRoots is List) { + return rawRoots + .whereType>() + .map(IdeExplorerProjectRoot.fromMap) + .toList(growable: false); + } + } + + throw ArgumentError( + 'Invalid data: expected a list of nodes, or a map with ' + 'a "roots" list.', + ); + } +} From ae00cb19f600ac79aaec102a6747a8eec1d23204 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Wed, 12 Aug 2026 12:35:32 -0700 Subject: [PATCH 04/17] rework data format --- .../lib/components/common/ide_explorer.dart | 154 ++++++++++-------- sites/docs/lib/main.server.dart | 2 + .../flutter_bench_task_ide_explorer.dart | 112 ++++--------- .../docs/ai/flutter_bench_task_explorer.md | 40 +++++ sites/docs/src/content/ai/evals.md | 2 +- .../src/data/flutter_bench_task_example.yml | 29 ---- 6 files changed, 157 insertions(+), 182 deletions(-) create mode 100644 sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart index 4b1caca9e9c..7044aaa71e0 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -19,7 +19,6 @@ import 'wrapped_code_block.dart'; /// /// When you have multiple projects to display, they're displayed as /// tabs that you can switch between. -/// class IdeExplorerProjectRoot { const IdeExplorerProjectRoot({ required this.id, @@ -204,6 +203,7 @@ class IdeExplorer extends StatelessComponent { super.key, required this.roots, this.instanceId, + this.customContents = const {}, }); /// Creates an [IdeExplorer] with a single root from a list of [children]. @@ -212,6 +212,7 @@ class IdeExplorer extends StatelessComponent { required List children, String rootLabel = '', this.instanceId, + this.customContents = const {}, }) : roots = [ IdeExplorerProjectRoot( id: 'root', @@ -220,12 +221,14 @@ class IdeExplorer extends StatelessComponent { ), ]; - // TODO: What is this used for? Is it not always 0 given that its static? + // Prevents DOM id collisions static int _nextInstanceId = 0; final List roots; final String? instanceId; + + final Map customContents; String _domId(String effectiveInstanceId, String nodeId) => 'ide-$effectiveInstanceId-$nodeId'; @@ -323,7 +326,6 @@ class IdeExplorer extends StatelessComponent { ); } - // TODO: The _BreadcumbNode class can just be a record List<_BreadcrumbNode> _flatten( List nodes, { required String instanceId, @@ -456,44 +458,50 @@ class IdeExplorer extends StatelessComponent { [.text(badge.label)], ), ]), - if (node.note case final note?) - div(classes: 'ide-note', [ - if (node.noteTitle case final title?) - div(classes: 'ide-note-title', [.text(title)]), - DashMarkdown(content: note, inline: true), - ]), - if (node.description case final description?) - div( - classes: 'ide-description', - [DashMarkdown(content: description)], - ), - if (node.tips.isNotEmpty) - div(classes: 'ide-tips', [ - const div(classes: 'ide-tips-title', [.text('Tips')]), - ul([ - for (final tip in node.tips) - li([DashMarkdown(content: tip, inline: true)]), + + if (customContents[node.id] case final customChild?) + div(classes: 'ide-custom-body', [customChild]) + else ...[ + if (node.note case final note?) + div(classes: 'ide-note', [ + if (node.noteTitle case final title?) + div(classes: 'ide-note-title', [.text(title)]), + DashMarkdown(content: note, inline: true), ]), - ]), - if (node.example case final example?) - div(classes: 'ide-example', [ - WrappedCodeBlock( - content: CodeBlockProcessor.highlightCode( - [ - for (final line in example.trimRight().split('\n')) - CodeLine(content: line, highlights: const []), - ], + if (node.description case final description?) + div( + classes: 'ide-description', + [DashMarkdown(content: description)], + ), + if (node.tips.isNotEmpty) + div(classes: 'ide-tips', [ + const div(classes: 'ide-tips-title', [.text('Tips')]), + ul([ + for (final tip in node.tips) + li([DashMarkdown(content: tip, inline: true)]), + ]), + ]), + if (node.example case final example?) + div(classes: 'ide-example', [ + WrappedCodeBlock( + content: CodeBlockProcessor.highlightCode( + [ + for (final line in example.trimRight().split('\n')) + CodeLine(content: line, highlights: const []), + ], + language: node.exampleLanguage, + ), language: node.exampleLanguage, + title: node.exampleTitle, ), - language: node.exampleLanguage, - title: node.exampleTitle, - ), - ]), - if (node.docsLink case final docsLink?) - a(href: docsLink, classes: 'ide-docs-link', [ - .text(node.docsLinkLabel), - const MaterialIcon('arrow_forward'), - ]), + ]), + if (node.docsLink case final docsLink?) + a(href: docsLink, classes: 'ide-docs-link', [ + .text(node.docsLinkLabel), + const MaterialIcon('arrow_forward'), + ]), + ], + if (node.children.isNotEmpty) div(classes: 'ide-contents', [ const div(classes: 'ide-contents-title', [.text('Contents')]), @@ -530,42 +538,27 @@ class IdeExplorer extends StatelessComponent { /// attribute, which names a top-level key in the page's data (loaded from /// a YAML/JSON file in the site's `src/data` directory). The data can /// either be a single list of nodes, or a map with a `roots` list if the -/// explorer should offer more than one top-level tree (e.g. "Project" and -/// "Global", shown as tabs above the sidebar): -/// -/// ```yaml -/// # A single implicit root: -/// - id: main-dart -/// label: main.dart -/// oneLiner: The app's entry point. -/// example: | -/// void main() => runApp(const MyApp()); -/// exampleLanguage: dart +/// explorer should offer more than one top-level tree. /// -/// # Or, multiple named roots shown as tabs: -/// roots: -/// - id: project -/// label: my_app/ -/// children: [ ... ] -/// - id: global -/// label: "~/" -/// children: [ ... ] +/// Usage from Markdown: +/// ```html +/// +/// +/// Arbitrary markdown content for the node with id 'someNodeId'. +/// +/// /// ``` -/// -/// Usage from Markdown: ``. -class DashIdeExplorer extends CustomComponentBase { - const DashIdeExplorer(); +class DashIdeExplorer extends CustomComponent { + const DashIdeExplorer() : super.base(); @override - Pattern get pattern => RegExp(r'^(Dash)?IdeExplorer$', caseSensitive: false); + Component? create(Node node, NodesBuilder builder) { + if (node is! ElementNode || + !(node.tag == 'IdeExplorer' || node.tag == 'DashIdeExplorer')) { + return null; + } - @override - Component apply( - String name, - Map attributes, - Component? child, - ) { - final dataKey = attributes['data']; + final dataKey = node.attributes['data']; if (dataKey == null) { throw ArgumentError( 'The element requires a "data" attribute naming a key ' @@ -573,6 +566,19 @@ class DashIdeExplorer extends CustomComponentBase { ); } + final pages = node.children + ?.whereType() + .where((n) => n.tag == 'IdePage') + .toList(growable: false) ?? []; + + final customContents = {}; + for (final page in pages) { + final id = page.attributes['id']; + if (id != null) { + customContents[id] = builder.build(page.children); + } + } + return Builder( builder: (context) { final rawData = context.page.data[dataKey]; @@ -580,9 +586,15 @@ class DashIdeExplorer extends CustomComponentBase { throw ArgumentError('No page data found for "$dataKey".'); } - final roots = parseRoots(rawData, rootLabel: attributes['rootLabel']); + final roots = parseRoots( + rawData, + rootLabel: node.attributes['rootLabel'], + ); - return IdeExplorer(roots: roots); + return IdeExplorer( + roots: roots, + customContents: customContents, + ); }, ); } diff --git a/sites/docs/lib/main.server.dart b/sites/docs/lib/main.server.dart index 91dd9eb6710..9617b42d50f 100644 --- a/sites/docs/lib/main.server.dart +++ b/sites/docs/lib/main.server.dart @@ -30,6 +30,7 @@ import 'src/components/pages/architecture_recommendations.dart'; import 'src/components/pages/archive_table.dart'; import 'src/components/pages/devtools_release_notes_index.dart'; import 'src/components/pages/expansion_list.dart'; +import 'src/components/pages/flutter_bench_task_ide_explorer.dart'; import 'src/components/pages/learning_resource_index.dart'; import 'src/components/pages/platforms_grid.dart'; import 'src/components/pages/widget_catalog.dart'; @@ -128,4 +129,5 @@ List get _embeddableComponents => [ 'DevToolsReleaseNotesIndex', const DevToolsReleaseNotesIndex(), ), + const FlutterBenchTaskExplorer(), ]; diff --git a/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart b/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart index 727d5009303..404466b1491 100644 --- a/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart +++ b/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart @@ -1,54 +1,33 @@ -import 'package:jaspr/client.dart'; +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/jaspr_content.dart'; import 'package:site_shared/components/common/ide_explorer.dart'; -/// A custom markdown component wrapper for [IdeExplorer]. -/// -/// The tree is authored as data, referenced by the required `data` -/// attribute, which names a top-level key in the page's data (loaded from -/// a YAML/JSON file in the site's `src/data` directory). The data can -/// either be a single list of nodes, or a map with a `roots` list if the -/// explorer should offer more than one top-level tree (e.g. "Project" and -/// "Global", shown as tabs above the sidebar): -/// -/// ```yaml -/// # A single implicit root: -/// - id: main-dart -/// label: main.dart -/// oneLiner: The app's entry point. -/// example: | -/// void main() => runApp(const MyApp()); -/// exampleLanguage: dart -/// -/// # Or, multiple named roots shown as tabs: -/// roots: -/// - id: project -/// label: my_app/ -/// children: [ ... ] -/// - id: global -/// label: "~/" -/// children: [ ... ] -/// ``` -/// -/// Usage from Markdown: ``. -class DashIdeExplorer extends CustomComponentBase { - const DashIdeExplorer(); - @override - Pattern get pattern => RegExp(r'^(Dash)?IdeExplorer$', caseSensitive: false); +/// Wraps [IdeExplorer] component +final class FlutterBenchTaskExplorer extends CustomComponent { + const FlutterBenchTaskExplorer() : super.base(); @override - Component apply( - String name, - Map attributes, - Component? child, - ) { - final dataKey = attributes['data']; - if (dataKey == null) { - throw ArgumentError( - 'The element requires a "data" attribute naming a key ' - 'in the page data.', - ); + Component? create(Node node, NodesBuilder builder) { + if (node is! ElementNode || node.tag != 'FlutterBenchTaskExplorer') { + return null; + } + + final dataKey = node.attributes['data'] ?? 'flutter_bench_task_example'; + final rootLabel = node.attributes['rootLabel']; + + final pages = node.children + ?.whereType() + .where((n) => n.tag == 'IdePage') + .toList(growable: false) ?? []; + + final customContents = {}; + for (final page in pages) { + final id = page.attributes['id']; + if (id != null) { + customContents[id] = builder.build(page.children); + } } return Builder( @@ -58,44 +37,15 @@ class DashIdeExplorer extends CustomComponentBase { throw ArgumentError('No page data found for "$dataKey".'); } - final roots = parseRoots(rawData, rootLabel: attributes['rootLabel']); + final roots = DashIdeExplorer.parseRoots(rawData, rootLabel: rootLabel); - return IdeExplorer(roots: roots); + return div(classes: 'flutter-bench-task-explorer', [ + IdeExplorer( + roots: roots, + customContents: customContents, + ), + ]); }, ); } - - /// Parses raw page data (from YAML/JSON) into a list of [IdeExplorerProjectRoot]s. - static List parseRoots( - Object? rawData, { - String? rootLabel, - }) { - if (rawData is List) { - return [ - IdeExplorerProjectRoot( - id: 'root', - label: rootLabel ?? '', - children: rawData - .whereType>() - .map(IdeTreeNode.fromMap) - .toList(growable: false), - ), - ]; - } - - if (rawData is Map) { - final rawRoots = rawData['roots']; - if (rawRoots is List) { - return rawRoots - .whereType>() - .map(IdeExplorerProjectRoot.fromMap) - .toList(growable: false); - } - } - - throw ArgumentError( - 'Invalid data: expected a list of nodes, or a map with ' - 'a "roots" list.', - ); - } } 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 00000000000..62e2c9ef95e --- /dev/null +++ b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md @@ -0,0 +1,40 @@ + + + + +A realistic prompt written the way developers talk to agents: +typically one or two sentences, behavior-focused rather than +prescriptive. + +**Tips** +- Prompts avoid naming exact APIs so the agent has to make + reasonable implementation choices, just like a real request. +- Some tasks include a follow-up instruction to simulate a + second turn of human feedback. + +```markdown +Create a Material theme data in `./lib` folder in a file called +`theme.dart`. Include both light and dark versions named +`lightTheme` and `darkTheme`. +``` +[How prompts are authored](https://docs.flutter.dev/ai/evals) + + + + + +Each task has a related metadata file that allows us to +customize the task to test with different inputs and slice the +output data. + +```yaml +cuj_id: theme-from-design-file +task_type: feature-add +priority_tier: p0 +expected_tools: [Read, Write, Edit, Bash] +``` +[How tasks are sliced and prioritized](https://docs.flutter.dev/ai/evals) + + + + diff --git a/sites/docs/src/content/ai/evals.md b/sites/docs/src/content/ai/evals.md index c6fd16e84a5..9dc42e3dbd7 100644 --- a/sites/docs/src/content/ai/evals.md +++ b/sites/docs/src/content/ai/evals.md @@ -29,4 +29,4 @@ Evals measure both deterministic code correctness (reasoning, safety, and conciseness) using automated model judges and expert human grading. - +{% render "docs/ai/flutter_bench_task_explorer.md" %} diff --git a/sites/docs/src/data/flutter_bench_task_example.yml b/sites/docs/src/data/flutter_bench_task_example.yml index 15f97c892eb..b25497cd832 100644 --- a/sites/docs/src/data/flutter_bench_task_example.yml +++ b/sites/docs/src/data/flutter_bench_task_example.yml @@ -6,23 +6,6 @@ roots: label: instruction.md oneLiner: The prompt the agent receives badge: input - description: | - A realistic prompt written the way developers talk to agents: - typically one or two sentences, behavior-focused rather than - prescriptive. - tips: - - Prompts avoid naming exact APIs so the agent has to make - reasonable implementation choices, just like a real request. - - Some tasks include a follow-up instruction to simulate a - second turn of human feedback. - example: | - Create a Material theme data in `./lib` folder in a file called - `theme.dart`. Include both light and dark versions named - `lightTheme` and `darkTheme`. - exampleLanguage: markdown - exampleTitle: instruction.md - docsLink: https://docs.flutter.dev/ai/evals - docsLinkLabel: How prompts are authored - id: target-codebase label: target_codebase/ @@ -135,18 +118,6 @@ roots: label: metadata.yaml oneLiner: Slicing and re-targeting info for this task badge: config - description: | - Each task has a related metadata file that allows us to - customize the task to test with different inputs and slice the - output data. - example: | - cuj_id: theme-from-design-file - task_type: feature-add - priority_tier: p0 - expected_tools: [Read, Write, Edit, Bash] - exampleLanguage: yaml - docsLink: https://docs.flutter.dev/ai/evals - docsLinkLabel: How tasks are sliced and prioritized - id: global label: Shared config From 485f2ab80b9bde7b59ad8c260cef4e09ca201293 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 13 Aug 2026 12:30:52 -0700 Subject: [PATCH 05/17] start refactor --- .../docs/ai/flutter_bench_task_explorer.md | 286 ++++++++++++++++-- 1 file changed, 264 insertions(+), 22 deletions(-) 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 index 62e2c9ef95e..2a9fcf60342 100644 --- a/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md +++ b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md @@ -1,40 +1,282 @@ - + -A realistic prompt written the way developers talk to agents: -typically one or two sentences, behavior-focused rather than -prescriptive. + -**Tips** -- Prompts avoid naming exact APIs so the agent has to make - reasonable implementation choices, just like a real request. -- Some tasks include a follow-up instruction to simulate a - second turn of human feedback. +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 -Create a Material theme data in `./lib` folder in a file called -`theme.dart`. Include both light and dark versions named -`lightTheme` and `darkTheme`. +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. +- `difficulty_explanation`: + Identifies common subtle pitfalls (like fixing only one axis or altering + item structures), which inform the grading rubrics. +- `verifier` and `agent` timeouts: + Provides sufficient execution budget for cold compilation and multistep + agent tool iterations. + + + + + +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. + + + +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. + + + + + +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 ``` -[How prompts are authored](https://docs.flutter.dev/ai/evals) - + + + + +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 +`package:eval_scoring`. + +```dart +// Heavily edited as to not risk contamination +AggregateGrader grader(EvaluationContext context) => + AggregateGrader.result( + outcome: AggregateGrader.outcome( + testing: TestingGrader(), + heuristics: AggregateGrader.outcomeHeuristics( + graderWeights: {Grader _grader: int weight}, + ), + ), + quality: AggregateGrader.quality( + staticAnalysis: StaticAnalysisGrader(), + heuristics: FormattingGrader(filesToInclude: const ['...']), + idiomaticReview: LLMGrader( + contextBuilders: [ + FileContentContextBuilder( + files: ['...'], + ), + ], + criteria: [ + LLMCriterion( + description: '''TODO: add grading instructions''', + ), + ], + ), + ), + dx: AggregateGrader.dx(), + ); +``` + + + + + +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. + + + + + + -Each task has a related metadata file that allows us to -customize the task to test with different inputs and slice the -output data. + + + + +The `eval_scoring` Dart package powers all grading across the benchmark +suite. + +**Key capabilities** + +- **Composite scoring (`AggregateGrader`)**: + Combines multiple sub-graders with configurable weights. +- **Deterministic graders**: + Wraps standard Dart and Flutter CLI tools (`StaticAnalysisGrader`, + `TestingGrader`, `FormattingGrader`, `DcmGrader`). +- **LLM judges (`LLMGrader`)**: + Leverages model judges powered by `package:genkit` to evaluate + qualitative rubrics with Likert and binary criteria. +- **Compute-once execution**: + Ensures shared grader instances are evaluated once and shared across + outcome, quality, and diagnostic reporting. + + + + + +Prebuilt Linux Docker container images (`flutter-linux` and `dart-linux`). + +**Features** + +- Pre-installs the Flutter SDK, Dart SDK, and shared evaluation utilities. +- Pre-caches common packages in `/opt/packages`. +- Reduces task container startup time from minutes to seconds. +- Guarantees identical SDK versions and tool environments across local runs + and CI pipelines. + + + + + +The evaluation job configuration file that specifies which models, tools, +and agent variants to evaluate. + +**Capabilities** + +- Configures model endpoints and temperature settings. +- Controls tool availability (e.g. enabling or disabling terminal access, + file search, or MCP tools). +- Executes matrix runs evaluating multiple agent architectures against the + benchmark suite. ```yaml -cuj_id: theme-from-design-file -task_type: feature-add -priority_tier: p0 -expected_tools: [Read, Write, Edit, Bash] +agents: + - model_name: gemini-2.5-pro + kwargs: + allowed_tools: [ Read, Write, Edit, Bash, Glob, Ripgrep ] ``` -[How tasks are sliced and prioritized](https://docs.flutter.dev/ai/evals) + + From 022ccafd7450d4835be19404b1d66e8ef04de9d6 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 13 Aug 2026 12:31:21 -0700 Subject: [PATCH 06/17] start refactor --- .../lib/_sass/components/_ide-explorer.scss | 2 +- .../lib/components/common/ide_explorer.dart | 349 +++++++++++------- sites/docs/lib/src/client/global_scripts.dart | 57 ++- .../flutter_bench_task_ide_explorer.dart | 65 ++-- .../docs/ai/flutter_bench_task_explorer.md | 77 ++-- .../src/data/flutter_bench_task_example.yml | 157 -------- 6 files changed, 307 insertions(+), 400 deletions(-) delete mode 100644 sites/docs/src/data/flutter_bench_task_example.yml diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss index 695bb9aed02..2c06d1531b9 100644 --- a/packages/site_shared/lib/_sass/components/_ide-explorer.scss +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -297,7 +297,7 @@ font-weight: 600; } -.ide-detail-one-liner { +.ide-detail-subtitle { margin-block-start: 0.15rem; color: var(--site-base-fgColor-alt); font-size: 0.9375rem; diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart index 7044aaa71e0..f086fa7ccb2 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -7,16 +7,15 @@ import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/components/file_icon.dart'; import 'package:jaspr_content/jaspr_content.dart'; -import '../../src/extensions/code_block_processor.dart'; import '../../src/markdown/markdown_parser.dart'; +import '../../src/utils/slugify.dart'; import '../../util.dart'; import 'material_icon.dart'; -import 'wrapped_code_block.dart'; -/// A single top-level tree shown by an [IdeExplorer], such as +/// A single top-level file tree shown by an [IdeExplorer], such as /// "Project" or "Global". It contains all of the 'files' (IdeTreeNodes) -/// inside the project thats displayed in the IDE. -/// +/// inside the project thats displayed in the IDE. +/// /// When you have multiple projects to display, they're displayed as /// tabs that you can switch between. class IdeExplorerProjectRoot { @@ -31,23 +30,20 @@ class IdeExplorerProjectRoot { final List children; factory IdeExplorerProjectRoot.fromMap(Map map) { - final id = map['id']?.toString() ?? 'root'; - final label = map['label']?.toString() ?? ''; - final rawChildren = map['children']; - final children = switch (rawChildren) { - final List list => - list - .whereType>() - .map(IdeTreeNode.fromMap) - .toList(growable: false), - _ => const [], - }; + if (map case { + 'id': final String id, + 'lable': final String label, + 'children': final List> children, + }) { + final treeNodes = children.map(IdeTreeNode.fromMap).toList(); + return IdeExplorerProjectRoot( + id: id, + label: label, + children: treeNodes.toList(), + ); + } - return IdeExplorerProjectRoot( - id: id, - label: label, - children: children, - ); + throw Exception('Malformed IdeExplorerProjectRoot data, failed to create'); } } @@ -59,16 +55,9 @@ class IdeTreeNode { bool? isFolder, this.startsClosed = false, this.badge, - this.oneLiner, + this.subtitle, this.note, this.noteTitle, - this.description, - this.tips = const [], - this.example, - this.exampleLanguage = 'plaintext', - this.exampleTitle, - this.docsLink, - this.docsLinkLabel = 'Learn more', this.children = const [], }) : _explicitIsFolder = isFolder; @@ -77,16 +66,9 @@ class IdeTreeNode { final bool? _explicitIsFolder; final bool startsClosed; final IdeBadge? badge; - final String? oneLiner; + final String? subtitle; final String? note; final String? noteTitle; - final String? description; - final List tips; - final String? example; - final String exampleLanguage; - final String? exampleTitle; - final String? docsLink; - final String docsLinkLabel; final List children; bool get isFolder => @@ -99,24 +81,9 @@ class IdeTreeNode { final isFolder = type != null ? type == 'folder' : null; final startsClosed = map['closed'] == true; final badge = map['badge'] != null ? IdeBadge.from(map['badge']) : null; - final oneLiner = map['oneLiner']?.toString(); + final subtitle = map['subtitle']?.toString(); final note = map['note']?.toString(); final noteTitle = map['noteTitle']?.toString(); - final description = map['description']?.toString(); - final rawTips = map['tips']; - final tips = switch (rawTips) { - final List list => - list - .map((e) => e?.toString() ?? '') - .where((tip) => tip.isNotEmpty) - .toList(growable: false), - _ => const [], - }; - final example = map['example']?.toString(); - final exampleLanguage = map['exampleLanguage']?.toString() ?? 'plaintext'; - final exampleTitle = map['exampleTitle']?.toString(); - final docsLink = map['docsLink']?.toString(); - final docsLinkLabel = map['docsLinkLabel']?.toString() ?? 'Learn more'; final rawChildren = map['children']; final children = switch (rawChildren) { final List list => @@ -133,16 +100,9 @@ class IdeTreeNode { isFolder: isFolder, startsClosed: startsClosed, badge: badge, - oneLiner: oneLiner, + subtitle: subtitle, note: note, noteTitle: noteTitle, - description: description, - tips: tips, - example: example, - exampleLanguage: exampleLanguage, - exampleTitle: exampleTitle, - docsLink: docsLink, - docsLinkLabel: docsLinkLabel, children: children, ); } @@ -187,7 +147,6 @@ final class IdeBadge { /// leading to it, used to render the breadcrumb in its detail pane. typedef _BreadcrumbNode = ({IdeTreeNode node, String domId, List path}); - /// An interactive file-tree explorer, similar to an IDE's sidebar. /// /// Renders a clickable directory tree next to a detail pane that shows @@ -227,7 +186,7 @@ class IdeExplorer extends StatelessComponent { final List roots; final String? instanceId; - + final Map customContents; String _domId(String effectiveInstanceId, String nodeId) => @@ -446,8 +405,8 @@ class IdeExplorer extends StatelessComponent { node.isFolder ? FileIcon.folderIcon : FileIcon.forFile(node.label), div(classes: 'ide-detail-heading', [ div(classes: 'ide-detail-title', [.text(node.label)]), - if (node.oneLiner case final oneLiner?) - div(classes: 'ide-detail-one-liner', [.text(oneLiner)]), + if (node.subtitle case final subtitle?) + div(classes: 'ide-detail-subtitle', [.text(subtitle)]), ]), if (node.badge case final badge?) span( @@ -458,7 +417,7 @@ class IdeExplorer extends StatelessComponent { [.text(badge.label)], ), ]), - + if (customContents[node.id] case final customChild?) div(classes: 'ide-custom-body', [customChild]) else ...[ @@ -468,40 +427,8 @@ class IdeExplorer extends StatelessComponent { div(classes: 'ide-note-title', [.text(title)]), DashMarkdown(content: note, inline: true), ]), - if (node.description case final description?) - div( - classes: 'ide-description', - [DashMarkdown(content: description)], - ), - if (node.tips.isNotEmpty) - div(classes: 'ide-tips', [ - const div(classes: 'ide-tips-title', [.text('Tips')]), - ul([ - for (final tip in node.tips) - li([DashMarkdown(content: tip, inline: true)]), - ]), - ]), - if (node.example case final example?) - div(classes: 'ide-example', [ - WrappedCodeBlock( - content: CodeBlockProcessor.highlightCode( - [ - for (final line in example.trimRight().split('\n')) - CodeLine(content: line, highlights: const []), - ], - language: node.exampleLanguage, - ), - language: node.exampleLanguage, - title: node.exampleTitle, - ), - ]), - if (node.docsLink case final docsLink?) - a(href: docsLink, classes: 'ide-docs-link', [ - .text(node.docsLinkLabel), - const MaterialIcon('arrow_forward'), - ]), ], - + if (node.children.isNotEmpty) div(classes: 'ide-contents', [ const div(classes: 'ide-contents-title', [.text('Contents')]), @@ -518,10 +445,10 @@ class IdeExplorer extends StatelessComponent { ? FileIcon.folderIcon : FileIcon.forFile(child.label), span(classes: 'ide-node-label', [.text(child.label)]), - if (child.oneLiner case final oneLiner?) + if (child.subtitle case final subtitle?) span( classes: 'ide-content-one-liner', - [.text(oneLiner)], + [.text(subtitle)], ), ], ), @@ -534,20 +461,23 @@ class IdeExplorer extends StatelessComponent { /// A custom markdown component wrapper for [IdeExplorer]. /// -/// The tree is authored as data, referenced by the required `data` -/// attribute, which names a top-level key in the page's data (loaded from -/// a YAML/JSON file in the site's `src/data` directory). The data can -/// either be a single list of nodes, or a map with a `roots` list if the -/// explorer should offer more than one top-level tree. -/// -/// Usage from Markdown: +/// Can be authored in Markdown using HTML-like tags: /// ```html -/// -/// -/// Arbitrary markdown content for the node with id 'someNodeId'. -/// +/// +/// +/// +/// Main entry point content. +/// +/// +/// +/// App widget definition. +/// +/// +/// /// /// ``` +/// +/// Or referenced by a `data` attribute pointing to a YAML/JSON data key. class DashIdeExplorer extends CustomComponent { const DashIdeExplorer() : super.base(); @@ -558,45 +488,180 @@ class DashIdeExplorer extends CustomComponent { return null; } - final dataKey = node.attributes['data']; - if (dataKey == null) { - throw ArgumentError( - 'The element requires a "data" attribute naming a key ' - 'in the page data.', + final customContents = {}; + final roots = parseRootsFromNode( + node, + builder, + customContents, + rootLabel: node.attributes['rootLabel'], + ); + + if (roots.isEmpty && node.attributes['data'] != null) { + final dataKey = node.attributes['data']!; + return Builder( + builder: (context) { + final rawData = context.page.data[dataKey]; + if (rawData == null) { + throw ArgumentError('No page data found for "$dataKey".'); + } + + final parsedRoots = parseRoots( + rawData, + rootLabel: node.attributes['rootLabel'], + ); + + return IdeExplorer( + roots: parsedRoots, + customContents: customContents, + ); + }, ); } - final pages = node.children - ?.whereType() - .where((n) => n.tag == 'IdePage') - .toList(growable: false) ?? []; + if (roots.isEmpty) { + return const Component.empty(); + } - final customContents = {}; - for (final page in pages) { - final id = page.attributes['id']; - if (id != null) { - customContents[id] = builder.build(page.children); - } + return IdeExplorer( + roots: roots, + customContents: customContents, + ); + } + + /// Parses [IdeExplorerProjectRoot]s from child AST [ElementNode]s. + static List parseRootsFromNode( + ElementNode node, + NodesBuilder builder, + Map customContents, { + String? rootLabel, + }) { + final directElementChildren = + node.children?.whereType().toList(growable: false) ?? []; + + final rootElements = directElementChildren + .where((n) => n.tag == 'IdeRoot') + .toList(growable: false); + + if (rootElements.isNotEmpty) { + return [ + for (final (index, rootEl) in rootElements.indexed) + IdeExplorerProjectRoot( + id: + rootEl.attributes['id'] ?? + (rootEl.attributes['label'] != null + ? slugify(rootEl.attributes['label']!) + : 'root-$index'), + label: rootEl.attributes['label'] ?? '', + children: _parseTreeNodes(rootEl.children, builder, customContents), + ), + ]; } - return Builder( - builder: (context) { - final rawData = context.page.data[dataKey]; - if (rawData == null) { - throw ArgumentError('No page data found for "$dataKey".'); + final treeElements = directElementChildren + .where((n) => n.tag == 'IdeFolder' || n.tag == 'IdePage') + .toList(growable: false); + + if (treeElements.isNotEmpty) { + return [ + IdeExplorerProjectRoot( + id: node.attributes['id'] ?? 'root', + label: rootLabel ?? node.attributes['rootLabel'] ?? '', + children: _parseTreeNodes(node.children, builder, customContents), + ), + ]; + } + + return const []; + } + + static 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 != 'IdeFolder' && child.tag != 'IdePage') { + continue; + } + + final label = child.attributes['label'] ?? child.attributes['name'] ?? ''; + final id = + child.attributes['id'] ?? + (label.isNotEmpty ? slugify(label) : 'node-$index'); + final isFolder = + child.tag == 'IdeFolder' || child.attributes['isFolder'] == 'true'; + final startsClosed = + child.attributes['closed'] == 'true' || + child.attributes['startsClosed'] == 'true'; + + final badgeLabel = child.attributes['badge']; + final badgeToneStr = + child.attributes['badgeTone'] ?? child.attributes['tone']; + final badgeTone = switch (badgeToneStr) { + 'info' => IdeBadgeTone.info, + 'success' => IdeBadgeTone.success, + 'warning' => IdeBadgeTone.warning, + _ => IdeBadgeTone.neutral, + }; + final badge = badgeLabel != null + ? IdeBadge(label: badgeLabel, tone: badgeTone) + : null; + + final subtitle = child.attributes['subtitle']; + + final nestedTreeNodes = _parseTreeNodes( + child.children, + builder, + customContents, + ); + + final hasBody = + child.children?.any((n) { + if (n is ElementNode && + (n.tag == 'IdeFolder' || n.tag == 'IdePage')) { + return false; + } + if (n is TextNode && n.text.trim().isEmpty) { + return false; + } + return true; + }) ?? + false; + + if (hasBody) { + final contentNodes = child.children! + .where((n) { + if (n is ElementNode && + (n.tag == 'IdeFolder' || n.tag == 'IdePage')) { + return false; + } + return true; + }) + .toList(growable: false); + + if (contentNodes.isNotEmpty) { + customContents[id] = builder.build(contentNodes); } + } - final roots = parseRoots( - rawData, - rootLabel: node.attributes['rootLabel'], - ); + result.add( + IdeTreeNode( + id: id, + label: label, + isFolder: isFolder, + startsClosed: startsClosed, + badge: badge, + subtitle: subtitle, + children: nestedTreeNodes, + ), + ); + } - return IdeExplorer( - roots: roots, - customContents: customContents, - ); - }, - ); + return result; } /// Parses raw page data (from YAML/JSON) into a list of [IdeExplorerProjectRoot]s. diff --git a/sites/docs/lib/src/client/global_scripts.dart b/sites/docs/lib/src/client/global_scripts.dart index cc4e0e07787..26fc9d70e5e 100644 --- a/sites/docs/lib/src/client/global_scripts.dart +++ b/sites/docs/lib/src/client/global_scripts.dart @@ -462,6 +462,23 @@ void _setUpSteppers() { } } +void _scrollTo(web.Element element, {required bool smooth}) { + // Scroll the next step into view, accounting for the fixed header and toc. + final headerOffset = + web.document.getElementById('site-header')?.clientHeight ?? 0; + final tocOffset = web.document.getElementById('pagenav')?.clientHeight ?? 0; + final elementPosition = element.getBoundingClientRect().top; + final offsetPosition = + elementPosition + web.window.scrollY - headerOffset - tocOffset; + + web.window.scrollTo( + web.ScrollToOptions( + top: offsetPosition, + behavior: smooth ? 'smooth' : 'auto', + ), + ); +} + /// Set up interactivity of the file/detail explorer created with /// the `` custom component. void _setUpIdeExplorers() { @@ -494,9 +511,6 @@ void _setUpIdeExplorer(web.Element explorer) { } // Expand every ancestor folder so the selected item stays visible. - // Skip the clicked node's own
(when a folder's summary was - // clicked directly) so the browser's native open/close toggle on that - // element isn't fought by also forcing it open here. final ownDetails = sidebarTarget?.tagName.toLowerCase() == 'summary' ? sidebarTarget!.parentElement : null; @@ -566,6 +580,26 @@ void _setUpIdeExplorer(web.Element explorer) { if (selectTarget != null) { final domId = selectTarget.getAttribute('data-ide-select'); if (domId != null) selectIdeNode(domId); + + if (selectTarget.tagName.toLowerCase() == 'summary') { + var isClickOnArrow = false; + if (target == selectTarget) { + final mouseEvent = event as web.MouseEvent; + final rect = selectTarget.getBoundingClientRect(); + final clickX = mouseEvent.clientX - rect.left; + // The arrow marker is rendered using a ::before pseudo-element + // on the left side of the summary element. The total width of the + // padding and arrow is around 20-30px. + // TODO: this can't possibly be the right way to do this. + if (clickX < 30) { + isClickOnArrow = true; + } + } + + if (!isClickOnArrow) { + event.preventDefault(); + } + } return; } @@ -583,20 +617,3 @@ void _setUpIdeExplorer(web.Element explorer) { explorer.addEventListener('click', handleClick.toJS); } - -void _scrollTo(web.Element element, {required bool smooth}) { - // Scroll the next step into view, accounting for the fixed header and toc. - final headerOffset = - web.document.getElementById('site-header')?.clientHeight ?? 0; - final tocOffset = web.document.getElementById('pagenav')?.clientHeight ?? 0; - final elementPosition = element.getBoundingClientRect().top; - final offsetPosition = - elementPosition + web.window.scrollY - headerOffset - tocOffset; - - web.window.scrollTo( - web.ScrollToOptions( - top: offsetPosition, - behavior: smooth ? 'smooth' : 'auto', - ), - ); -} diff --git a/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart b/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart index 404466b1491..96622617e62 100644 --- a/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart +++ b/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart @@ -14,38 +14,43 @@ final class FlutterBenchTaskExplorer extends CustomComponent { return null; } - final dataKey = node.attributes['data'] ?? 'flutter_bench_task_example'; - final rootLabel = node.attributes['rootLabel']; - - final pages = node.children - ?.whereType() - .where((n) => n.tag == 'IdePage') - .toList(growable: false) ?? []; - final customContents = {}; - for (final page in pages) { - final id = page.attributes['id']; - if (id != null) { - customContents[id] = builder.build(page.children); - } + final roots = DashIdeExplorer.parseRootsFromNode( + node, + builder, + customContents, + rootLabel: node.attributes['rootLabel'], + ); + + if (roots.isEmpty && node.attributes['data'] != null) { + final dataKey = node.attributes['data']!; + return Builder( + builder: (context) { + final rawData = context.page.data[dataKey]; + if (rawData == null) { + throw ArgumentError('No page data found for "$dataKey".'); + } + + final parsedRoots = DashIdeExplorer.parseRoots( + rawData, + rootLabel: node.attributes['rootLabel'], + ); + + return div(classes: 'flutter-bench-task-explorer', [ + IdeExplorer( + roots: parsedRoots, + customContents: customContents, + ), + ]); + }, + ); } - return Builder( - builder: (context) { - final rawData = context.page.data[dataKey]; - if (rawData == null) { - throw ArgumentError('No page data found for "$dataKey".'); - } - - final roots = DashIdeExplorer.parseRoots(rawData, rootLabel: rootLabel); - - return div(classes: 'flutter-bench-task-explorer', [ - IdeExplorer( - roots: roots, - customContents: customContents, - ), - ]); - }, - ); + return div(classes: 'flutter-bench-task-explorer', [ + IdeExplorer( + roots: roots, + customContents: customContents, + ), + ]); } } 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 index 2a9fcf60342..0da8cc420d6 100644 --- a/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md +++ b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md @@ -2,7 +2,7 @@ - + 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 @@ -10,8 +10,8 @@ developers interact with agents. **Design principles** -- Prompts state symptoms and expected outcomes without naming exact remedy - widgets.. +- 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. @@ -26,7 +26,7 @@ Write widget tests in - + The task configuration file specifies execution bounds, metadata, and target artifacts evaluated during grading. @@ -35,16 +35,13 @@ target artifacts evaluated during grading. - `artifacts`: Lists files that must be present in the workspace after the run. -- `difficulty_explanation`: - Identifies common subtle pitfalls (like fixing only one axis or altering +- `difficulty_explanation`: Identifies common subtle pitfalls (like fixing only one axis or altering item structures), which inform the grading rubrics. -- `verifier` and `agent` timeouts: - Provides sufficient execution budget for cold compilation and multistep - agent tool iterations. +- `verifier` and `agent` timeouts: Provides sufficient execution budget for cold compilation and multistep agent tool iterations. - + The target codebase is an isolated, containerized Flutter workspace. @@ -57,7 +54,7 @@ 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. - + Defines the container image for the task environment. @@ -73,7 +70,7 @@ Defines the container image for the task environment. - + Contains the Flutter application source code. @@ -81,7 +78,7 @@ 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. @@ -90,7 +87,7 @@ file may point to a remote codebase. - + The test directory for the target project. @@ -98,7 +95,7 @@ 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. @@ -106,13 +103,13 @@ 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. @@ -134,7 +131,7 @@ linter: - + The automated verification harness used to grade the agent's performance. @@ -147,44 +144,24 @@ The harness evaluates the agent's output across three weighted dimensions: - **Quality**: Static analysis, formatting, and code craftsmanship. - **DX**: Developer experience and tool interaction efficiency. - + Defines the multi-dimensional scoring pipeline using `package:eval_scoring`. ```dart -// Heavily edited as to not risk contamination -AggregateGrader grader(EvaluationContext context) => +// Heavily edited +AggregateGrader grader(context) => AggregateGrader.result( - outcome: AggregateGrader.outcome( - testing: TestingGrader(), - heuristics: AggregateGrader.outcomeHeuristics( - graderWeights: {Grader _grader: int weight}, - ), - ), - quality: AggregateGrader.quality( - staticAnalysis: StaticAnalysisGrader(), - heuristics: FormattingGrader(filesToInclude: const ['...']), - idiomaticReview: LLMGrader( - contextBuilders: [ - FileContentContextBuilder( - files: ['...'], - ), - ], - criteria: [ - LLMCriterion( - description: '''TODO: add grading instructions''', - ), - ], - ), - ), - dx: AggregateGrader.dx(), + outcome: _outcomeGraders, + quality: _qualityGraders, + dx: _dxGraders, ); ``` - + The test harness entrypoint script executed inside the grading container. @@ -197,7 +174,7 @@ eval_scoring run [tasks] - + Contains the ground truth reference implementation maintained by the benchmark authors. @@ -207,7 +184,7 @@ 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. @@ -220,7 +197,7 @@ comprehensive tests. Not safe to share publicly. - + The `eval_scoring` Dart package powers all grading across the benchmark suite. @@ -241,7 +218,7 @@ suite. - + Prebuilt Linux Docker container images (`flutter-linux` and `dart-linux`). @@ -255,7 +232,7 @@ Prebuilt Linux Docker container images (`flutter-linux` and `dart-linux`). - + The evaluation job configuration file that specifies which models, tools, and agent variants to evaluate. diff --git a/sites/docs/src/data/flutter_bench_task_example.yml b/sites/docs/src/data/flutter_bench_task_example.yml deleted file mode 100644 index b25497cd832..00000000000 --- a/sites/docs/src/data/flutter_bench_task_example.yml +++ /dev/null @@ -1,157 +0,0 @@ -roots: - - id: task - label: theme_from_design_file/ - children: - - id: instruction - label: instruction.md - oneLiner: The prompt the agent receives - badge: input - - - id: target-codebase - label: target_codebase/ - oneLiner: A containerized Flutter or Dart project - badge: - label: input - tone: info - description: | - A containerized environment that can be preseeded with a Flutter - or Dart project. - - Some more ambitious evals start from scratch, while other tasks - start from existing codebases and add features, fix bugs, or - refactor code. These codebases are large projects, but are not - known open-source apps, to avoid contaminating the evaluation - process. - children: - - id: lib - label: lib/ - oneLiner: Application source code - children: - - id: main-dart - label: main.dart - oneLiner: App entry point - example: | - void main() => runApp(const MyApp()); - exampleLanguage: dart - - id: theme-dart - label: theme.dart - oneLiner: Created by the agent during the eval - badge: - label: agent-authored - tone: warning - note: | - This file doesn't exist when the task starts. It's the - file the agent is expected to create. - noteTitle: Doesn't exist yet - - id: widgets - label: widgets/ - closed: true - oneLiner: Shared widgets used across the sample app - children: - - id: app-bar-dart - label: app_bar.dart - - id: card-dart - label: card.dart - - id: test - label: test/ - oneLiner: Existing and agent-authored tests - closed: true - children: - - id: theme-test-dart - label: theme_test.dart - oneLiner: Golden test the agent's code must satisfy - badge: hidden - - id: widget-test-dart - label: widget_test.dart - - id: pubspec - label: pubspec.yaml - oneLiner: Declares the project's dependencies - example: | - name: sample_app - environment: - sdk: ^3.6.0 - dependencies: - flutter: - sdk: flutter - exampleLanguage: yaml - - id: analysis-options - label: analysis_options.yaml - oneLiner: Lint rules the agent's code is checked against - - - id: verification - label: verification/ - oneLiner: Automated tests and scoring guidelines - badge: - label: hidden from agent - tone: warning - description: | - Automated tests and scoring guidelines, discussed in depth later - on this page. The agent never sees this directory; it's mounted - only when the grading harness runs. - tips: - - Rubrics are graded by a separate model judge, not the agent - being evaluated, to avoid the agent grading its own work. - children: - - id: rubric - label: rubric.yaml - oneLiner: Grading criteria for this task - example: | - - criterion: Uses ThemeData.light() and ThemeData.dark() - weight: 0.4 - - criterion: Exposes lightTheme and darkTheme top-level getters - weight: 0.4 - - criterion: No analyzer warnings introduced - weight: 0.2 - exampleLanguage: yaml - - id: verification-tests - label: tests/ - oneLiner: Scripts that check the resulting codebase - children: - - id: check-analyze-sh - label: check_analyze.sh - oneLiner: Fails the task if `flutter analyze` reports issues - - id: check-theme-py - label: check_theme.py - oneLiner: Statically inspects theme.dart for required symbols - - - id: metadata - label: metadata.yaml - oneLiner: Slicing and re-targeting info for this task - badge: config - - - id: global - label: Shared config - children: - - id: harness-config - label: harness_config.yaml - oneLiner: Settings shared by every task in the benchmark - badge: - label: shared - tone: success - description: | - Model, timeout, and sandboxing defaults that apply across the - whole suite unless a task's own `metadata.yaml` overrides them. - example: | - default_model: claude-sonnet-5 - timeout_seconds: 900 - sandbox: docker - exampleLanguage: yaml - - id: judge-prompts - label: judge_prompts/ - oneLiner: Prompt templates used by the automated grading model - closed: true - children: - - id: reasoning-judge-md - label: reasoning_judge.md - oneLiner: Scores whether the agent's approach was sound - - id: safety-judge-md - label: safety_judge.md - oneLiner: Flags destructive or unsafe actions - - id: conciseness-judge-md - label: conciseness_judge.md - oneLiner: Penalizes unnecessary steps or verbosity - - id: readme - label: README.md - oneLiner: How to add a new task to the benchmark - docsLink: https://docs.flutter.dev/ai/evals - docsLinkLabel: Read the full evals methodology \ No newline at end of file From abac75dd4b90a85db9fb7a625c8052231922b6eb Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 13 Aug 2026 13:03:42 -0700 Subject: [PATCH 07/17] more refactor --- .../lib/components/common/ide_explorer.dart | 94 ++++++++++--------- 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart index f086fa7ccb2..4b158f28d5c 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -8,7 +8,6 @@ import 'package:jaspr_content/components/file_icon.dart'; import 'package:jaspr_content/jaspr_content.dart'; import '../../src/markdown/markdown_parser.dart'; -import '../../src/utils/slugify.dart'; import '../../util.dart'; import 'material_icon.dart'; @@ -32,7 +31,7 @@ class IdeExplorerProjectRoot { factory IdeExplorerProjectRoot.fromMap(Map map) { if (map case { 'id': final String id, - 'lable': final String label, + 'label': final String label, 'children': final List> children, }) { final treeNodes = children.map(IdeTreeNode.fromMap).toList(); @@ -43,7 +42,7 @@ class IdeExplorerProjectRoot { ); } - throw Exception('Malformed IdeExplorerProjectRoot data, failed to create'); + throw Exception('Malformed IdeExplorerProjectRoot data'); } } @@ -52,59 +51,70 @@ class IdeTreeNode { const IdeTreeNode({ required this.id, required this.label, - bool? isFolder, this.startsClosed = false, this.badge, this.subtitle, this.note, - this.noteTitle, + this.title, this.children = const [], - }) : _explicitIsFolder = isFolder; + }); final String id; final String label; - final bool? _explicitIsFolder; final bool startsClosed; final IdeBadge? badge; + final String? title; final String? subtitle; final String? note; - final String? noteTitle; final List children; - bool get isFolder => - _explicitIsFolder ?? (children.isNotEmpty || label.endsWith('/')); + bool get isFolder => children.isNotEmpty || label.endsWith('/'); factory IdeTreeNode.fromMap(Map map) { - final id = map['id']?.toString() ?? ''; - final label = map['label']?.toString() ?? ''; - final type = map['type']?.toString(); - final isFolder = type != null ? type == 'folder' : null; - final startsClosed = map['closed'] == true; - final badge = map['badge'] != null ? IdeBadge.from(map['badge']) : null; - final subtitle = map['subtitle']?.toString(); - final note = map['note']?.toString(); - final noteTitle = map['noteTitle']?.toString(); - final rawChildren = map['children']; - final children = switch (rawChildren) { - final List list => - list - .whereType>() - .map(IdeTreeNode.fromMap) - .toList(growable: false), - _ => const [], - }; - - return IdeTreeNode( - id: id, - label: label, - isFolder: isFolder, - startsClosed: startsClosed, - badge: badge, - subtitle: subtitle, - note: note, - noteTitle: noteTitle, - children: children, - ); + switch (map) { + // explicit case to handle folder + case { + 'id': final String id, + 'label': final String label, + 'children': final List> children, + } + when children.isNotEmpty || label.endsWith('/'): + final treeNodes = children.map(IdeTreeNode.fromMap).toList(); + final startsClosed = + map.containsKey('startsClosed') && map['startsClosed'] == true; + // TODO: this doesn't allow for tone + final badge = IdeBadge.from(map['badge']); + + return IdeTreeNode( + id: id, + label: label, + startsClosed: startsClosed, + badge: badge, + title: map['title']?.toString(), + subtitle: map['subtitle']?.toString(), + note: map['note']?.toString(), + children: treeNodes, + ); + // explicit case to handle files + case { + 'id': final String id, + 'label': final String label, + // Files shouldn't be empty, that's bad UX + } + when map.containsKey('note'): + // TODO: this doesn't allow for tone + final badge = IdeBadge.from(map['badge']); + return IdeTreeNode( + id: id, + label: label, + badge: badge, + title: map['title']?.toString() ?? label, + subtitle: map['subtitle']?.toString() ?? '', + note: map['note']!.toString(), + ); + default: + throw Exception('Malformed IdeTreeNode data'); + } } } @@ -423,7 +433,7 @@ class IdeExplorer extends StatelessComponent { else ...[ if (node.note case final note?) div(classes: 'ide-note', [ - if (node.noteTitle case final title?) + if (node.title case final title?) div(classes: 'ide-note-title', [.text(title)]), DashMarkdown(content: note, inline: true), ]), @@ -652,7 +662,7 @@ class DashIdeExplorer extends CustomComponent { IdeTreeNode( id: id, label: label, - isFolder: isFolder, + startsClosed: startsClosed, badge: badge, subtitle: subtitle, From 9fce03b89fd30dc1110fafaaed4bd55a3ffe99f6 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 13 Aug 2026 13:18:05 -0700 Subject: [PATCH 08/17] more refactor --- .../lib/_sass/components/_ide-explorer.scss | 50 +++++++++ .../lib/components/common/ide_explorer.dart | 102 ++++++++++++------ .../docs/ai/flutter_bench_task_explorer.md | 18 ++-- 3 files changed, 128 insertions(+), 42 deletions(-) diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss index 2c06d1531b9..920383b1a31 100644 --- a/packages/site_shared/lib/_sass/components/_ide-explorer.scss +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -201,6 +201,8 @@ text-transform: uppercase; } +.ide-badge-color-info, +.ide-color-info, .ide-tone-info { &.ide-badge-dot, &.ide-badge { @@ -213,6 +215,11 @@ } } +.ide-badge-color-tip, +.ide-color-tip, +.ide-tone-tip, +.ide-badge-color-success, +.ide-color-success, .ide-tone-success { &.ide-badge-dot, &.ide-badge { @@ -225,6 +232,22 @@ } } +.ide-badge-color-important, +.ide-color-important, +.ide-tone-important { + &.ide-badge-dot, + &.ide-badge { + background-color: var(--site-alert-important-color); + } + + &.ide-badge { + background-color: color-mix(in srgb, var(--site-alert-important-color) 12%, transparent); + color: var(--site-alert-important-color); + } +} + +.ide-badge-color-warning, +.ide-color-warning, .ide-tone-warning { &.ide-badge-dot, &.ide-badge { @@ -237,6 +260,33 @@ } } +.ide-badge-color-error, +.ide-color-error, +.ide-tone-error { + &.ide-badge-dot, + &.ide-badge { + background-color: var(--site-alert-error-color); + } + + &.ide-badge { + background-color: color-mix(in srgb, var(--site-alert-error-color) 12%, transparent); + color: var(--site-alert-error-color); + } +} + +.ide-badge-color-neutral, +.ide-color-neutral, +.ide-tone-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; diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart index 4b158f28d5c..001e6c88706 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -82,8 +82,10 @@ class IdeTreeNode { final treeNodes = children.map(IdeTreeNode.fromMap).toList(); final startsClosed = map.containsKey('startsClosed') && map['startsClosed'] == true; - // TODO: this doesn't allow for tone - final badge = IdeBadge.from(map['badge']); + final badge = IdeBadge.tryFrom( + map['badge'], + map['badgeColor'] ?? map['badgeTone'], + ); return IdeTreeNode( id: id, @@ -95,15 +97,18 @@ class IdeTreeNode { note: map['note']?.toString(), children: treeNodes, ); + // explicit case to handle files case { 'id': final String id, 'label': final String label, - // Files shouldn't be empty, that's bad UX } + // Files shouldn't be empty, that's bad UX when map.containsKey('note'): - // TODO: this doesn't allow for tone - final badge = IdeBadge.from(map['badge']); + final badge = IdeBadge.tryFrom( + map['badge'], + map['badgeColor'] ?? map['badgeTone'], + ); return IdeTreeNode( id: id, label: label, @@ -119,7 +124,29 @@ class IdeTreeNode { } /// Corresponds to colors used for the [IdeBadge] -enum IdeBadgeTone { neutral, info, success, warning } +enum IdeBadgeColor { + neutral, + info, + tip, + important, + warning, + error; + + static IdeBadgeColor from(Object? data) { + if (data is IdeBadgeColor) return data; + return switch (data?.toString().toLowerCase()) { + 'info' => IdeBadgeColor.info, + 'tip' || 'success' => IdeBadgeColor.tip, + 'important' => IdeBadgeColor.important, + 'warning' => IdeBadgeColor.warning, + 'error' || 'danger' => IdeBadgeColor.error, + _ => IdeBadgeColor.neutral, + }; + } +} + +/// Alias for [IdeBadgeColor]. +typedef IdeBadgeTone = IdeBadgeColor; /// A highlighted pill-style label displayed next to an [IdeTreeNode] title. /// Intended to be used to display a single word of crucial metadata, @@ -128,29 +155,43 @@ enum IdeBadgeTone { neutral, info, success, warning } final class IdeBadge { const IdeBadge({ required this.label, - this.tone = IdeBadgeTone.neutral, + this.color = IdeBadgeColor.neutral, }); final String label; - final IdeBadgeTone tone; + final IdeBadgeColor color; + + /// Backwards-compatible getter for [color]. + IdeBadgeColor get tone => color; - factory IdeBadge.from(Object? data) { + factory IdeBadge.from(Object? data, [Object? colorData]) { if (data is String) { - return IdeBadge(label: data); + final color = IdeBadgeColor.from(colorData); + return IdeBadge(label: data, color: color); } if (data is Map) { final label = data['label']?.toString() ?? ''; - final toneStr = data['tone']?.toString(); - final tone = switch (toneStr) { - 'info' => IdeBadgeTone.info, - 'success' => IdeBadgeTone.success, - 'warning' => IdeBadgeTone.warning, - _ => IdeBadgeTone.neutral, - }; - return IdeBadge(label: label, tone: tone); + final colorStr = + data['color'] ?? + data['badgeColor'] ?? + data['tone'] ?? + data['badgeTone'] ?? + colorData; + final color = IdeBadgeColor.from(colorStr); + return IdeBadge(label: label, color: color); } throw ArgumentError('Invalid badge data: $data'); } + + static IdeBadge? tryFrom(Object? data, [Object? colorData]) { + if (data == null) return null; + if (data is String && data.isEmpty) return null; + try { + return IdeBadge.from(data, colorData); + } catch (_) { + return null; + } + } } /// A flattened [IdeTreeNode] paired with the path of ancestor labels @@ -380,7 +421,11 @@ class IdeExplorer extends StatelessComponent { Component _buildBadgeDot(IdeBadge? badge) { if (badge == null) return const Component.empty(); return span( - classes: ['ide-badge-dot', 'ide-tone-${badge.tone.name}'].toClasses, + classes: [ + 'ide-badge-dot', + 'ide-badge-color-${badge.color.name}', + 'ide-tone-${badge.color.name}', + ].toClasses, attributes: { 'role': 'img', 'aria-label': badge.label, @@ -422,7 +467,8 @@ class IdeExplorer extends StatelessComponent { span( classes: [ 'ide-badge', - 'ide-tone-${badge.tone.name}', + 'ide-badge-color-${badge.color.name}', + 'ide-tone-${badge.color.name}', ].toClasses, [.text(badge.label)], ), @@ -602,24 +648,14 @@ class DashIdeExplorer extends CustomComponent { final id = child.attributes['id'] ?? (label.isNotEmpty ? slugify(label) : 'node-$index'); - final isFolder = - child.tag == 'IdeFolder' || child.attributes['isFolder'] == 'true'; final startsClosed = child.attributes['closed'] == 'true' || child.attributes['startsClosed'] == 'true'; final badgeLabel = child.attributes['badge']; - final badgeToneStr = - child.attributes['badgeTone'] ?? child.attributes['tone']; - final badgeTone = switch (badgeToneStr) { - 'info' => IdeBadgeTone.info, - 'success' => IdeBadgeTone.success, - 'warning' => IdeBadgeTone.warning, - _ => IdeBadgeTone.neutral, - }; - final badge = badgeLabel != null - ? IdeBadge(label: badgeLabel, tone: badgeTone) - : null; + final badgeColorStr = + child.attributes['badgeColor'] ?? child.attributes['badgeTone']; + final badge = IdeBadge.tryFrom(badgeLabel, badgeColorStr); final subtitle = child.attributes['subtitle']; 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 index 0da8cc420d6..cee0ff3e7af 100644 --- a/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md +++ b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md @@ -41,7 +41,7 @@ target artifacts evaluated during grading. - + The target codebase is an isolated, containerized Flutter workspace. @@ -78,7 +78,7 @@ 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. @@ -95,7 +95,7 @@ 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. @@ -131,7 +131,7 @@ linter: - + The automated verification harness used to grade the agent's performance. @@ -144,7 +144,7 @@ The harness evaluates the agent's output across three weighted dimensions: - **Quality**: Static analysis, formatting, and code craftsmanship. - **DX**: Developer experience and tool interaction efficiency. - + Defines the multi-dimensional scoring pipeline using `package:eval_scoring`. @@ -174,7 +174,7 @@ eval_scoring run [tasks] - + Contains the ground truth reference implementation maintained by the benchmark authors. @@ -197,7 +197,7 @@ comprehensive tests. Not safe to share publicly. - + The `eval_scoring` Dart package powers all grading across the benchmark suite. @@ -218,7 +218,7 @@ suite. - + Prebuilt Linux Docker container images (`flutter-linux` and `dart-linux`). @@ -232,7 +232,7 @@ Prebuilt Linux Docker container images (`flutter-linux` and `dart-linux`). - + The evaluation job configuration file that specifies which models, tools, and agent variants to evaluate. From d8b773470df73c9f4004bd18aaadfeb277b9f34c Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 13 Aug 2026 13:51:29 -0700 Subject: [PATCH 09/17] more refactor --- .../lib/_sass/components/_ide-explorer.scss | 27 +--- .../lib/components/common/ide_explorer.dart | 119 ++++++------------ sites/docs/lib/main.server.dart | 2 - .../flutter_bench_task_ide_explorer.dart | 56 --------- .../docs/ai/flutter_bench_task_explorer.md | 6 +- 5 files changed, 48 insertions(+), 162 deletions(-) delete mode 100644 sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss index 920383b1a31..817def3baf7 100644 --- a/packages/site_shared/lib/_sass/components/_ide-explorer.scss +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -201,9 +201,7 @@ text-transform: uppercase; } -.ide-badge-color-info, -.ide-color-info, -.ide-tone-info { +.ide-badge-color-info { &.ide-badge-dot, &.ide-badge { background-color: var(--site-alert-info-color); @@ -215,12 +213,7 @@ } } -.ide-badge-color-tip, -.ide-color-tip, -.ide-tone-tip, -.ide-badge-color-success, -.ide-color-success, -.ide-tone-success { +.ide-badge-color-tip { &.ide-badge-dot, &.ide-badge { background-color: var(--site-alert-tip-color); @@ -232,9 +225,7 @@ } } -.ide-badge-color-important, -.ide-color-important, -.ide-tone-important { +.ide-badge-color-important { &.ide-badge-dot, &.ide-badge { background-color: var(--site-alert-important-color); @@ -246,9 +237,7 @@ } } -.ide-badge-color-warning, -.ide-color-warning, -.ide-tone-warning { +.ide-badge-color-warning { &.ide-badge-dot, &.ide-badge { background-color: var(--site-alert-warning-color); @@ -260,9 +249,7 @@ } } -.ide-badge-color-error, -.ide-color-error, -.ide-tone-error { +.ide-badge-color-error { &.ide-badge-dot, &.ide-badge { background-color: var(--site-alert-error-color); @@ -274,9 +261,7 @@ } } -.ide-badge-color-neutral, -.ide-color-neutral, -.ide-tone-neutral { +.ide-badge-color-neutral { &.ide-badge-dot { background-color: var(--site-base-fgColor-alt); } diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart index 001e6c88706..dbaf0a1cd8d 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -53,6 +53,7 @@ class IdeTreeNode { required this.label, this.startsClosed = false, this.badge, + this.badgeColor, this.subtitle, this.note, this.title, @@ -62,7 +63,8 @@ class IdeTreeNode { final String id; final String label; final bool startsClosed; - final IdeBadge? badge; + final String? badge; + final IdeBadgeColor? badgeColor; final String? title; final String? subtitle; final String? note; @@ -82,16 +84,13 @@ class IdeTreeNode { final treeNodes = children.map(IdeTreeNode.fromMap).toList(); final startsClosed = map.containsKey('startsClosed') && map['startsClosed'] == true; - final badge = IdeBadge.tryFrom( - map['badge'], - map['badgeColor'] ?? map['badgeTone'], - ); return IdeTreeNode( id: id, label: label, startsClosed: startsClosed, - badge: badge, + badge: map['badge']?.toString(), + badgeColor: IdeBadgeColor.fromString(map['badgeColor']?.toString()), title: map['title']?.toString(), subtitle: map['subtitle']?.toString(), note: map['note']?.toString(), @@ -105,14 +104,12 @@ class IdeTreeNode { } // Files shouldn't be empty, that's bad UX when map.containsKey('note'): - final badge = IdeBadge.tryFrom( - map['badge'], - map['badgeColor'] ?? map['badgeTone'], - ); + return IdeTreeNode( id: id, label: label, - badge: badge, + badge: map['badge']?.toString(), + badgeColor: IdeBadgeColor.fromString(map['badgeColor']?.toString()), title: map['title']?.toString() ?? label, subtitle: map['subtitle']?.toString() ?? '', note: map['note']!.toString(), @@ -123,7 +120,7 @@ class IdeTreeNode { } } -/// Corresponds to colors used for the [IdeBadge] +/// Corresponds to colors used for badges on an [IdeTreeNode]. enum IdeBadgeColor { neutral, info, @@ -132,68 +129,19 @@ enum IdeBadgeColor { warning, error; - static IdeBadgeColor from(Object? data) { - if (data is IdeBadgeColor) return data; - return switch (data?.toString().toLowerCase()) { + static IdeBadgeColor fromString(String? data) { + if (data == null) return IdeBadgeColor.neutral; + return switch (data.toLowerCase()) { 'info' => IdeBadgeColor.info, - 'tip' || 'success' => IdeBadgeColor.tip, + 'tip' => IdeBadgeColor.tip, 'important' => IdeBadgeColor.important, 'warning' => IdeBadgeColor.warning, - 'error' || 'danger' => IdeBadgeColor.error, + 'error' => IdeBadgeColor.error, _ => IdeBadgeColor.neutral, }; } } -/// Alias for [IdeBadgeColor]. -typedef IdeBadgeTone = IdeBadgeColor; - -/// A highlighted pill-style label displayed next to an [IdeTreeNode] title. -/// Intended to be used to display a single word of crucial metadata, -/// e.g. if you were including pubspec.lock in the component, -/// you could add "gitignored". -final class IdeBadge { - const IdeBadge({ - required this.label, - this.color = IdeBadgeColor.neutral, - }); - - final String label; - final IdeBadgeColor color; - - /// Backwards-compatible getter for [color]. - IdeBadgeColor get tone => color; - - factory IdeBadge.from(Object? data, [Object? colorData]) { - if (data is String) { - final color = IdeBadgeColor.from(colorData); - return IdeBadge(label: data, color: color); - } - if (data is Map) { - final label = data['label']?.toString() ?? ''; - final colorStr = - data['color'] ?? - data['badgeColor'] ?? - data['tone'] ?? - data['badgeTone'] ?? - colorData; - final color = IdeBadgeColor.from(colorStr); - return IdeBadge(label: label, color: color); - } - throw ArgumentError('Invalid badge data: $data'); - } - - static IdeBadge? tryFrom(Object? data, [Object? colorData]) { - if (data == null) return null; - if (data is String && data.isEmpty) return null; - try { - return IdeBadge.from(data, colorData); - } catch (_) { - return null; - } - } -} - /// 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}); @@ -383,7 +331,10 @@ class IdeExplorer extends StatelessComponent { [ icon, span(classes: 'ide-node-label', [.text(node.label)]), - _buildBadgeDot(node.badge), + _buildBadgeDot( + node.badge, + node.badgeColor ?? IdeBadgeColor.neutral, + ), ], ), ]); @@ -402,7 +353,10 @@ class IdeExplorer extends StatelessComponent { [ icon, span(classes: 'ide-node-label', [.text(node.label)]), - _buildBadgeDot(node.badge), + _buildBadgeDot( + node.badge, + node.badgeColor ?? IdeBadgeColor.neutral, + ), ], ), ul([ @@ -418,18 +372,21 @@ class IdeExplorer extends StatelessComponent { ]); } - Component _buildBadgeDot(IdeBadge? badge) { + Component _buildBadgeDot( + String? badge, [ + IdeBadgeColor color = IdeBadgeColor.neutral, + ]) { if (badge == null) return const Component.empty(); return span( classes: [ 'ide-badge-dot', - 'ide-badge-color-${badge.color.name}', - 'ide-tone-${badge.color.name}', + 'ide-badge-color-${color.name}', + 'ide-tone-${color.name}', ].toClasses, attributes: { 'role': 'img', - 'aria-label': badge.label, - 'title': badge.label, + 'aria-label': badge, + 'title': badge, }, [], ); @@ -459,7 +416,7 @@ class IdeExplorer extends StatelessComponent { div(classes: 'ide-detail-header', [ node.isFolder ? FileIcon.folderIcon : FileIcon.forFile(node.label), div(classes: 'ide-detail-heading', [ - div(classes: 'ide-detail-title', [.text(node.label)]), + div(classes: 'ide-detail-title', [.text(node.title ?? node.label)]), if (node.subtitle case final subtitle?) div(classes: 'ide-detail-subtitle', [.text(subtitle)]), ]), @@ -467,10 +424,10 @@ class IdeExplorer extends StatelessComponent { span( classes: [ 'ide-badge', - 'ide-badge-color-${badge.color.name}', - 'ide-tone-${badge.color.name}', + 'ide-badge-color-' + '${node.badgeColor?.name ?? IdeBadgeColor.neutral.name}', ].toClasses, - [.text(badge.label)], + [.text(badge)], ), ]), @@ -652,10 +609,12 @@ class DashIdeExplorer extends CustomComponent { child.attributes['closed'] == 'true' || child.attributes['startsClosed'] == 'true'; - final badgeLabel = child.attributes['badge']; + final badge = child.attributes['badge']; final badgeColorStr = child.attributes['badgeColor'] ?? child.attributes['badgeTone']; - final badge = IdeBadge.tryFrom(badgeLabel, badgeColorStr); + final badgeColor = badgeColorStr != null + ? IdeBadgeColor.fromString(badgeColorStr) + : null; final subtitle = child.attributes['subtitle']; @@ -698,9 +657,9 @@ class DashIdeExplorer extends CustomComponent { IdeTreeNode( id: id, label: label, - startsClosed: startsClosed, badge: badge, + badgeColor: badgeColor, subtitle: subtitle, children: nestedTreeNodes, ), diff --git a/sites/docs/lib/main.server.dart b/sites/docs/lib/main.server.dart index 9617b42d50f..91dd9eb6710 100644 --- a/sites/docs/lib/main.server.dart +++ b/sites/docs/lib/main.server.dart @@ -30,7 +30,6 @@ import 'src/components/pages/architecture_recommendations.dart'; import 'src/components/pages/archive_table.dart'; import 'src/components/pages/devtools_release_notes_index.dart'; import 'src/components/pages/expansion_list.dart'; -import 'src/components/pages/flutter_bench_task_ide_explorer.dart'; import 'src/components/pages/learning_resource_index.dart'; import 'src/components/pages/platforms_grid.dart'; import 'src/components/pages/widget_catalog.dart'; @@ -129,5 +128,4 @@ List get _embeddableComponents => [ 'DevToolsReleaseNotesIndex', const DevToolsReleaseNotesIndex(), ), - const FlutterBenchTaskExplorer(), ]; diff --git a/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart b/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart deleted file mode 100644 index 96622617e62..00000000000 --- a/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:jaspr/dom.dart'; -import 'package:jaspr/jaspr.dart'; -import 'package:jaspr_content/jaspr_content.dart'; -import 'package:site_shared/components/common/ide_explorer.dart'; - - -/// Wraps [IdeExplorer] component -final class FlutterBenchTaskExplorer extends CustomComponent { - const FlutterBenchTaskExplorer() : super.base(); - - @override - Component? create(Node node, NodesBuilder builder) { - if (node is! ElementNode || node.tag != 'FlutterBenchTaskExplorer') { - return null; - } - - final customContents = {}; - final roots = DashIdeExplorer.parseRootsFromNode( - node, - builder, - customContents, - rootLabel: node.attributes['rootLabel'], - ); - - if (roots.isEmpty && node.attributes['data'] != null) { - final dataKey = node.attributes['data']!; - return Builder( - builder: (context) { - final rawData = context.page.data[dataKey]; - if (rawData == null) { - throw ArgumentError('No page data found for "$dataKey".'); - } - - final parsedRoots = DashIdeExplorer.parseRoots( - rawData, - rootLabel: node.attributes['rootLabel'], - ); - - return div(classes: 'flutter-bench-task-explorer', [ - IdeExplorer( - roots: parsedRoots, - customContents: customContents, - ), - ]); - }, - ); - } - - return div(classes: 'flutter-bench-task-explorer', [ - IdeExplorer( - roots: roots, - customContents: customContents, - ), - ]); - } -} 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 index cee0ff3e7af..6dbdbbc6e2d 100644 --- a/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md +++ b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md @@ -1,8 +1,8 @@ - + - + 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 @@ -256,4 +256,4 @@ agents: - + From d13168d6ef16abda8d9bf9b3a2feecc52adc7aab Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 13 Aug 2026 13:58:36 -0700 Subject: [PATCH 10/17] more refactor --- .../lib/components/common/ide_explorer.dart | 42 ++++++++++++++----- .../docs/ai/flutter_bench_task_explorer.md | 2 +- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart index dbaf0a1cd8d..33237f833a6 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -90,7 +90,13 @@ class IdeTreeNode { label: label, startsClosed: startsClosed, badge: map['badge']?.toString(), - badgeColor: IdeBadgeColor.fromString(map['badgeColor']?.toString()), + badgeColor: IdeBadgeColor.fromString( + (map['badgeColor'] ?? + map['badge_color'] ?? + map['badge-color'] ?? + map['badgecolor']) + ?.toString(), + ), title: map['title']?.toString(), subtitle: map['subtitle']?.toString(), note: map['note']?.toString(), @@ -109,7 +115,13 @@ class IdeTreeNode { id: id, label: label, badge: map['badge']?.toString(), - badgeColor: IdeBadgeColor.fromString(map['badgeColor']?.toString()), + badgeColor: IdeBadgeColor.fromString( + (map['badgeColor'] ?? + map['badge_color'] ?? + map['badge-color'] ?? + map['badgecolor']) + ?.toString(), + ), title: map['title']?.toString() ?? label, subtitle: map['subtitle']?.toString() ?? '', note: map['note']!.toString(), @@ -133,10 +145,10 @@ enum IdeBadgeColor { if (data == null) return IdeBadgeColor.neutral; return switch (data.toLowerCase()) { 'info' => IdeBadgeColor.info, - 'tip' => IdeBadgeColor.tip, + 'tip' || 'success' => IdeBadgeColor.tip, 'important' => IdeBadgeColor.important, 'warning' => IdeBadgeColor.warning, - 'error' => IdeBadgeColor.error, + 'error' || 'danger' => IdeBadgeColor.error, _ => IdeBadgeColor.neutral, }; } @@ -381,7 +393,6 @@ class IdeExplorer extends StatelessComponent { classes: [ 'ide-badge-dot', 'ide-badge-color-${color.name}', - 'ide-tone-${color.name}', ].toClasses, attributes: { 'role': 'img', @@ -502,11 +513,14 @@ class DashIdeExplorer extends CustomComponent { } final customContents = {}; + final rootLabel = node.attributes['rootLabel'] ?? + node.attributes['rootlabel'] ?? + node.attributes['root-label']; final roots = parseRootsFromNode( node, builder, customContents, - rootLabel: node.attributes['rootLabel'], + rootLabel: rootLabel, ); if (roots.isEmpty && node.attributes['data'] != null) { @@ -520,7 +534,7 @@ class DashIdeExplorer extends CustomComponent { final parsedRoots = parseRoots( rawData, - rootLabel: node.attributes['rootLabel'], + rootLabel: rootLabel, ); return IdeExplorer( @@ -578,7 +592,11 @@ class DashIdeExplorer extends CustomComponent { return [ IdeExplorerProjectRoot( id: node.attributes['id'] ?? 'root', - label: rootLabel ?? node.attributes['rootLabel'] ?? '', + label: rootLabel ?? + node.attributes['rootLabel'] ?? + node.attributes['rootlabel'] ?? + node.attributes['root-label'] ?? + '', children: _parseTreeNodes(node.children, builder, customContents), ), ]; @@ -607,11 +625,15 @@ class DashIdeExplorer extends CustomComponent { (label.isNotEmpty ? slugify(label) : 'node-$index'); final startsClosed = child.attributes['closed'] == 'true' || - child.attributes['startsClosed'] == 'true'; + child.attributes['startsClosed'] == 'true' || + child.attributes['startsclosed'] == 'true' || + child.attributes['starts-closed'] == 'true'; final badge = child.attributes['badge']; final badgeColorStr = - child.attributes['badgeColor'] ?? child.attributes['badgeTone']; + child.attributes['badgeColor'] ?? + child.attributes['badgecolor'] ?? + child.attributes['badge-color']; final badgeColor = badgeColorStr != null ? IdeBadgeColor.fromString(badgeColorStr) : null; 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 index 6dbdbbc6e2d..fffbe480e93 100644 --- a/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md +++ b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md @@ -2,7 +2,7 @@ - + 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 From 1862e74aac10ef0af54959538f3c399c1c715807 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 13 Aug 2026 14:06:52 -0700 Subject: [PATCH 11/17] more refactor --- .../lib/_sass/components/_ide-explorer.scss | 46 ++++++++++++++++--- .../lib/components/common/ide_explorer.dart | 35 +++++++++----- sites/docs/lib/src/client/global_scripts.dart | 33 +++---------- .../docs/ai/flutter_bench_task_explorer.md | 5 +- 4 files changed, 70 insertions(+), 49 deletions(-) diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss index 817def3baf7..08c3afe2bfe 100644 --- a/packages/site_shared/lib/_sass/components/_ide-explorer.scss +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -116,36 +116,60 @@ } summary { + display: flex; + align-items: center; + width: 100%; list-style: none; + padding: 0; + margin: 0; + cursor: default; &::-webkit-details-marker { display: none; } + } + + .ide-folder-arrow { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1.25rem; + height: 1.5rem; + padding: 0; + padding-inline-start: 0.35rem; + border: none; + background: transparent; + cursor: pointer; &::before { content: ''; display: inline-block; flex-shrink: 0; - width: 0.6rem; - height: 0.6rem; - margin-inline-end: 0.3rem; + width: 0.55rem; + height: 0.55rem; 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::before { + details[open] > summary .ide-folder-arrow::before { transform: rotate(90deg); } - .ide-node-row, - summary { + .ide-node-row { display: flex; align-items: center; - width: 100%; + flex: 1; + min-width: 0; padding: 0.25rem 0.6rem; border: none; + border-radius: 0.2rem; background: transparent; color: inherit; font-family: var(--site-code-fontFamily); @@ -172,6 +196,14 @@ } } + li.ide-node-file > .ide-node-row { + padding-inline-start: 1.25rem; + } + + .ide-folder-summary > .ide-node-row { + padding-inline-start: 0; + } + .ide-node-label { overflow: hidden; text-overflow: ellipsis; diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart index 33237f833a6..bb9ae49bcff 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -145,10 +145,10 @@ enum IdeBadgeColor { if (data == null) return IdeBadgeColor.neutral; return switch (data.toLowerCase()) { 'info' => IdeBadgeColor.info, - 'tip' || 'success' => IdeBadgeColor.tip, + 'tip' => IdeBadgeColor.tip, 'important' => IdeBadgeColor.important, 'warning' => IdeBadgeColor.warning, - 'error' || 'danger' => IdeBadgeColor.error, + 'error' => IdeBadgeColor.error, _ => IdeBadgeColor.neutral, }; } @@ -357,17 +357,28 @@ class IdeExplorer extends StatelessComponent { open: !node.startsClosed, [ summary( - classes: isSelected ? 'active' : null, - attributes: { - 'data-ide-select': domId, - if (isSelected) 'aria-current': 'true', - }, + classes: 'ide-folder-summary', [ - icon, - span(classes: 'ide-node-label', [.text(node.label)]), - _buildBadgeDot( - node.badge, - node.badgeColor ?? IdeBadgeColor.neutral, + const span( + classes: 'ide-folder-arrow', + attributes: {'aria-hidden': 'true'}, + [], + ), + 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)]), + _buildBadgeDot( + node.badge, + node.badgeColor ?? IdeBadgeColor.neutral, + ), + ], ), ], ), diff --git a/sites/docs/lib/src/client/global_scripts.dart b/sites/docs/lib/src/client/global_scripts.dart index 26fc9d70e5e..2da567a2261 100644 --- a/sites/docs/lib/src/client/global_scripts.dart +++ b/sites/docs/lib/src/client/global_scripts.dart @@ -511,16 +511,14 @@ void _setUpIdeExplorer(web.Element explorer) { } // Expand every ancestor folder so the selected item stays visible. - final ownDetails = sidebarTarget?.tagName.toLowerCase() == 'summary' - ? sidebarTarget!.parentElement - : null; - var current = sidebarTarget; + 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; - if (ancestorDetails != ownDetails) { - (ancestorDetails as web.HTMLDetailsElement).open = true; - } + (ancestorDetails as web.HTMLDetailsElement).open = true; current = ancestorDetails.parentElement; } } @@ -580,26 +578,7 @@ void _setUpIdeExplorer(web.Element explorer) { if (selectTarget != null) { final domId = selectTarget.getAttribute('data-ide-select'); if (domId != null) selectIdeNode(domId); - - if (selectTarget.tagName.toLowerCase() == 'summary') { - var isClickOnArrow = false; - if (target == selectTarget) { - final mouseEvent = event as web.MouseEvent; - final rect = selectTarget.getBoundingClientRect(); - final clickX = mouseEvent.clientX - rect.left; - // The arrow marker is rendered using a ::before pseudo-element - // on the left side of the summary element. The total width of the - // padding and arrow is around 20-30px. - // TODO: this can't possibly be the right way to do this. - if (clickX < 30) { - isClickOnArrow = true; - } - } - - if (!isClickOnArrow) { - event.preventDefault(); - } - } + event.preventDefault(); return; } 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 index fffbe480e93..4cad69338be 100644 --- a/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md +++ b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md @@ -35,9 +35,8 @@ target artifacts evaluated during grading. - `artifacts`: Lists files that must be present in the workspace after the run. -- `difficulty_explanation`: Identifies common subtle pitfalls (like fixing only one axis or altering - item structures), which inform the grading rubrics. -- `verifier` and `agent` timeouts: Provides sufficient execution budget for cold compilation and multistep agent tool iterations. +- `task` definition: Task-related data, like name, author and tags. +- infra and agent configurations, such as timeout limits and retries. From 13730687e9d40f6dc12e885ad5b68764918c1de3 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 13 Aug 2026 15:23:32 -0700 Subject: [PATCH 12/17] more refactor --- .../lib/_sass/components/_ide-explorer.scss | 39 ++++- .../lib/components/common/ide_explorer.dart | 151 +++++++++++------ .../docs/ai/flutter_bench_task_explorer.md | 156 ++++++------------ 3 files changed, 181 insertions(+), 165 deletions(-) diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss index 08c3afe2bfe..ca5e888d90c 100644 --- a/packages/site_shared/lib/_sass/components/_ide-explorer.scss +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -107,11 +107,23 @@ padding-inline-start: 0.9rem; } - > ul { + >ul { padding-inline-start: 0; } + ul>li.ide-node:last-child { + margin-bottom: .5rem; + } + li.ide-node { + margin-bottom: 0; + } + + details { + margin: 0; + } + + details[open]>summary { margin: 0; } @@ -158,7 +170,7 @@ } } - details[open] > summary .ide-folder-arrow::before { + details[open]>summary .ide-folder-arrow::before { transform: rotate(90deg); } @@ -196,18 +208,20 @@ } } - li.ide-node-file > .ide-node-row { + li.ide-node-file>.ide-node-row { padding-inline-start: 1.25rem; } - .ide-folder-summary > .ide-node-row { + .ide-folder-summary>.ide-node-row { padding-inline-start: 0; + margin: 0; } .ide-node-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 2; } } @@ -234,6 +248,7 @@ } .ide-badge-color-info { + &.ide-badge-dot, &.ide-badge { background-color: var(--site-alert-info-color); @@ -246,6 +261,7 @@ } .ide-badge-color-tip { + &.ide-badge-dot, &.ide-badge { background-color: var(--site-alert-tip-color); @@ -258,6 +274,7 @@ } .ide-badge-color-important { + &.ide-badge-dot, &.ide-badge { background-color: var(--site-alert-important-color); @@ -270,6 +287,7 @@ } .ide-badge-color-warning { + &.ide-badge-dot, &.ide-badge { background-color: var(--site-alert-warning-color); @@ -282,6 +300,7 @@ } .ide-badge-color-error { + &.ide-badge-dot, &.ide-badge { background-color: var(--site-alert-error-color); @@ -343,7 +362,9 @@ display: flex; align-items: flex-start; gap: 0.6rem; - margin-block-end: 0.6rem; + margin-block-end: 1rem; + padding-bottom: 1rem; + border-bottom: .5px solid; svg { flex-shrink: 0; @@ -358,6 +379,12 @@ 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: 1.25rem; @@ -498,4 +525,4 @@ text-overflow: ellipsis; white-space: nowrap; } -} +} \ No newline at end of file diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart index bb9ae49bcff..14c311fbf37 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -2,6 +2,7 @@ // 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'; @@ -51,7 +52,8 @@ class IdeTreeNode { const IdeTreeNode({ required this.id, required this.label, - this.startsClosed = false, + this.isDefaultPage = false, + this.startsClosed = true, this.badge, this.badgeColor, this.subtitle, @@ -62,6 +64,7 @@ class IdeTreeNode { final String id; final String label; + final bool isDefaultPage; final bool startsClosed; final String? badge; final IdeBadgeColor? badgeColor; @@ -83,19 +86,18 @@ class IdeTreeNode { when children.isNotEmpty || label.endsWith('/'): final treeNodes = children.map(IdeTreeNode.fromMap).toList(); final startsClosed = - map.containsKey('startsClosed') && map['startsClosed'] == true; + (map['starts-closed'] ?? map['startsClosed']) as bool? ?? true; + final isDefaultPage = + (map['is-default-page'] ?? map['isDefaultPage']) == true; return IdeTreeNode( id: id, label: label, + isDefaultPage: isDefaultPage, startsClosed: startsClosed, badge: map['badge']?.toString(), badgeColor: IdeBadgeColor.fromString( - (map['badgeColor'] ?? - map['badge_color'] ?? - map['badge-color'] ?? - map['badgecolor']) - ?.toString(), + (map['badge-color'] ?? map['badgeColor'])?.toString(), ), title: map['title']?.toString(), subtitle: map['subtitle']?.toString(), @@ -110,17 +112,16 @@ class IdeTreeNode { } // Files shouldn't be empty, that's bad UX when map.containsKey('note'): - + final isDefaultPage = + (map['is-default-page'] ?? map['isDefaultPage']) == true; + return IdeTreeNode( id: id, label: label, + isDefaultPage: isDefaultPage, badge: map['badge']?.toString(), badgeColor: IdeBadgeColor.fromString( - (map['badgeColor'] ?? - map['badge_color'] ?? - map['badge-color'] ?? - map['badgecolor']) - ?.toString(), + (map['badge-color'] ?? map['badgeColor'])?.toString(), ), title: map['title']?.toString() ?? label, subtitle: map['subtitle']?.toString() ?? '', @@ -219,10 +220,31 @@ class IdeExplorer extends StatelessComponent { path: root.label.isEmpty ? [] : [root.label], ), }; + final allFlatNodes = flatNodesByRoot.values .expand((nodes) => nodes) .toList(growable: false); - final firstNodeDomId = flatNodesByRoot[roots.first.id]?.firstOrNull?.domId; + + final defaultFlatNode = allFlatNodes.firstWhereOrNull( + (flat) => flat.node.isDefaultPage, + ); + + final activeRootId = defaultFlatNode != null + ? (roots + .firstWhereOrNull( + (r) => + flatNodesByRoot[r.id]?.any( + (n) => n.domId == defaultFlatNode.domId, + ) ?? + false, + ) + ?.id ?? + roots.first.id) + : roots.first.id; + + final selectedNodeDomId = + defaultFlatNode?.domId ?? + flatNodesByRoot[activeRootId]?.firstOrNull?.domId; return div(classes: 'ide-explorer not-content', [ div(classes: 'ide-sidebar', [ @@ -231,16 +253,16 @@ class IdeExplorer extends StatelessComponent { classes: 'ide-root-tabs', attributes: {'role': 'tablist'}, [ - for (final (rootIndex, root) in roots.indexed) + for (final root in roots) button( classes: [ 'ide-root-tab', - if (rootIndex == 0) 'active', + if (root.id == activeRootId) 'active', ].toClasses, attributes: { 'data-ide-root': root.id, 'role': 'tab', - 'aria-selected': '${rootIndex == 0}', + 'aria-selected': '${root.id == activeRootId}', }, [.text(root.label.isEmpty ? root.id : root.label)], ), @@ -251,11 +273,13 @@ class IdeExplorer extends StatelessComponent { div(classes: 'ide-root-tabs ide-root-tabs-single', [ _buildToggleAllButton(), ]), - for (final (rootIndex, root) in roots.indexed) + + //TODO Start Tree portion (move to separate method?) + for (final root in roots) div( classes: [ 'ide-tree', - if (rootIndex == 0) 'active', + if (root.id == activeRootId) 'active', ].toClasses, attributes: {'data-ide-root': root.id}, [ @@ -264,7 +288,7 @@ class IdeExplorer extends StatelessComponent { _buildTreeNode( node, instanceId: effectiveInstanceId, - selectedDomId: firstNodeDomId, + selectedDomId: selectedNodeDomId, ), ]), ], @@ -275,7 +299,7 @@ class IdeExplorer extends StatelessComponent { _buildDetailPanel( flat, instanceId: effectiveInstanceId, - isActive: flat.domId == firstNodeDomId, + isActive: flat.domId == selectedNodeDomId, ), ]), ]); @@ -320,6 +344,19 @@ class IdeExplorer extends StatelessComponent { return result; } + bool _hasSelectedChild( + IdeTreeNode node, + String instanceId, + String? selectedDomId, + ) { + if (selectedDomId == null) return false; + for (final child in node.children) { + if (_domId(instanceId, child.id) == selectedDomId) return true; + if (_hasSelectedChild(child, instanceId, selectedDomId)) return true; + } + return false; + } + Component _buildTreeNode( IdeTreeNode node, { required String instanceId, @@ -352,9 +389,13 @@ class IdeExplorer extends StatelessComponent { ]); } + final isOpen = + !node.startsClosed || + _hasSelectedChild(node, instanceId, selectedDomId); + return li(classes: 'ide-node ide-node-folder', [ details( - open: !node.startsClosed, + open: isOpen, [ summary( classes: 'ide-folder-summary', @@ -436,21 +477,40 @@ class IdeExplorer extends StatelessComponent { ], ), div(classes: 'ide-detail-header', [ + // icon node.isFolder ? FileIcon.folderIcon : FileIcon.forFile(node.label), + // everything else div(classes: 'ide-detail-heading', [ - div(classes: 'ide-detail-title', [.text(node.title ?? node.label)]), + 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 ?? IdeBadgeColor.neutral.name}', + ].toClasses, + [.text(badge)], + ), + ]), + + // Subtitle row if (node.subtitle case final subtitle?) div(classes: 'ide-detail-subtitle', [.text(subtitle)]), ]), - if (node.badge case final badge?) - span( - classes: [ - 'ide-badge', - 'ide-badge-color-' - '${node.badgeColor?.name ?? IdeBadgeColor.neutral.name}', - ].toClasses, - [.text(badge)], - ), + + // if (node.badge case final badge?) + // span( + // classes: [ + // 'ide-badge', + // 'ide-badge-color-' + // '${node.badgeColor?.name ?? IdeBadgeColor.neutral.name}', + // ].toClasses, + // [.text(badge)], + // ), ]), if (customContents[node.id] case final customChild?) @@ -524,9 +584,7 @@ class DashIdeExplorer extends CustomComponent { } final customContents = {}; - final rootLabel = node.attributes['rootLabel'] ?? - node.attributes['rootlabel'] ?? - node.attributes['root-label']; + final rootLabel = node.attributes['root-label']; final roots = parseRootsFromNode( node, builder, @@ -603,11 +661,7 @@ class DashIdeExplorer extends CustomComponent { return [ IdeExplorerProjectRoot( id: node.attributes['id'] ?? 'root', - label: rootLabel ?? - node.attributes['rootLabel'] ?? - node.attributes['rootlabel'] ?? - node.attributes['root-label'] ?? - '', + label: rootLabel ?? node.attributes['root-label'] ?? '', children: _parseTreeNodes(node.children, builder, customContents), ), ]; @@ -630,21 +684,17 @@ class DashIdeExplorer extends CustomComponent { continue; } - final label = child.attributes['label'] ?? child.attributes['name'] ?? ''; + final label = child.attributes['label'] ?? ''; final id = child.attributes['id'] ?? (label.isNotEmpty ? slugify(label) : 'node-$index'); - final startsClosed = - child.attributes['closed'] == 'true' || - child.attributes['startsClosed'] == 'true' || - child.attributes['startsclosed'] == 'true' || - child.attributes['starts-closed'] == 'true'; + final isDefaultPage = child.attributes['is-default-page'] == 'true'; + final startsClosed = child.attributes['starts-closed'] != null + ? child.attributes['starts-closed'] == 'true' + : true; final badge = child.attributes['badge']; - final badgeColorStr = - child.attributes['badgeColor'] ?? - child.attributes['badgecolor'] ?? - child.attributes['badge-color']; + final badgeColorStr = child.attributes['badge-color']; final badgeColor = badgeColorStr != null ? IdeBadgeColor.fromString(badgeColorStr) : null; @@ -690,6 +740,7 @@ class DashIdeExplorer extends CustomComponent { IdeTreeNode( id: id, label: label, + isDefaultPage: isDefaultPage, startsClosed: startsClosed, badge: badge, badgeColor: badgeColor, 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 index 4cad69338be..b0290cd0a1e 100644 --- a/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md +++ b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md @@ -2,45 +2,7 @@ - - -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. - - - - + The target codebase is an isolated, containerized Flutter workspace. @@ -53,22 +15,6 @@ 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. - - -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. - - - Contains the Flutter application source code. @@ -77,7 +23,7 @@ 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. @@ -94,7 +40,7 @@ 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. @@ -128,9 +74,25 @@ linter: + + +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. @@ -143,10 +105,9 @@ The harness evaluates the agent's output across three weighted dimensions: - **Quality**: Static analysis, formatting, and code craftsmanship. - **DX**: Developer experience and tool interaction efficiency. - + -Defines the multi-dimensional scoring pipeline using -`package:eval_scoring`. +Defines the multi-dimensional scoring pipeline using a separate scoring package. ```dart // Heavily edited @@ -173,7 +134,7 @@ eval_scoring run [tasks] - + Contains the ground truth reference implementation maintained by the benchmark authors. @@ -192,64 +153,41 @@ comprehensive tests. Not safe to share publicly. - - - - - - -The `eval_scoring` Dart package powers all grading across the benchmark -suite. + -**Key capabilities** - -- **Composite scoring (`AggregateGrader`)**: - Combines multiple sub-graders with configurable weights. -- **Deterministic graders**: - Wraps standard Dart and Flutter CLI tools (`StaticAnalysisGrader`, - `TestingGrader`, `FormattingGrader`, `DcmGrader`). -- **LLM judges (`LLMGrader`)**: - Leverages model judges powered by `package:genkit` to evaluate - qualitative rubrics with Likert and binary criteria. -- **Compute-once execution**: - Ensures shared grader instances are evaluated once and shared across - outcome, quality, and diagnostic reporting. - - +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** -Prebuilt Linux Docker container images (`flutter-linux` and `dart-linux`). +- 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. -**Features** +```markdown +We currently keep receiving `RenderFlex overflowed` errors both +horizontally and vertically. Implement the correct widgets to resolve these +layout issues. -- Pre-installs the Flutter SDK, Dart SDK, and shared evaluation utilities. -- Pre-caches common packages in `/opt/packages`. -- Reduces task container startup time from minutes to seconds. -- Guarantees identical SDK versions and tool environments across local runs - and CI pipelines. +Write widget tests in +`test/main_test.dart` that verifies the changes. +``` - - -The evaluation job configuration file that specifies which models, tools, -and agent variants to evaluate. + -**Capabilities** +The task configuration file specifies execution bounds, metadata, and +target artifacts evaluated during grading. -- Configures model endpoints and temperature settings. -- Controls tool availability (e.g. enabling or disabling terminal access, - file search, or MCP tools). -- Executes matrix runs evaluating multiple agent architectures against the - benchmark suite. +**Key sections** -```yaml -agents: - - model_name: gemini-2.5-pro - kwargs: - allowed_tools: [ Read, Write, Edit, Bash, Glob, Ripgrep ] -``` +- `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. From f083b82f9909bb45d747c6ecb582d3651650acd4 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 13 Aug 2026 15:40:25 -0700 Subject: [PATCH 13/17] tidy scss --- packages/site_shared/lib/_sass/components/_ide-explorer.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss index ca5e888d90c..38766c7c37e 100644 --- a/packages/site_shared/lib/_sass/components/_ide-explorer.scss +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -221,7 +221,6 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - flex: 2; } } From e944483273b459b4574121b7bf9928845ba0abb4 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Fri, 14 Aug 2026 11:02:03 -0700 Subject: [PATCH 14/17] fix scss funkiness --- .../lib/_sass/components/_ide-explorer.scss | 15 +- .../lib/components/common/ide_explorer.dart | 674 ++++++------------ 2 files changed, 242 insertions(+), 447 deletions(-) diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss index 38766c7c37e..b693465de1a 100644 --- a/packages/site_shared/lib/_sass/components/_ide-explorer.scss +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -208,16 +208,19 @@ } } - li.ide-node-file>.ide-node-row { - padding-inline-start: 1.25rem; - } + li.ide-node-file { + display: flex; + align-items: center; + width: 100%; - .ide-folder-summary>.ide-node-row { - padding-inline-start: 0; - margin: 0; + >.ide-node-row { + margin-inline-start: 1.25rem; + } } .ide-node-label { + flex: 1; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart index 14c311fbf37..7ead53b3807 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -8,16 +8,149 @@ import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/components/file_icon.dart'; import 'package:jaspr_content/jaspr_content.dart'; -import '../../src/markdown/markdown_parser.dart'; import '../../util.dart'; import 'material_icon.dart'; -/// A single top-level file tree shown by an [IdeExplorer], such as -/// "Project" or "Global". It contains all of the 'files' (IdeTreeNodes) -/// inside the project thats displayed in the IDE. + +/// A custom markdown component that parses `` and its +/// ``, ``, and `` children. Defers +/// building the IDE html to the [IdeExplorer] component. +class DashIdeExplorer extends CustomComponent { + const DashIdeExplorer() : super.base(); + + @override + Component? create(Node node, NodesBuilder builder) { + if (node is! ElementNode || + !(node.tag == 'IdeExplorer' || node.tag == 'DashIdeExplorer')) { + return null; + } + + final rootElements = node.children + ?.whereType() + .where((n) => n.tag == 'IdeRoot') + .toList(growable: false); + + if (rootElements == null || rootElements.isEmpty) { + print( + '[ERROR] requires at least one child element.', + ); + return const Component.empty(); + } + + final customContents = {}; + final roots = [ + for (final (index, rootEl) in rootElements.indexed) + IdeExplorerProjectRoot( + id: + rootEl.attributes['id'] ?? + (rootEl.attributes['label'] != null + ? slugify(rootEl.attributes['label']!) + : 'root-$index'), + label: rootEl.attributes['label'] ?? '', + children: _parseTreeNodes( + rootEl.children, + builder, + customContents, + ), + ), + ]; + + return IdeExplorer( + roots: roots, + customContents: customContents, + ); + } + + static 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 != 'IdeFolder' && child.tag != 'IdePage') { + continue; + } + + final label = child.attributes['label'] ?? ''; + final id = + child.attributes['id'] ?? + (label.isNotEmpty ? slugify(label) : 'node-$index'); + final isDefaultPage = child.attributes['is-default-page'] == 'true'; + final startsClosed = child.attributes['starts-closed'] != null + ? child.attributes['starts-closed'] == 'true' + : true; + + final badge = child.attributes['badge']; + final badgeColorStr = child.attributes['badge-color']; + final badgeColor = badgeColorStr != null + ? IdeBadgeColor.fromString(badgeColorStr) + : null; + + final subtitle = child.attributes['subtitle']; + + final nestedTreeNodes = _parseTreeNodes( + child.children, + builder, + customContents, + ); + + final hasBody = + child.children?.any((n) { + if (n is ElementNode && + (n.tag == 'IdeFolder' || n.tag == 'IdePage')) { + return false; + } + if (n is TextNode && n.text.trim().isEmpty) { + return false; + } + return true; + }) ?? + false; + + if (hasBody) { + final contentNodes = child.children! + .where((n) { + if (n is ElementNode && + (n.tag == 'IdeFolder' || n.tag == 'IdePage')) { + return false; + } + return true; + }) + .toList(growable: false); + + if (contentNodes.isNotEmpty) { + customContents[id] = builder.build(contentNodes); + } + } + + result.add( + IdeTreeNode( + id: id, + label: label, + isDefaultPage: isDefaultPage, + startsClosed: startsClosed, + badge: badge, + badgeColor: badgeColor, + subtitle: subtitle, + children: nestedTreeNodes, + ), + ); + } + + return result; + } +} + +/// 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 you have multiple projects to display, they're displayed as -/// tabs that you can switch between. +/// When multiple roots are provided, they are rendered as tabs that can be +/// switched between. class IdeExplorerProjectRoot { const IdeExplorerProjectRoot({ required this.id, @@ -28,23 +161,6 @@ class IdeExplorerProjectRoot { final String id; final String label; final List children; - - factory IdeExplorerProjectRoot.fromMap(Map map) { - if (map case { - 'id': final String id, - 'label': final String label, - 'children': final List> children, - }) { - final treeNodes = children.map(IdeTreeNode.fromMap).toList(); - return IdeExplorerProjectRoot( - id: id, - label: label, - children: treeNodes.toList(), - ); - } - - throw Exception('Malformed IdeExplorerProjectRoot data'); - } } /// A single file or folder entry in an [IdeExplorer] tree. @@ -57,7 +173,6 @@ class IdeTreeNode { this.badge, this.badgeColor, this.subtitle, - this.note, this.title, this.children = const [], }); @@ -70,67 +185,9 @@ class IdeTreeNode { final IdeBadgeColor? badgeColor; final String? title; final String? subtitle; - final String? note; final List children; bool get isFolder => children.isNotEmpty || label.endsWith('/'); - - factory IdeTreeNode.fromMap(Map map) { - switch (map) { - // explicit case to handle folder - case { - 'id': final String id, - 'label': final String label, - 'children': final List> children, - } - when children.isNotEmpty || label.endsWith('/'): - final treeNodes = children.map(IdeTreeNode.fromMap).toList(); - final startsClosed = - (map['starts-closed'] ?? map['startsClosed']) as bool? ?? true; - final isDefaultPage = - (map['is-default-page'] ?? map['isDefaultPage']) == true; - - return IdeTreeNode( - id: id, - label: label, - isDefaultPage: isDefaultPage, - startsClosed: startsClosed, - badge: map['badge']?.toString(), - badgeColor: IdeBadgeColor.fromString( - (map['badge-color'] ?? map['badgeColor'])?.toString(), - ), - title: map['title']?.toString(), - subtitle: map['subtitle']?.toString(), - note: map['note']?.toString(), - children: treeNodes, - ); - - // explicit case to handle files - case { - 'id': final String id, - 'label': final String label, - } - // Files shouldn't be empty, that's bad UX - when map.containsKey('note'): - final isDefaultPage = - (map['is-default-page'] ?? map['isDefaultPage']) == true; - - return IdeTreeNode( - id: id, - label: label, - isDefaultPage: isDefaultPage, - badge: map['badge']?.toString(), - badgeColor: IdeBadgeColor.fromString( - (map['badge-color'] ?? map['badgeColor'])?.toString(), - ), - title: map['title']?.toString() ?? label, - subtitle: map['subtitle']?.toString() ?? '', - note: map['note']!.toString(), - ); - default: - throw Exception('Malformed IdeTreeNode data'); - } - } } /// Corresponds to colors used for badges on an [IdeTreeNode]. @@ -157,18 +214,18 @@ enum IdeBadgeColor { /// 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}); +typedef _BreadcrumbNode = ({ + IdeTreeNode node, + String domId, + List path, +}); + /// 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: a description, tips, an example snippet, -/// and a link to learn more. Clicking an entry in the tree (or in a -/// folder's "Contents" list) swaps which detail pane is shown. -/// -/// Can be constructed directly in Dart with [IdeExplorer.new] or -/// [IdeExplorer.fromNodes], or used in Markdown via `` -/// powered by [DashIdeExplorer]. +/// per-file/folder documentation. Clicking an entry in the tree swaps which +/// detail pane is shown. class IdeExplorer extends StatelessComponent { const IdeExplorer({ super.key, @@ -177,28 +234,10 @@ class IdeExplorer extends StatelessComponent { this.customContents = const {}, }); - /// Creates an [IdeExplorer] with a single root from a list of [children]. - IdeExplorer.fromNodes({ - super.key, - required List children, - String rootLabel = '', - this.instanceId, - this.customContents = const {}, - }) : roots = [ - IdeExplorerProjectRoot( - id: 'root', - label: rootLabel, - children: children, - ), - ]; - - // Prevents DOM id collisions static int _nextInstanceId = 0; final List roots; - final String? instanceId; - final Map customContents; String _domId(String effectiveInstanceId, String nodeId) => @@ -249,49 +288,17 @@ class IdeExplorer extends StatelessComponent { return div(classes: 'ide-explorer not-content', [ div(classes: 'ide-sidebar', [ if (roots.length > 1) - 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)], - ), - _buildToggleAllButton(), - ], - ) + _buildRootTabs(activeRootId: activeRootId) else div(classes: 'ide-root-tabs ide-root-tabs-single', [ _buildToggleAllButton(), ]), - - //TODO Start Tree portion (move to separate method?) for (final root in roots) - div( - classes: [ - 'ide-tree', - if (root.id == activeRootId) 'active', - ].toClasses, - attributes: {'data-ide-root': root.id}, - [ - ul([ - for (final node in root.children) - _buildTreeNode( - node, - instanceId: effectiveInstanceId, - selectedDomId: selectedNodeDomId, - ), - ]), - ], + _buildTree( + root, + activeRootId: activeRootId, + effectiveInstanceId: effectiveInstanceId, + selectedNodeDomId: selectedNodeDomId, ), ]), div(classes: 'ide-detail', [ @@ -305,6 +312,54 @@ class IdeExplorer extends StatelessComponent { ]); } + Component _buildRootTabs({required String activeRootId}) { + 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)], + ), + _buildToggleAllButton(), + ], + ); + } + + Component _buildTree( + IdeExplorerProjectRoot root, { + required String activeRootId, + required String effectiveInstanceId, + required String? selectedNodeDomId, + }) { + return div( + classes: [ + 'ide-tree', + if (root.id == activeRootId) 'active', + ].toClasses, + attributes: {'data-ide-root': root.id}, + [ + ul([ + for (final node in root.children) + _buildTreeNode( + node, + instanceId: effectiveInstanceId, + selectedDomId: selectedNodeDomId, + ), + ]), + ], + ); + } + Component _buildToggleAllButton() { return const button( classes: 'ide-toggle-all', @@ -364,29 +419,10 @@ class IdeExplorer extends StatelessComponent { }) { final domId = _domId(instanceId, node.id); final isSelected = domId == selectedDomId; - final icon = node.isFolder - ? FileIcon.folderIcon - : FileIcon.forFile(node.label); + final row = _buildNodeRow(node, domId: domId, isSelected: isSelected); if (!node.isFolder) { - return li(classes: 'ide-node ide-node-file', [ - 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)]), - _buildBadgeDot( - node.badge, - node.badgeColor ?? IdeBadgeColor.neutral, - ), - ], - ), - ]); + return li(classes: 'ide-node ide-node-file', [row]); } final isOpen = @@ -405,22 +441,7 @@ class IdeExplorer extends StatelessComponent { attributes: {'aria-hidden': 'true'}, [], ), - 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)]), - _buildBadgeDot( - node.badge, - node.badgeColor ?? IdeBadgeColor.neutral, - ), - ], - ), + row, ], ), ul([ @@ -436,6 +457,33 @@ class IdeExplorer extends StatelessComponent { ]); } + Component _buildNodeRow( + IdeTreeNode node, { + required String domId, + required bool isSelected, + }) { + 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)]), + _buildBadgeDot( + node.badge, + node.badgeColor ?? IdeBadgeColor.neutral, + ), + ], + ); + } + Component _buildBadgeDot( String? badge, [ IdeBadgeColor color = IdeBadgeColor.neutral, @@ -477,52 +525,28 @@ class IdeExplorer extends StatelessComponent { ], ), div(classes: 'ide-detail-header', [ - // icon node.isFolder ? FileIcon.folderIcon : FileIcon.forFile(node.label), - // everything else 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 ?? IdeBadgeColor.neutral.name}', + 'ide-badge-color-${node.badgeColor?.name ?? 'neutral'}', ].toClasses, [.text(badge)], ), ]), - - // Subtitle row if (node.subtitle case final subtitle?) div(classes: 'ide-detail-subtitle', [.text(subtitle)]), ]), - - // if (node.badge case final badge?) - // span( - // classes: [ - // 'ide-badge', - // 'ide-badge-color-' - // '${node.badgeColor?.name ?? IdeBadgeColor.neutral.name}', - // ].toClasses, - // [.text(badge)], - // ), ]), if (customContents[node.id] case final customChild?) - div(classes: 'ide-custom-body', [customChild]) - else ...[ - if (node.note case final note?) - div(classes: 'ide-note', [ - if (node.title case final title?) - div(classes: 'ide-note-title', [.text(title)]), - DashMarkdown(content: note, inline: true), - ]), - ], + div(classes: 'ide-custom-body', [customChild]), if (node.children.isNotEmpty) div(classes: 'ide-contents', [ @@ -554,236 +578,4 @@ class IdeExplorer extends StatelessComponent { } } -/// A custom markdown component wrapper for [IdeExplorer]. -/// -/// Can be authored in Markdown using HTML-like tags: -/// ```html -/// -/// -/// -/// Main entry point content. -/// -/// -/// -/// App widget definition. -/// -/// -/// -/// -/// ``` -/// -/// Or referenced by a `data` attribute pointing to a YAML/JSON data key. -class DashIdeExplorer extends CustomComponent { - const DashIdeExplorer() : super.base(); - - @override - Component? create(Node node, NodesBuilder builder) { - if (node is! ElementNode || - !(node.tag == 'IdeExplorer' || node.tag == 'DashIdeExplorer')) { - return null; - } - - final customContents = {}; - final rootLabel = node.attributes['root-label']; - final roots = parseRootsFromNode( - node, - builder, - customContents, - rootLabel: rootLabel, - ); - - if (roots.isEmpty && node.attributes['data'] != null) { - final dataKey = node.attributes['data']!; - return Builder( - builder: (context) { - final rawData = context.page.data[dataKey]; - if (rawData == null) { - throw ArgumentError('No page data found for "$dataKey".'); - } - - final parsedRoots = parseRoots( - rawData, - rootLabel: rootLabel, - ); - - return IdeExplorer( - roots: parsedRoots, - customContents: customContents, - ); - }, - ); - } - - if (roots.isEmpty) { - return const Component.empty(); - } - - return IdeExplorer( - roots: roots, - customContents: customContents, - ); - } - - /// Parses [IdeExplorerProjectRoot]s from child AST [ElementNode]s. - static List parseRootsFromNode( - ElementNode node, - NodesBuilder builder, - Map customContents, { - String? rootLabel, - }) { - final directElementChildren = - node.children?.whereType().toList(growable: false) ?? []; - - final rootElements = directElementChildren - .where((n) => n.tag == 'IdeRoot') - .toList(growable: false); - - if (rootElements.isNotEmpty) { - return [ - for (final (index, rootEl) in rootElements.indexed) - IdeExplorerProjectRoot( - id: - rootEl.attributes['id'] ?? - (rootEl.attributes['label'] != null - ? slugify(rootEl.attributes['label']!) - : 'root-$index'), - label: rootEl.attributes['label'] ?? '', - children: _parseTreeNodes(rootEl.children, builder, customContents), - ), - ]; - } - - final treeElements = directElementChildren - .where((n) => n.tag == 'IdeFolder' || n.tag == 'IdePage') - .toList(growable: false); - - if (treeElements.isNotEmpty) { - return [ - IdeExplorerProjectRoot( - id: node.attributes['id'] ?? 'root', - label: rootLabel ?? node.attributes['root-label'] ?? '', - children: _parseTreeNodes(node.children, builder, customContents), - ), - ]; - } - - return const []; - } - - static 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 != 'IdeFolder' && child.tag != 'IdePage') { - continue; - } - - final label = child.attributes['label'] ?? ''; - final id = - child.attributes['id'] ?? - (label.isNotEmpty ? slugify(label) : 'node-$index'); - final isDefaultPage = child.attributes['is-default-page'] == 'true'; - final startsClosed = child.attributes['starts-closed'] != null - ? child.attributes['starts-closed'] == 'true' - : true; - final badge = child.attributes['badge']; - final badgeColorStr = child.attributes['badge-color']; - final badgeColor = badgeColorStr != null - ? IdeBadgeColor.fromString(badgeColorStr) - : null; - - final subtitle = child.attributes['subtitle']; - - final nestedTreeNodes = _parseTreeNodes( - child.children, - builder, - customContents, - ); - - final hasBody = - child.children?.any((n) { - if (n is ElementNode && - (n.tag == 'IdeFolder' || n.tag == 'IdePage')) { - return false; - } - if (n is TextNode && n.text.trim().isEmpty) { - return false; - } - return true; - }) ?? - false; - - if (hasBody) { - final contentNodes = child.children! - .where((n) { - if (n is ElementNode && - (n.tag == 'IdeFolder' || n.tag == 'IdePage')) { - return false; - } - return true; - }) - .toList(growable: false); - - if (contentNodes.isNotEmpty) { - customContents[id] = builder.build(contentNodes); - } - } - - result.add( - IdeTreeNode( - id: id, - label: label, - isDefaultPage: isDefaultPage, - startsClosed: startsClosed, - badge: badge, - badgeColor: badgeColor, - subtitle: subtitle, - children: nestedTreeNodes, - ), - ); - } - - return result; - } - - /// Parses raw page data (from YAML/JSON) into a list of [IdeExplorerProjectRoot]s. - static List parseRoots( - Object? rawData, { - String? rootLabel, - }) { - if (rawData is List) { - return [ - IdeExplorerProjectRoot( - id: 'root', - label: rootLabel ?? '', - children: rawData - .whereType>() - .map(IdeTreeNode.fromMap) - .toList(growable: false), - ), - ]; - } - - if (rawData is Map) { - final rawRoots = rawData['roots']; - if (rawRoots is List) { - return rawRoots - .whereType>() - .map(IdeExplorerProjectRoot.fromMap) - .toList(growable: false); - } - } - - throw ArgumentError( - 'Invalid data: expected a list of nodes, or a map with ' - 'a "roots" list.', - ); - } -} From 95d3c93465d981c405380a4c4c156e549d76a9e7 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Fri, 14 Aug 2026 11:24:09 -0700 Subject: [PATCH 15/17] more refactor --- .../lib/_sass/components/_ide-explorer.scss | 393 ++++++------ .../lib/components/common/ide_explorer.dart | 560 ++++++++++++------ 2 files changed, 582 insertions(+), 371 deletions(-) diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss index b693465de1a..fd9a6b96a1e 100644 --- a/packages/site_shared/lib/_sass/components/_ide-explorer.scss +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -1,6 +1,99 @@ @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; @@ -36,9 +129,8 @@ } .ide-root-tabs { - display: flex; - align-items: center; - gap: 0.25rem; + @include flex-center; + gap: $ide-spacing-xs; padding: 0.375rem; border-bottom: 1px solid var(--site-outline-variant); @@ -48,20 +140,13 @@ } .ide-root-tab { + @include ide-button-base; flex: 1; - padding: 0.3rem 0; - border: none; - border-radius: 0.2rem; - background: transparent; + padding: $ide-spacing-sm 0; + border-radius: $ide-radius-sm; color: var(--site-base-fgColor-alt); - font-family: inherit; - font-size: 0.75rem; + font-size: $ide-font-sm; font-weight: 500; - cursor: pointer; - - &:hover { - @include mixins.interaction-style(4%); - } &.active { background-color: var(--site-base-bgColor); @@ -70,24 +155,17 @@ } .ide-toggle-all { - flex-shrink: 0; - display: flex; - align-items: center; + @include no-shrink; + @include flex-center; + @include ide-button-base; justify-content: center; - width: 1.75rem; - height: 1.75rem; - border: none; - border-radius: 0.2rem; - background: transparent; + width: $ide-button-size; + height: $ide-button-size; + border-radius: $ide-radius-sm; color: var(--site-base-fgColor-alt); - cursor: pointer; .material-symbols { - font-size: 1.1rem; - } - - &:hover { - @include mixins.interaction-style(4%); + font-size: $ide-icon-md; } } @@ -102,17 +180,16 @@ } ul { - list-style: none; - margin: 0; + @include reset-list; padding-inline-start: 0.9rem; } - >ul { + > ul { padding-inline-start: 0; } - ul>li.ide-node:last-child { - margin-bottom: .5rem; + ul > li.ide-node:last-child { + margin-bottom: $ide-spacing-lg; } li.ide-node { @@ -123,13 +200,12 @@ margin: 0; } - details[open]>summary { + details[open] > summary { margin: 0; } summary { - display: flex; - align-items: center; + @include flex-center; width: 100%; list-style: none; padding: 0; @@ -142,12 +218,12 @@ } .ide-folder-arrow { + @include no-shrink; display: inline-flex; align-items: center; justify-content: center; - flex-shrink: 0; - width: 1.25rem; - height: 1.5rem; + width: $ide-folder-arrow-width; + height: $ide-folder-arrow-height; padding: 0; padding-inline-start: 0.35rem; border: none; @@ -157,9 +233,9 @@ &::before { content: ''; display: inline-block; - flex-shrink: 0; - width: 0.55rem; - height: 0.55rem; + @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; @@ -170,30 +246,23 @@ } } - details[open]>summary .ide-folder-arrow::before { + details[open] > summary .ide-folder-arrow::before { transform: rotate(90deg); } .ide-node-row { - display: flex; - align-items: center; + @include flex-center; + @include ide-button-base; flex: 1; min-width: 0; - padding: 0.25rem 0.6rem; - border: none; - border-radius: 0.2rem; - background: transparent; + padding: $ide-spacing-xs $ide-spacing-xl; + border-radius: $ide-radius-sm; color: inherit; font-family: var(--site-code-fontFamily); - font-size: 0.8125rem; + font-size: $ide-font-md; text-align: left; - cursor: pointer; outline-offset: -2px; - &:hover { - @include mixins.interaction-style(4%); - } - &.active { background-color: var(--site-primary-color-highlight); color: var(--site-primary-color); @@ -201,20 +270,19 @@ } svg { - flex-shrink: 0; - width: 0.9rem; - height: 0.9rem; - margin-inline-end: 0.4rem; + @include no-shrink; + width: $ide-icon-sm; + height: $ide-icon-sm; + margin-inline-end: $ide-spacing-md; } } li.ide-node-file { - display: flex; - align-items: center; + @include flex-center; width: 100%; - >.ide-node-row { - margin-inline-start: 1.25rem; + > .ide-node-row { + margin-inline-start: $ide-folder-arrow-width; } } @@ -228,92 +296,42 @@ } .ide-badge-dot { - flex-shrink: 0; - width: 0.4rem; - height: 0.4rem; - margin-inline-start: 0.4rem; + @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 { - flex-shrink: 0; - padding: 0.1rem 0.5rem; - border-radius: 1rem; + @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: 0.6875rem; + font-size: $ide-font-xs; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; } -.ide-badge-color-info { - - &.ide-badge-dot, - &.ide-badge { - background-color: var(--site-alert-info-color); - } - - &.ide-badge { - background-color: color-mix(in srgb, var(--site-alert-info-color) 12%, transparent); - color: var(--site-alert-info-color); - } -} - -.ide-badge-color-tip { - - &.ide-badge-dot, - &.ide-badge { - background-color: var(--site-alert-tip-color); - } - - &.ide-badge { - background-color: color-mix(in srgb, var(--site-alert-tip-color) 12%, transparent); - color: var(--site-alert-tip-color); - } -} - -.ide-badge-color-important { - - &.ide-badge-dot, - &.ide-badge { - background-color: var(--site-alert-important-color); - } - - &.ide-badge { - background-color: color-mix(in srgb, var(--site-alert-important-color) 12%, transparent); - color: var(--site-alert-important-color); - } -} - -.ide-badge-color-warning { - - &.ide-badge-dot, - &.ide-badge { - background-color: var(--site-alert-warning-color); - } - - &.ide-badge { - background-color: color-mix(in srgb, var(--site-alert-warning-color) 12%, transparent); - color: var(--site-alert-warning-color); - } -} - -.ide-badge-color-error { - - &.ide-badge-dot, - &.ide-badge { - background-color: var(--site-alert-error-color); - } +// 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, var(--site-alert-error-color) 12%, transparent); - color: var(--site-alert-error-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); @@ -328,11 +346,11 @@ .ide-detail { flex: 1; min-width: 0; - padding: 1.25rem 1.5rem; + padding: $ide-spacing-5xl $ide-spacing-6xl; overflow-y: auto; @include breakpoints.screen-below('md') { - padding: 1rem; + padding: $ide-spacing-4xl; } } @@ -349,29 +367,29 @@ } .ide-path { - margin-block-end: 0.5rem; + margin-block-end: $ide-spacing-lg; color: var(--site-base-fgColor-alt); font-family: var(--site-code-fontFamily); - font-size: 0.75rem; + font-size: $ide-font-sm; } .ide-path-sep { - margin-inline: 0.3rem; + margin-inline: $ide-spacing-sm; color: var(--site-outline); } .ide-detail-header { display: flex; align-items: flex-start; - gap: 0.6rem; - margin-block-end: 1rem; - padding-bottom: 1rem; - border-bottom: .5px solid; + gap: $ide-spacing-xl; + margin-block-end: $ide-spacing-4xl; + padding-bottom: $ide-spacing-4xl; + border-bottom: 0.5px solid; svg { - flex-shrink: 0; - width: 1.5rem; - height: 1.5rem; + @include no-shrink; + width: $ide-icon-lg; + height: $ide-icon-lg; margin-block-start: 0.15rem; } } @@ -389,23 +407,23 @@ .ide-detail-title { font-family: var(--site-code-fontFamily); - font-size: 1.25rem; + font-size: $ide-font-2xl; font-weight: 600; } .ide-detail-subtitle { margin-block-start: 0.15rem; color: var(--site-base-fgColor-alt); - font-size: 0.9375rem; + font-size: $ide-font-xl; } .ide-note { - padding: 0.6rem 0.75rem; - margin-block-end: 1rem; - border-radius: 0.3rem; + 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: 0.9rem; + font-size: $ide-font-lg; p { margin: 0; @@ -413,31 +431,28 @@ } .ide-note-title { + @include uppercase-label; margin-block-end: 0.2rem; color: var(--site-base-fgColor-alt); - font-size: 0.6875rem; - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; } .ide-description { - margin-block-end: 1rem; + margin-block-end: $ide-spacing-4xl; } .ide-tips { - padding: 0.6rem 0.85rem; - margin-block-end: 1rem; + padding: $ide-spacing-xl $ide-spacing-3xl; + margin-block-end: $ide-spacing-4xl; border: 1px solid var(--site-outline-variant); - border-radius: 0.3rem; + border-radius: $ide-radius-md; ul { - margin: 0; + @include reset-list; padding-inline-start: 1.1rem; } li { - margin-block-end: 0.25rem; + margin-block-end: $ide-spacing-xs; &:last-child { margin-block-end: 0; @@ -446,33 +461,30 @@ } .ide-tips-title { - margin-block-end: 0.4rem; + @include uppercase-label; + margin-block-end: $ide-spacing-md; color: var(--site-primary-color); - font-size: 0.6875rem; - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; } .ide-example { - margin-block-end: 1rem; + margin-block-end: $ide-spacing-4xl; } .ide-docs-link { + @include flex-center; display: inline-flex; - align-items: center; - gap: 0.3rem; - padding: 0.3rem 0.75rem; - margin-block-end: 1rem; - border-radius: 1rem; + 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: 0.8125rem; + font-size: $ide-font-md; font-weight: 600; text-decoration: none; .material-symbols { - font-size: 1rem; + font-size: $ide-spacing-4xl; } &:hover { @@ -481,49 +493,40 @@ } .ide-contents-title { - margin-block-end: 0.5rem; + @include uppercase-label; + margin-block-end: $ide-spacing-lg; color: var(--site-base-fgColor-alt); - font-size: 0.6875rem; - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; } .ide-contents-list { display: flex; flex-direction: column; - gap: 0.25rem; + gap: $ide-spacing-xs; } .ide-content-link { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.6rem; - border: none; - border-radius: 0.3rem; + @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: 0.8125rem; + font-size: $ide-font-md; text-align: left; - cursor: pointer; - - &:hover { - @include mixins.interaction-style(4%); - } svg { - flex-shrink: 0; - width: 0.9rem; - height: 0.9rem; + @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: 0.75rem; + font-size: $ide-font-sm; text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer.dart index 7ead53b3807..00755ccdd11 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -11,28 +11,48 @@ import 'package:jaspr_content/jaspr_content.dart'; import '../../util.dart'; import 'material_icon.dart'; - /// A custom markdown component that parses `` and its /// ``, ``, and `` children. Defers /// building the IDE html to the [IdeExplorer] component. class DashIdeExplorer extends CustomComponent { const DashIdeExplorer() : super.base(); + // Tag name constants + static const String _tagIdeExplorer = 'IdeExplorer'; + static const String _tagDashIdeExplorer = 'DashIdeExplorer'; + static const String _tagIdeRoot = 'IdeRoot'; + 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'; + static const String _defaultTrue = 'true'; + @override Component? create(Node node, NodesBuilder builder) { if (node is! ElementNode || - !(node.tag == 'IdeExplorer' || node.tag == 'DashIdeExplorer')) { + !(node.tag == _tagIdeExplorer || node.tag == _tagDashIdeExplorer)) { return null; } final rootElements = node.children ?.whereType() - .where((n) => n.tag == 'IdeRoot') + .where((n) => n.tag == _tagIdeRoot) .toList(growable: false); if (rootElements == null || rootElements.isEmpty) { print( - '[ERROR] requires at least one child element.', + '[ERROR] <$_tagIdeExplorer> requires at least one <$_tagIdeRoot> child element.', ); return const Component.empty(); } @@ -41,12 +61,12 @@ class DashIdeExplorer extends CustomComponent { final roots = [ for (final (index, rootEl) in rootElements.indexed) IdeExplorerProjectRoot( - id: - rootEl.attributes['id'] ?? - (rootEl.attributes['label'] != null - ? slugify(rootEl.attributes['label']!) - : 'root-$index'), - label: rootEl.attributes['label'] ?? '', + id: _generateNodeId( + rootEl.attributes, + _defaultRootPrefix, + index, + ), + label: rootEl.attributes[_attrLabel] ?? '', children: _parseTreeNodes( rootEl.children, builder, @@ -61,6 +81,62 @@ class DashIdeExplorer extends CustomComponent { ); } + /// Generates a node ID from attributes or creates a default one. + static 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'; + } + + /// Parses a boolean attribute value, returning [defaultValue] if not present. + static bool _getBoolAttribute( + Map attributes, + String key, { + required bool defaultValue, + }) { + final value = attributes[key]; + return value != null ? value == _defaultTrue : defaultValue; + } + + /// Checks if a node represents body content (not a folder/page structure). + static 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; + } + + /// Checks if a node has custom body content. + static bool _hasCustomBodyContent(List? children) { + return children?.any(_isBodyContent) ?? false; + } + + /// Extracts non-structural content nodes from children. + static 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); + } + static List _parseTreeNodes( List? nodes, NodesBuilder builder, @@ -71,78 +147,97 @@ class DashIdeExplorer extends CustomComponent { final result = []; for (final (index, child) in nodes.whereType().indexed) { - if (child.tag != 'IdeFolder' && child.tag != 'IdePage') { + if (child.tag != _tagIdeFolder && child.tag != _tagIdePage) { continue; } - final label = child.attributes['label'] ?? ''; - final id = - child.attributes['id'] ?? - (label.isNotEmpty ? slugify(label) : 'node-$index'); - final isDefaultPage = child.attributes['is-default-page'] == 'true'; - final startsClosed = child.attributes['starts-closed'] != null - ? child.attributes['starts-closed'] == 'true' - : true; - - final badge = child.attributes['badge']; - final badgeColorStr = child.attributes['badge-color']; - final badgeColor = badgeColorStr != null - ? IdeBadgeColor.fromString(badgeColorStr) - : null; - - final subtitle = child.attributes['subtitle']; - - final nestedTreeNodes = _parseTreeNodes( - child.children, + final treeNode = _buildTreeNodeFromElement( + child, + index, builder, customContents, ); - final hasBody = - child.children?.any((n) { - if (n is ElementNode && - (n.tag == 'IdeFolder' || n.tag == 'IdePage')) { - return false; - } - if (n is TextNode && n.text.trim().isEmpty) { - return false; - } - return true; - }) ?? - false; - - if (hasBody) { - final contentNodes = child.children! - .where((n) { - if (n is ElementNode && - (n.tag == 'IdeFolder' || n.tag == 'IdePage')) { - return false; - } - return true; - }) - .toList(growable: false); - - if (contentNodes.isNotEmpty) { - customContents[id] = builder.build(contentNodes); - } - } - - result.add( - IdeTreeNode( - id: id, - label: label, - isDefaultPage: isDefaultPage, - startsClosed: startsClosed, - badge: badge, - badgeColor: badgeColor, - subtitle: subtitle, - children: nestedTreeNodes, - ), - ); + result.add(treeNode); } return result; } + + /// Builds a single [IdeTreeNode] from an [ElementNode]. + static 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, + ); + } + + /// Stores custom body content for a node if it exists. + static 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); + } + } } /// A single top-level file tree shown by an [IdeExplorer], which may @@ -220,7 +315,6 @@ typedef _BreadcrumbNode = ({ List path, }); - /// An interactive file-tree explorer, similar to an IDE's sidebar. /// /// Renders a clickable directory tree next to a detail pane that shows @@ -236,6 +330,29 @@ class IdeExplorer extends StatelessComponent { static int _nextInstanceId = 0; + // CSS class constants + static const String _cssIdeExplorer = 'ide-explorer'; + static const String _cssNotContent = 'not-content'; + static const String _cssIdeSidebar = 'ide-sidebar'; + static const String _cssIdeDetail = 'ide-detail'; + static const String _cssIdeDetailPanel = 'ide-detail-panel'; + static const String _cssActive = 'active'; + static const String _cssIdePath = 'ide-path'; + static const String _cssIdePathSep = 'ide-path-sep'; + static const String _cssIdeDetailHeader = 'ide-detail-header'; + static const String _cssIdeDetailHeading = 'ide-detail-heading'; + static const String _cssIdeDetailTitleRow = 'ide-detail-title-row'; + static const String _cssIdeDetailTitle = 'ide-detail-title'; + static const String _cssIdeDetailSubtitle = 'ide-detail-subtitle'; + static const String _cssIdeBadge = 'ide-badge'; + static const String _cssIdeCustomBody = 'ide-custom-body'; + static const String _cssIdeContents = 'ide-contents'; + static const String _cssIdeContentsTitle = 'ide-contents-title'; + static const String _cssIdeContentsList = 'ide-contents-list'; + static const String _cssIdeContentLink = 'ide-content-link'; + static const String _cssIdeNodeLabel = 'ide-node-label'; + static const String _cssIdeContentOneLiner = 'ide-content-one-liner'; + final List roots; final String? instanceId; final Map customContents; @@ -251,7 +368,41 @@ class IdeExplorer extends StatelessComponent { final effectiveInstanceId = instanceId ?? '${_nextInstanceId++}'; - final flatNodesByRoot = { + 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: '$_cssIdeExplorer $_cssNotContent', [ + _buildSidebar( + activeRootId: activeRootId, + effectiveInstanceId: effectiveInstanceId, + selectedNodeDomId: selectedNodeDomId, + ), + _buildDetailPane( + allFlatNodes: allFlatNodes, + 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, @@ -259,56 +410,82 @@ class IdeExplorer extends StatelessComponent { path: root.label.isEmpty ? [] : [root.label], ), }; + } - final allFlatNodes = flatNodesByRoot.values - .expand((nodes) => nodes) - .toList(growable: false); - - final defaultFlatNode = allFlatNodes.firstWhereOrNull( + /// Finds the first node marked as the default page. + _BreadcrumbNode? _findDefaultNode(List<_BreadcrumbNode> allFlatNodes) { + return allFlatNodes.firstWhereOrNull( (flat) => flat.node.isDefaultPage, ); + } - final activeRootId = defaultFlatNode != null - ? (roots - .firstWhereOrNull( - (r) => - flatNodesByRoot[r.id]?.any( - (n) => n.domId == defaultFlatNode.domId, - ) ?? - false, - ) - ?.id ?? - roots.first.id) - : roots.first.id; - - final selectedNodeDomId = - defaultFlatNode?.domId ?? + /// Determines which root should be active based on the default node. + String _determineActiveRoot( + _BreadcrumbNode? defaultFlatNode, + Map> flatNodesByRoot, + ) { + if (defaultFlatNode == null) { + return roots.first.id; + } + + // Find which root contains the default node + 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; + } - return div(classes: 'ide-explorer not-content', [ - div(classes: 'ide-sidebar', [ - if (roots.length > 1) - _buildRootTabs(activeRootId: activeRootId) - else - div(classes: 'ide-root-tabs ide-root-tabs-single', [ - _buildToggleAllButton(), - ]), - for (final root in roots) - _buildTree( - root, - activeRootId: activeRootId, - effectiveInstanceId: effectiveInstanceId, - selectedNodeDomId: selectedNodeDomId, - ), - ]), - div(classes: 'ide-detail', [ - for (final flat in allFlatNodes) - _buildDetailPanel( - flat, - instanceId: effectiveInstanceId, - isActive: flat.domId == selectedNodeDomId, - ), - ]), + /// Builds the sidebar containing the file tree. + Component _buildSidebar({ + required String activeRootId, + required String effectiveInstanceId, + required String? selectedNodeDomId, + }) { + return div(classes: _cssIdeSidebar, [ + if (roots.length > 1) + _buildRootTabs(activeRootId: activeRootId) + else + div(classes: 'ide-root-tabs ide-root-tabs-single', [ + _buildToggleAllButton(), + ]), + for (final root in roots) + _buildTree( + root, + activeRootId: activeRootId, + effectiveInstanceId: effectiveInstanceId, + selectedNodeDomId: selectedNodeDomId, + ), + ]); + } + + /// Builds the detail pane showing node content. + Component _buildDetailPane({ + required List<_BreadcrumbNode> allFlatNodes, + required String effectiveInstanceId, + required String? selectedNodeDomId, + }) { + return div(classes: _cssIdeDetail, [ + for (final flat in allFlatNodes) + _buildDetailPanel( + flat, + instanceId: effectiveInstanceId, + isActive: flat.domId == selectedNodeDomId, + ), ]); } @@ -511,71 +688,102 @@ class IdeExplorer extends StatelessComponent { final node = flat.node; return div( - classes: ['ide-detail-panel', if (isActive) 'active'].toClasses, + classes: [_cssIdeDetailPanel, if (isActive) _cssActive].toClasses, attributes: {'data-ide-panel': flat.domId}, [ - if (flat.path.isNotEmpty) - div( - classes: 'ide-path', - [ - for (final (i, segment) in flat.path.indexed) ...[ - if (i > 0) const span(classes: 'ide-path-sep', [.text('/')]), - span([.text(segment.replaceFirst(RegExp(r'/$'), ''))]), - ], - ], - ), - div(classes: 'ide-detail-header', [ - node.isFolder ? FileIcon.folderIcon : FileIcon.forFile(node.label), - 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)]), - ]), - ]), - + if (flat.path.isNotEmpty) _buildBreadcrumb(flat.path), + _buildDetailPanelHeader(node), if (customContents[node.id] case final customChild?) - div(classes: 'ide-custom-body', [customChild]), - + _buildCustomBody(customChild), if (node.children.isNotEmpty) - div(classes: 'ide-contents', [ - const div(classes: 'ide-contents-title', [.text('Contents')]), - div(classes: 'ide-contents-list', [ - for (final child in node.children) - button( - classes: 'ide-content-link', - type: ButtonType.button, - attributes: { - 'data-ide-select': _domId(instanceId, child.id), - }, - [ - child.isFolder - ? FileIcon.folderIcon - : FileIcon.forFile(child.label), - span(classes: 'ide-node-label', [.text(child.label)]), - if (child.subtitle case final subtitle?) - span( - classes: 'ide-content-one-liner', - [.text(subtitle)], - ), - ], - ), - ]), - ]), + _buildContentsSection(node.children, instanceId), ], ); } -} + /// Builds the breadcrumb path display. + Component _buildBreadcrumb(List path) { + return div( + classes: _cssIdePath, + [ + for (final (i, segment) in path.indexed) ...[ + if (i > 0) const span(classes: _cssIdePathSep, [.text('/')]), + span([.text(segment.replaceFirst(RegExp(r'/$'), ''))]), + ], + ], + ); + } + + /// Builds the detail panel header with icon, title, and badge. + Component _buildDetailPanelHeader(IdeTreeNode node) { + return div(classes: _cssIdeDetailHeader, [ + node.isFolder ? FileIcon.folderIcon : FileIcon.forFile(node.label), + div(classes: _cssIdeDetailHeading, [ + _buildTitleRow(node), + if (node.subtitle case final subtitle?) + div(classes: _cssIdeDetailSubtitle, [.text(subtitle)]), + ]), + ]); + } + + /// Builds the title row with title text and optional badge. + Component _buildTitleRow(IdeTreeNode node) { + return div(classes: _cssIdeDetailTitleRow, [ + div(classes: _cssIdeDetailTitle, [ + .text(node.title ?? node.label), + ]), + if (node.badge case final badge?) _buildBadge(node, badge), + ]); + } + + /// Builds a badge component. + Component _buildBadge(IdeTreeNode node, String badge) { + return span( + classes: [ + _cssIdeBadge, + 'ide-badge-color-${node.badgeColor?.name ?? 'neutral'}', + ].toClasses, + [ + .text(badge), + ], + ); + } + /// Builds the custom body content wrapper. + Component _buildCustomBody(Component customChild) { + return div(classes: _cssIdeCustomBody, [customChild]); + } + + /// Builds the contents section listing child nodes. + Component _buildContentsSection( + List children, + String instanceId, + ) { + return div(classes: _cssIdeContents, [ + const div(classes: _cssIdeContentsTitle, [.text('Contents')]), + div(classes: _cssIdeContentsList, [ + for (final child in children) _buildContentLink(child, instanceId), + ]), + ]); + } + + /// Builds a single content link for a child node. + Component _buildContentLink(IdeTreeNode child, String instanceId) { + return button( + classes: _cssIdeContentLink, + type: ButtonType.button, + attributes: { + 'data-ide-select': _domId(instanceId, child.id), + }, + [ + child.isFolder ? FileIcon.folderIcon : FileIcon.forFile(child.label), + span(classes: _cssIdeNodeLabel, [.text(child.label)]), + if (child.subtitle case final subtitle?) + span( + classes: _cssIdeContentOneLiner, + [.text(subtitle)], + ), + ], + ); + } +} From 6eb79dbb416b58a0d5de368e9eb8ddc6bb004b8b Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Fri, 14 Aug 2026 11:45:47 -0700 Subject: [PATCH 16/17] more refactor --- .../{ => ide_explorer}/ide_explorer.dart | 233 +---------------- .../ide_explorer/markdown_component.dart | 234 ++++++++++++++++++ sites/docs/lib/main.server.dart | 4 +- 3 files changed, 238 insertions(+), 233 deletions(-) rename packages/site_shared/lib/components/common/{ => ide_explorer}/ide_explorer.dart (71%) create mode 100644 packages/site_shared/lib/components/common/ide_explorer/markdown_component.dart diff --git a/packages/site_shared/lib/components/common/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart similarity index 71% rename from packages/site_shared/lib/components/common/ide_explorer.dart rename to packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart index 00755ccdd11..8333c2aa173 100644 --- a/packages/site_shared/lib/components/common/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart @@ -8,237 +8,8 @@ import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/components/file_icon.dart'; import 'package:jaspr_content/jaspr_content.dart'; -import '../../util.dart'; -import 'material_icon.dart'; - -/// A custom markdown component that parses `` and its -/// ``, ``, and `` children. Defers -/// building the IDE html to the [IdeExplorer] component. -class DashIdeExplorer extends CustomComponent { - const DashIdeExplorer() : super.base(); - - // Tag name constants - static const String _tagIdeExplorer = 'IdeExplorer'; - static const String _tagDashIdeExplorer = 'DashIdeExplorer'; - static const String _tagIdeRoot = 'IdeRoot'; - 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'; - static const String _defaultTrue = 'true'; - - @override - Component? create(Node node, NodesBuilder builder) { - if (node is! ElementNode || - !(node.tag == _tagIdeExplorer || node.tag == _tagDashIdeExplorer)) { - return null; - } - - final rootElements = node.children - ?.whereType() - .where((n) => n.tag == _tagIdeRoot) - .toList(growable: false); - - if (rootElements == null || rootElements.isEmpty) { - print( - '[ERROR] <$_tagIdeExplorer> requires at least one <$_tagIdeRoot> child element.', - ); - return const Component.empty(); - } - - final customContents = {}; - final roots = [ - for (final (index, rootEl) in rootElements.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. - static 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'; - } - - /// Parses a boolean attribute value, returning [defaultValue] if not present. - static bool _getBoolAttribute( - Map attributes, - String key, { - required bool defaultValue, - }) { - final value = attributes[key]; - return value != null ? value == _defaultTrue : defaultValue; - } - - /// Checks if a node represents body content (not a folder/page structure). - static 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; - } - - /// Checks if a node has custom body content. - static bool _hasCustomBodyContent(List? children) { - return children?.any(_isBodyContent) ?? false; - } - - /// Extracts non-structural content nodes from children. - static 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); - } - - static 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]. - static 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, - ); - } - - /// Stores custom body content for a node if it exists. - static 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); - } - } -} +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 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..1a4650e138c --- /dev/null +++ b/packages/site_shared/lib/components/common/ide_explorer/markdown_component.dart @@ -0,0 +1,234 @@ +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 _tagDashIdeExplorer = 'DashIdeExplorer'; + static const String _tagIdeRoot = 'IdeRoot'; + 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'; + static const String _defaultTrue = 'true'; + + @override + Component? create(Node node, NodesBuilder builder) { + if (node is! ElementNode || + !(node.tag == _tagIdeExplorer || node.tag == _tagDashIdeExplorer)) { + return null; + } + + final rootElements = node.children + ?.whereType() + .where((n) => n.tag == _tagIdeRoot) + .toList(growable: false); + + if (rootElements == null || rootElements.isEmpty) { + print( + '[ERROR] <$_tagIdeExplorer> requires at least one <$_tagIdeRoot> child element.', + ); + return const Component.empty(); + } + + final customContents = {}; + final roots = [ + for (final (index, rootEl) in rootElements.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. + static 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'; + } + + /// Parses a boolean attribute value, returning [defaultValue] if not present. + static bool _getBoolAttribute( + Map attributes, + String key, { + required bool defaultValue, + }) { + final value = attributes[key]; + return value != null ? value == _defaultTrue : defaultValue; + } + + /// Checks if a node represents body content (not a folder/page structure). + static 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; + } + + /// Checks if a node has custom body content. + static bool _hasCustomBodyContent(List? children) { + return children?.any(_isBodyContent) ?? false; + } + + /// Extracts non-structural content nodes from children. + static 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); + } + + static 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]. + static 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, + ); + } + + /// Stores custom body content for a node if it exists. + static 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); + } + } +} diff --git a/sites/docs/lib/main.server.dart b/sites/docs/lib/main.server.dart index 91dd9eb6710..02c4f7c967a 100644 --- a/sites/docs/lib/main.server.dart +++ b/sites/docs/lib/main.server.dart @@ -8,7 +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.dart'; +import 'package:site_shared/components/common/ide_explorer/ide_explorer.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'; @@ -100,7 +100,7 @@ List get _embeddableComponents => [ const CodePreview(), const YoutubeEmbed(), const FileTree(), - const DashIdeExplorer(), + const IdeExplorerMarkdownComponent(), const Quiz(), const ProgressRing(), const SummaryCard(), From 2ff76c9609786d67359856d9050e656d6d1f8413 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Sat, 15 Aug 2026 11:50:32 -0700 Subject: [PATCH 17/17] more refactor --- .../common/ide_explorer/ide_explorer.dart | 544 +++++++++++------- .../ide_explorer/markdown_component.dart | 110 ++-- sites/docs/lib/main.server.dart | 2 +- .../docs/ai/flutter_bench_task_explorer.md | 4 +- 4 files changed, 397 insertions(+), 263 deletions(-) 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 index 8333c2aa173..2fdbc422825 100644 --- a/packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart +++ b/packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart @@ -6,14 +6,13 @@ import 'package:collection/collection.dart'; import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/components/file_icon.dart'; -import 'package:jaspr_content/jaspr_content.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") +/// (e.g. "Project" and "Shared"). /// /// When multiple roots are provided, they are rendered as tabs that can be /// switched between. @@ -86,6 +85,13 @@ typedef _BreadcrumbNode = ({ 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 @@ -101,36 +107,10 @@ class IdeExplorer extends StatelessComponent { static int _nextInstanceId = 0; - // CSS class constants - static const String _cssIdeExplorer = 'ide-explorer'; - static const String _cssNotContent = 'not-content'; - static const String _cssIdeSidebar = 'ide-sidebar'; - static const String _cssIdeDetail = 'ide-detail'; - static const String _cssIdeDetailPanel = 'ide-detail-panel'; - static const String _cssActive = 'active'; - static const String _cssIdePath = 'ide-path'; - static const String _cssIdePathSep = 'ide-path-sep'; - static const String _cssIdeDetailHeader = 'ide-detail-header'; - static const String _cssIdeDetailHeading = 'ide-detail-heading'; - static const String _cssIdeDetailTitleRow = 'ide-detail-title-row'; - static const String _cssIdeDetailTitle = 'ide-detail-title'; - static const String _cssIdeDetailSubtitle = 'ide-detail-subtitle'; - static const String _cssIdeBadge = 'ide-badge'; - static const String _cssIdeCustomBody = 'ide-custom-body'; - static const String _cssIdeContents = 'ide-contents'; - static const String _cssIdeContentsTitle = 'ide-contents-title'; - static const String _cssIdeContentsList = 'ide-contents-list'; - static const String _cssIdeContentLink = 'ide-content-link'; - static const String _cssIdeNodeLabel = 'ide-node-label'; - static const String _cssIdeContentOneLiner = 'ide-content-one-liner'; - final List roots; final String? instanceId; final Map customContents; - String _domId(String effectiveInstanceId, String nodeId) => - 'ide-$effectiveInstanceId-$nodeId'; - @override Component build(BuildContext context) { if (roots.isEmpty) { @@ -155,14 +135,16 @@ class IdeExplorer extends StatelessComponent { activeRootId, ); - return div(classes: '$_cssIdeExplorer $_cssNotContent', [ - _buildSidebar( + return div(classes: 'ide-explorer not-content', [ + _IdeSidebar( + roots: roots, activeRootId: activeRootId, effectiveInstanceId: effectiveInstanceId, selectedNodeDomId: selectedNodeDomId, ), - _buildDetailPane( + _IdeDetailPane( allFlatNodes: allFlatNodes, + customContents: customContents, effectiveInstanceId: effectiveInstanceId, selectedNodeDomId: selectedNodeDomId, ), @@ -199,7 +181,6 @@ class IdeExplorer extends StatelessComponent { return roots.first.id; } - // Find which root contains the default node final rootWithDefault = roots.firstWhereOrNull( (r) => flatNodesByRoot[r.id]?.any( @@ -221,46 +202,81 @@ class IdeExplorer extends StatelessComponent { flatNodesByRoot[activeRootId]?.firstOrNull?.domId; } - /// Builds the sidebar containing the file tree. - Component _buildSidebar({ - required String activeRootId, - required String effectiveInstanceId, - required String? selectedNodeDomId, + List<_BreadcrumbNode> _flatten( + List nodes, { + required String instanceId, + required List path, }) { - return div(classes: _cssIdeSidebar, [ + 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) - _buildRootTabs(activeRootId: activeRootId) + _IdeRootTabs(roots: roots, activeRootId: activeRootId) else - div(classes: 'ide-root-tabs ide-root-tabs-single', [ - _buildToggleAllButton(), + const div(classes: 'ide-root-tabs ide-root-tabs-single', [ + _IdeToggleAllButton(), ]), for (final root in roots) - _buildTree( - root, - activeRootId: activeRootId, + _IdeTree( + root: root, + isActive: root.id == activeRootId, effectiveInstanceId: effectiveInstanceId, selectedNodeDomId: selectedNodeDomId, ), ]); } +} - /// Builds the detail pane showing node content. - Component _buildDetailPane({ - required List<_BreadcrumbNode> allFlatNodes, - required String effectiveInstanceId, - required String? selectedNodeDomId, - }) { - return div(classes: _cssIdeDetail, [ - for (final flat in allFlatNodes) - _buildDetailPanel( - flat, - instanceId: effectiveInstanceId, - isActive: flat.domId == 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, + }); - Component _buildRootTabs({required String activeRootId}) { + final List roots; + final String activeRootId; + + @override + Component build(BuildContext context) { return div( classes: 'ide-root-tabs', attributes: {'role': 'tablist'}, @@ -278,28 +294,60 @@ class IdeExplorer extends StatelessComponent { }, [.text(root.label.isEmpty ? root.id : root.label)], ), - _buildToggleAllButton(), + const _IdeToggleAllButton(), ], ); } +} - Component _buildTree( - IdeExplorerProjectRoot root, { - required String activeRootId, - required String effectiveInstanceId, - required String? selectedNodeDomId, - }) { +/// 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 (root.id == activeRootId) 'active', + if (isActive) 'active', ].toClasses, attributes: {'data-ide-root': root.id}, [ ul([ for (final node in root.children) - _buildTreeNode( - node, + _IdeTreeNode( + node: node, instanceId: effectiveInstanceId, selectedDomId: selectedNodeDomId, ), @@ -307,75 +355,40 @@ class IdeExplorer extends StatelessComponent { ], ); } +} - Component _buildToggleAllButton() { - 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'), - ], - ); - } +/// 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, + }); - 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: _domId(instanceId, node.id), - path: path, - )); - result.addAll( - _flatten( - node.children, - instanceId: instanceId, - path: nodePath, - ), - ); - } - return result; - } + final IdeTreeNode node; + final String instanceId; + final String? selectedDomId; - bool _hasSelectedChild( - IdeTreeNode node, - String instanceId, - String? selectedDomId, - ) { + bool _hasSelectedChild(IdeTreeNode parent) { if (selectedDomId == null) return false; - for (final child in node.children) { - if (_domId(instanceId, child.id) == selectedDomId) return true; - if (_hasSelectedChild(child, instanceId, selectedDomId)) return true; + for (final child in parent.children) { + if (_ideDomId(instanceId, child.id) == selectedDomId) return true; + if (_hasSelectedChild(child)) return true; } return false; } - Component _buildTreeNode( - IdeTreeNode node, { - required String instanceId, - required String? selectedDomId, - }) { - final domId = _domId(instanceId, node.id); + @override + Component build(BuildContext context) { + final domId = _ideDomId(instanceId, node.id); final isSelected = domId == selectedDomId; - final row = _buildNodeRow(node, domId: domId, isSelected: isSelected); + 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, instanceId, selectedDomId); + final isOpen = !node.startsClosed || _hasSelectedChild(node); return li(classes: 'ide-node ide-node-folder', [ details( @@ -394,8 +407,8 @@ class IdeExplorer extends StatelessComponent { ), ul([ for (final child in node.children) - _buildTreeNode( - child, + _IdeTreeNode( + node: child, instanceId: instanceId, selectedDomId: selectedDomId, ), @@ -404,12 +417,22 @@ class IdeExplorer extends StatelessComponent { ), ]); } +} - Component _buildNodeRow( - IdeTreeNode node, { - required String domId, - required bool isSelected, - }) { +/// 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); @@ -424,19 +447,29 @@ class IdeExplorer extends StatelessComponent { [ icon, span(classes: 'ide-node-label', [.text(node.label)]), - _buildBadgeDot( - node.badge, - node.badgeColor ?? IdeBadgeColor.neutral, + _IdeBadgeDot( + badge: node.badge, + color: node.badgeColor ?? IdeBadgeColor.neutral, ), ], ); } +} - Component _buildBadgeDot( - String? badge, [ - IdeBadgeColor color = 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', @@ -444,114 +477,217 @@ class IdeExplorer extends StatelessComponent { ].toClasses, attributes: { 'role': 'img', - 'aria-label': badge, - 'title': badge, + 'aria-label': badge!, + 'title': badge!, }, [], ); } +} - Component _buildDetailPanel( - _BreadcrumbNode flat, { - required String instanceId, - required bool isActive, - }) { +// --------------------------------------------------------------------------- +// 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: [_cssIdeDetailPanel, if (isActive) _cssActive].toClasses, + classes: ['ide-detail-panel', if (isActive) 'active'].toClasses, attributes: {'data-ide-panel': flat.domId}, [ - if (flat.path.isNotEmpty) _buildBreadcrumb(flat.path), - _buildDetailPanelHeader(node), - if (customContents[node.id] case final customChild?) - _buildCustomBody(customChild), - if (node.children.isNotEmpty) - _buildContentsSection(node.children, instanceId), + 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, + ), ], ); } +} - /// Builds the breadcrumb path display. - Component _buildBreadcrumb(List path) { +/// 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: _cssIdePath, + classes: 'ide-path', [ for (final (i, segment) in path.indexed) ...[ - if (i > 0) const span(classes: _cssIdePathSep, [.text('/')]), + 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}); - /// Builds the detail panel header with icon, title, and badge. - Component _buildDetailPanelHeader(IdeTreeNode node) { - return div(classes: _cssIdeDetailHeader, [ - node.isFolder ? FileIcon.folderIcon : FileIcon.forFile(node.label), - div(classes: _cssIdeDetailHeading, [ - _buildTitleRow(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: _cssIdeDetailSubtitle, [.text(subtitle)]), + div(classes: 'ide-detail-subtitle', [.text(subtitle)]), ]), ]); } +} - /// Builds the title row with title text and optional badge. - Component _buildTitleRow(IdeTreeNode node) { - return div(classes: _cssIdeDetailTitleRow, [ - div(classes: _cssIdeDetailTitle, [ - .text(node.title ?? node.label), - ]), - if (node.badge case final badge?) _buildBadge(node, badge), - ]); - } +/// Container for the custom markdown prose and code blocks of a page/folder. +class _IdePageBody extends StatelessComponent { + const _IdePageBody({required this.child}); - /// Builds a badge component. - Component _buildBadge(IdeTreeNode node, String badge) { - return span( - classes: [ - _cssIdeBadge, - 'ide-badge-color-${node.badgeColor?.name ?? 'neutral'}', - ].toClasses, - [ - .text(badge), - ], - ); - } + final Component child; - /// Builds the custom body content wrapper. - Component _buildCustomBody(Component customChild) { - return div(classes: _cssIdeCustomBody, [customChild]); + @override + Component build(BuildContext context) { + return div(classes: 'ide-custom-body', [child]); } +} - /// Builds the contents section listing child nodes. - Component _buildContentsSection( - List children, - String instanceId, - ) { - return div(classes: _cssIdeContents, [ - const div(classes: _cssIdeContentsTitle, [.text('Contents')]), - div(classes: _cssIdeContentsList, [ - for (final child in children) _buildContentLink(child, instanceId), +/// 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); - /// Builds a single content link for a child node. - Component _buildContentLink(IdeTreeNode child, String instanceId) { return button( - classes: _cssIdeContentLink, + classes: 'ide-content-link', type: ButtonType.button, attributes: { - 'data-ide-select': _domId(instanceId, child.id), + 'data-ide-select': _ideDomId(instanceId, child.id), }, [ - child.isFolder ? FileIcon.folderIcon : FileIcon.forFile(child.label), - span(classes: _cssIdeNodeLabel, [.text(child.label)]), + icon, + span(classes: 'ide-node-label', [.text(child.label)]), if (child.subtitle case final subtitle?) span( - classes: _cssIdeContentOneLiner, + 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 index 1a4650e138c..19fc3aaa8e2 100644 --- a/packages/site_shared/lib/components/common/ide_explorer/markdown_component.dart +++ b/packages/site_shared/lib/components/common/ide_explorer/markdown_component.dart @@ -5,15 +5,14 @@ import '../../../util.dart'; import 'ide_explorer.dart'; /// A custom markdown component that parses `` and its -/// ``, ``, and `` children. Defers +/// ``, ``, 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 _tagDashIdeExplorer = 'DashIdeExplorer'; - static const String _tagIdeRoot = 'IdeRoot'; + static const String _tagIdeProjectRoot = 'IdeProjectRoot'; static const String _tagIdeFolder = 'IdeFolder'; static const String _tagIdePage = 'IdePage'; @@ -29,30 +28,29 @@ class IdeExplorerMarkdownComponent extends CustomComponent { // Default values static const String _defaultRootPrefix = 'root'; static const String _defaultNodePrefix = 'node'; - static const String _defaultTrue = 'true'; @override Component? create(Node node, NodesBuilder builder) { - if (node is! ElementNode || - !(node.tag == _tagIdeExplorer || node.tag == _tagDashIdeExplorer)) { + if (node is! ElementNode || node.tag != _tagIdeExplorer) { return null; } - final rootElements = node.children + final projectRootElements = node.children ?.whereType() - .where((n) => n.tag == _tagIdeRoot) - .toList(growable: false); + .where((n) => n.tag == _tagIdeProjectRoot) + .toList(); - if (rootElements == null || rootElements.isEmpty) { + if (projectRootElements == null || projectRootElements.isEmpty) { print( - '[ERROR] <$_tagIdeExplorer> requires at least one <$_tagIdeRoot> child element.', + '[ERROR] <$_tagIdeExplorer> requires at ' + 'least one <$_tagIdeProjectRoot> child element.', ); return const Component.empty(); } final customContents = {}; final roots = [ - for (final (index, rootEl) in rootElements.indexed) + for (final (index, rootEl) in projectRootElements.indexed) IdeExplorerProjectRoot( id: _generateNodeId( rootEl.attributes, @@ -75,7 +73,7 @@ class IdeExplorerMarkdownComponent extends CustomComponent { } /// Generates a node ID from attributes or creates a default one. - static String _generateNodeId( + String _generateNodeId( Map attributes, String prefix, int index, @@ -90,47 +88,7 @@ class IdeExplorerMarkdownComponent extends CustomComponent { return '$prefix-$index'; } - /// Parses a boolean attribute value, returning [defaultValue] if not present. - static bool _getBoolAttribute( - Map attributes, - String key, { - required bool defaultValue, - }) { - final value = attributes[key]; - return value != null ? value == _defaultTrue : defaultValue; - } - - /// Checks if a node represents body content (not a folder/page structure). - static 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; - } - - /// Checks if a node has custom body content. - static bool _hasCustomBodyContent(List? children) { - return children?.any(_isBodyContent) ?? false; - } - - /// Extracts non-structural content nodes from children. - static 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); - } - - static List _parseTreeNodes( + List _parseTreeNodes( List? nodes, NodesBuilder builder, Map customContents, @@ -158,7 +116,7 @@ class IdeExplorerMarkdownComponent extends CustomComponent { } /// Builds a single [IdeTreeNode] from an [ElementNode]. - static IdeTreeNode _buildTreeNodeFromElement( + IdeTreeNode _buildTreeNodeFromElement( ElementNode element, int index, NodesBuilder builder, @@ -215,8 +173,43 @@ class IdeExplorerMarkdownComponent extends CustomComponent { ); } + /// 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. - static void _storeCustomContentIfPresent( + void _storeCustomContentIfPresent( List? children, String nodeId, NodesBuilder builder, @@ -231,4 +224,9 @@ class IdeExplorerMarkdownComponent extends CustomComponent { 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/main.server.dart b/sites/docs/lib/main.server.dart index 02c4f7c967a..5068063b7a8 100644 --- a/sites/docs/lib/main.server.dart +++ b/sites/docs/lib/main.server.dart @@ -8,7 +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/ide_explorer.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'; 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 index b0290cd0a1e..47a8ed4ffb1 100644 --- a/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md +++ b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md @@ -1,6 +1,6 @@ - + @@ -191,6 +191,6 @@ target artifacts evaluated during grading. - +