diff --git a/packages/site_shared/lib/_sass/base/_breakpoints.scss b/packages/site_shared/lib/_sass/base/_breakpoints.scss new file mode 100644 index 0000000000..c1005c0395 --- /dev/null +++ b/packages/site_shared/lib/_sass/base/_breakpoints.scss @@ -0,0 +1,31 @@ +@use 'sass:map'; + +$breakpoints: ( + 'sm': 640px, + 'md': 768px, + 'lg': 1024px, + 'xl': 1280px, + '2xl': 1536px, + 'max': 1440px, + '3xl': 1920px, +); + +@mixin screen($name) { + $value: map.get($breakpoints, $name); + @if $value == null { + @error 'Unknown breakpoint: #{$name}'; + } + @media (min-width: $value) { + @content; + } +} + +@mixin screen-below($name) { + $value: map.get($breakpoints, $name); + @if $value == null { + @error 'Unknown breakpoint: #{$name}'; + } + @media (max-width: $value - 1px) { + @content; + } +} \ No newline at end of file diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss new file mode 100644 index 0000000000..695bb9aed0 --- /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: min(600px, 80vh); + margin-block: 1.71em; + border: 1px solid var(--site-outline); + border-radius: var(--site-radius); + background-color: var(--site-base-bgColor); + font-family: var(--site-ui-fontFamily); + + + @include breakpoints.screen-below('md') { + flex-direction: column; + height: auto; + } +} + +.ide-sidebar { + display: flex; + flex-direction: column; + flex-shrink: 0; + width: min(15rem, 35%); + border-right: 1px solid var(--site-outline); + background-color: var(--site-raised-bgColor-translucent); + + @include breakpoints.screen-below('md') { + width: auto; + max-height: 14rem; + border-right: none; + border-bottom: 1px solid var(--site-outline); + } +} + +.ide-root-tabs { + 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 0000000000..7044aaa71e --- /dev/null +++ b/packages/site_shared/lib/components/common/ide_explorer.dart @@ -0,0 +1,635 @@ +// 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'; + +/// 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 [], + }; + + return IdeExplorerProjectRoot( + id: id, + label: label, + children: children, + ); + } +} + +/// A single file or folder entry in an [IdeExplorer] tree. +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 => + _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 [], + }; + + 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, + ); + } +} + +/// 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, + }); + + final String label; + final IdeBadgeTone tone; + + 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 _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]. +class IdeExplorer extends StatelessComponent { + const IdeExplorer({ + super.key, + required this.roots, + this.instanceId, + 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) => + '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(growable: false); + 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, + 'role': 'tab', + 'aria-selected': '${rootIndex == 0}', + }, + [.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, + instanceId: effectiveInstanceId, + selectedDomId: firstNodeDomId, + ), + ]), + ], + ), + ]), + div(classes: 'ide-detail', [ + for (final flat in allFlatNodes) + _buildDetailPanel( + flat, + instanceId: effectiveInstanceId, + 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', + 'aria-label': 'Expand or collapse all folders', + }, + [ + MaterialIcon('unfold_more', label: 'Expand or collapse all folders'), + ], + ); + } + + 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; + } + + 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 + : 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, + if (isSelected) 'aria-current': 'true', + }, + [ + 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, + if (isSelected) 'aria-current': 'true', + }, + [ + icon, + span(classes: 'ide-node-label', [.text(node.label)]), + _buildBadgeDot(node.badge), + ], + ), + ul([ + for (final child in node.children) + _buildTreeNode( + child, + instanceId: instanceId, + 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: { + 'role': 'img', + 'aria-label': badge.label, + 'title': badge.label, + }, + [], + ); + } + + Component _buildDetailPanel( + _BreadcrumbNode flat, { + required String instanceId, + 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 (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.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')]), + 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.oneLiner case final oneLiner?) + span( + classes: 'ide-content-one-liner', + [.text(oneLiner)], + ), + ], + ), + ]), + ]), + ], + ); + } +} + +/// 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: +/// ```html +/// +/// +/// Arbitrary markdown content for the node with id 'someNodeId'. +/// +/// +/// ``` +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 dataKey = node.attributes['data']; + if (dataKey == null) { + throw ArgumentError( + 'The element requires a "data" attribute naming a key ' + 'in the page data.', + ); + } + + 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]; + if (rawData == null) { + throw ArgumentError('No page data found for "$dataKey".'); + } + + final roots = parseRoots( + rawData, + rootLabel: node.attributes['rootLabel'], + ); + + return 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/lib/_sass/_site.scss b/sites/docs/lib/_sass/_site.scss index 99092c6a67..8945534d0f 100644 --- a/sites/docs/lib/_sass/_site.scss +++ b/sites/docs/lib/_sass/_site.scss @@ -37,6 +37,7 @@ @use 'package:site_shared/_sass/components/code'; @use 'package:site_shared/_sass/components/cookie-notice'; @use 'package:site_shared/_sass/components/dropdown'; +@use 'package:site_shared/_sass/components/ide-explorer'; @use 'package:site_shared/_sass/components/menu-toggle'; @use 'package:site_shared/_sass/components/progress-ring'; @use 'package:site_shared/_sass/components/quiz'; diff --git a/sites/docs/lib/main.server.dart b/sites/docs/lib/main.server.dart index 2738c1fd43..9617b42d50 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'; @@ -29,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'; @@ -99,6 +101,7 @@ List get _embeddableComponents => [ const CodePreview(), const YoutubeEmbed(), const FileTree(), + const DashIdeExplorer(), const Quiz(), const ProgressRing(), const SummaryCard(), @@ -126,4 +129,5 @@ List get _embeddableComponents => [ 'DevToolsReleaseNotesIndex', const DevToolsReleaseNotesIndex(), ), + const FlutterBenchTaskExplorer(), ]; diff --git a/sites/docs/lib/src/client/global_scripts.dart b/sites/docs/lib/src/client/global_scripts.dart index caa9bf379d..cc4e0e0778 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/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 0000000000..404466b149 --- /dev/null +++ b/sites/docs/lib/src/components/pages/flutter_bench_task_ide_explorer.dart @@ -0,0 +1,51 @@ +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 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( + 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, + ), + ]); + }, + ); + } +} diff --git a/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md new file mode 100644 index 0000000000..62e2c9ef95 --- /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 e4985cc52b..9dc42e3dbd 100644 --- a/sites/docs/src/content/ai/evals.md +++ b/sites/docs/src/content/ai/evals.md @@ -28,3 +28,5 @@ Evals measure both deterministic code correctness (compilation, lints, automated tests) and qualitative performance (reasoning, safety, and conciseness) using automated model judges and expert human grading. + +{% render "docs/ai/flutter_bench_task_explorer.md" %} 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 0000000000..b25497cd83 --- /dev/null +++ b/sites/docs/src/data/flutter_bench_task_example.yml @@ -0,0 +1,157 @@ +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