From ca6f88d83236289d66f1909fa55d5d386819fe3b Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 9 Jul 2026 20:57:23 -0400 Subject: [PATCH 1/4] feat: add BlockVariant for named widget block styles Adds an opt-in Mix context selector so Dart stylesheets can target a named WidgetBlock (e.g. @webview, @chart) without adding style fields to Markdown, block arguments, or frontmatter. --- docs/reference/deck-options.mdx | 46 ++++ docs/reference/markdown-syntax.mdx | 12 + packages/superdeck/CHANGELOG.md | 4 +- .../src/rendering/blocks/block_widget.dart | 24 +- .../lib/src/styling/block_variant.dart | 57 ++++ packages/superdeck/lib/superdeck.dart | 1 + .../test/src/rendering/block_widget_test.dart | 252 ++++++++++++++++++ .../test/src/rendering/slide_view_test.dart | 57 ++++ .../test/src/styling/block_variant_test.dart | 107 ++++++++ 9 files changed, 550 insertions(+), 10 deletions(-) create mode 100644 packages/superdeck/lib/src/styling/block_variant.dart create mode 100644 packages/superdeck/test/src/styling/block_variant_test.dart diff --git a/docs/reference/deck-options.mdx b/docs/reference/deck-options.mdx index 7920de76..c0e31f2d 100644 --- a/docs/reference/deck-options.mdx +++ b/docs/reference/deck-options.mdx @@ -34,6 +34,52 @@ DeckOptions( ) ``` +#### Named widget block styles + +Use `BlockVariant` in a `SlideStyle` stylesheet to target every named widget +block with an exact, case-sensitive name. The selector applies to the block +container and its complete widget subtree, so container spacing and descendant +Mix styles resolve together. + +```dart +import 'package:mix/mix.dart'; +import 'package:superdeck/superdeck.dart'; + +const webviewBlock = BlockVariant('webview'); + +final options = DeckOptions( + baseStyle: SlideStyle( + blockContainer: BoxStyler( + padding: EdgeInsetsGeometryMix.all(40), + ).variants([ + VariantStyle( + webviewBlock, + BoxStyler(padding: EdgeInsetsGeometryMix.all(0)), + ), + ]), + ), +); +``` + +`BlockVariant('webview')` matches both `@webview` and `@widget` blocks whose +`name` is `webview`. Custom shorthand annotations work the same way, so +`@chart` matches `BlockVariant('chart')`. The matching rule uses normal Mix +precedence after the base style, and its margin and padding are included when +SuperDeck calculates the child widget's usable size. + +Only `WidgetBlock` names are selectable. Content blocks and section containers +are not selectable; there are no per-instance IDs, classes, attributes, or +selector composition. A plain `NamedVariant('image')` remains a manual Mix +variant and is not activated merely because an `@image` block is rendered. +Every widget block with the same name receives the same rule. Hardcoded styling +inside a custom widget is unchanged unless that widget declares Mix styles that +can respond to the inherited `BlockVariant`. + +Fullscreen layout does not create a separate block selector. It only removes +header and footer chrome, so the same `BlockVariant('webview')` rule applies to +normal and fullscreen WebView blocks; those two layouts cannot receive +different rules based on `BlockVariant` alone. + ### `styles` **Type:** `Map` | **Default:** `{}` diff --git a/docs/reference/markdown-syntax.mdx b/docs/reference/markdown-syntax.mdx index 194cfe50..f251b537 100644 --- a/docs/reference/markdown-syntax.mdx +++ b/docs/reference/markdown-syntax.mdx @@ -44,6 +44,18 @@ SuperDeck supports three core block types. Built-in widgets (`image`, `dartpad`, `webview`, `qrcode`) use the same widget syntax as `@widget`. +### Styling named widget blocks + +Widget names can be selected from Dart stylesheets with `BlockVariant`. For +example, `@webview` and the equivalent `@widget { name: "webview" }` both +match `BlockVariant('webview')`; `@chart` matches `BlockVariant('chart')`. +Matching is exact and case-sensitive. + +The selector affects the widget block container and descendants in that widget +subtree. It does not select `@block`, `@section`, individual block instances, +or arbitrary `NamedVariant` values. See the [DeckOptions reference](/reference/deck-options#named-widget-block-styles) +for the stylesheet declaration and limitations. + ## Common block properties ### `flex` diff --git a/packages/superdeck/CHANGELOG.md b/packages/superdeck/CHANGELOG.md index 8b07c7b4..85e4b256 100644 --- a/packages/superdeck/CHANGELOG.md +++ b/packages/superdeck/CHANGELOG.md @@ -1,7 +1,7 @@ ## Unreleased -- Support `layout: fullscreen` slides, which suppress resolved headers and - footers while preserving the resolved background and style. +- Add `BlockVariant` for opt-in, name-based `WidgetBlock` styling in Dart + stylesheets. - Export `FileDeckLoader` and `BundledDeckLoader` from the public barrel so apps can use `SuperDeckApp.deckLoader` without importing from `src/`. - Fall back to bundled deck assets when auto-selection cannot discover a diff --git a/packages/superdeck/lib/src/rendering/blocks/block_widget.dart b/packages/superdeck/lib/src/rendering/blocks/block_widget.dart index c3ce8a3b..60a0dc93 100644 --- a/packages/superdeck/lib/src/rendering/blocks/block_widget.dart +++ b/packages/superdeck/lib/src/rendering/blocks/block_widget.dart @@ -6,6 +6,7 @@ import 'package:mix/mix.dart'; import 'package:superdeck_core/superdeck_core.dart'; import '../../deck/slide_configuration.dart'; +import '../../styling/block_variant.dart'; import '../../styling/components/slide.dart'; import '../../ui/widgets/error_widgets.dart'; import '../../ui/widgets/overflow_clip.dart'; @@ -39,8 +40,12 @@ class _BlockContainer extends StatefulWidget { class _BlockContainerState extends State<_BlockContainer> { @override Widget build(context) { - // Get the resolved SlideSpec (provided by SlideView) - final spec = SlideSpec.of(context); + // Widget blocks resolve their style inside [BlockVariantScope] so a named + // block variant affects both the rendered container and its child size. + final spec = switch (widget.block) { + WidgetBlock() => widget.configuration.style.resolve(context).spec, + ContentBlock() => SlideSpec.of(context), + }; final blockOffset = spec.blockContainer.spec.calculateBlockOffset; @@ -204,12 +209,15 @@ class CustomBlockWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return _BlockContainer( - block: block, - size: size, - configuration: configuration, - runtimeKey: runtimeKey, - child: _CustomBlockChild(block: block), + return BlockVariantScope( + name: block.name, + child: _BlockContainer( + block: block, + size: size, + configuration: configuration, + runtimeKey: runtimeKey, + child: _CustomBlockChild(block: block), + ), ); } } diff --git a/packages/superdeck/lib/src/styling/block_variant.dart b/packages/superdeck/lib/src/styling/block_variant.dart new file mode 100644 index 00000000..7fe230c1 --- /dev/null +++ b/packages/superdeck/lib/src/styling/block_variant.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +/// A Mix context selector for a named widget-block container and subtree. +/// +/// A block variant is active only while building a widget block whose resolved +/// name exactly matches [name]. +final class BlockVariant extends ContextVariant { + /// The widget block name this variant matches. + final String name; + + const BlockVariant(this.name) : super('block_$name', _neverMatches); + + @override + bool Function(BuildContext) get shouldApply => when; + + @override + bool when(BuildContext context) { + return BlockVariantScope.maybeOf(context)?.name == name; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is BlockVariant && other.name == name; + } + + @override + int get hashCode => name.hashCode; +} + +bool _neverMatches(BuildContext _) => false; + +/// Provides the current named widget block to descendants. +/// +/// This scope is used by SuperDeck's renderer. It is intentionally not +/// exported from the public package barrel. +class BlockVariantScope extends InheritedWidget { + /// The resolved widget-block name for this subtree. + final String name; + + const BlockVariantScope({ + super.key, + required this.name, + required super.child, + }); + + /// Returns the nearest block variant scope, if one is active. + static BlockVariantScope? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(BlockVariantScope oldWidget) { + return name != oldWidget.name; + } +} diff --git a/packages/superdeck/lib/superdeck.dart b/packages/superdeck/lib/superdeck.dart index 31ba7ce7..db2527ad 100644 --- a/packages/superdeck/lib/superdeck.dart +++ b/packages/superdeck/lib/superdeck.dart @@ -12,6 +12,7 @@ export 'src/thumbnails/async_thumbnail.dart'; export 'src/thumbnails/thumbnail_service.dart'; // Styling +export 'src/styling/block_variant.dart' show BlockVariant; export 'src/styling/default_style.dart'; export 'src/styling/components/markdown_alert.dart'; export 'src/styling/components/markdown_alert_type.dart'; diff --git a/packages/superdeck/test/src/rendering/block_widget_test.dart b/packages/superdeck/test/src/rendering/block_widget_test.dart index 86062aa0..2886861f 100644 --- a/packages/superdeck/test/src/rendering/block_widget_test.dart +++ b/packages/superdeck/test/src/rendering/block_widget_test.dart @@ -1,5 +1,8 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:superdeck/superdeck.dart' show BlockVariant, SlideStyle; +import 'package:superdeck/src/rendering/blocks/block_provider.dart'; import 'package:superdeck/src/rendering/blocks/block_widget.dart'; import 'package:superdeck_core/superdeck_core.dart'; @@ -214,6 +217,219 @@ void main() { }); }); + group('named widget block variants', () { + testWidgets( + 'changes only the matching custom widget container and its usable size', + (tester) async { + _setSlideViewport(tester); + late Size chartSize; + late Size siblingSize; + + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'custom-block-variant', + sections: [ + SectionBlock([ + WidgetBlock(name: 'chart'), + WidgetBlock(name: 'sibling'), + ]), + ], + ), + style: SlideStyle( + blockContainer: BoxStyler(padding: EdgeInsetsGeometryMix.all(40)) + .variants([ + VariantStyle( + const BlockVariant('chart'), + BoxStyler(padding: EdgeInsetsGeometryMix.all(0)), + ), + ]), + ), + widgets: { + 'chart': (_) => + _BlockSizeProbe(onBuild: (size) => chartSize = size), + 'sibling': (_) => + _BlockSizeProbe(onBuild: (size) => siblingSize = size), + }, + ); + + expect(chartSize, const Size(640, 620)); + expect(siblingSize, const Size(560, 540)); + }, + ); + + testWidgets( + 'applies the same selector to equivalent resolved widget blocks', + (tester) async { + _setSlideViewport(tester); + final sizes = {}; + + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'equivalent-webview-blocks', + sections: [ + SectionBlock([ + WidgetBlock( + name: 'webview', + args: const {'source': 'shorthand'}, + ), + WidgetBlock( + name: 'webview', + args: const {'source': 'widget'}, + ), + ]), + ], + ), + style: SlideStyle( + blockContainer: BoxStyler(padding: EdgeInsetsGeometryMix.all(40)) + .variants([ + VariantStyle( + const BlockVariant('webview'), + BoxStyler(padding: EdgeInsetsGeometryMix.all(0)), + ), + ]), + ), + widgets: { + 'webview': (args) => _BlockSizeProbe( + onBuild: (size) => sizes[args['source']! as String] = size, + ), + }, + ); + + expect(sizes, { + 'shorthand': const Size(640, 620), + 'widget': const Size(640, 620), + }); + }, + ); + + testWidgets('keeps plain NamedVariant rules inactive for named blocks', ( + tester, + ) async { + _setSlideViewport(tester); + late Size imageSize; + + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'legacy-named-variant', + sections: [ + SectionBlock([WidgetBlock(name: 'image')]), + ], + ), + style: SlideStyle( + blockContainer: BoxStyler(padding: EdgeInsetsGeometryMix.all(40)) + .variants([ + VariantStyle( + const NamedVariant('image'), + BoxStyler(padding: EdgeInsetsGeometryMix.all(0)), + ), + ]), + ), + widgets: { + 'image': (_) => + _BlockSizeProbe(onBuild: (size) => imageSize = size), + }, + ); + + expect(imageSize, const Size(1200, 540)); + }); + + testWidgets('keeps default image and gist container spacing unchanged', ( + tester, + ) async { + _setSlideViewport(tester); + late Size imageSize; + late Size gistSize; + + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'default-image-gist-spacing', + sections: [ + SectionBlock([ + WidgetBlock(name: 'image'), + WidgetBlock(name: 'gist'), + ]), + ], + ), + widgets: { + 'image': (_) => + _BlockSizeProbe(onBuild: (size) => imageSize = size), + 'gist': (_) => _BlockSizeProbe(onBuild: (size) => gistSize = size), + }, + ); + + expect(imageSize, const Size(560, 540)); + expect(gistSize, const Size(560, 540)); + }); + + testWidgets( + 'exposes the active selector to a nested Mix-styled descendant', + (tester) async { + _setSlideViewport(tester); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'nested-mix-descendant', + sections: [ + SectionBlock([WidgetBlock(name: 'webview')]), + ], + ), + widgets: {'webview': (_) => const _VariantAwareWidget()}, + ); + + final paddings = tester + .widgetList(find.byType(Container)) + .map((container) => container.padding) + .toList(); + + expect(paddings, contains(const EdgeInsets.all(24))); + }, + ); + + testWidgets( + 'uses matching variant margin and padding once in BlockConfiguration', + (tester) async { + _setSlideViewport(tester); + late Size blockSize; + + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'variant-layout-offset', + sections: [ + SectionBlock([WidgetBlock(name: 'webview')]), + ], + ), + style: SlideStyle( + blockContainer: + BoxStyler( + padding: EdgeInsetsGeometryMix.all(10), + margin: EdgeInsetsGeometryMix.all(5), + ).variants([ + VariantStyle( + const BlockVariant('webview'), + BoxStyler( + padding: EdgeInsetsGeometryMix.all(20), + margin: EdgeInsetsGeometryMix.all(10), + ), + ), + ]), + ), + widgets: { + 'webview': (_) => + _BlockSizeProbe(onBuild: (size) => blockSize = size), + }, + ); + + expect(blockSize, const Size(1220, 560)); + expect(tester.takeException(), isNull); + }, + ); + }); + group('markdown content types', () { testWidgets('renders headings and lists', (tester) async { await SlideTestHarness.pumpSlide(tester, SlideFixtures.mixedMarkdown()); @@ -255,6 +471,42 @@ class _TallWidget extends StatelessWidget { } } +class _BlockSizeProbe extends StatelessWidget { + const _BlockSizeProbe({required this.onBuild}); + + final ValueChanged onBuild; + + @override + Widget build(BuildContext context) { + onBuild(BlockConfiguration.of(context).size); + return const SizedBox.shrink(); + } +} + +class _VariantAwareWidget extends StatelessWidget { + const _VariantAwareWidget(); + + @override + Widget build(BuildContext context) { + return Box( + style: BoxStyler(padding: EdgeInsetsGeometryMix.all(4)).variants([ + VariantStyle( + const BlockVariant('webview'), + BoxStyler(padding: EdgeInsetsGeometryMix.all(24)), + ), + ]), + child: const SizedBox(width: 1, height: 1), + ); + } +} + +void _setSlideViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); +} + Alignment _toAlignment(ContentAlignment alignment) { return switch (alignment) { ContentAlignment.topLeft => Alignment.topLeft, diff --git a/packages/superdeck/test/src/rendering/slide_view_test.dart b/packages/superdeck/test/src/rendering/slide_view_test.dart index 44bb1add..9f25a2f2 100644 --- a/packages/superdeck/test/src/rendering/slide_view_test.dart +++ b/packages/superdeck/test/src/rendering/slide_view_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; import 'package:superdeck/superdeck.dart'; import 'package:superdeck/src/deck/slide_configuration_builder.dart'; +import 'package:superdeck/src/rendering/blocks/block_provider.dart'; import 'package:superdeck/src/rendering/blocks/block_widget.dart'; import 'package:superdeck/src/rendering/slides/slide_view.dart'; import 'package:superdeck/src/ui/widgets/provider.dart'; @@ -190,6 +192,61 @@ void main() { expect(sectionRect.top, 0); expect(sectionRect.size, const Size(1280, 720)); }); + + testWidgets( + 'fullscreen removes only chrome and preserves named block styles', + (tester) async { + late Size blockSize; + final slide = Slide( + key: 'fullscreen-block-variant', + options: SlideOptions(layout: SlideLayout.fullscreen), + sections: [ + SectionBlock([WidgetBlock(name: 'webview')]), + ], + ); + final configuration = + const SlideConfigurationBuilder().buildConfigurations( + [slide], + DeckOptions( + baseStyle: SlideStyle( + blockContainer: + BoxStyler( + padding: EdgeInsetsGeometryMix.all(40), + ).variants([ + VariantStyle( + const BlockVariant('webview'), + BoxStyler(padding: EdgeInsetsGeometryMix.all(0)), + ), + ]), + ), + widgets: { + 'webview': (_) => Builder( + builder: (context) { + blockSize = BlockConfiguration.of(context).size; + return const SizedBox.shrink(); + }, + ), + }, + parts: const SlideParts( + header: PreferredSize( + preferredSize: Size.fromHeight(80), + child: SizedBox.shrink(), + ), + footer: PreferredSize( + preferredSize: Size.fromHeight(40), + child: SizedBox.shrink(), + ), + ), + ), + ).single; + + await SlideTestHarness.pumpConfiguration(tester, configuration); + + expect(configuration.parts!.header, isNull); + expect(configuration.parts!.footer, isNull); + expect(blockSize, const Size(1280, 720)); + }, + ); }); group('slide size', () { diff --git a/packages/superdeck/test/src/styling/block_variant_test.dart b/packages/superdeck/test/src/styling/block_variant_test.dart new file mode 100644 index 00000000..91ee1068 --- /dev/null +++ b/packages/superdeck/test/src/styling/block_variant_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck/superdeck.dart' show BlockVariant; +import 'package:superdeck/src/styling/block_variant.dart' + show BlockVariantScope; + +void main() { + group('BlockVariant', () { + testWidgets('matches only the exact, case-sensitive block name', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: BlockVariantScope( + name: 'webview', + child: Builder( + builder: (context) { + expect(const BlockVariant('webview').when(context), isTrue); + expect( + const BlockVariant('webview').shouldApply(context), + isTrue, + ); + expect(const BlockVariant('WebView').when(context), isFalse); + expect( + const BlockVariant('WebView').shouldApply(context), + isFalse, + ); + expect(const BlockVariant('image').when(context), isFalse); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + }); + + testWidgets('uses the nearest scope for nested widget blocks', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: BlockVariantScope( + name: 'webview', + child: Column( + children: [ + Builder( + builder: (context) { + expect(const BlockVariant('webview').when(context), isTrue); + return const SizedBox.shrink(); + }, + ), + BlockVariantScope( + name: 'chart', + child: Builder( + builder: (context) { + expect(const BlockVariant('chart').when(context), isTrue); + expect( + const BlockVariant('webview').when(context), + isFalse, + ); + return const SizedBox.shrink(); + }, + ), + ), + Builder( + builder: (context) { + expect(const BlockVariant('webview').when(context), isTrue); + return const SizedBox.shrink(); + }, + ), + ], + ), + ), + ), + ); + }); + + testWidgets('does not leak outside its widget subtree', (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + BlockVariantScope( + name: 'image', + child: Builder( + builder: (context) { + expect(const BlockVariant('image').when(context), isTrue); + return const SizedBox.shrink(); + }, + ), + ), + Builder( + builder: (context) { + expect(const BlockVariant('image').when(context), isFalse); + return const SizedBox.shrink(); + }, + ), + ], + ), + ), + ); + }); + }); +} From d75b467b07507236952cab24316ec6618b4b68a8 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 9 Jul 2026 21:05:24 -0400 Subject: [PATCH 2/4] feat: render @webview blocks edge-to-edge by default Adds a default BlockVariant('webview') rule to the base SlideStyle so webview blocks get zero padding and margin without any stylesheet. Uses BlockVariant rather than NamedVariant because a plain NamedVariant does not activate for a rendered widget block. --- docs/reference/deck-options.mdx | 5 ++++ packages/superdeck/CHANGELOG.md | 1 + .../lib/src/styling/default_style.dart | 13 ++++++++++ .../test/src/rendering/block_widget_test.dart | 25 +++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/docs/reference/deck-options.mdx b/docs/reference/deck-options.mdx index c0e31f2d..d3abca04 100644 --- a/docs/reference/deck-options.mdx +++ b/docs/reference/deck-options.mdx @@ -67,6 +67,11 @@ final options = DeckOptions( precedence after the base style, and its margin and padding are included when SuperDeck calculates the child widget's usable size. +SuperDeck already ships this exact zero padding and margin rule for `@webview` +blocks by default, so a webview renders edge-to-edge without any stylesheet. The +example above therefore doubles as the pattern for giving any other named block +its own spacing. + Only `WidgetBlock` names are selectable. Content blocks and section containers are not selectable; there are no per-instance IDs, classes, attributes, or selector composition. A plain `NamedVariant('image')` remains a manual Mix diff --git a/packages/superdeck/CHANGELOG.md b/packages/superdeck/CHANGELOG.md index 85e4b256..8af6d486 100644 --- a/packages/superdeck/CHANGELOG.md +++ b/packages/superdeck/CHANGELOG.md @@ -2,6 +2,7 @@ - Add `BlockVariant` for opt-in, name-based `WidgetBlock` styling in Dart stylesheets. +- Render `@webview` blocks edge-to-edge (zero padding and margin) by default. - Export `FileDeckLoader` and `BundledDeckLoader` from the public barrel so apps can use `SuperDeckApp.deckLoader` without importing from `src/`. - Fall back to bundled deck assets when auto-selection cannot discover a diff --git a/packages/superdeck/lib/src/styling/default_style.dart b/packages/superdeck/lib/src/styling/default_style.dart index f2cff336..97cda36e 100644 --- a/packages/superdeck/lib/src/styling/default_style.dart +++ b/packages/superdeck/lib/src/styling/default_style.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:mix/mix.dart'; +import 'block_variant.dart'; import 'components/markdown_alert.dart'; import 'components/markdown_alert_type.dart'; import 'components/markdown_blockquote.dart'; @@ -30,6 +31,11 @@ TextStyle get _baseTextStyle => _safeGoogleFont( const onGist = NamedVariant('gist'); const onImage = NamedVariant('image'); +// A webview fills its own block, so it ships edge-to-edge by default. This uses +// [BlockVariant] because a plain [NamedVariant] does not activate for a rendered +// widget block; only [BlockVariant] reliably targets the `@webview` container. +const onWebview = BlockVariant('webview'); + WidgetModifierConfig _pad(EdgeInsetsGeometryMix value) => WidgetModifierConfig.padding(value); @@ -230,6 +236,13 @@ SlideStyle _createDefaultSlideStyle() { margin: EdgeInsetsGeometryMix.all(0), ), ), + VariantStyle( + onWebview, + BoxStyler( + padding: EdgeInsetsGeometryMix.all(0), + margin: EdgeInsetsGeometryMix.all(0), + ), + ), ]), slideContainer: BoxStyler(), diff --git a/packages/superdeck/test/src/rendering/block_widget_test.dart b/packages/superdeck/test/src/rendering/block_widget_test.dart index 2886861f..594c36a6 100644 --- a/packages/superdeck/test/src/rendering/block_widget_test.dart +++ b/packages/superdeck/test/src/rendering/block_widget_test.dart @@ -365,6 +365,31 @@ void main() { expect(gistSize, const Size(560, 540)); }); + testWidgets('gives webview blocks zero padding and margin by default', ( + tester, + ) async { + _setSlideViewport(tester); + late Size webviewSize; + + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'default-webview-spacing', + sections: [ + SectionBlock([WidgetBlock(name: 'webview')]), + ], + ), + widgets: { + 'webview': (_) => + _BlockSizeProbe(onBuild: (size) => webviewSize = size), + }, + ); + + // Full block size with no padding/margin subtracted (default 40 padding + // would yield 1200x540). + expect(webviewSize, const Size(1280, 620)); + }); + testWidgets( 'exposes the active selector to a nested Mix-styled descendant', (tester) async { From 61b181d6f8a09a9a08be1611e4ccc0f0d5bd3184 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 10 Jul 2026 10:41:04 -0400 Subject: [PATCH 3/4] chore(demo): sync macOS Xcode project with native plugin pods Regenerated by pod install: adds irondash_engine_context and super_native_extensions pods and the Embed Pods Frameworks build phase so the macOS demo builds without a manual pod install. --- demo/macos/Podfile.lock | 12 ++++++++++++ demo/macos/Runner.xcodeproj/project.pbxproj | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/demo/macos/Podfile.lock b/demo/macos/Podfile.lock index 9d359e12..86c3a2b6 100644 --- a/demo/macos/Podfile.lock +++ b/demo/macos/Podfile.lock @@ -1,15 +1,27 @@ PODS: - FlutterMacOS (1.0.0) + - irondash_engine_context (0.0.1): + - FlutterMacOS + - super_native_extensions (0.0.1): + - FlutterMacOS DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) + - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) + - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`) EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral + irondash_engine_context: + :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos + super_native_extensions: + :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba + super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 diff --git a/demo/macos/Runner.xcodeproj/project.pbxproj b/demo/macos/Runner.xcodeproj/project.pbxproj index a5630017..9dec7ab9 100644 --- a/demo/macos/Runner.xcodeproj/project.pbxproj +++ b/demo/macos/Runner.xcodeproj/project.pbxproj @@ -244,6 +244,7 @@ 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 8A14AB4F804B7CAA2E64DF2E /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -392,6 +393,23 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 8A14AB4F804B7CAA2E64DF2E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; E1EAB9F2FA8DCA9689F733BC /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; From da2d9f716e385261fddb0ce01ad925a2edb8a6c7 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 10 Jul 2026 11:01:33 -0400 Subject: [PATCH 4/4] fix: paint an opaque floor behind every slide SlideParts.background defaults to a transparent BackgroundPart, so a slide with no background (e.g. a chrome-less template) rendered see-through and bled adjacent slides through during cross-fade transitions. Always paint an opaque base color beneath the background part, sourced from a shared constant reused by the app shell letterbox. --- packages/superdeck/CHANGELOG.md | 2 ++ .../lib/src/rendering/slides/slide_view.dart | 6 ++++ packages/superdeck/lib/src/ui/app_shell.dart | 2 +- .../superdeck/lib/src/utils/constants.dart | 9 +++++ .../test/src/rendering/slide_view_test.dart | 36 +++++++++++++++++++ 5 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/superdeck/CHANGELOG.md b/packages/superdeck/CHANGELOG.md index 8af6d486..1d3421a9 100644 --- a/packages/superdeck/CHANGELOG.md +++ b/packages/superdeck/CHANGELOG.md @@ -3,6 +3,8 @@ - Add `BlockVariant` for opt-in, name-based `WidgetBlock` styling in Dart stylesheets. - Render `@webview` blocks edge-to-edge (zero padding and margin) by default. +- Always paint an opaque slide background so background-less slides no longer + bleed adjacent slides through during transitions. - Export `FileDeckLoader` and `BundledDeckLoader` from the public barrel so apps can use `SuperDeckApp.deckLoader` without importing from `src/`. - Fall back to bundled deck assets when auto-selection cannot discover a diff --git a/packages/superdeck/lib/src/rendering/slides/slide_view.dart b/packages/superdeck/lib/src/rendering/slides/slide_view.dart index d59a6184..220d8057 100644 --- a/packages/superdeck/lib/src/rendering/slides/slide_view.dart +++ b/packages/superdeck/lib/src/rendering/slides/slide_view.dart @@ -101,6 +101,12 @@ class SlideView extends StatelessWidget { size: kResolution, child: Stack( children: [ + // Opaque floor so a slide is never transparent: a transparent or + // absent background part would otherwise let other slides bleed + // through during cross-fade transitions. + const Positioned.fill( + child: ColoredBox(color: kSlideBackgroundColor), + ), // Background fills entire viewport (not affected by modifier) Positioned.fill(child: backgroundWidget), // Content wrapped with StyleBuilder to apply modifiers diff --git a/packages/superdeck/lib/src/ui/app_shell.dart b/packages/superdeck/lib/src/ui/app_shell.dart index d7a492ab..24d8e2cf 100644 --- a/packages/superdeck/lib/src/ui/app_shell.dart +++ b/packages/superdeck/lib/src/ui/app_shell.dart @@ -274,7 +274,7 @@ class _SplitViewState extends State final buildFailure = deckController.session.buildFailure.value; return Scaffold( - backgroundColor: const Color.fromARGB(255, 9, 9, 9), + backgroundColor: kSlideBackgroundColor, floatingActionButtonLocation: FloatingActionButtonLocation.miniEndFloat, floatingActionButton: _buildFloatingAction( diff --git a/packages/superdeck/lib/src/utils/constants.dart b/packages/superdeck/lib/src/utils/constants.dart index 85fb7e09..32bfab2e 100644 --- a/packages/superdeck/lib/src/utils/constants.dart +++ b/packages/superdeck/lib/src/utils/constants.dart @@ -8,5 +8,14 @@ const _kHeight = 720.0; const kResolution = Size(_kWidth, _kHeight); +/// Opaque backdrop painted behind a slide when no background part is set. +/// +/// Guarantees a slide is never transparent: without it, a background-less slide +/// (e.g. a template with no chrome) would show through to whatever is behind it, +/// which bleeds the outgoing/incoming slide through during cross-fade +/// transitions. Matches the app shell letterbox so the default is invisible +/// except during transitions. +const kSlideBackgroundColor = Color(0xFF090909); + const kIsTest = bool.fromEnvironment('FLUTTER_TEST'); const kCanRunProcess = kDebugMode && !kIsWeb && !kIsTest; diff --git a/packages/superdeck/test/src/rendering/slide_view_test.dart b/packages/superdeck/test/src/rendering/slide_view_test.dart index 9f25a2f2..deefa900 100644 --- a/packages/superdeck/test/src/rendering/slide_view_test.dart +++ b/packages/superdeck/test/src/rendering/slide_view_test.dart @@ -7,6 +7,7 @@ import 'package:superdeck/src/rendering/blocks/block_provider.dart'; import 'package:superdeck/src/rendering/blocks/block_widget.dart'; import 'package:superdeck/src/rendering/slides/slide_view.dart'; import 'package:superdeck/src/ui/widgets/provider.dart'; +import 'package:superdeck/src/utils/constants.dart' show kSlideBackgroundColor; import 'package:superdeck_core/superdeck_core.dart'; import '../../helpers/layout_assertions.dart'; @@ -34,6 +35,41 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('paints an opaque default background when none is set', ( + tester, + ) async { + await SlideTestHarness.pumpSlide(tester, Slide(key: 'no-bg')); + + expect( + find.byWidgetPredicate( + (w) => w is ColoredBox && w.color == kSlideBackgroundColor, + ), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('keeps the opaque floor beneath a configured background', ( + tester, + ) async { + await SlideTestHarness.pumpSlide( + tester, + Slide(key: 'has-bg'), + parts: const SlideParts( + background: SizedBox(key: ValueKey('custom-bg')), + ), + ); + + expect(find.byKey(const ValueKey('custom-bg')), findsOneWidget); + // The floor persists under a custom background so opacity is guaranteed. + expect( + find.byWidgetPredicate( + (w) => w is ColoredBox && w.color == kSlideBackgroundColor, + ), + findsOneWidget, + ); + }); + testWidgets('renders in debug mode without throwing', (tester) async { await SlideTestHarness.pumpSlide( tester,