diff --git a/packages/site_shared/lib/_sass/components/_blog.scss b/packages/site_shared/lib/_sass/components/_blog.scss index eb8e688cd2..f132a91fad 100644 --- a/packages/site_shared/lib/_sass/components/_blog.scss +++ b/packages/site_shared/lib/_sass/components/_blog.scss @@ -157,14 +157,11 @@ margin-top: 0.25rem; margin-bottom: 0.5rem; color: var(--site-base-fgColor); + } - a { - color: inherit; - text-decoration: none; - - &:hover { - color: var(--site-primary-color); - } + &:hover, &:focus-visible { + .blog-card-title { + color: var(--site-link-fgColor); } } @@ -238,7 +235,7 @@ &:hover { transform: translateY(-4px); box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.1); - border-color: var(--site-primary-color); + border-color: var(--site-link-fgColor); } .blog-card-image { @@ -365,16 +362,6 @@ } } - > a { - text-decoration: none; - color: inherit; - display: block; - - &:hover .post-info-name { - color: var(--site-primary-color); - } - } - .post-info-author { display: flex; align-items: center; @@ -434,6 +421,10 @@ font-weight: var(--site-fontWeight-bold); } + a:hover { + color: var(--site-link-fgColor); + } + .post-info-author-name { white-space: nowrap; } @@ -485,7 +476,7 @@ color: var(--site-base-fgColor); &:hover { - color: var(--site-primary-color); + color: var(--site-link-fgColor); } } } diff --git a/packages/site_shared/lib/_sass/components/_code.scss b/packages/site_shared/lib/_sass/components/_code.scss index acc8e2e417..5ad9733d7b 100644 --- a/packages/site_shared/lib/_sass/components/_code.scss +++ b/packages/site_shared/lib/_sass/components/_code.scss @@ -469,3 +469,13 @@ iframe[src^="https://dartpad"] { } } } + +body.dark-mode { + .opal, + .opal span { + color: var(--opal-dark-color) !important; + font-style: var(--opal-dark-font-style) !important; + font-weight: var(--opal-dark-font-weight) !important; + text-decoration: var(--opal-dark-text-decoration) !important; + } +} diff --git a/packages/site_shared/lib/_sass/components/_theming.scss b/packages/site_shared/lib/_sass/components/_theming.scss index 23458610ea..c7fc8944eb 100644 --- a/packages/site_shared/lib/_sass/components/_theming.scss +++ b/packages/site_shared/lib/_sass/components/_theming.scss @@ -1,3 +1,11 @@ +body:not(.dark-mode) .dark-mode-visible { + display: none !important; +} + +body.dark-mode .light-mode-visible { + display: none !important; +} + #theme-switcher { position: relative; diff --git a/packages/site_shared/lib/components/blog/blog_card.dart b/packages/site_shared/lib/components/blog/blog_card.dart index 2833d3e6b4..1bcd6b51f3 100644 --- a/packages/site_shared/lib/components/blog/blog_card.dart +++ b/packages/site_shared/lib/components/blog/blog_card.dart @@ -9,42 +9,39 @@ import 'package:jaspr_content/jaspr_content.dart'; import '../../blog.dart'; import '../../util.dart'; +import 'blog_card_layout.dart'; class BlogCard extends StatelessComponent { const BlogCard({ required this.post, required this.url, - this.className, - this.priority = BlogCardPriority.normal, + this.layout = .grid, super.key, }); final Post post; final String url; - final String? className; - final BlogCardPriority priority; + final BlogCardLayout layout; @override Component build(BuildContext context) { final authors = context.page.authorsByIds(post.authorIds); return a( href: url, - classes: ['blog-card', ?className].toClasses, + classes: ['blog-card', layout.className].toClasses, attributes: {'data-category': post.category}, [ if (post.coverImage?.url case final postImage?) div(classes: 'blog-card-image', [ img( src: postImage, - // The link already includes the post title below the image. + // The image is decorative since the title follows it. alt: '', - loading: switch (priority) { - .featured => MediaLoading.eager, - .high => MediaLoading.lazy, - .normal => MediaLoading.lazy, - }, + loading: layout == .featured + ? MediaLoading.eager + : MediaLoading.lazy, attributes: { - if (priority == .featured) + if (layout == .featured) 'fetchpriority': 'high' else 'decoding': 'async', @@ -96,19 +93,3 @@ class BlogCard extends StatelessComponent { ); } } - -/// Priority levels for blog cards. -/// -/// Used to configure loading behavior of their content, particularly images. -enum BlogCardPriority { - /// A featured blog card. - featured, - - /// A high-priority blog card. - /// - /// Often the first 5 or so cards. - high, - - /// A normal-priority blog card. - normal, -} diff --git a/packages/site_shared/lib/components/blog/blog_card_layout.dart b/packages/site_shared/lib/components/blog/blog_card_layout.dart new file mode 100644 index 0000000000..8c41798585 --- /dev/null +++ b/packages/site_shared/lib/components/blog/blog_card_layout.dart @@ -0,0 +1,37 @@ +// 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. + +/// The layout of a card in the blog index, +/// determined by its position among the visible cards. +enum BlogCardLayout { + /// A full-width card with a large image and title, + /// used to highlight the most prominent post. + featured, + + /// A bordered tile that's arranged in a grid with other cards. + grid, + + /// A full-width row with a small image beside the post details. + list, + ; + + /// The CSS class applied to a `.blog-card` element to + /// style it with this layout. + String get className => switch (this) { + .featured => 'layout-featured', + .grid => 'layout-grid', + .list => 'layout-list', + }; + + /// Returns the layout for the card at the specified [index] + /// among the visible cards. + /// + /// The first card is [featured], the next four are [grid], + /// and all remaining cards are [list]. + static BlogCardLayout forIndex(int index) => switch (index) { + 0 => featured, + < 5 => grid, + _ => list, + }; +} diff --git a/packages/site_shared/lib/components/blog/blog_index.dart b/packages/site_shared/lib/components/blog/blog_index.dart index b9b7c3ba05..71e2186fb3 100644 --- a/packages/site_shared/lib/components/blog/blog_index.dart +++ b/packages/site_shared/lib/components/blog/blog_index.dart @@ -65,18 +65,11 @@ class BlogIndex extends StatelessComponent { classes: 'blog-posts-grid', attributes: {'data-selected': 'all'}, [ - for (var i = 0; i < posts.length; i++) + for (final (i, (:post, :url)) in posts.indexed) BlogCard( - post: posts[i].post, - url: posts[i].url, - priority: switch (i) { - 0 => BlogCardPriority.featured, - < 5 => BlogCardPriority.high, - _ => BlogCardPriority.normal, - }, - className: i == 0 - ? 'layout-featured' - : (i < 5 ? 'layout-grid' : 'layout-list'), + post: post, + url: url, + layout: .forIndex(i), ), ], ), diff --git a/packages/site_shared/lib/components/blog/blog_next_posts.dart b/packages/site_shared/lib/components/blog/blog_next_posts.dart index a3076991bd..af3521cbd7 100644 --- a/packages/site_shared/lib/components/blog/blog_next_posts.dart +++ b/packages/site_shared/lib/components/blog/blog_next_posts.dart @@ -44,11 +44,10 @@ class BlogNextPosts extends StatelessComponent { return section(classes: 'blog-next-posts', [ h2([.text(nextPostsTitle)]), div(classes: 'blog-posts-grid', [ - for (final post in nextPosts) + for (final (:post, :url) in nextPosts) BlogCard( - post: post.post, - url: post.url, - className: 'layout-grid', + post: post, + url: url, ), ]), ]); diff --git a/packages/site_shared/lib/components/blog/client/blog_categories.dart b/packages/site_shared/lib/components/blog/client/blog_categories.dart index 2719bde888..cf7ec042ba 100644 --- a/packages/site_shared/lib/components/blog/client/blog_categories.dart +++ b/packages/site_shared/lib/components/blog/client/blog_categories.dart @@ -10,6 +10,7 @@ import 'package:jaspr/jaspr.dart'; import 'package:universal_web/web.dart' as web; import '../../../util.dart'; +import '../blog_card_layout.dart'; class BlogCategory { const BlogCategory({ @@ -81,6 +82,10 @@ class _BlogCategoriesState extends State { .where((c) => c.slug == slug) .firstOrNull; + // The cards already have the layout for the selected category, + // including the server-rendered layout when no category is selected. + if (category == selectedCategory) return; + setState(() { selectedCategory = category; }); @@ -105,38 +110,25 @@ class _BlogCategoriesState extends State { void _applyLayout(BlogCategory? category) { final container = web.document.getElementById('blog-container'); - if (container != null) { - if (category == null) { - container.removeAttribute('data-selected'); - } else { - container.setAttribute('data-selected', category.slug); - } + if (container == null) return; + + container.setAttribute('data-selected', category?.slug ?? 'all'); + + final cards = container.querySelectorAll('.blog-card'); + final showFeatured = category?.showFeatured ?? true; + var visibleCount = showFeatured ? 0 : 1; + + for (var cardIndex = 0; cardIndex < cards.length; cardIndex += 1) { + final card = cards.item(cardIndex) as web.Element; + final cardCategory = card.getAttribute('data-category'); + + final isVisible = category == null || cardCategory == category.slug; + final targetLayout = isVisible + ? BlogCardLayout.forIndex(visibleCount++) + : null; - final cards = container.querySelectorAll('.blog-card'); - final showFeatured = category?.showFeatured ?? true; - var visibleCount = showFeatured ? 0 : 1; - - for (var i = 0; i < cards.length; i++) { - final card = cards.item(i) as web.Element; - final cardCategory = card.getAttribute('data-category'); - - final isVisible = category == null || cardCategory == category.slug; - - // Remove existing layout classes. - card.classList.remove('layout-featured'); - card.classList.remove('layout-grid'); - card.classList.remove('layout-list'); - - if (isVisible) { - if (visibleCount == 0) { - card.classList.add('layout-featured'); - } else if (visibleCount < 5) { - card.classList.add('layout-grid'); - } else { - card.classList.add('layout-list'); - } - visibleCount++; - } + for (final layout in BlogCardLayout.values) { + card.classList.toggle(layout.className, layout == targetLayout); } } } diff --git a/packages/site_shared/lib/components/common/dropdown.dart b/packages/site_shared/lib/components/common/dropdown.dart index 0f55d31ee4..592cf425c5 100644 --- a/packages/site_shared/lib/components/common/dropdown.dart +++ b/packages/site_shared/lib/components/common/dropdown.dart @@ -40,7 +40,7 @@ final class DropdownState extends State { return GlobalEventListener( onClick: (event) { if (!_expanded) return; - final target = event.target as web.HTMLElement?; + final target = event.target as web.Element?; if (target == null || target.closest('#${component.id}') == null) { toggle(to: false); } @@ -58,7 +58,7 @@ final class DropdownState extends State { }, 'focusout': (e) { final relatedTarget = - (e as web.FocusEvent).relatedTarget as web.HTMLElement?; + (e as web.FocusEvent).relatedTarget as web.Element?; if (relatedTarget == null || relatedTarget.closest('#${component.id}') == null) { toggle(to: false); @@ -79,9 +79,16 @@ final class DropdownState extends State { }, child: component.toggle, ), - div(id: '${component.id}-content', classes: 'dropdown-content', [ - component.content, - ]), + div( + id: '${component.id}-content', + classes: 'dropdown-content', + // Make the content focusable, but not tabbable, + // so clicking an area of it that isn't otherwise focusable keeps + // focus within the dropdown rather than triggering the `focusout` + // handler above with a null `relatedTarget` and closing it. + attributes: const {'tabindex': '-1'}, + [component.content], + ), ], ), ); diff --git a/packages/site_shared/lib/components/layout/theme_switcher.dart b/packages/site_shared/lib/components/layout/theme_switcher.dart index a06e647c47..4456d92719 100644 --- a/packages/site_shared/lib/components/layout/theme_switcher.dart +++ b/packages/site_shared/lib/components/layout/theme_switcher.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'package:universal_web/web.dart' as web; @@ -12,7 +14,18 @@ import '../common/material_icon.dart'; @client final class ThemeSwitcher extends StatefulComponent { - const ThemeSwitcher(); + const ThemeSwitcher({super.key}); + + @override + State createState() => _ThemeSwitcherState(); +} + +/// A [ThemeSwitcher] variant for use inside an existing `@client` component, +/// such as a client-hydrated header, to avoid nested `@client` anchors. +// TODO: Remove this variant once +// Jaspr fixes nested `@client` components handling. +final class NestedThemeSwitcher extends StatefulComponent { + const NestedThemeSwitcher({super.key}); @override State createState() => _ThemeSwitcherState(); @@ -32,47 +45,118 @@ enum _Theme { String get id => '$name-mode'; } -final class _ThemeSwitcherState extends State { - _Theme _currentTheme = _Theme.light; +final class _ThemeSwitcherState extends State { + /// The theme the user selected, which is marked as selected in the menu. + _Theme _currentTheme = .light; + + /// The media query for whether the device prefers a dark color scheme, + /// used to resolve the [_Theme.auto] theme. + late final web.MediaQueryList _prefersDarkQuery; + + /// The subscriptions to window and media query events, + /// which are canceled when this state is disposed. + final List> _subscriptions = []; @override void initState() { + super.initState(); if (kIsWeb) { - final classList = web.document.body!.classList; - // If them theme is auto, it and the result will be added as classes. - // So it should be checked for first. - if (classList.contains(_Theme.auto.id)) { - _currentTheme = _Theme.auto; - } else if (classList.contains(_Theme.dark.id)) { - _currentTheme = _Theme.dark; - } else if (classList.contains(_Theme.light.id)) { - _currentTheme = _Theme.light; - } else { - // Default to light mode if no theme is set yet. - _currentTheme = _Theme.light; - classList.add(_Theme.light.id); - } + _prefersDarkQuery = web.window.matchMedia('(prefers-color-scheme: dark)'); + _syncThemeFromStorage(); + _subscriptions.addAll([ + web.EventStreamProviders.pageShowEvent + .forTarget(web.window) + .listen(_onPageShow), + web.EventStreamProviders.storageEvent + .forTarget(web.window) + .listen(_onStorageChange), + web.EventStreamProviders.changeEvent + .forTarget(_prefersDarkQuery) + .listen(_onPrefersDarkChange), + ]); } + } - super.initState(); + @override + void dispose() { + // DOM event stream cancellations complete synchronously and + // don't need to be awaited in synchronous lifecycle teardown. + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + super.dispose(); } - void _setTheme(_Theme newTheme) { - if (newTheme == _currentTheme) return; + /// Resyncs the theme when the page is restored from the back/forward cache, + /// as it might have been changed on another page. + void _onPageShow(web.Event event) { + if ((event as web.PageTransitionEvent).persisted) { + _syncThemeFromStorage(); + } + } - final classList = web.document.body!.classList; - for (final mode in _Theme.values) { - classList.remove(mode.id); + /// Resyncs the theme when another tab or window changes + /// the stored theme preference or clears storage. + void _onStorageChange(web.StorageEvent event) { + if (event.key == null || event.key == 'theme') { + _syncThemeFromStorage(); } - classList.add(newTheme.id); - if (newTheme == _Theme.auto) { - classList.add( - web.window.matchMedia('(prefers-color-scheme: dark)').matches - ? _Theme.dark.id - : _Theme.light.id, + } + + /// Reapplies the automatic theme when + /// the device's preferred color scheme changes. + void _onPrefersDarkChange(web.Event _) { + if (_currentTheme == .auto) { + _applyThemeToBody(.auto); + } + } + + /// Updates the theme classes on the document body to reflect [theme], + /// resolving [_Theme.auto] to light or dark based on the device preference. + void _applyThemeToBody(_Theme theme) { + final classList = web.document.body?.classList; + if (classList == null) return; + + final isAuto = theme == .auto; + final isDark = isAuto ? _prefersDarkQuery.matches : theme == .dark; + + classList.toggle(_Theme.light.id, !isDark); + classList.toggle(_Theme.dark.id, isDark); + classList.toggle(_Theme.auto.id, isAuto); + } + + /// Applies the theme preference saved in local storage + /// and updates [_currentTheme] to match it. + void _syncThemeFromStorage() { + _Theme resolvedTheme; + try { + // Match the early theme script, which defaults to light + // when no valid theme is stored. + final storedTheme = web.window.localStorage.getItem('theme'); + resolvedTheme = _Theme.values.firstWhere( + (theme) => theme.id == storedTheme, + orElse: () => .light, ); + } catch (_) { + // localStorage is not available, keep the current theme. + resolvedTheme = _currentTheme; } + _applyThemeToBody(resolvedTheme); + + if (resolvedTheme != _currentTheme) { + setState(() { + _currentTheme = resolvedTheme; + }); + } + } + + /// Applies [newTheme] and saves it as the user's theme preference. + void _setTheme(_Theme newTheme) { + if (newTheme == _currentTheme) return; + + _applyThemeToBody(newTheme); + try { web.window.localStorage.setItem('theme', newTheme.id); } catch (e) { diff --git a/packages/site_shared/lib/server_util.dart b/packages/site_shared/lib/server_util.dart index be8240d576..e803312882 100644 --- a/packages/site_shared/lib/server_util.dart +++ b/packages/site_shared/lib/server_util.dart @@ -6,3 +6,4 @@ library; export 'src/utils/cache_busted_build_asset_url.dart'; +export 'src/utils/theme_script.dart'; diff --git a/packages/site_shared/lib/src/layouts/dash_layout.dart b/packages/site_shared/lib/src/layouts/dash_layout.dart index ddd2b23d17..2553ad6b63 100644 --- a/packages/site_shared/lib/src/layouts/dash_layout.dart +++ b/packages/site_shared/lib/src/layouts/dash_layout.dart @@ -11,6 +11,7 @@ import 'package:jaspr_content/jaspr_content.dart'; import '../../components/layout/banner.dart'; import '../../util.dart'; import '../utils/cache_busted_build_asset_url.dart'; +import '../utils/theme_script.dart'; /// The base Jaspr Content layout for all sites. abstract class DashLayout implements PageLayout { @@ -247,24 +248,7 @@ function glueCookieNotificationBarLoaded() { children: [ // The theme setting logic should remain before other scripts to // avoid a flash of the initial theme on load. - const script( - content: ''' -try { - const storedTheme = window.localStorage.getItem('theme') ?? 'light-mode'; - if (storedTheme === 'auto-mode') { - const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)'); - document.body.classList.add( - 'auto-mode', - prefersDarkMode.matches ? 'dark-mode' : 'light-mode', - ); - } else { - document.body.classList.add(storedTheme); - } -} catch (e) { - // localStorage is not available, do nothing and fall back to default. -} - ''', - ), + themeInitScript, const a( id: 'skip-to-main', classes: 'filled-button', diff --git a/packages/site_shared/lib/src/utils/theme_script.dart b/packages/site_shared/lib/src/utils/theme_script.dart new file mode 100644 index 0000000000..5da723e65e --- /dev/null +++ b/packages/site_shared/lib/src/utils/theme_script.dart @@ -0,0 +1,25 @@ +// 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'; + +/// Inline `