diff --git a/.agents/skills/superdeck-presentations/SKILL.md b/.agents/skills/superdeck-presentations/SKILL.md index 6e5c6261..e163a28e 100644 --- a/.agents/skills/superdeck-presentations/SKILL.md +++ b/.agents/skills/superdeck-presentations/SKILL.md @@ -33,15 +33,41 @@ Load only the reference needed for the task: - Images have two authoring paths. Prefer standalone Markdown `![alt](src)` when the image belongs in the Markdown content flow; use `@image { src: ... }` when the image needs block-level layout control such as `fit`, fixed size, `flex`, `align`, `scrollable`, or `data:` source support. - Markdown class markers such as `{.heading}` or `{.title}` drive Hero transitions for supported Markdown elements; the class does not need a `hero-` prefix. Use the same tag on matching elements across adjacent slides, and do not duplicate the same tag on one slide. - `@column` is intentionally unsupported; use `@block`. -- Block and widget `align` controls visible content alignment. Current renderer stores `section.align` but does not apply it to child layout; set `align` on child blocks/widgets when visual placement matters. +- Effective content alignment is `block align → section align → centerLeft`. + Use section alignment as a shared default and child alignment for exceptions. +- `flex` is a positive integer. Section flex controls vertical height; child + block flex controls horizontal width. +- Section `spacing` creates finite, non-negative gaps only between sibling + blocks and affects horizontal space allocation. Block `margin` is consumed + inside that block's allocated frame, outside its decoration/border — it + reduces only that block's own usable area, never creates a shared gutter, + and never changes flex ratios (unlike CSS margins; use section `spacing` for + gutters). Block `padding` is consumed inside the decorated container, + between the border and the content. Both accept scalar, symmetric, or + physical-edge forms. Omitted object edges normalize to zero; explicit `null` + edges are invalid. An absent override inherits the resolved style value for + that inset; an explicit `0` removes it. A present override + replaces only the matching inset after variants resolve while preserving + other style data (decoration, clipping, animation). +- `SlideStyler.blockContainer` accepts `BlockStyler`, a constrained Mix styler + supporting only `padding`, `margin`, `decoration`, `foregroundDecoration`, + `clipBehavior`, context/`BlockVariant` variants, and animation. It cannot + express widget modifiers, width/height/constraints, transforms, or box + alignment; use `BoxStyler` for other style slots (`slideContainer`, code + block containers, alert containers). - `scrollable` is valid on `@block` and widget blocks, not on `@section`. - `layout: fullscreen` removes resolved header/footer chrome while retaining the slide's resolved background and style. `normal` is the default. - Built-ins `image`, `dartpad`, `webview`, and `qrcode` are always registered and can be overridden by user widgets with the same name. +- `@image scale` is a finite number greater than zero. It changes painting, not + layout, and clips using the effective alignment and image/content frame. - `@dartpad` and `@webview` use the same deck-scoped WebView controller cache. A `cacheKey` enables sequential reuse across remounts, never concurrent sharing by two live blocks. - Custom widgets must be registered in `DeckOptions.widgets`; use shorthand `@widgetName { ... }` in `slides.md` for registered widget names. - `BlockVariant('name')` is a Dart/Mix stylesheet selector for all `WidgetBlock`s with that exact, case-sensitive name. It affects the matching container and its widget subtree, not `@block` content. - Styles, templates, widgets, slide parts, and plugins are configured in Dart through `DeckOptions`/`SuperDeckApp`, not through a separate `styles.yaml`. - The CLI reads `slides.md`, writes `.superdeck/superdeck.json`, and ensures `.superdeck/` is listed in Flutter assets unless `--skip-pubspec` is used. +- `DeckOptions(debug: true)` diagnoses non-scrollable Markdown and custom + widget overflow without changing Markdown wrapping or rebuilding content; + static capture omits diagnostics. ## Source Map diff --git a/.agents/skills/superdeck-presentations/references/authoring.md b/.agents/skills/superdeck-presentations/references/authoring.md index 81063121..e12640e0 100644 --- a/.agents/skills/superdeck-presentations/references/authoring.md +++ b/.agents/skills/superdeck-presentations/references/authoring.md @@ -120,17 +120,71 @@ Left content Right content ``` -Use `align` on child blocks or widget blocks for visible alignment: +Set a shared alignment on the section and override individual children when +needed: ```markdown -@block { align: center } +@section { + spacing: 32 + align: center +} + +@block { padding: 16 } + +# Inherits center -# Centered title +@block { + padding: { + horizontal: 32, + vertical: 16, + } + align: bottomRight +} + +# Overrides the section ``` Valid alignments are `topLeft`, `topCenter`, `topRight`, `centerLeft`, `center`, `centerRight`, `bottomLeft`, `bottomCenter`, `bottomRight`. -Current renderer note: `@section { align: ... }` is accepted and stored, but visible content placement is driven by each child block/widget's `align`. +Effective alignment is explicit child alignment, then inherited section +alignment, then `centerLeft`. + +Section `spacing` is a finite, non-negative logical-pixel gap between sibling +blocks only. It never adds leading/trailing space and is clamped when an +impossible request would place children outside the section. + +Block/widget `margin` and `padding` accept exactly: + +```markdown +padding: 16 + +padding: { + horizontal: 24, + vertical: 16, +} + +padding: { + top: 12, + right: 24, + bottom: 12, + left: 24, +} +``` + +`margin` accepts the same three forms. Omitted object keys become zero, while +an explicitly authored `null` edge is invalid and reports its exact path. +Never mix symmetric and physical-edge keys. All flex values must be positive +integers. + +`margin` and `padding` own different layout roles: `margin` is consumed inside +a block's already-allocated frame, outside its decoration/border, and reduces +only that block's own usable area — it never creates a shared gutter with +sibling blocks and never changes flex ratios (unlike CSS margins; use section +`spacing` for gutters). `padding` is consumed inside the decorated container, +between the border and the content. An absent (`null`) override inherits the +resolved style value for that inset; an explicit `0` removes it. A present +override replaces only the matching inset after style variants resolve — +decoration, clipping, and animation are preserved. Use `scrollable: true` on overflowing blocks/widgets, not on sections: @@ -166,7 +220,9 @@ Use `@image` when the image should be its own widget block/column, or when you n @image { src: assets/hero.png fit: cover + width: 640 height: 420 + scale: 1.2 align: center } ``` @@ -175,7 +231,10 @@ Use `@image` when the image should be its own widget block/column, or when you n - `src` required: relative asset path, URL, absolute path, `file://`, `data:`, or Windows absolute path. - `fit`: `fill`, `contain`, `cover`, `fitWidth`, `fitHeight`, `none`, `scaleDown`; default `contain`. -- `width`, `height`: positive logical pixels. +- `width`, `height`: positive logical pixels; integer or decimal values are both accepted. +- `scale`: finite number greater than zero, default `1`; integer or decimal + values are both accepted; changes painted pixels without changing the frame + or flex layout and clips using effective alignment. Key differences: @@ -334,9 +393,12 @@ Or explicit form: } ``` -All properties become the widget factory's `Map` arguments. Block-level controls such as `flex`, `align`, and `scrollable` are consumed by SuperDeck and are not passed as custom widget args. +All properties become the widget factory's `Map` arguments. +Block-level controls such as `flex`, `align`, `margin`, `padding`, and +`scrollable` are consumed by SuperDeck and are not passed as custom widget +args. -To style every custom block with the same name, declare `BlockVariant('metricCard')` in a Dart `SlideStyle`. The match is exact and case-sensitive, and it applies to the widget block container plus descendants rather than to individual Markdown block instances. +To style every custom block with the same name, declare `BlockVariant('metricCard')` in a Dart `SlideStyler`. The match is exact and case-sensitive, and it applies to the widget block container plus descendants rather than to individual Markdown block instances. Custom widget authoring rules: diff --git a/.agents/skills/superdeck-presentations/references/runtime-customization.md b/.agents/skills/superdeck-presentations/references/runtime-customization.md index bd71ca25..9accd77c 100644 --- a/.agents/skills/superdeck-presentations/references/runtime-customization.md +++ b/.agents/skills/superdeck-presentations/references/runtime-customization.md @@ -225,7 +225,9 @@ Ack generation constraints that matter for SuperDeck widgets: - Generated extension types implement `Map` and expose `parse`, `safeParse`, and typed getters. - Nested object fields should reference named top-level schemas when you need typed nested getters. - Do not expect `Ack.any()`/`Ack.anyOf()` or inline anonymous object branches to generate useful typed wrappers. -- Keep `align`, `flex`, `scrollable`, and `name` out of widget-specific schemas because SuperDeck consumes those reserved block keys before calling the factory. +- Keep `align`, `flex`, `margin`, `padding`, `scrollable`, and `name` out of + widget-specific schemas because SuperDeck consumes those reserved block keys + before calling the factory. Register it: @@ -252,7 +254,8 @@ Use it: Notes: -- `align`, `flex`, `scrollable`, and `name` are reserved block keys; do not expect them inside `args`. +- `align`, `flex`, `margin`, `padding`, `scrollable`, and `name` are reserved + block keys; do not expect them inside `args`. - Widget names can include letters, digits, underscores, and hyphens because directive tags match `@[\w-]+`. - A custom widget can override a built-in by registering `image`, `dartpad`, `webview`, or `qrcode`, but do that only intentionally. - Use `SlideConfiguration.of(context)` for slide metadata and `LayoutBuilder` for block size. @@ -309,6 +312,18 @@ Unknown templates or styles throw `ArgumentError` during configuration build, so Template styles are isolated. A slide using `template: brand` and `style: cover` looks up `cover` in `SlideTemplate.styles`, not `DeckOptions.styles`. +### `blockContainer` and `BlockStyler` + +`SlideStyler.blockContainer` is a `BlockStyler`, not a `BoxStyler`. `BlockStyler` +is a constrained Mix styler exposing only `padding`, `margin` (plus Mix +spacing convenience methods), `decoration`, `foregroundDecoration`, +`clipBehavior`, context/`BlockVariant` variants, and animation. It cannot +express widget modifiers, width/height/constraints, transforms, or box +alignment — block/section `align` owns content placement, and section +`spacing` plus flex own geometry. Use `BoxStyler` for other style slots +(`slideContainer`, code block containers, alert containers); those are +unaffected by this constraint. + ### Named Widget Block Styles Use `BlockVariant` to target every widget block with an exact, case-sensitive name. The selector resolves around the matching block container and its descendants, so container padding/margin and widget subtree styles see the same active variant. @@ -320,13 +335,13 @@ import 'package:superdeck/superdeck.dart'; const webviewBlock = BlockVariant('webview'); final options = DeckOptions( - baseStyle: SlideStyle( - blockContainer: BoxStyler( + baseStyle: SlideStyler( + blockContainer: BlockStyler( padding: EdgeInsetsGeometryMix.all(40), ).variants([ VariantStyle( webviewBlock, - BoxStyler(padding: EdgeInsetsGeometryMix.all(0)), + BlockStyler(padding: EdgeInsetsGeometryMix.all(0)), ), ]), ), @@ -335,6 +350,12 @@ final options = DeckOptions( `BlockVariant('webview')` matches `@webview` and `@widget { name: "webview" }`; `BlockVariant('chart')` matches `@chart`. It does not select Markdown `@block` content, arbitrary `NamedVariant` values, or individual instances. SuperDeck already applies a zero-padding, zero-margin `BlockVariant('webview')` rule by default, so WebView blocks are edge-to-edge unless the style overrides it. +A Markdown block-level `margin` or `padding` value applies after these Mix +variants resolve. It replaces only the matching inset; the other inset, +decoration, border, animation, and the active selector remain intact. An +absent (`null`) override inherits the resolved style value; an explicit `0` +removes it. + ## Images and Assets The CLI manages `.superdeck/` assets and ensures this entry exists unless `--skip-pubspec` is used: diff --git a/.agents/skills/superdeck-presentations/references/verification.md b/.agents/skills/superdeck-presentations/references/verification.md index 4755b6f5..7102b33f 100644 --- a/.agents/skills/superdeck-presentations/references/verification.md +++ b/.agents/skills/superdeck-presentations/references/verification.md @@ -97,16 +97,20 @@ Invalid directive options: Unexpected layout: - Remember: sections stack vertically; blocks inside a section are horizontal. -- Set `align` on blocks/widgets for visible alignment. +- Alignment resolves from child block to section to `centerLeft`. +- Use section `spacing` for gaps between blocks, block/widget `margin` for + inset inside a block's own frame (outside its decoration), and block/widget + `padding` for inset inside the decorated container (between border and + content). - Use block/widget `scrollable: true`; do not set `scrollable` on sections. -- Use `flex` integers; invalid values throw during parse/model validation. +- Use positive `flex` integers; invalid values throw during parse/model validation. - For `layout: fullscreen`, verify that header/footer are absent while the intended background and style remain. Widget does not render: - Verify the widget name is registered in `DeckOptions.widgets`. - Built-ins are `image`, `dartpad`, `webview`, and `qrcode`. -- Check that widget arguments do not collide with reserved block keys: `name`, `align`, `flex`, `scrollable`. +- Check that widget arguments do not collide with reserved block keys: `name`, `align`, `flex`, `margin`, `padding`, `scrollable`. - Remember that reserved block keys are consumed by SuperDeck and are not passed to custom widget args. - If using shorthand, verify the directive name exactly matches the registered widget name. - If using Ack-generated args wrappers, confirm the schema is a top-level `@AckType()` declaration, the `part` file is present, generator dependencies are installed, and `build_runner` has regenerated the `.g.dart` file. @@ -143,6 +147,8 @@ Images fail: - Confirm which image path is being used: Markdown `![Alt](src)` inside content or widget-block `@image { src: ... }`. - If the image should behave like normal slide content, use a standalone Markdown image. If it needs `fit`, fixed dimensions, column sizing, alignment, scrolling, or `data:` URI support, use `@image`. +- Use `scale` only for painting within an image frame; it must be finite and + greater than zero and does not change block allocation. - For Markdown images, reject `asset:` URLs and `..` traversal. - For Markdown images, do not use `data:` URIs; use `@image` for `data:` sources. - For web/release, declare project asset directories in `pubspec.yaml`. diff --git a/.planning/feature-templates.md b/.planning/feature-templates.md index 063d91e4..b3245715 100644 --- a/.planning/feature-templates.md +++ b/.planning/feature-templates.md @@ -16,7 +16,7 @@ Templates provide reusable **chrome configurations** (background, header, footer - **Rationale:** Keep templates simple, focused, and non-invasive to existing markdown authoring ### 2. Isolated Style Systems -- Templates bundle their own style hierarchy: `baseStyle` + `Map styles` +- Templates bundle their own style hierarchy: `baseStyle` + `Map styles` - When a template is used, deck-level styles are ignored for that slide - Style resolution: `defaultSlideStyle → template.baseStyle → template.styles[slide.style]` - **Rationale:** Templates provide complete visual control without style conflicts @@ -61,11 +61,11 @@ final class SlideTemplate { /// Base style applied to all slides using this template. /// Merged after defaultSlideStyle but before named styles. - final SlideStyle? baseStyle; + final SlideStyler? baseStyle; /// Named style variants available within this template. /// Keys match the `style:` field in slide frontmatter. - final Map styles; + final Map styles; const SlideTemplate({ this.parts = const SlideParts(), @@ -81,8 +81,8 @@ final class SlideTemplate { ```dart class DeckOptions { // Existing fields - final SlideStyle? baseStyle; - final Map styles; + final SlideStyler? baseStyle; + final Map styles; final Map widgets; final SlideParts parts; final bool debug; @@ -149,7 +149,7 @@ class TemplateResolver { } class TemplateResolutionResult { - final SlideStyle style; // Fully merged style + final SlideStyler style; // Fully merged style final SlideParts parts; // Resolved parts (template or deck) final bool usingTemplate; // True if template was applied } @@ -519,7 +519,7 @@ final corporateTemplate = SlideTemplate( ), ), ), - baseStyle: SlideStyle( + baseStyle: SlideStyler( h1: TextStyler().style( TextStyleMix(fontSize: 64, color: Colors.blue[900]!), ), @@ -528,12 +528,12 @@ final corporateTemplate = SlideTemplate( ), ), styles: { - 'title': SlideStyle( + 'title': SlideStyler( h1: TextStyler().style( TextStyleMix(fontSize: 96, fontWeight: FontWeight.bold), ), ), - 'emphasis': SlideStyle( + 'emphasis': SlideStyler( slideContainer: BoxStyler( decoration: BoxDecorationMix( border: BorderMix.all(BorderSideMix(color: Colors.blue, width: 8)), @@ -753,7 +753,7 @@ Available styles: announcement, quote, emphasis ```dart final extendedTemplate = SlideTemplate( inheritsFrom: 'corporate', // Extend another template - baseStyle: SlideStyle(...), // Override specific styles + baseStyle: SlideStyler(...), // Override specific styles ); ``` diff --git a/AGENTS.md b/AGENTS.md index f2e0046a..4140920d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,7 +168,7 @@ Slides use `@tag` directives in Markdown to define layout and content: - Any unrecognized `@name` becomes a `WidgetBlock` (e.g., `@image`, `@chart`, `@callout`) ### Style System -Styles are defined in Dart through `SlideStyle`, `DeckOptions.baseStyle`, and `DeckOptions.styles`. +Styles are defined in Dart through `SlideStyler`, `DeckOptions.baseStyle`, and `DeckOptions.styles`. ## Documentation Locations diff --git a/demo/.superdeck/build_status.json b/demo/.superdeck/build_status.json index 45a1a204..e2b3dcfa 100644 --- a/demo/.superdeck/build_status.json +++ b/demo/.superdeck/build_status.json @@ -1,6 +1,6 @@ { "status": "success", - "timestamp": "2026-07-10T15:12:04.507060Z", - "slideCount": 31, + "timestamp": "2026-07-12T23:35:50.974090Z", + "slideCount": 34, "error": null } \ No newline at end of file diff --git a/demo/.superdeck/superdeck.json b/demo/.superdeck/superdeck.json index 1b27a53c..f4178f68 100644 --- a/demo/.superdeck/superdeck.json +++ b/demo/.superdeck/superdeck.json @@ -14,6 +14,7 @@ } ], "flex": 2, + "spacing": 0.0, "type": "section" } ], @@ -41,6 +42,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -66,6 +68,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -98,6 +101,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -117,6 +121,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" }, { @@ -144,6 +149,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -169,6 +175,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -188,6 +195,194 @@ } ], "flex": 1, + "spacing": 0.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "rOonJMpU", + "options": {}, + "sections": [ + { + "blocks": [ + { + "content": "## Predictable Layout Primitives {.heading}\n", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n### Edge to edge\n`padding: 0`\n", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n### Uniform inset\n`padding: 16`\n", + "flex": 1, + "padding": { + "top": 16.0, + "right": 16.0, + "bottom": 16.0, + "left": 16.0 + }, + "scrollable": false, + "type": "block" + }, + { + "content": "### Independent axes\n`48 × 24`", + "align": "bottomRight", + "flex": 1, + "padding": { + "top": 24.0, + "right": 48.0, + "bottom": 24.0, + "left": 48.0 + }, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 1, + "spacing": 32.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "GF2IH5bQ", + "options": { + "style": "boxed" + }, + "sections": [ + { + "blocks": [ + { + "content": "## Spacing vs Margin vs Padding {.heading}\n", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n### Spacing\n`spacing: 40` is the shared gap between sibling block frames.\n", + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n### Margin\n`margin: 24` is consumed inside this block's frame, outside its border.\n", + "flex": 1, + "margin": { + "top": 24.0, + "right": 24.0, + "bottom": 24.0, + "left": 24.0 + }, + "scrollable": false, + "type": "block" + }, + { + "content": "### Padding\n`padding` sits between the border and this content.", + "flex": 1, + "padding": { + "top": 24.0, + "right": 48.0, + "bottom": 24.0, + "left": 48.0 + }, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 1, + "spacing": 40.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "V916Dr35", + "options": {}, + "sections": [ + { + "blocks": [ + { + "content": "## Image Framing Scale {.heading}\n", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "name": "image", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "assets/concepta-icon.png", + "fit": "contain", + "width": 300, + "height": 300, + "scale": 1 + }, + { + "name": "image", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "assets/concepta-icon.png", + "fit": "contain", + "width": 300, + "height": 300, + "scale": 1.25 + } + ], + "align": "center", + "flex": 1, + "spacing": 64.0, "type": "section" } ], @@ -214,6 +409,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -234,6 +430,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" }, { @@ -247,6 +444,7 @@ } ], "flex": 2, + "spacing": 0.0, "type": "section" }, { @@ -260,6 +458,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -285,6 +484,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -310,6 +510,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -329,6 +530,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" }, { @@ -342,6 +544,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" }, { @@ -365,6 +568,7 @@ } ], "flex": 4, + "spacing": 0.0, "type": "section" } ], @@ -403,6 +607,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -441,6 +646,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -479,6 +685,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -517,6 +724,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -542,6 +750,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -561,6 +770,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -586,6 +796,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -607,6 +818,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -632,6 +844,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -663,6 +876,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -688,6 +902,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -720,6 +935,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -745,6 +961,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -767,6 +984,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -790,6 +1008,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -812,6 +1031,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -836,6 +1056,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -861,6 +1082,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -881,6 +1103,7 @@ ], "align": "bottomCenter", "flex": 2, + "spacing": 0.0, "type": "section" }, { @@ -911,6 +1134,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], diff --git a/demo/.superdeck/superdeck_full.json b/demo/.superdeck/superdeck_full.json index a7208f81..1313981a 100644 --- a/demo/.superdeck/superdeck_full.json +++ b/demo/.superdeck/superdeck_full.json @@ -43,6 +43,7 @@ } ], "flex": 2, + "spacing": 0.0, "type": "section" } ], @@ -152,6 +153,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -272,6 +274,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -334,6 +337,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -371,6 +375,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" }, { @@ -560,6 +565,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -680,6 +686,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -727,6 +734,464 @@ } ], "flex": 1, + "spacing": 0.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "rOonJMpU", + "options": {}, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Predictable Layout Primitives {.heading}" + } + ], + "generatedId": "predictable-layout-primitives-heading" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Edge to edge" + } + ], + "generatedId": "edge-to-edge" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "padding: 0" + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Uniform inset" + } + ], + "generatedId": "uniform-inset" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "padding: 16" + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "padding": { + "top": 16.0, + "right": 16.0, + "bottom": 16.0, + "left": 16.0 + }, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Independent axes" + } + ], + "generatedId": "independent-axes" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "48 × 24" + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "bottomRight", + "flex": 1, + "padding": { + "top": 24.0, + "right": 48.0, + "bottom": 24.0, + "left": 48.0 + }, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 1, + "spacing": 32.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "GF2IH5bQ", + "options": { + "style": "boxed" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Spacing vs Margin vs Padding {.heading}" + } + ], + "generatedId": "spacing-vs-margin-vs-padding-heading" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Spacing" + } + ], + "generatedId": "spacing" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "spacing: 40" + } + ] + }, + { + "type": "text", + "text": " is the shared gap between sibling block frames." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Margin" + } + ], + "generatedId": "margin" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "margin: 24" + } + ] + }, + { + "type": "text", + "text": " is consumed inside this block's frame, outside its border." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "margin": { + "top": 24.0, + "right": 24.0, + "bottom": 24.0, + "left": 24.0 + }, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Padding" + } + ], + "generatedId": "padding" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "padding" + } + ] + }, + { + "type": "text", + "text": " sits between the border and this content." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "padding": { + "top": 24.0, + "right": 48.0, + "bottom": 24.0, + "left": 48.0 + }, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 1, + "spacing": 40.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "V916Dr35", + "options": {}, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Image Framing Scale {.heading}" + } + ], + "generatedId": "image-framing-scale-heading" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "name": "image", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "assets/concepta-icon.png", + "fit": "contain", + "width": 300, + "height": 300, + "scale": 1 + }, + { + "name": "image", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "assets/concepta-icon.png", + "fit": "contain", + "width": 300, + "height": 300, + "scale": 1.25 + } + ], + "align": "center", + "flex": 1, + "spacing": 64.0, "type": "section" } ], @@ -797,6 +1262,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -835,6 +1301,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" }, { @@ -866,6 +1333,7 @@ } ], "flex": 2, + "spacing": 0.0, "type": "section" }, { @@ -897,6 +1365,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -966,6 +1435,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -1026,6 +1496,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -1063,6 +1534,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" }, { @@ -1107,6 +1579,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" }, { @@ -1156,6 +1629,7 @@ } ], "flex": 4, + "spacing": 0.0, "type": "section" } ], @@ -1244,6 +1718,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -1323,6 +1798,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -1402,6 +1878,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -1481,6 +1958,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -1541,6 +2019,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -1597,6 +2076,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -1666,6 +2146,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -1710,6 +2191,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -1826,6 +2308,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -1964,6 +2447,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -2030,6 +2514,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -2148,6 +2633,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -2236,6 +2722,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -2300,6 +2787,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -2351,6 +2839,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -2401,6 +2890,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -2425,6 +2915,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -2450,6 +2941,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], @@ -2488,6 +2980,7 @@ ], "align": "bottomCenter", "flex": 2, + "spacing": 0.0, "type": "section" }, { @@ -2589,6 +3082,7 @@ } ], "flex": 1, + "spacing": 0.0, "type": "section" } ], diff --git a/demo/integration_test/helpers/test_helpers.dart b/demo/integration_test/helpers/test_helpers.dart index 9bf60ce0..d4be3a44 100644 --- a/demo/integration_test/helpers/test_helpers.dart +++ b/demo/integration_test/helpers/test_helpers.dart @@ -123,6 +123,40 @@ Future captureAllSlidesForReview( return outputDir; } +/// Captures clean, static slide frames without interactive app-shell chrome. +Future captureRenderedSlidesForReview( + WidgetTester tester, + DeckController controller, { + required String suiteName, + required String scenarioName, +}) async { + if (Platform.environment['SUPERDECK_CAPTURE_SLIDES'] != '1') return null; + + final outputDir = await _createReviewScreenshotOutputDir( + suiteName: suiteName, + scenarioName: scenarioName, + ); + final renderContext = tester.element(find.byType(Scaffold).first); + final captureService = SlideCaptureService(); + final slides = controller.slides.value; + + for (var index = 0; index < slides.length; index++) { + final slide = slides[index]; + final slideNumber = (index + 1).toString().padLeft(2, '0'); + final title = slide.options.title ?? slide.key; + final fileName = '${slideNumber}_${_slug(title)}.png'; + final bytes = await captureService.capture( + quality: SlideCaptureQuality.good, + slide: slide, + context: renderContext, + ); + + await File('${outputDir.path}/$fileName').writeAsBytes(bytes, flush: true); + } + + return outputDir; +} + Future captureCurrentViewForReview( WidgetTester tester, { required String suiteName, @@ -229,12 +263,14 @@ class TestApp extends StatelessWidget { super.key, this.deckLoader, this.workspace, + this.options, this.extraWidgets = const {}, this.plugins = const [], }); final DeckLoader? deckLoader; final DeckWorkspace? workspace; + final DeckOptions? options; final Map extraWidgets; final List plugins; @@ -259,24 +295,32 @@ class TestApp extends StatelessWidget { deckLoader: deckLoader, workspace: workspace, assetCacheStore: const _TransparentThumbnailCacheStore(), - options: DeckOptions( - baseStyle: borderedStyle(), - widgets: { - ...demoWidgets, - 'twitter': _testTwitterWidget, - ...extraWidgets, - }, - styles: {'announcement': announcementStyle(), 'quote': quoteStyle()}, - templates: { - 'corporate': corporateTemplate(), - 'minimal': minimalTemplate(), - }, - parts: const SlideParts( - header: HeaderPart(), - footer: FooterPart(), - background: BackgroundPart(), - ), - ), + options: + options ?? + DeckOptions( + baseStyle: borderedStyle(), + widgets: { + ...demoWidgets, + 'twitter': _testTwitterWidget, + ...extraWidgets, + }, + // Mirror the deck-level styles registered in `lib/main.dart` so + // the harness resolves every `style:` used in `slides.md`. + styles: { + 'announcement': announcementStyle(), + 'quote': quoteStyle(), + 'boxed': boxedStyle(), + }, + templates: { + 'corporate': corporateTemplate(), + 'minimal': minimalTemplate(), + }, + parts: const SlideParts( + header: HeaderPart(), + footer: FooterPart(), + background: BackgroundPart(), + ), + ), transitionDuration: _testSlideTransitionDuration, plugins: plugins, ), diff --git a/demo/integration_test/layout_showcase_test.dart b/demo/integration_test/layout_showcase_test.dart new file mode 100644 index 00000000..c7d5870e --- /dev/null +++ b/demo/integration_test/layout_showcase_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +import 'package:superdeck_example/src/layout_showcase/layout_showcase.dart'; + +import 'helpers/test_helpers.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('layout showcase', () { + setUpAll(TestApp.initialize); + + testWidgets('renders and captures every showcase slide', (tester) async { + tester.view + ..physicalSize = const Size(1280, 720) + ..devicePixelRatio = 1; + addTearDown(() { + tester.view + ..resetPhysicalSize() + ..resetDevicePixelRatio(); + }); + + final loader = FileDeckLoader(workspace: layoutShowcaseWorkspace); + addTearDown(loader.dispose); + + await tester.pumpWidget( + TestApp( + deckLoader: loader, + workspace: layoutShowcaseWorkspace, + options: layoutShowcaseOptions(), + ), + ); + await tester.pumpFor(const Duration(milliseconds: 250)); + await tester.pumpUntil( + () => findDeckController(tester) != null, + debugLabel: 'layout showcase controller', + ); + + final controller = findDeckController(tester)!; + await tester.waitForSlidesLoaded(controller); + + expect(controller.presentation.totalSlides.value, 13); + expect(find.text('The system makes room.'), findsOneWidget); + expect( + controller.slides.value.map((slide) => slide.options.title), + containsAll(['Image frame matrix', 'Image treatment matrix']), + ); + + final widgetBlocks = controller.slides.value + .expand((slide) => slide.sections) + .expand((section) => section.blocks) + .whereType() + .toList(); + expect( + widgetBlocks.where((block) => block.name == 'image'), + hasLength(18), + ); + expect( + widgetBlocks + .where((block) => block.name != 'image') + .map((block) => block.name), + ['showcaseMetric'], + ); + assertPresentationHealthy(tester, controller); + + for (var index = 0; index < controller.slides.value.length; index++) { + await tester.navigateToSlide( + controller, + index, + context: 'layout showcase slide $index', + ); + assertPresentationHealthy(tester, controller); + } + + final screenshotsDir = await captureRenderedSlidesForReview( + tester, + controller, + suiteName: 'layout_showcase', + scenarioName: 'editorial_deck', + ); + await assertReviewScreenshots( + outputDir: screenshotsDir, + expectedCount: 13, + ); + }); + }); +} diff --git a/demo/layout_showcase/.superdeck/build_status.json b/demo/layout_showcase/.superdeck/build_status.json new file mode 100644 index 00000000..5d533521 --- /dev/null +++ b/demo/layout_showcase/.superdeck/build_status.json @@ -0,0 +1,6 @@ +{ + "status": "success", + "timestamp": "2026-07-13T04:01:58.442714Z", + "slideCount": 13, + "error": null +} \ No newline at end of file diff --git a/demo/layout_showcase/.superdeck/superdeck.json b/demo/layout_showcase/.superdeck/superdeck.json new file mode 100644 index 00000000..2e72ba89 --- /dev/null +++ b/demo/layout_showcase/.superdeck/superdeck.json @@ -0,0 +1,1155 @@ +[ + { + "key": "ULf9oR1o", + "options": { + "title": "The system makes room", + "style": "cover", + "layout": "fullscreen" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### NATIVE AUTHORING / VISUAL PROOF\n\n# The system makes room.\n\n## Thirteen frames for layout, image control, and rich Markdown.\n\n**Sections. Blocks. Images. Type.** One coherent visual language.\n", + "align": "centerLeft", + "flex": 5, + "padding": { + "top": 28.0, + "right": 34.0, + "bottom": 28.0, + "left": 34.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "center", + "flex": 6, + "margin": { + "top": 14.0, + "right": 14.0, + "bottom": 14.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover", + "scale": 1.04 + } + ], + "align": "center", + "flex": 1, + "spacing": 44.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "vAw6fVN5", + "options": { + "title": "The layout model", + "style": "panels" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### 01 / PREDICTABLE GEOMETRY\n\n## The layout model explains itself.\n", + "align": "centerLeft", + "flex": 1, + "padding": { + "top": 14.0, + "right": 24.0, + "bottom": 14.0, + "left": 24.0 + }, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n### SECTION = HEIGHT\n\nSections stack **vertically**. Their flex values decide how much of the slide each row receives.\n", + "flex": 2, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n### 2×\n\nThis block gets twice the width of its neighbor.\n", + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n### 1×\n\nThe ratio stays stable as the canvas scales.\n", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 2, + "spacing": 18.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n#### MARGIN\n\nSpace outside the framed block.\n", + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n#### PADDING\n\nSpace inside the frame. **Decoration stays between them**, so the box model remains visible and teachable.\n", + "flex": 2, + "scrollable": false, + "type": "block" + }, + { + "content": "#### ALIGN\n\nContent lands at any of nine anchor points.", + "align": "bottomRight", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 2, + "spacing": 18.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "7BuNcOQM", + "options": { + "title": "Asymmetric composition", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### 02 / SECTIONS + BLOCKS + IMAGES\n\n## Editorial, asymmetric, still deterministic.\n", + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n### Build hierarchy before decoration.\n\nA strong composition begins with **one dominant relationship**. Here, a 4:7 split gives the image authority while the copy keeps a clear reading edge.\n\n- **Flex** sets the proportion.\n- **Spacing** creates the gutter.\n- **Alignment** pins the content.\n\n> The result feels art-directed because every constraint has a job.\n", + "align": "centerLeft", + "flex": 4, + "padding": { + "top": 0.0, + "right": 18.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "centerRight", + "flex": 7, + "margin": { + "top": 6.0, + "right": 0.0, + "bottom": 6.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover", + "scale": 1.12 + } + ], + "align": "center", + "flex": 5, + "spacing": 28.0, + "type": "section" + }, + { + "blocks": [ + { + "name": "image", + "align": "centerLeft", + "flex": 2, + "margin": { + "top": 12.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/material_detail.png", + "fit": "cover" + }, + { + "content": "\n\n#### SECONDARY RHYTHM\n\nRepeated image frames can change proportion without changing the rules around them.\n", + "align": "centerLeft", + "flex": 4, + "margin": { + "top": 12.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 10.0, + "bottom": 0.0, + "left": 10.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "centerRight", + "flex": 2, + "margin": { + "top": 12.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/momentum_ribbon.png", + "fit": "cover", + "scale": 1.18 + } + ], + "align": "center", + "flex": 2, + "spacing": 24.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "lFQehDPS", + "options": { + "title": "Image fit behavior", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### 03 / IMAGE FIT\n\n## Same source. Two honest choices.\n", + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n#### CONTAIN / FULL FRAME\n\n**Nothing is cropped.** Empty space is preserved.\n", + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n#### COVER / FULL BLEED\n\n**The frame is filled.** Edges are intentionally cropped.\n", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 1, + "spacing": 24.0, + "type": "section" + }, + { + "blocks": [ + { + "name": "image", + "align": "center", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "contain", + "width": 560, + "height": 420 + }, + { + "name": "image", + "align": "center", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover", + "width": 560, + "height": 420 + } + ], + "align": "center", + "flex": 5, + "spacing": 24.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "SyRf8syA", + "options": { + "title": "Image placement controls", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### 04 / SCALE + ALIGNMENT\n\n## Placement is a controlled variable.\n", + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "name": "image", + "align": "topLeft", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "contain", + "scale": 0.72 + }, + { + "name": "image", + "align": "center", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover", + "scale": 1 + }, + { + "name": "image", + "align": "bottomRight", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover", + "scale": 1.35 + } + ], + "align": "center", + "flex": 4, + "spacing": 22.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n#### 72% / TOP LEFT\n\nFull image, deliberate quiet space.\n", + "flex": 1, + "margin": { + "top": 14.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n#### 100% / CENTER\n\nThe reliable default crop.\n", + "flex": 1, + "margin": { + "top": 14.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "content": "#### 135% / BOTTOM RIGHT\n\nAn intentional detail view.", + "flex": 1, + "margin": { + "top": 14.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 1, + "spacing": 22.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "VaHpsMyO", + "options": { + "title": "Image frame matrix", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### 05 / FRAME SIZE + BLOCK MARGIN\n\n## Frame size is authored, not guessed.\n", + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "name": "image", + "align": "center", + "flex": 1, + "margin": { + "top": 8.0, + "right": 66.0, + "bottom": 8.0, + "left": 66.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/material_detail.png", + "fit": "cover", + "scale": 1.06 + }, + { + "name": "image", + "align": "center", + "flex": 1, + "margin": { + "top": 52.0, + "right": 38.0, + "bottom": 52.0, + "left": 38.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover" + }, + { + "name": "image", + "align": "centerRight", + "flex": 1, + "margin": { + "top": 100.0, + "right": 4.0, + "bottom": 100.0, + "left": 4.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/momentum_ribbon.png", + "fit": "cover", + "scale": 1.14 + } + ], + "align": "center", + "flex": 4, + "spacing": 18.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n#### PORTRAIT / TALL\n\nHorizontal margin narrows the decorated image block.\n", + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n#### SQUARE / BALANCED\n\nTwo-axis margin creates a compact focal tile.\n", + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": "#### LANDSCAPE / WIDE\n\nVertical margin produces a cinematic banner.", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 1, + "spacing": 18.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "UFjcGQIQ", + "options": { + "title": "Image treatment matrix", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### 06 / FIT + SURFACE TREATMENT\n\n## One image block can carry many behaviors.\n", + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n#### FIT WIDTH\n\n### Width leads.\n\nThe source fills the horizontal axis first.\n", + "align": "centerLeft", + "flex": 2, + "margin": { + "top": 16.0, + "right": 0.0, + "bottom": 16.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "center", + "flex": 4, + "margin": { + "top": 10.0, + "right": 0.0, + "bottom": 10.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/material_detail.png", + "fit": "fitWidth", + "width": 320, + "height": 205 + }, + { + "content": "\n\n#### FIT HEIGHT\n\n### Height leads.\n\nPadding adds an inset inside the frame.\n", + "align": "centerLeft", + "flex": 2, + "margin": { + "top": 16.0, + "right": 0.0, + "bottom": 16.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "centerRight", + "flex": 4, + "margin": { + "top": 18.0, + "right": 0.0, + "bottom": 18.0, + "left": 0.0 + }, + "padding": { + "top": 12.0, + "right": 12.0, + "bottom": 12.0, + "left": 12.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "fitHeight", + "width": 320, + "height": 205 + } + ], + "align": "center", + "flex": 2, + "spacing": 18.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n#### FILL\n\n### Frame leads.\n\nPixels stretch to occupy both axes.\n", + "align": "centerLeft", + "flex": 2, + "margin": { + "top": 16.0, + "right": 0.0, + "bottom": 16.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "center", + "flex": 4, + "margin": { + "top": 10.0, + "right": 0.0, + "bottom": 10.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/momentum_ribbon.png", + "fit": "fill", + "width": 320, + "height": 205 + }, + { + "content": "\n\n#### SCALE DOWN\n\n### Source leads.\n\nMargin and padding create a floating study.\n", + "align": "centerLeft", + "flex": 2, + "margin": { + "top": 16.0, + "right": 0.0, + "bottom": 16.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "bottomRight", + "flex": 4, + "margin": { + "top": 16.0, + "right": 8.0, + "bottom": 16.0, + "left": 8.0 + }, + "padding": { + "top": 18.0, + "right": 18.0, + "bottom": 18.0, + "left": 18.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/material_detail.png", + "fit": "scaleDown", + "width": 320, + "height": 205, + "scale": 0.82 + } + ], + "align": "center", + "flex": 2, + "spacing": 18.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "6D9iENdn", + "options": { + "title": "Markdown hierarchy", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### 07 / RICH MARKDOWN\n\n## The content model has range.\n", + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n### Inline hierarchy\n\n**Bold establishes consequence.** *Italic adds a second voice.* ~~Strikethrough retires the old idea.~~ A [link creates a path](https://superdeck.dev), and `inline code` names the mechanism.\n\n> Typography can carry structure before a single custom widget enters the frame.\n", + "flex": 5, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n### List rhythm\n\n- **Lead** with the decision.\n- *Qualify* it with context.\n- Use `code` for exact syntax.\n\n1. Establish the frame.\n2. Repeat the cadence.\n3. Break the pattern once.\n", + "flex": 4, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 4, + "spacing": 42.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n#### STRONG\n\n**Make the signal unmistakable.**\n", + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n#### EMPHASIS\n\n*Change the tone without changing size.*\n", + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": "#### LINK + CODE\n\n[Open the docs](https://superdeck.dev) or use `@section`.", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 1, + "spacing": 22.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "EpRapYGr", + "options": { + "title": "Table-driven storytelling", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### 08 / TABLES\n\n## Dense information can still breathe.\n", + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n| Layout signal | Before | After | Movement |\n| :-- | --: | --: | --: |\n| Time to first read | 8.4 s | **2.6 s** | **−69%** |\n| Competing focal points | 7 | **2** | **−71%** |\n| Grid exceptions | 12 | **1** | **−92%** |\n| Review confidence | 54% | **91%** | **+37 pt** |\n", + "align": "centerLeft", + "flex": 7, + "scrollable": false, + "type": "block" + }, + { + "content": "#### THE READING\n\n## 3.2×\n\nfaster orientation after the hierarchy was simplified.\n\n> A table earns attention when the comparison—not the border—is the story.", + "align": "centerLeft", + "flex": 3, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 10.0 + }, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 5, + "spacing": 34.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "J51RgH9X", + "options": { + "title": "GitHub alerts", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### 09 / GITHUB ALERTS\n\n## Guidance with the right degree of urgency.\n", + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n> [!NOTE]\n> **Sections own height.** Blocks inside them divide the available width.\n", + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n> [!TIP]\n> Reuse a stable spacing rhythm, then change only the value that carries meaning.\n", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 2, + "spacing": 18.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n> [!IMPORTANT]\n> Keep the focal relationship obvious at thumbnail size and at full screen.\n", + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": "\n\n> [!WARNING]\n> Paint-only image scale can crop beyond its original fit. Review the final frame.\n", + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": "> [!CAUTION]\n> Do not hide critical state in color alone. Pair the accent with a clear label.", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 2, + "spacing": 24.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "uhvZJU9L", + "options": { + "title": "Indicators and readiness", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### 10 / INDICATORS\n\n## Status should scan before it explains.\n", + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n### Release readiness\n\n- [x] Layout contract locked\n- [x] Image crops reviewed\n- [x] Contrast checked at 1280 × 720\n- [ ] Speaker notes approved\n- [ ] Final export published\n\n> **3 / 5 complete.** The remaining work is visible without opening another tool.\n", + "flex": 5, + "scrollable": false, + "type": "block" + }, + { + "content": "### Signal board\n\n| Workstream | State | Owner |\n| :-- | :-- | :-- |\n| Layout | **● READY** | Design |\n| Content | **● REVIEW** | Editorial |\n| Capture | **● READY** | QA |\n| Export | **○ QUEUED** | Release |\n\n**READY** means verified. *REVIEW* means a decision remains. `QUEUED` means the path is known.", + "flex": 5, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 4, + "spacing": 42.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "tRy64s9H", + "options": { + "title": "A bounded extension point", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": "\n\n#### 11 / NATIVE FIRST, EXTENSIBLE WHEN NEEDED\n\n## One escape hatch, clearly bounded.\n", + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": "\n\n#### BUILT-IN AUTHORING\n\n### Most of this deck is only three primitives.\n\n- `@section` for vertical rhythm\n- `@block` for Markdown content\n- `@image` for visual media\n\n**No custom content widget is required** for the layouts, tables, alerts, checklists, or image studies.\n", + "align": "centerLeft", + "flex": 3, + "padding": { + "top": 0.0, + "right": 8.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "center", + "flex": 4, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/material_detail.png", + "fit": "cover", + "scale": 1.08 + }, + { + "name": "showcaseMetric", + "flex": 3, + "scrollable": false, + "type": "widget", + "value": "01", + "label": "Registered widget", + "detail": "This single metric card is the deliberately custom extension point in the content deck.", + "accent": "#59D6C8" + } + ], + "align": "center", + "flex": 5, + "spacing": 26.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "vZXKbD3F", + "options": { + "title": "The outcome", + "style": "closing", + "layout": "fullscreen" + }, + "sections": [ + { + "blocks": [ + { + "name": "image", + "align": "centerLeft", + "flex": 4, + "margin": { + "top": 26.0, + "right": 0.0, + "bottom": 26.0, + "left": 26.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/momentum_ribbon.png", + "fit": "cover", + "scale": 1.24 + }, + { + "content": "#### 12 / THE OUTCOME\n\n# Built to be seen.\n\n## Every frame can be authored, inspected, and captured.\n\n**Native layouts. Rich Markdown. Controlled imagery.** One bounded widget extension and three original image assets.", + "align": "centerLeft", + "flex": 5, + "padding": { + "top": 0.0, + "right": 54.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 1, + "spacing": 50.0, + "type": "section" + } + ], + "comments": [] + } +] \ No newline at end of file diff --git a/demo/layout_showcase/.superdeck/superdeck_full.json b/demo/layout_showcase/.superdeck/superdeck_full.json new file mode 100644 index 00000000..e742e901 --- /dev/null +++ b/demo/layout_showcase/.superdeck/superdeck_full.json @@ -0,0 +1,3966 @@ +[ + { + "key": "ULf9oR1o", + "options": { + "title": "The system makes room", + "style": "cover", + "layout": "fullscreen" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "NATIVE AUTHORING / VISUAL PROOF" + } + ], + "generatedId": "native-authoring--visual-proof" + }, + { + "type": "element", + "tag": "h1", + "children": [ + { + "type": "text", + "text": "The system makes room." + } + ], + "generatedId": "the-system-makes-room" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Thirteen frames for layout, image control, and rich Markdown." + } + ], + "generatedId": "thirteen-frames-for-layout-image-control-and-rich-markdown" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "Sections. Blocks. Images. Type." + } + ] + }, + { + "type": "text", + "text": " One coherent visual language." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 5, + "padding": { + "top": 28.0, + "right": 34.0, + "bottom": 28.0, + "left": 34.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "center", + "flex": 6, + "margin": { + "top": 14.0, + "right": 14.0, + "bottom": 14.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover", + "scale": 1.04 + } + ], + "align": "center", + "flex": 1, + "spacing": 44.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "vAw6fVN5", + "options": { + "title": "The layout model", + "style": "panels" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "01 / PREDICTABLE GEOMETRY" + } + ], + "generatedId": "01--predictable-geometry" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "The layout model explains itself." + } + ], + "generatedId": "the-layout-model-explains-itself" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 1, + "padding": { + "top": 14.0, + "right": 24.0, + "bottom": 14.0, + "left": 24.0 + }, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "SECTION = HEIGHT" + } + ], + "generatedId": "section--height" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Sections stack " + }, + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "vertically" + } + ] + }, + { + "type": "text", + "text": ". Their flex values decide how much of the slide each row receives." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 2, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "2×" + } + ], + "generatedId": "2" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "This block gets twice the width of its neighbor." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "1×" + } + ], + "generatedId": "1" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "The ratio stays stable as the canvas scales." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 2, + "spacing": 18.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "MARGIN" + } + ], + "generatedId": "margin" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Space outside the framed block." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "PADDING" + } + ], + "generatedId": "padding" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Space inside the frame. " + }, + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "Decoration stays between them" + } + ] + }, + { + "type": "text", + "text": ", so the box model remains visible and teachable." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 2, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "ALIGN" + } + ], + "generatedId": "align" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Content lands at any of nine anchor points." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "bottomRight", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 2, + "spacing": 18.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "7BuNcOQM", + "options": { + "title": "Asymmetric composition", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "02 / SECTIONS + BLOCKS + IMAGES" + } + ], + "generatedId": "02--sections--blocks--images" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Editorial, asymmetric, still deterministic." + } + ], + "generatedId": "editorial-asymmetric-still-deterministic" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Build hierarchy before decoration." + } + ], + "generatedId": "build-hierarchy-before-decoration" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "A strong composition begins with " + }, + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "one dominant relationship" + } + ] + }, + { + "type": "text", + "text": ". Here, a 4:7 split gives the image authority while the copy keeps a clear reading edge." + } + ] + }, + { + "type": "element", + "tag": "ul", + "children": [ + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "Flex" + } + ] + }, + { + "type": "text", + "text": " sets the proportion." + } + ] + }, + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "Spacing" + } + ] + }, + { + "type": "text", + "text": " creates the gutter." + } + ] + }, + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "Alignment" + } + ] + }, + { + "type": "text", + "text": " pins the content." + } + ] + } + ] + }, + { + "type": "element", + "tag": "blockquote", + "children": [ + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "The result feels art-directed because every constraint has a job." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 4, + "padding": { + "top": 0.0, + "right": 18.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "centerRight", + "flex": 7, + "margin": { + "top": 6.0, + "right": 0.0, + "bottom": 6.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover", + "scale": 1.12 + } + ], + "align": "center", + "flex": 5, + "spacing": 28.0, + "type": "section" + }, + { + "blocks": [ + { + "name": "image", + "align": "centerLeft", + "flex": 2, + "margin": { + "top": 12.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/material_detail.png", + "fit": "cover" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "SECONDARY RHYTHM" + } + ], + "generatedId": "secondary-rhythm" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Repeated image frames can change proportion without changing the rules around them." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 4, + "margin": { + "top": 12.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 10.0, + "bottom": 0.0, + "left": 10.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "centerRight", + "flex": 2, + "margin": { + "top": 12.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/momentum_ribbon.png", + "fit": "cover", + "scale": 1.18 + } + ], + "align": "center", + "flex": 2, + "spacing": 24.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "lFQehDPS", + "options": { + "title": "Image fit behavior", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "03 / IMAGE FIT" + } + ], + "generatedId": "03--image-fit" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Same source. Two honest choices." + } + ], + "generatedId": "same-source-two-honest-choices" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "CONTAIN / FULL FRAME" + } + ], + "generatedId": "contain--full-frame" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "Nothing is cropped." + } + ] + }, + { + "type": "text", + "text": " Empty space is preserved." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "COVER / FULL BLEED" + } + ], + "generatedId": "cover--full-bleed" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "The frame is filled." + } + ] + }, + { + "type": "text", + "text": " Edges are intentionally cropped." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 1, + "spacing": 24.0, + "type": "section" + }, + { + "blocks": [ + { + "name": "image", + "align": "center", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "contain", + "width": 560, + "height": 420 + }, + { + "name": "image", + "align": "center", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover", + "width": 560, + "height": 420 + } + ], + "align": "center", + "flex": 5, + "spacing": 24.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "SyRf8syA", + "options": { + "title": "Image placement controls", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "04 / SCALE + ALIGNMENT" + } + ], + "generatedId": "04--scale--alignment" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Placement is a controlled variable." + } + ], + "generatedId": "placement-is-a-controlled-variable" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "name": "image", + "align": "topLeft", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "contain", + "scale": 0.72 + }, + { + "name": "image", + "align": "center", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover", + "scale": 1 + }, + { + "name": "image", + "align": "bottomRight", + "flex": 1, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover", + "scale": 1.35 + } + ], + "align": "center", + "flex": 4, + "spacing": 22.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "72% / TOP LEFT" + } + ], + "generatedId": "72--top-left" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Full image, deliberate quiet space." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "margin": { + "top": 14.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "100% / CENTER" + } + ], + "generatedId": "100--center" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "The reliable default crop." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "margin": { + "top": 14.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "135% / BOTTOM RIGHT" + } + ], + "generatedId": "135--bottom-right" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "An intentional detail view." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "margin": { + "top": 14.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 1, + "spacing": 22.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "VaHpsMyO", + "options": { + "title": "Image frame matrix", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "05 / FRAME SIZE + BLOCK MARGIN" + } + ], + "generatedId": "05--frame-size--block-margin" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Frame size is authored, not guessed." + } + ], + "generatedId": "frame-size-is-authored-not-guessed" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "name": "image", + "align": "center", + "flex": 1, + "margin": { + "top": 8.0, + "right": 66.0, + "bottom": 8.0, + "left": 66.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/material_detail.png", + "fit": "cover", + "scale": 1.06 + }, + { + "name": "image", + "align": "center", + "flex": 1, + "margin": { + "top": 52.0, + "right": 38.0, + "bottom": 52.0, + "left": 38.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "cover" + }, + { + "name": "image", + "align": "centerRight", + "flex": 1, + "margin": { + "top": 100.0, + "right": 4.0, + "bottom": 100.0, + "left": 4.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/momentum_ribbon.png", + "fit": "cover", + "scale": 1.14 + } + ], + "align": "center", + "flex": 4, + "spacing": 18.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "PORTRAIT / TALL" + } + ], + "generatedId": "portrait--tall" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Horizontal margin narrows the decorated image block." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "SQUARE / BALANCED" + } + ], + "generatedId": "square--balanced" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Two-axis margin creates a compact focal tile." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "LANDSCAPE / WIDE" + } + ], + "generatedId": "landscape--wide" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Vertical margin produces a cinematic banner." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 1, + "spacing": 18.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "UFjcGQIQ", + "options": { + "title": "Image treatment matrix", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "06 / FIT + SURFACE TREATMENT" + } + ], + "generatedId": "06--fit--surface-treatment" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "One image block can carry many behaviors." + } + ], + "generatedId": "one-image-block-can-carry-many-behaviors" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "FIT WIDTH" + } + ], + "generatedId": "fit-width" + }, + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Width leads." + } + ], + "generatedId": "width-leads" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "The source fills the horizontal axis first." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 2, + "margin": { + "top": 16.0, + "right": 0.0, + "bottom": 16.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "center", + "flex": 4, + "margin": { + "top": 10.0, + "right": 0.0, + "bottom": 10.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/material_detail.png", + "fit": "fitWidth", + "width": 320, + "height": 205 + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "FIT HEIGHT" + } + ], + "generatedId": "fit-height" + }, + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Height leads." + } + ], + "generatedId": "height-leads" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Padding adds an inset inside the frame." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 2, + "margin": { + "top": 16.0, + "right": 0.0, + "bottom": 16.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "centerRight", + "flex": 4, + "margin": { + "top": 18.0, + "right": 0.0, + "bottom": 18.0, + "left": 0.0 + }, + "padding": { + "top": 12.0, + "right": 12.0, + "bottom": 12.0, + "left": 12.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/architectural_steps.png", + "fit": "fitHeight", + "width": 320, + "height": 205 + } + ], + "align": "center", + "flex": 2, + "spacing": 18.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "FILL" + } + ], + "generatedId": "fill" + }, + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Frame leads." + } + ], + "generatedId": "frame-leads" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Pixels stretch to occupy both axes." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 2, + "margin": { + "top": 16.0, + "right": 0.0, + "bottom": 16.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "center", + "flex": 4, + "margin": { + "top": 10.0, + "right": 0.0, + "bottom": 10.0, + "left": 0.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/momentum_ribbon.png", + "fit": "fill", + "width": 320, + "height": 205 + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "SCALE DOWN" + } + ], + "generatedId": "scale-down" + }, + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Source leads." + } + ], + "generatedId": "source-leads" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Margin and padding create a floating study." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 2, + "margin": { + "top": 16.0, + "right": 0.0, + "bottom": 16.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "bottomRight", + "flex": 4, + "margin": { + "top": 16.0, + "right": 8.0, + "bottom": 16.0, + "left": 8.0 + }, + "padding": { + "top": 18.0, + "right": 18.0, + "bottom": 18.0, + "left": 18.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/material_detail.png", + "fit": "scaleDown", + "width": 320, + "height": 205, + "scale": 0.82 + } + ], + "align": "center", + "flex": 2, + "spacing": 18.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "6D9iENdn", + "options": { + "title": "Markdown hierarchy", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "07 / RICH MARKDOWN" + } + ], + "generatedId": "07--rich-markdown" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "The content model has range." + } + ], + "generatedId": "the-content-model-has-range" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Inline hierarchy" + } + ], + "generatedId": "inline-hierarchy" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "Bold establishes consequence." + } + ] + }, + { + "type": "text", + "text": " " + }, + { + "type": "element", + "tag": "em", + "children": [ + { + "type": "text", + "text": "Italic adds a second voice." + } + ] + }, + { + "type": "text", + "text": " " + }, + { + "type": "element", + "tag": "del", + "children": [ + { + "type": "text", + "text": "Strikethrough retires the old idea." + } + ] + }, + { + "type": "text", + "text": " A " + }, + { + "type": "element", + "tag": "a", + "attributes": { + "href": "https://superdeck.dev" + }, + "children": [ + { + "type": "text", + "text": "link creates a path" + } + ] + }, + { + "type": "text", + "text": ", and " + }, + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "inline code" + } + ] + }, + { + "type": "text", + "text": " names the mechanism." + } + ] + }, + { + "type": "element", + "tag": "blockquote", + "children": [ + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Typography can carry structure before a single custom widget enters the frame." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 5, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "List rhythm" + } + ], + "generatedId": "list-rhythm" + }, + { + "type": "element", + "tag": "ul", + "children": [ + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "Lead" + } + ] + }, + { + "type": "text", + "text": " with the decision." + } + ] + }, + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "element", + "tag": "em", + "children": [ + { + "type": "text", + "text": "Qualify" + } + ] + }, + { + "type": "text", + "text": " it with context." + } + ] + }, + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "text", + "text": "Use " + }, + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "code" + } + ] + }, + { + "type": "text", + "text": " for exact syntax." + } + ] + } + ] + }, + { + "type": "element", + "tag": "ol", + "children": [ + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "text", + "text": "Establish the frame." + } + ] + }, + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "text", + "text": "Repeat the cadence." + } + ] + }, + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "text", + "text": "Break the pattern once." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 4, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 4, + "spacing": 42.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "STRONG" + } + ], + "generatedId": "strong" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "Make " + }, + { + "type": "text", + "text": "the " + }, + { + "type": "text", + "text": "signal unmistakable." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "EMPHASIS" + } + ], + "generatedId": "emphasis" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "em", + "children": [ + { + "type": "text", + "text": "Change " + }, + { + "type": "text", + "text": "the " + }, + { + "type": "text", + "text": "tone " + }, + { + "type": "text", + "text": "without " + }, + { + "type": "text", + "text": "changing size." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "LINK + CODE" + } + ], + "generatedId": "link--code" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "a", + "attributes": { + "href": "https://superdeck.dev" + }, + "children": [ + { + "type": "text", + "text": "Open the docs" + } + ] + }, + { + "type": "text", + "text": " or use " + }, + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "@section" + } + ] + }, + { + "type": "text", + "text": "." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 1, + "spacing": 22.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "EpRapYGr", + "options": { + "title": "Table-driven storytelling", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "08 / TABLES" + } + ], + "generatedId": "08--tables" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Dense information can still breathe." + } + ], + "generatedId": "dense-information-can-still-breathe" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "table", + "children": [ + { + "type": "element", + "tag": "thead", + "children": [ + { + "type": "element", + "tag": "tr", + "children": [ + { + "type": "element", + "tag": "th", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Layout signal" + } + ] + }, + { + "type": "element", + "tag": "th", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "text", + "text": "Before" + } + ] + }, + { + "type": "element", + "tag": "th", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "text", + "text": "After" + } + ] + }, + { + "type": "element", + "tag": "th", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "text", + "text": "Movement" + } + ] + } + ] + } + ] + }, + { + "type": "element", + "tag": "tbody", + "children": [ + { + "type": "element", + "tag": "tr", + "children": [ + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Time to first read" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "text", + "text": "8.4 s" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "2." + }, + { + "type": "text", + "text": "6 s" + } + ] + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "−69%" + } + ] + } + ] + } + ] + }, + { + "type": "element", + "tag": "tr", + "children": [ + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Competing focal points" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "text", + "text": "7" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "2" + } + ] + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "−71%" + } + ] + } + ] + } + ] + }, + { + "type": "element", + "tag": "tr", + "children": [ + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Grid exceptions" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "text", + "text": "12" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "1" + } + ] + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "−92%" + } + ] + } + ] + } + ] + }, + { + "type": "element", + "tag": "tr", + "children": [ + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Review confidence" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "text", + "text": "54%" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "91%" + } + ] + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "right" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "+" + }, + { + "type": "text", + "text": "37 pt" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 7, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "THE READING" + } + ], + "generatedId": "the-reading" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "3.2×" + } + ], + "generatedId": "32" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "faster orientation after the hierarchy was simplified." + } + ] + }, + { + "type": "element", + "tag": "blockquote", + "children": [ + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "A table earns attention when the comparison—not the border—is the story." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 3, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 10.0 + }, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 5, + "spacing": 34.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "J51RgH9X", + "options": { + "title": "GitHub alerts", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "09 / GITHUB ALERTS" + } + ], + "generatedId": "09--github-alerts" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Guidance with the right degree of urgency." + } + ], + "generatedId": "guidance-with-the-right-degree-of-urgency" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "div", + "attributes": { + "class": "markdown-alert markdown-alert-note" + }, + "children": [ + { + "type": "element", + "tag": "p", + "attributes": { + "class": "markdown-alert-title" + }, + "children": [ + { + "type": "text", + "text": "Note" + } + ] + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "Sections own height." + } + ] + }, + { + "type": "text", + "text": " Blocks inside them divide the available width." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "div", + "attributes": { + "class": "markdown-alert markdown-alert-tip" + }, + "children": [ + { + "type": "element", + "tag": "p", + "attributes": { + "class": "markdown-alert-title" + }, + "children": [ + { + "type": "text", + "text": "Tip" + } + ] + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Reuse a stable spacing rhythm, then change only the value that carries meaning." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 2, + "spacing": 18.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "div", + "attributes": { + "class": "markdown-alert markdown-alert-important" + }, + "children": [ + { + "type": "element", + "tag": "p", + "attributes": { + "class": "markdown-alert-title" + }, + "children": [ + { + "type": "text", + "text": "Important" + } + ] + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Keep the focal relationship obvious at thumbnail size and at full screen." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "div", + "attributes": { + "class": "markdown-alert markdown-alert-warning" + }, + "children": [ + { + "type": "element", + "tag": "p", + "attributes": { + "class": "markdown-alert-title" + }, + "children": [ + { + "type": "text", + "text": "Warning" + } + ] + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Paint-only image scale can crop beyond its original fit. Review the final frame." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "div", + "attributes": { + "class": "markdown-alert markdown-alert-caution" + }, + "children": [ + { + "type": "element", + "tag": "p", + "attributes": { + "class": "markdown-alert-title" + }, + "children": [ + { + "type": "text", + "text": "Caution" + } + ] + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "text", + "text": "Do not hide critical state in color alone. Pair the accent with a clear label." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 2, + "spacing": 24.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "uhvZJU9L", + "options": { + "title": "Indicators and readiness", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "10 / INDICATORS" + } + ], + "generatedId": "10--indicators" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Status should scan before it explains." + } + ], + "generatedId": "status-should-scan-before-it-explains" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Release readiness" + } + ], + "generatedId": "release-readiness" + }, + { + "type": "element", + "tag": "ul", + "attributes": { + "class": "contains-task-list" + }, + "children": [ + { + "type": "element", + "tag": "li", + "attributes": { + "class": "task-list-item" + }, + "children": [ + { + "type": "element", + "tag": "input", + "attributes": { + "type": "checkbox", + "checked": "true" + }, + "children": [] + }, + { + "type": "text", + "text": "Layout contract locked" + } + ] + }, + { + "type": "element", + "tag": "li", + "attributes": { + "class": "task-list-item" + }, + "children": [ + { + "type": "element", + "tag": "input", + "attributes": { + "type": "checkbox", + "checked": "true" + }, + "children": [] + }, + { + "type": "text", + "text": "Image crops reviewed" + } + ] + }, + { + "type": "element", + "tag": "li", + "attributes": { + "class": "task-list-item" + }, + "children": [ + { + "type": "element", + "tag": "input", + "attributes": { + "type": "checkbox", + "checked": "true" + }, + "children": [] + }, + { + "type": "text", + "text": "Contrast checked at 1280 × 720" + } + ] + }, + { + "type": "element", + "tag": "li", + "attributes": { + "class": "task-list-item" + }, + "children": [ + { + "type": "element", + "tag": "input", + "attributes": { + "type": "checkbox" + }, + "children": [] + }, + { + "type": "text", + "text": "Speaker notes approved" + } + ] + }, + { + "type": "element", + "tag": "li", + "attributes": { + "class": "task-list-item" + }, + "children": [ + { + "type": "element", + "tag": "input", + "attributes": { + "type": "checkbox" + }, + "children": [] + }, + { + "type": "text", + "text": "Final export published" + } + ] + } + ] + }, + { + "type": "element", + "tag": "blockquote", + "children": [ + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "3 / 5 complete." + } + ] + }, + { + "type": "text", + "text": " The remaining work is visible without opening another tool." + } + ] + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 5, + "scrollable": false, + "type": "block" + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Signal board" + } + ], + "generatedId": "signal-board" + }, + { + "type": "element", + "tag": "table", + "children": [ + { + "type": "element", + "tag": "thead", + "children": [ + { + "type": "element", + "tag": "tr", + "children": [ + { + "type": "element", + "tag": "th", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Workstream" + } + ] + }, + { + "type": "element", + "tag": "th", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "State" + } + ] + }, + { + "type": "element", + "tag": "th", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Owner" + } + ] + } + ] + } + ] + }, + { + "type": "element", + "tag": "tbody", + "children": [ + { + "type": "element", + "tag": "tr", + "children": [ + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Layout" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "● READY" + } + ] + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Design" + } + ] + } + ] + }, + { + "type": "element", + "tag": "tr", + "children": [ + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Content" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "● REVIEW" + } + ] + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Editorial" + } + ] + } + ] + }, + { + "type": "element", + "tag": "tr", + "children": [ + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Capture" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "● READY" + } + ] + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "QA" + } + ] + } + ] + }, + { + "type": "element", + "tag": "tr", + "children": [ + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Export" + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "○ QUEUED" + } + ] + } + ] + }, + { + "type": "element", + "tag": "td", + "attributes": { + "align": "left" + }, + "children": [ + { + "type": "text", + "text": "Release" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "READY" + } + ] + }, + { + "type": "text", + "text": " means verified. " + }, + { + "type": "element", + "tag": "em", + "children": [ + { + "type": "text", + "text": "REVIEW" + } + ] + }, + { + "type": "text", + "text": " means a decision remains. " + }, + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "QUEUED" + } + ] + }, + { + "type": "text", + "text": " means the path is known." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "flex": 5, + "scrollable": false, + "type": "block" + } + ], + "align": "topLeft", + "flex": 4, + "spacing": 42.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "tRy64s9H", + "options": { + "title": "A bounded extension point", + "style": "compact" + }, + "sections": [ + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "11 / NATIVE FIRST, EXTENSIBLE WHEN NEEDED" + } + ], + "generatedId": "11--native-first-extensible-when-needed" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "One escape hatch, clearly bounded." + } + ], + "generatedId": "one-escape-hatch-clearly-bounded" + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 1, + "scrollable": false, + "type": "block" + } + ], + "flex": 1, + "spacing": 0.0, + "type": "section" + }, + { + "blocks": [ + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "BUILT-IN AUTHORING" + } + ], + "generatedId": "built-in-authoring" + }, + { + "type": "element", + "tag": "h3", + "children": [ + { + "type": "text", + "text": "Most of this deck is only three primitives." + } + ], + "generatedId": "most-of-this-deck-is-only-three-primitives" + }, + { + "type": "element", + "tag": "ul", + "children": [ + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "@section" + } + ] + }, + { + "type": "text", + "text": " for vertical rhythm" + } + ] + }, + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "@block" + } + ] + }, + { + "type": "text", + "text": " for Markdown content" + } + ] + }, + { + "type": "element", + "tag": "li", + "children": [ + { + "type": "element", + "tag": "code", + "children": [ + { + "type": "text", + "text": "@image" + } + ] + }, + { + "type": "text", + "text": " for visual media" + } + ] + } + ] + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "No custom content widget is required" + } + ] + }, + { + "type": "text", + "text": " for the layouts, tables, alerts, checklists, or image studies." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 3, + "padding": { + "top": 0.0, + "right": 8.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + }, + { + "name": "image", + "align": "center", + "flex": 4, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/material_detail.png", + "fit": "cover", + "scale": 1.08 + }, + { + "name": "showcaseMetric", + "flex": 3, + "scrollable": false, + "type": "widget", + "value": "01", + "label": "Registered widget", + "detail": "This single metric card is the deliberately custom extension point in the content deck.", + "accent": "#59D6C8" + } + ], + "align": "center", + "flex": 5, + "spacing": 26.0, + "type": "section" + } + ], + "comments": [] + }, + { + "key": "vZXKbD3F", + "options": { + "title": "The outcome", + "style": "closing", + "layout": "fullscreen" + }, + "sections": [ + { + "blocks": [ + { + "name": "image", + "align": "centerLeft", + "flex": 4, + "margin": { + "top": 26.0, + "right": 0.0, + "bottom": 26.0, + "left": 26.0 + }, + "padding": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "widget", + "src": "layout_showcase/assets/momentum_ribbon.png", + "fit": "cover", + "scale": 1.24 + }, + { + "content": { + "type": "document", + "children": [ + { + "type": "element", + "tag": "h4", + "children": [ + { + "type": "text", + "text": "12 / THE OUTCOME" + } + ], + "generatedId": "12--the-outcome" + }, + { + "type": "element", + "tag": "h1", + "children": [ + { + "type": "text", + "text": "Built to be seen." + } + ], + "generatedId": "built-to-be-seen" + }, + { + "type": "element", + "tag": "h2", + "children": [ + { + "type": "text", + "text": "Every frame can be authored, inspected, and captured." + } + ], + "generatedId": "every-frame-can-be-authored-inspected-and-captured" + }, + { + "type": "element", + "tag": "p", + "children": [ + { + "type": "element", + "tag": "strong", + "children": [ + { + "type": "text", + "text": "Native layouts. Rich Markdown. Controlled imagery." + } + ] + }, + { + "type": "text", + "text": " One bounded widget extension and three original image assets." + } + ] + } + ], + "linkReferences": {}, + "footnoteLabels": [], + "footnoteReferences": {} + }, + "align": "centerLeft", + "flex": 5, + "padding": { + "top": 0.0, + "right": 54.0, + "bottom": 0.0, + "left": 0.0 + }, + "scrollable": false, + "type": "block" + } + ], + "align": "center", + "flex": 1, + "spacing": 50.0, + "type": "section" + } + ], + "comments": [] + } +] \ No newline at end of file diff --git a/demo/layout_showcase/README.md b/demo/layout_showcase/README.md new file mode 100644 index 00000000..7b104dd3 --- /dev/null +++ b/demo/layout_showcase/README.md @@ -0,0 +1,61 @@ +# Layout Showcase + +A thirteen-slide editorial deck that demonstrates SuperDeck's predictable +layout primitives, built-in image controls, and GitHub-flavored Markdown with +a cohesive visual system. + +The content deck is intentionally native-first: sections, blocks, images, +tables, alerts, checklists, and inline styling all use standard SuperDeck +authoring. Only the final comparison slide contains a registered Flutter +content widget, and it labels that extension point explicitly. The deck's +background, footer, and theme remain custom presentation chrome. + +From the `demo/` directory, build the Markdown contract: + +```bash +fvm dart run tool/build_layout_showcase.dart +``` + +Run the deck on macOS: + +```bash +fvm flutter run -t lib/layout_showcase_main.dart -d macos +``` + +Capture clean 1280x720 review screenshots: + +```bash +SUPERDECK_CAPTURE_SLIDES=1 \ +SUPERDECK_SCREENSHOT_DIR=../.context/layout_showcase_screenshots \ +fvm flutter test integration_test/layout_showcase_test.dart \ + -d macos --fail-fast --timeout 2m +``` + +The deck source is `slides.md`. Runtime styling, slide parts, and the single +metric widget live under `demo/lib/src/layout_showcase/`. + +## Spacing controls + +- `@section spacing` creates horizontal gutters between sibling blocks. +- Block or widget `margin` creates outer breathing room inside that block's + allocated frame. It is also how adjacent vertical section rows are visually + separated. +- Block or widget `padding` controls the inner distance between the decorated + frame and its content. + +The frame-size and treatment-matrix slides intentionally vary these controls +alongside `width`, `height`, `fit`, `scale`, and `align` so their effects can be +compared in captured output. + +## Image assets + +All three PNGs under `assets/` are original AI-generated images created for +this demo. Their prompts intentionally request landscape, crop-safe +compositions without text, logos, or watermarks: + +- `momentum_ribbon.png`: a sculptural translucent ribbon with coral, violet, + and teal light on a near-black field. +- `architectural_steps.png`: a monumental concrete stair and curved portal + with restrained coral, ultraviolet, and cool teal lighting. +- `material_detail.png`: smoked glass, brushed black metal, and pale stone in a + precise macro still life with coral, violet, and teal refractions. diff --git a/demo/layout_showcase/assets/architectural_steps.png b/demo/layout_showcase/assets/architectural_steps.png new file mode 100644 index 00000000..63bfa1a1 Binary files /dev/null and b/demo/layout_showcase/assets/architectural_steps.png differ diff --git a/demo/layout_showcase/assets/material_detail.png b/demo/layout_showcase/assets/material_detail.png new file mode 100644 index 00000000..8ea9dc90 Binary files /dev/null and b/demo/layout_showcase/assets/material_detail.png differ diff --git a/demo/layout_showcase/assets/momentum_ribbon.png b/demo/layout_showcase/assets/momentum_ribbon.png new file mode 100644 index 00000000..f9f9b246 Binary files /dev/null and b/demo/layout_showcase/assets/momentum_ribbon.png differ diff --git a/demo/layout_showcase/slides.md b/demo/layout_showcase/slides.md new file mode 100644 index 00000000..37c34544 --- /dev/null +++ b/demo/layout_showcase/slides.md @@ -0,0 +1,797 @@ +--- +title: The system makes room +style: cover +layout: fullscreen +--- + +@section { + spacing: 44 + align: center +} + +@block { + flex: 5 + align: centerLeft + padding: { horizontal: 34, vertical: 28 } +} + +#### NATIVE AUTHORING / VISUAL PROOF + +# The system makes room. + +## Thirteen frames for layout, image control, and rich Markdown. + +**Sections. Blocks. Images. Type.** One coherent visual language. + +@image { + src: layout_showcase/assets/architectural_steps.png + fit: cover + scale: 1.04 + flex: 6 + align: center + padding: 0 + margin: { top: 14, right: 14, bottom: 14 } +} + +--- +title: The layout model +style: panels +--- + +@section { flex: 1 } + +@block { + align: centerLeft + padding: { horizontal: 24, vertical: 14 } +} + +#### 01 / PREDICTABLE GEOMETRY + +## The layout model explains itself. + +@section { + flex: 2 + spacing: 18 + align: center +} + +@block { flex: 2 } + +### SECTION = HEIGHT + +Sections stack **vertically**. Their flex values decide how much of the slide each row receives. + +@block { flex: 1 } + +### 2× + +This block gets twice the width of its neighbor. + +@block { flex: 1 } + +### 1× + +The ratio stays stable as the canvas scales. + +@section { + flex: 2 + spacing: 18 + align: center +} + +@block { flex: 1 } + +#### MARGIN + +Space outside the framed block. + +@block { flex: 2 } + +#### PADDING + +Space inside the frame. **Decoration stays between them**, so the box model remains visible and teachable. + +@block { + flex: 1 + align: bottomRight +} + +#### ALIGN + +Content lands at any of nine anchor points. + +--- +title: Asymmetric composition +style: compact +--- + +@section { flex: 1 } + +@block { align: centerLeft } + +#### 02 / SECTIONS + BLOCKS + IMAGES + +## Editorial, asymmetric, still deterministic. + +@section { + flex: 5 + spacing: 28 + align: center +} + +@block { + flex: 4 + align: centerLeft + padding: { right: 18 } +} + +### Build hierarchy before decoration. + +A strong composition begins with **one dominant relationship**. Here, a 4:7 split gives the image authority while the copy keeps a clear reading edge. + +- **Flex** sets the proportion. +- **Spacing** creates the gutter. +- **Alignment** pins the content. + +> The result feels art-directed because every constraint has a job. + +@image { + src: layout_showcase/assets/architectural_steps.png + fit: cover + scale: 1.12 + flex: 7 + align: centerRight + padding: 0 + margin: { vertical: 6 } +} + +@section { + flex: 2 + spacing: 24 + align: center +} + +@image { + src: layout_showcase/assets/material_detail.png + fit: cover + flex: 2 + align: centerLeft + padding: 0 + margin: { top: 12 } +} + +@block { + flex: 4 + align: centerLeft + padding: { horizontal: 10 } + margin: { top: 12 } +} + +#### SECONDARY RHYTHM + +Repeated image frames can change proportion without changing the rules around them. + +@image { + src: layout_showcase/assets/momentum_ribbon.png + fit: cover + scale: 1.18 + flex: 2 + align: centerRight + padding: 0 + margin: { top: 12 } +} + +--- +title: Image fit behavior +style: compact +--- + +@section { flex: 1 } + +@block { align: centerLeft } + +#### 03 / IMAGE FIT + +## Same source. Two honest choices. + +@section { + flex: 1 + spacing: 24 + align: center +} + +@block { flex: 1 } + +#### CONTAIN / FULL FRAME + +**Nothing is cropped.** Empty space is preserved. + +@block { flex: 1 } + +#### COVER / FULL BLEED + +**The frame is filled.** Edges are intentionally cropped. + +@section { + flex: 5 + spacing: 24 + align: center +} + +@image { + src: layout_showcase/assets/architectural_steps.png + fit: contain + width: 560 + height: 420 + flex: 1 + align: center + padding: 0 +} + +@image { + src: layout_showcase/assets/architectural_steps.png + fit: cover + width: 560 + height: 420 + flex: 1 + align: center + padding: 0 +} + +--- +title: Image placement controls +style: compact +--- + +@section { flex: 1 } + +@block { align: centerLeft } + +#### 04 / SCALE + ALIGNMENT + +## Placement is a controlled variable. + +@section { + flex: 4 + spacing: 22 + align: center +} + +@image { + src: layout_showcase/assets/architectural_steps.png + fit: contain + scale: 0.72 + flex: 1 + align: topLeft + padding: 0 +} + +@image { + src: layout_showcase/assets/architectural_steps.png + fit: cover + scale: 1 + flex: 1 + align: center + padding: 0 +} + +@image { + src: layout_showcase/assets/architectural_steps.png + fit: cover + scale: 1.35 + flex: 1 + align: bottomRight + padding: 0 +} + +@section { + flex: 1 + spacing: 22 + align: topLeft +} + +@block { + flex: 1 + margin: { top: 14 } +} + +#### 72% / TOP LEFT + +Full image, deliberate quiet space. + +@block { + flex: 1 + margin: { top: 14 } +} + +#### 100% / CENTER + +The reliable default crop. + +@block { + flex: 1 + margin: { top: 14 } +} + +#### 135% / BOTTOM RIGHT + +An intentional detail view. + +--- +title: Image frame matrix +style: compact +--- + +@section { flex: 1 } + +@block { align: centerLeft } + +#### 05 / FRAME SIZE + BLOCK MARGIN + +## Frame size is authored, not guessed. + +@section { + flex: 4 + spacing: 18 + align: center +} + +@image { + src: layout_showcase/assets/material_detail.png + fit: cover + scale: 1.06 + flex: 1 + align: center + padding: 0 + margin: { top: 8, right: 66, bottom: 8, left: 66 } +} + +@image { + src: layout_showcase/assets/architectural_steps.png + fit: cover + flex: 1 + align: center + padding: 0 + margin: { top: 52, right: 38, bottom: 52, left: 38 } +} + +@image { + src: layout_showcase/assets/momentum_ribbon.png + fit: cover + scale: 1.14 + flex: 1 + align: centerRight + padding: 0 + margin: { top: 100, right: 4, bottom: 100, left: 4 } +} + +@section { + flex: 1 + spacing: 18 + align: topLeft +} + +@block { flex: 1 } + +#### PORTRAIT / TALL + +Horizontal margin narrows the decorated image block. + +@block { flex: 1 } + +#### SQUARE / BALANCED + +Two-axis margin creates a compact focal tile. + +@block { flex: 1 } + +#### LANDSCAPE / WIDE + +Vertical margin produces a cinematic banner. + +--- +title: Image treatment matrix +style: compact +--- + +@section { flex: 1 } + +@block { align: centerLeft } + +#### 06 / FIT + SURFACE TREATMENT + +## One image block can carry many behaviors. + +@section { + flex: 2 + spacing: 18 + align: center +} + +@block { + flex: 2 + align: centerLeft + margin: { vertical: 16 } +} + +#### FIT WIDTH + +### Width leads. + +The source fills the horizontal axis first. + +@image { + src: layout_showcase/assets/material_detail.png + fit: fitWidth + width: 320 + height: 205 + flex: 4 + align: center + padding: 0 + margin: { vertical: 10 } +} + +@block { + flex: 2 + align: centerLeft + margin: { vertical: 16 } +} + +#### FIT HEIGHT + +### Height leads. + +Padding adds an inset inside the frame. + +@image { + src: layout_showcase/assets/architectural_steps.png + fit: fitHeight + width: 320 + height: 205 + flex: 4 + align: centerRight + padding: 12 + margin: { vertical: 18 } +} + +@section { + flex: 2 + spacing: 18 + align: center +} + +@block { + flex: 2 + align: centerLeft + margin: { vertical: 16 } +} + +#### FILL + +### Frame leads. + +Pixels stretch to occupy both axes. + +@image { + src: layout_showcase/assets/momentum_ribbon.png + fit: fill + width: 320 + height: 205 + flex: 4 + align: center + padding: 0 + margin: { vertical: 10 } +} + +@block { + flex: 2 + align: centerLeft + margin: { vertical: 16 } +} + +#### SCALE DOWN + +### Source leads. + +Margin and padding create a floating study. + +@image { + src: layout_showcase/assets/material_detail.png + fit: scaleDown + width: 320 + height: 205 + scale: 0.82 + flex: 4 + align: bottomRight + padding: 18 + margin: { horizontal: 8, vertical: 16 } +} + +--- +title: Markdown hierarchy +style: compact +--- + +@section { flex: 1 } + +@block { align: centerLeft } + +#### 07 / RICH MARKDOWN + +## The content model has range. + +@section { + flex: 4 + spacing: 42 + align: topLeft +} + +@block { flex: 5 } + +### Inline hierarchy + +**Bold establishes consequence.** *Italic adds a second voice.* ~~Strikethrough retires the old idea.~~ A [link creates a path](https://superdeck.dev), and `inline code` names the mechanism. + +> Typography can carry structure before a single custom widget enters the frame. + +@block { flex: 4 } + +### List rhythm + +- **Lead** with the decision. +- *Qualify* it with context. +- Use `code` for exact syntax. + +1. Establish the frame. +2. Repeat the cadence. +3. Break the pattern once. + +@section { + flex: 1 + spacing: 22 + align: center +} + +@block { flex: 1 } + +#### STRONG + +**Make the signal unmistakable.** + +@block { flex: 1 } + +#### EMPHASIS + +*Change the tone without changing size.* + +@block { flex: 1 } + +#### LINK + CODE + +[Open the docs](https://superdeck.dev) or use `@section`. + +--- +title: Table-driven storytelling +style: compact +--- + +@section { flex: 1 } + +@block { align: centerLeft } + +#### 08 / TABLES + +## Dense information can still breathe. + +@section { + flex: 5 + spacing: 34 + align: center +} + +@block { + flex: 7 + align: centerLeft +} + +| Layout signal | Before | After | Movement | +| :-- | --: | --: | --: | +| Time to first read | 8.4 s | **2.6 s** | **−69%** | +| Competing focal points | 7 | **2** | **−71%** | +| Grid exceptions | 12 | **1** | **−92%** | +| Review confidence | 54% | **91%** | **+37 pt** | + +@block { + flex: 3 + align: centerLeft + padding: { left: 10 } +} + +#### THE READING + +## 3.2× + +faster orientation after the hierarchy was simplified. + +> A table earns attention when the comparison—not the border—is the story. + +--- +title: GitHub alerts +style: compact +--- + +@section { flex: 1 } + +@block { align: centerLeft } + +#### 09 / GITHUB ALERTS + +## Guidance with the right degree of urgency. + +@section { + flex: 2 + spacing: 18 + align: topLeft +} + +@block { flex: 1 } + +> [!NOTE] +> **Sections own height.** Blocks inside them divide the available width. + +@block { flex: 1 } + +> [!TIP] +> Reuse a stable spacing rhythm, then change only the value that carries meaning. + +@section { + flex: 2 + spacing: 24 + align: topLeft +} + +@block { flex: 1 } + +> [!IMPORTANT] +> Keep the focal relationship obvious at thumbnail size and at full screen. + +@block { flex: 1 } + +> [!WARNING] +> Paint-only image scale can crop beyond its original fit. Review the final frame. + +@block { flex: 1 } + +> [!CAUTION] +> Do not hide critical state in color alone. Pair the accent with a clear label. + +--- +title: Indicators and readiness +style: compact +--- + +@section { flex: 1 } + +@block { align: centerLeft } + +#### 10 / INDICATORS + +## Status should scan before it explains. + +@section { + flex: 4 + spacing: 42 + align: topLeft +} + +@block { flex: 5 } + +### Release readiness + +- [x] Layout contract locked +- [x] Image crops reviewed +- [x] Contrast checked at 1280 × 720 +- [ ] Speaker notes approved +- [ ] Final export published + +> **3 / 5 complete.** The remaining work is visible without opening another tool. + +@block { flex: 5 } + +### Signal board + +| Workstream | State | Owner | +| :-- | :-- | :-- | +| Layout | **● READY** | Design | +| Content | **● REVIEW** | Editorial | +| Capture | **● READY** | QA | +| Export | **○ QUEUED** | Release | + +**READY** means verified. *REVIEW* means a decision remains. `QUEUED` means the path is known. + +--- +title: A bounded extension point +style: compact +--- + +@section { flex: 1 } + +@block { align: centerLeft } + +#### 11 / NATIVE FIRST, EXTENSIBLE WHEN NEEDED + +## One escape hatch, clearly bounded. + +@section { + flex: 5 + spacing: 26 + align: center +} + +@block { + flex: 3 + align: centerLeft + padding: { right: 8 } +} + +#### BUILT-IN AUTHORING + +### Most of this deck is only three primitives. + +- `@section` for vertical rhythm +- `@block` for Markdown content +- `@image` for visual media + +**No custom content widget is required** for the layouts, tables, alerts, checklists, or image studies. + +@image { + src: layout_showcase/assets/material_detail.png + fit: cover + scale: 1.08 + flex: 4 + align: center + padding: 0 +} + +@showcaseMetric { + value: "01" + label: Registered widget + detail: This single metric card is the deliberately custom extension point in the content deck. + accent: "#59D6C8" + flex: 3 +} + +--- +title: The outcome +style: closing +layout: fullscreen +--- + +@section { + spacing: 50 + align: center +} + +@image { + src: layout_showcase/assets/momentum_ribbon.png + fit: cover + scale: 1.24 + flex: 4 + align: centerLeft + padding: 0 + margin: { top: 26, bottom: 26, left: 26 } +} + +@block { + flex: 5 + align: centerLeft + padding: { right: 54 } +} + +#### 12 / THE OUTCOME + +# Built to be seen. + +## Every frame can be authored, inspected, and captured. + +**Native layouts. Rich Markdown. Controlled imagery.** One bounded widget extension and three original image assets. diff --git a/demo/lib/layout_showcase_main.dart b/demo/lib/layout_showcase_main.dart new file mode 100644 index 00000000..2d270f16 --- /dev/null +++ b/demo/lib/layout_showcase_main.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; +import 'package:signals_flutter/signals_flutter.dart'; +import 'package:superdeck/superdeck.dart'; + +import 'src/layout_showcase/layout_showcase.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + SignalsObserver.instance = null; + await SuperDeckApp.initialize(); + + runApp(const LayoutShowcaseApp()); +} diff --git a/demo/lib/main.dart b/demo/lib/main.dart index 74d8f19d..88566293 100644 --- a/demo/lib/main.dart +++ b/demo/lib/main.dart @@ -25,7 +25,11 @@ void main() async { baseStyle: borderedStyle(), widgets: {...demoWidgets, 'twitter': _twitterWidget}, // debug: true, - styles: {'announcement': announcementStyle(), 'quote': quoteStyle()}, + styles: { + 'announcement': announcementStyle(), + 'quote': quoteStyle(), + 'boxed': boxedStyle(), + }, templates: { 'corporate': corporateTemplate(), 'minimal': minimalTemplate(), diff --git a/demo/lib/src/layout_showcase/layout_showcase.dart b/demo/lib/src/layout_showcase/layout_showcase.dart new file mode 100644 index 00000000..e64f808a --- /dev/null +++ b/demo/lib/src/layout_showcase/layout_showcase.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:superdeck/superdeck.dart'; + +import 'showcase_parts.dart'; +import 'showcase_style.dart'; +import 'showcase_widgets.dart'; +import 'showcase_workspace.dart'; + +export 'showcase_workspace.dart'; + +DeckOptions layoutShowcaseOptions() { + return DeckOptions( + baseStyle: showcaseBaseStyle(), + styles: { + 'cover': showcaseCoverStyle(), + 'compact': showcaseCompactStyle(), + 'panels': showcasePanelStyle(), + 'closing': showcaseClosingStyle(), + }, + widgets: showcaseWidgets, + parts: const SlideParts( + header: null, + background: ShowcaseBackground(), + footer: ShowcaseFooter(), + ), + ); +} + +class LayoutShowcaseApp extends StatelessWidget { + const LayoutShowcaseApp({ + super.key, + this.deckLoader, + this.transitionDuration = const Duration(milliseconds: 650), + }); + + final DeckLoader? deckLoader; + final Duration transitionDuration; + + @override + Widget build(BuildContext context) { + return SuperDeckApp( + options: layoutShowcaseOptions(), + workspace: layoutShowcaseWorkspace, + deckLoader: deckLoader, + transitionDuration: transitionDuration, + ); + } +} diff --git a/demo/lib/src/layout_showcase/showcase_parts.dart b/demo/lib/src/layout_showcase/showcase_parts.dart new file mode 100644 index 00000000..0d8362a5 --- /dev/null +++ b/demo/lib/src/layout_showcase/showcase_parts.dart @@ -0,0 +1,144 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:superdeck/superdeck.dart'; + +class ShowcaseBackground extends StatelessWidget { + const ShowcaseBackground({super.key}); + + static const _accents = [ + Color(0xFFFF7A59), + Color(0xFF8B7CFF), + Color(0xFFFFB15A), + Color(0xFFDF6FFF), + Color(0xFF58D5C9), + Color(0xFFFF876B), + ]; + + @override + Widget build(BuildContext context) { + final slide = SlideConfiguration.of(context); + final accent = _accents[slide.slideIndex % _accents.length]; + + return DecoratedBox( + decoration: const BoxDecoration(color: Color(0xFF09090D)), + child: Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: const Alignment(-0.82, -0.78), + radius: 1.08, + colors: [ + accent.withValues(alpha: 0.15), + const Color(0xFF111018).withValues(alpha: 0.7), + const Color(0xFF09090D), + ], + stops: const [0, 0.42, 1], + ), + ), + ), + Positioned( + right: -180, + bottom: -220, + width: 560, + height: 560, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [accent.withValues(alpha: 0.11), Colors.transparent], + ), + ), + ), + ), + CustomPaint(painter: _ShowcaseGridPainter(accent: accent)), + ], + ), + ); + } +} + +class ShowcaseFooter extends StatelessWidget implements PreferredSizeWidget { + const ShowcaseFooter({super.key}); + + @override + Size get preferredSize => const Size.fromHeight(38); + + @override + Widget build(BuildContext context) { + final slide = SlideConfiguration.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 50), + child: Row( + children: [ + const Text( + 'SUPERDECK / LAYOUT STUDY', + style: TextStyle( + color: Color(0xFF77737D), + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 2.2, + ), + ), + const Spacer(), + Container(width: 48, height: 1, color: const Color(0x35FFFFFF)), + const SizedBox(width: 14), + Text( + '${slide.slideIndex + 1}'.padLeft(2, '0'), + style: const TextStyle( + color: Color(0xFFAAA6AF), + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.8, + ), + ), + ], + ), + ); + } +} + +class _ShowcaseGridPainter extends CustomPainter { + const _ShowcaseGridPainter({required this.accent}); + + final Color accent; + + @override + void paint(Canvas canvas, Size size) { + final linePaint = Paint() + ..color = const Color(0x0FFFFFFF) + ..strokeWidth = 1; + const step = 80.0; + + for (var x = 0.0; x <= size.width; x += step) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), linePaint); + } + for (var y = 0.0; y <= size.height; y += step) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), linePaint); + } + + final arcPaint = Paint() + ..color = accent.withValues(alpha: 0.1) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2; + final diameter = math.min(size.width, size.height) * 1.12; + canvas.drawArc( + Rect.fromCircle( + center: Offset(size.width * 0.92, size.height * 0.1), + radius: diameter / 2, + ), + math.pi * 0.58, + math.pi * 0.82, + false, + arcPaint, + ); + } + + @override + bool shouldRepaint(_ShowcaseGridPainter oldDelegate) { + return accent != oldDelegate.accent; + } +} diff --git a/demo/lib/src/layout_showcase/showcase_style.dart b/demo/lib/src/layout_showcase/showcase_style.dart new file mode 100644 index 00000000..1f9db006 --- /dev/null +++ b/demo/lib/src/layout_showcase/showcase_style.dart @@ -0,0 +1,377 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:mix/mix.dart'; +import 'package:superdeck/superdeck.dart'; + +import '../google_font_helpers.dart'; + +const _imageBlock = BlockVariant('image'); +const _metricBlock = BlockVariant('showcaseMetric'); + +final _displayFamily = safeGoogleFontFamily(GoogleFonts.interTight); +final _bodyFamily = safeGoogleFontFamily(GoogleFonts.inter); + +const _ink = Color(0xFFF7F4F2); +const _mutedInk = Color(0xFFC9C6CE); +const _coral = Color(0xFFFF8A65); +const _violet = Color(0xFFA890FF); +const _teal = Color(0xFF59D6C8); + +SlideStyler showcaseBaseStyle() { + return SlideStyler( + h1: TextStyler().style( + TextStyleMix( + fontFamily: _displayFamily, + fontSize: 82, + fontWeight: FontWeight.w800, + height: 0.98, + letterSpacing: -3, + color: _ink, + ), + ), + h2: TextStyler().style( + TextStyleMix( + fontFamily: _displayFamily, + fontSize: 48, + fontWeight: FontWeight.w700, + height: 1.04, + letterSpacing: -1.5, + color: _ink, + ), + ), + h3: TextStyler().style( + TextStyleMix( + fontFamily: _displayFamily, + fontSize: 30, + fontWeight: FontWeight.w600, + height: 1.12, + letterSpacing: -0.7, + color: _ink, + ), + ), + h4: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: 2.6, + color: _coral, + ), + ), + p: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 21, + fontWeight: FontWeight.w400, + height: 1.48, + color: _mutedInk, + ), + ), + strong: TextStyleMix(fontWeight: FontWeight.w800, color: _ink), + em: TextStyleMix(fontStyle: FontStyle.italic, color: _coral), + del: TextStyleMix( + color: Color(0xFF77737D), + decoration: TextDecoration.lineThrough, + decorationColor: Color(0xFFAAA6AF), + ), + link: TextStyleMix( + color: _teal, + decoration: TextDecoration.underline, + decorationColor: Color(0x9959D6C8), + ), + list: MarkdownListStyler( + text: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 20, + height: 1.5, + color: const Color(0xFFD5D1D9), + ), + ), + bullet: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 18, + fontWeight: FontWeight.w700, + color: const Color(0xFFFF8A65), + ), + ), + ), + blockquote: MarkdownBlockquoteStyler( + textStyle: TextStyleMix( + fontFamily: _displayFamily, + fontSize: 25, + height: 1.35, + fontWeight: FontWeight.w500, + color: const Color(0xFFF0ECF1), + ), + padding: const EdgeInsets.only(left: 26), + decoration: const BoxDecoration( + border: Border(left: BorderSide(color: Color(0xFFFF8A65), width: 3)), + ), + ), + alert: MarkdownAlertStyler( + note: _alertType(const Color(0xFF6CB6FF)), + tip: _alertType(_teal), + important: _alertType(_violet), + warning: _alertType(const Color(0xFFFFB15A)), + caution: _alertType(const Color(0xFFFF6B7A)), + ), + table: MarkdownTableStyler( + headStyle: TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 14, + height: 1.25, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: _coral, + ), + bodyStyle: TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 16, + height: 1.35, + color: const Color(0xFFD8D4DC), + ), + headAlignment: TextAlign.left, + border: const TableBorder( + top: BorderSide(color: Color(0x38FFFFFF)), + bottom: BorderSide(color: Color(0x38FFFFFF)), + horizontalInside: BorderSide(color: Color(0x20FFFFFF)), + verticalInside: BorderSide(color: Color(0x18FFFFFF)), + ), + cellPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + cellDecoration: const BoxDecoration(color: Color(0x0CFFFFFF)), + verticalAlignment: TableCellVerticalAlignment.middle, + ), + checkbox: MarkdownCheckboxStyler( + textStyle: TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 18, + height: 1.5, + color: const Color(0xFFD8D4DC), + ), + icon: IconStyler(color: _teal, size: 20), + ), + blockContainer: BlockStyler(padding: EdgeInsetsGeometryMix.all(0)) + .variants([ + VariantStyle( + _imageBlock, + BlockStyler( + padding: EdgeInsetsGeometryMix.all(0), + margin: EdgeInsetsGeometryMix.all(0), + clipBehavior: Clip.antiAlias, + decoration: BoxDecorationMix( + color: const Color(0xFF121219), + border: BorderMix.all( + BorderSideMix(color: const Color(0x2EFFFFFF), width: 1), + ), + borderRadius: BorderRadiusMix.circular(30), + ), + ), + ), + VariantStyle( + _metricBlock, + BlockStyler( + padding: EdgeInsetsGeometryMix.symmetric( + horizontal: 30, + vertical: 28, + ), + decoration: _panelDecoration(radius: 26), + ), + ), + ]), + slideContainer: BoxStyler( + padding: EdgeInsetsGeometryMix.symmetric(horizontal: 50, vertical: 30), + ), + ); +} + +SlideStyler showcaseCompactStyle() { + return SlideStyler( + h2: TextStyler().style( + TextStyleMix( + fontFamily: _displayFamily, + fontSize: 40, + fontWeight: FontWeight.w700, + height: 1.04, + letterSpacing: -1.2, + color: _ink, + ), + ), + h3: TextStyler().style( + TextStyleMix( + fontFamily: _displayFamily, + fontSize: 24, + fontWeight: FontWeight.w600, + height: 1.12, + letterSpacing: -0.4, + color: _ink, + ), + ), + h4: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 2.2, + color: _coral, + ), + ), + p: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 17, + fontWeight: FontWeight.w400, + height: 1.42, + color: _mutedInk, + ), + ), + list: MarkdownListStyler( + text: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 17, + height: 1.42, + color: const Color(0xFFD5D1D9), + ), + ), + bullet: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 16, + fontWeight: FontWeight.w700, + color: _coral, + ), + ), + ), + blockquote: MarkdownBlockquoteStyler( + textStyle: TextStyleMix( + fontFamily: _displayFamily, + fontSize: 20, + height: 1.34, + fontWeight: FontWeight.w500, + color: const Color(0xFFF0ECF1), + ), + padding: const EdgeInsets.only(left: 20), + decoration: const BoxDecoration( + border: Border(left: BorderSide(color: _coral, width: 3)), + ), + ), + slideContainer: BoxStyler( + padding: EdgeInsetsGeometryMix.symmetric(horizontal: 50, vertical: 24), + ), + ); +} + +SlideStyler showcasePanelStyle() { + return showcaseCompactStyle().merge( + SlideStyler( + blockContainer: BlockStyler( + padding: EdgeInsetsGeometryMix.symmetric(horizontal: 24, vertical: 22), + margin: EdgeInsetsGeometryMix.symmetric(vertical: 8), + decoration: _panelDecoration(radius: 22), + ), + ), + ); +} + +SlideStyler showcaseCoverStyle() { + return SlideStyler( + h1: TextStyler().style( + TextStyleMix( + fontFamily: _displayFamily, + fontSize: 96, + fontWeight: FontWeight.w800, + height: 0.92, + letterSpacing: -4.5, + color: const Color(0xFFF9F6F4), + ), + ), + h2: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 25, + fontWeight: FontWeight.w400, + height: 1.35, + color: const Color(0xFFBCB8C1), + ), + ), + slideContainer: BoxStyler( + padding: EdgeInsetsGeometryMix.symmetric(horizontal: 34, vertical: 34), + ), + ); +} + +SlideStyler showcaseClosingStyle() { + return SlideStyler( + h1: TextStyler().style( + TextStyleMix( + fontFamily: _displayFamily, + fontSize: 74, + fontWeight: FontWeight.w800, + height: 0.96, + letterSpacing: -3, + color: const Color(0xFFF9F6F4), + ), + ), + h2: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 28, + fontWeight: FontWeight.w400, + height: 1.3, + color: const Color(0xFFFFA184), + ), + ), + ); +} + +BoxDecorationMix _panelDecoration({required double radius}) { + return BoxDecorationMix( + gradient: LinearGradientMix( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: const [Color(0x14FFFFFF), Color(0x08FFFFFF)], + ), + border: BorderMix.all( + BorderSideMix(color: const Color(0x24FFFFFF), width: 1), + ), + borderRadius: BorderRadiusMix.circular(radius), + ); +} + +MarkdownAlertTypeStyler _alertType(Color color) { + return MarkdownAlertTypeStyler( + heading: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 13, + fontWeight: FontWeight.w800, + letterSpacing: 1.5, + color: color, + ), + ), + description: TextStyler().style( + TextStyleMix( + fontFamily: _bodyFamily, + fontSize: 15, + height: 1.38, + color: const Color(0xFFD8D4DC), + ), + ), + icon: IconStyler(color: color, size: 19), + container: BoxStyler( + padding: EdgeInsetsGeometryMix.symmetric(horizontal: 20, vertical: 16), + margin: EdgeInsetsGeometryMix.symmetric(vertical: 4), + decoration: BoxDecorationMix( + color: color.withValues(alpha: 0.07), + border: BorderMix(left: BorderSideMix(color: color, width: 3)), + borderRadius: BorderRadiusMix.circular(14), + ), + ), + containerFlex: FlexBoxStyler() + .spacing(9) + .crossAxisAlignment(CrossAxisAlignment.start), + headingFlex: FlexBoxStyler(spacing: 8), + ); +} diff --git a/demo/lib/src/layout_showcase/showcase_widgets.dart b/demo/lib/src/layout_showcase/showcase_widgets.dart new file mode 100644 index 00000000..c7f76a90 --- /dev/null +++ b/demo/lib/src/layout_showcase/showcase_widgets.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:superdeck/superdeck.dart'; + +final showcaseWidgets = { + 'showcaseMetric': ShowcaseMetric.new, +}; + +class ShowcaseMetric extends StatelessWidget { + ShowcaseMetric(Map args, {super.key}) + : value = args['value'] as String? ?? '—', + label = args['label'] as String? ?? '', + detail = args['detail'] as String? ?? '', + accent = _parseColor(args['accent'], const Color(0xFFFF8A65)); + + final String value; + final String label; + final String detail; + final Color accent; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 42, + height: 4, + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(99), + ), + ), + const Spacer(), + Text( + value, + style: const TextStyle( + color: Color(0xFFF8F4F2), + fontSize: 58, + height: 0.92, + fontWeight: FontWeight.w800, + letterSpacing: -2.6, + ), + ), + const SizedBox(height: 16), + Text( + label, + style: const TextStyle( + color: Color(0xFFF0ECEF), + fontSize: 19, + height: 1.2, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + detail, + style: const TextStyle( + color: Color(0xFF99959F), + fontSize: 14, + height: 1.35, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ); + }, + ); + } +} + +Color _parseColor(Object? value, Color fallback) { + if (value is! String) return fallback; + final hex = value.replaceFirst('#', ''); + if (hex.length != 6) return fallback; + final parsed = int.tryParse(hex, radix: 16); + if (parsed == null) return fallback; + return Color(0xFF000000 | parsed); +} diff --git a/demo/lib/src/layout_showcase/showcase_workspace.dart b/demo/lib/src/layout_showcase/showcase_workspace.dart new file mode 100644 index 00000000..73d008f7 --- /dev/null +++ b/demo/lib/src/layout_showcase/showcase_workspace.dart @@ -0,0 +1,6 @@ +import 'package:superdeck_core/superdeck_core.dart'; + +final layoutShowcaseWorkspace = DeckWorkspace( + slidesPath: 'layout_showcase/slides.md', + outputDir: 'layout_showcase/.superdeck', +); diff --git a/demo/lib/src/style.dart b/demo/lib/src/style.dart index 21473c58..00878dfb 100644 --- a/demo/lib/src/style.dart +++ b/demo/lib/src/style.dart @@ -23,7 +23,7 @@ SlideStyler announcementStyle() { fontWeight: FontWeight.w100, ), ), - blockContainer: BoxStyler( + blockContainer: BlockStyler( decoration: BoxDecorationMix( gradient: LinearGradientMix( begin: Alignment.topCenter, @@ -67,6 +67,23 @@ SlideStyler quoteStyle() { ); } +/// Decorated block containers so the layout demo slides can visibly +/// distinguish section spacing, block margin, and block padding. +SlideStyler boxedStyle() { + return SlideStyler( + blockContainer: BlockStyler( + padding: EdgeInsetsGeometryMix.all(24), + decoration: BoxDecorationMix( + color: Colors.white.withValues(alpha: 0.06), + border: BoxBorderMix.all( + BorderSideMix(color: Colors.white38, width: 2), + ), + borderRadius: BorderRadiusMix.all(const Radius.circular(12)), + ), + ), + ); +} + SlideStyler borderedStyle() { return SlideStyler( modifier: WidgetModifierConfig( diff --git a/demo/lib/src/templates.dart b/demo/lib/src/templates.dart index ec395d81..e77da6fc 100644 --- a/demo/lib/src/templates.dart +++ b/demo/lib/src/templates.dart @@ -39,7 +39,7 @@ SlideTemplate corporateTemplate() { fontWeight: FontWeight.w900, ), ), - blockContainer: BoxStyler( + blockContainer: BlockStyler( decoration: BoxDecorationMix( gradient: LinearGradientMix( begin: Alignment.topLeft, diff --git a/demo/pubspec.yaml b/demo/pubspec.yaml index 19ddf770..8aa4be6d 100644 --- a/demo/pubspec.yaml +++ b/demo/pubspec.yaml @@ -35,3 +35,5 @@ flutter: assets: - assets/ - .superdeck/ + - layout_showcase/assets/ + - layout_showcase/.superdeck/ diff --git a/demo/slides.md b/demo/slides.md index 1889b984..678bca46 100644 --- a/demo/slides.md +++ b/demo/slides.md @@ -101,6 +101,94 @@ SuperDeck supports flexible layouts using sections and columns. --- +## Predictable Layout Primitives {.heading} + +@section { + spacing: 32 + align: center +} + +@block { padding: 0 } + +### Edge to edge +`padding: 0` + +@block { padding: 16 } + +### Uniform inset +`padding: 16` + +@block { + align: bottomRight + padding: { + horizontal: 48, + vertical: 24, + } +} + +### Independent axes +`48 × 24` + +--- +style: boxed +--- + +## Spacing vs Margin vs Padding {.heading} + +@section { + spacing: 40 + align: center +} + +@block + +### Spacing +`spacing: 40` is the shared gap between sibling block frames. + +@block { margin: 24 } + +### Margin +`margin: 24` is consumed inside this block's frame, outside its border. + +@block { + padding: { + horizontal: 48, + vertical: 24, + } +} + +### Padding +`padding` sits between the border and this content. + +--- + +## Image Framing Scale {.heading} + +@section { + spacing: 64 + align: center +} + +@image { + src: assets/concepta-icon.png + fit: contain + width: 300 + height: 300 + scale: 1 + padding: 0 +} + +@image { + src: assets/concepta-icon.png + fit: contain + width: 300 + height: 300 + scale: 1.25 + padding: 0 +} + +--- + @block { flex: 2 align: centerRight diff --git a/demo/tool/build_layout_showcase.dart b/demo/tool/build_layout_showcase.dart new file mode 100644 index 00000000..eea324e6 --- /dev/null +++ b/demo/tool/build_layout_showcase.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +import 'package:superdeck_builder/superdeck_builder.dart'; +import 'package:superdeck_core/superdeck_core.dart'; +import 'package:superdeck_example/src/layout_showcase/showcase_workspace.dart'; + +Future main() async { + final store = DeckBuildStore(workspace: layoutShowcaseWorkspace); + final builder = DeckBuilder(workspace: layoutShowcaseWorkspace, store: store); + + try { + final slides = await builder.build(); + stdout.writeln('Built ${slides.length} layout showcase slides.'); + } finally { + await builder.dispose(); + } +} diff --git a/docs/guides/markdown-authoring.mdx b/docs/guides/markdown-authoring.mdx index 9dd9d7c4..1652c6bd 100644 --- a/docs/guides/markdown-authoring.mdx +++ b/docs/guides/markdown-authoring.mdx @@ -73,6 +73,7 @@ Groups blocks horizontally. Use sections to create multi-column layouts. @section { align: center flex: 2 + spacing: 32 } ```` @@ -84,6 +85,10 @@ Renders markdown content. Most slides use one or more columns. @block { flex: 2 align: topLeft + padding: { + horizontal: 32, + vertical: 16, + } } # Promise, Problem, Proof @@ -94,13 +99,61 @@ Renders markdown content. Most slides use one or more columns. ### Common properties -Use these properties on blocks. Sections support `flex` and `align`; -`scrollable` applies to child blocks and widgets. - -- `flex` – Relative size (default: `1`). A column with `flex: 2` takes twice the space of `flex: 1` -- `align` – Content alignment such as `topLeft`, `center`, and `bottomRight` +Sections stack vertically; blocks within a section lay out horizontally. Use +these properties to control the two axes and the content inside each rectangle. + +- `flex` – Positive integer relative size (default: `1`). Section flex controls + height; child block flex controls width +- `spacing` – Finite, non-negative gap between sibling blocks; applies to + sections only and never creates leading or trailing space +- `margin` – Internal block inset consumed inside the block's allocated frame, + outside its decoration/border; accepts a scalar, a symmetric object, or a + physical-edge object +- `padding` – Internal block inset consumed inside the decorated container, + between the border and the content; accepts a scalar, a symmetric object, or + a physical-edge object +- `align` – Content alignment such as `topLeft`, `center`, and `bottomRight`. + Resolution is block alignment, then inherited section alignment, then + `centerLeft` - `scrollable` – Enable scrolling for overflow content (default: `false`) +These controls are independent and nest in this order: the +`SlideStyler.slideContainer` supplies the outer slide inset, section `spacing` +separates columns, block `margin` reduces the usable area of that block's own +allocated frame, block `padding` reduces the content rectangle inside the +decorated container, and `align` positions content in that final rectangle. +Unlike CSS margins, block `margin` never creates a shared gutter with sibling +blocks and never changes flex ratios — use section `spacing` for gutters. + +Accepted `padding`/`margin` forms: + +````markdown +@block { padding: 16 } + +@block { + padding: { + horizontal: 24, + vertical: 16, + } +} + +@block { + padding: { + top: 12, + right: 24, + bottom: 12, + left: 24, + } +} +```` + +`margin` accepts the same three forms. Do not mix symmetric and physical-edge +keys. Omitted keys become zero. An absent (`null`) override retains the +resolved style value for that inset; an explicit `0` removes it. A present +override replaces only the matching inset (`padding` replaces padding, +`margin` replaces margin) after style variants resolve — decoration, border, +clipping, and animation are preserved. + For complete value definitions, see the [Markdown syntax reference](../reference/markdown-syntax). --- @@ -115,10 +168,17 @@ Display images with fit options. @image { src: assets/value-loop.png fit: contain + width: 640 height: 420 + scale: 1.2 } ```` +`fit` describes the image inside its frame. `scale` changes painted pixels +without changing that frame or flex geometry, clips to the frame, and uses the +effective block alignment as its transform origin. It defaults to `1` and must +be finite and greater than zero. + For all `@image` arguments, see the [Markdown syntax reference](../reference/markdown-syntax). ### `@dartpad` @@ -165,7 +225,12 @@ Register widgets in `DeckOptions.widgets`, then use them with the shorthand synt } ```` -The widget name becomes the block type (`@metricCard`), and all properties are passed to the registered `WidgetFactory` as `Map`. Your widget (or a helper like `MetricCardArgs.parse(args)`) decides how to interpret them. +The widget name becomes the block type (`@metricCard`). Widget-specific +properties are passed to the registered `WidgetFactory` as +`Map`; `name`, `align`, `flex`, `margin`, `padding`, and +`scrollable` are reserved and consumed by SuperDeck. Your widget (or a helper +like `MetricCardArgs.parse(args)`) decides how to interpret the remaining +values. --- @@ -218,6 +283,20 @@ Elements with matching class names animate smoothly during slide transitions. --- +## Overflow diagnostics + +Non-scrollable content is hard-clipped. Use `scrollable: true` when overflow is +intentional and should remain reachable. + +Enable `DeckOptions(debug: true)` while authoring to diagnose non-scrollable +Markdown and custom-widget overflow. SuperDeck paints a red indicator and logs +one message per stable issue with the slide/block keys, available and measured +sizes, and overflow axis. The probe observes the existing layout without +changing Markdown wrapping or rebuilding content, and static captures omit +diagnostics. + +--- + ## Styling Apply named styles to individual slides using front matter: @@ -245,6 +324,18 @@ See the [SuperDeck Overview](./superdeck-overview) for more styling options. --- +## Upgrading to the next major + +- Replace zero or negative `flex` values with positive integers. +- Review `@section { align: ... }`. Section alignment now becomes the inherited + default for children that do not set block alignment. +- Rebuild `.superdeck/superdeck.json` after adding `spacing`, `padding`, + `margin`, or image `scale`. +- To downgrade to 1.x, remove the new authoring properties and rebuild the + contract with the 1.x toolchain. + +--- + ## Next steps - Explore syntax details in the [Markdown syntax reference](../reference/markdown-syntax) diff --git a/docs/guides/superdeck-overview.mdx b/docs/guides/superdeck-overview.mdx index 6ddd3172..99884370 100644 --- a/docs/guides/superdeck-overview.mdx +++ b/docs/guides/superdeck-overview.mdx @@ -145,7 +145,7 @@ final customStyle = SlideStyler( ), ), ), - blockContainer: BoxStyler( + blockContainer: BlockStyler( decoration: BoxDecorationMix( borderRadius: BorderRadiusMix.all(Radius.circular(16)), ), @@ -155,6 +155,13 @@ final customStyle = SlideStyler( Import `package:flutter/material.dart` and `package:mix/mix.dart` to access the builders above. Use `customStyle` as `DeckOptions.baseStyle` or as a named style variant. +`blockContainer` accepts `BlockStyler`, a constrained Mix styler that supports +only `padding`, `margin`, `decoration`, `foregroundDecoration`, `clipBehavior`, +variants, and animation — it does not support widget modifiers, size +constraints, transforms, or box alignment. See the +[DeckOptions reference](../reference/deck-options#named-widget-block-styles) +for the full styling API. + **Styleable elements** - Typography: `h1`–`h6`, paragraphs, blockquotes diff --git a/docs/reference/block-types.mdx b/docs/reference/block-types.mdx index 948c3a98..f7a3f2a8 100644 --- a/docs/reference/block-types.mdx +++ b/docs/reference/block-types.mdx @@ -9,24 +9,86 @@ SuperDeck has 3 core block types. Built-in widgets (`image`, `dartpad`, `webview ## Common block properties -Content and widget blocks support these properties. Sections support `align` -and `flex`, but scrolling is configured on child blocks or widgets. +Content and widget blocks support `align`, `flex`, `margin`, `padding`, and +`scrollable`. Sections support `align`, `flex`, and `spacing`; scrolling is +configured on a child block or widget. ### `align` -**Type:** String | **Default:** `centerLeft` +**Type:** String | **Default:** inherited section alignment, then `centerLeft` Controls content alignment: - `topLeft`, `topCenter`, `topRight` - `centerLeft`, `center`, `centerRight` - `bottomLeft`, `bottomCenter`, `bottomRight` -Blocks default to `centerLeft` because paragraph-like content reads better -left-aligned. Set `align: center` explicitly for titles or short callouts. +Effective alignment is `block align`, then inherited `section align`, then +`centerLeft`. Set `align: center` explicitly for titles or short callouts. ### `flex` -**Type:** Number | **Default:** `1` +**Type:** Positive integer | **Default:** `1` -Controls relative sizing (`flex: 2` takes twice the space of `flex: 1`). +Controls relative sizing (`flex: 2` takes twice the space of `flex: 1`). Zero, +negative, fractional, `NaN`, and infinite values are invalid. + +### `margin` +**Type:** Number or object | **Default:** resolved block-container style + +Consumed inside the block's already-allocated frame, outside its +decoration/border. It reduces only that block's own usable area — unlike CSS +margins, it never creates a shared gutter with sibling blocks and never +changes flex ratios. Use section `spacing` for gutters between blocks. + +Accepted forms: + +```markdown +margin: 16 + +margin: { + horizontal: 24, + vertical: 16, +} + +margin: { + top: 12, + right: 24, + bottom: 12, + left: 24, +} +``` + +Object keys omitted from an accepted form become zero. Symmetric and physical +edge keys cannot be mixed. Values must be finite and non-negative. + +### `padding` +**Type:** Number or object | **Default:** resolved block-container style + +Consumed inside the decorated block container, between the border and the +content. Overrides the block's internal padding after Mix variants resolve. A +present override replaces only the matching inset (`padding` here, `margin` +above); it does not replace the other inset, border, decoration, animation, or +other style fields. An absent (`null`) override inherits the resolved style +value; an explicit `0` removes that inset. + +Accepted forms: + +```markdown +padding: 16 + +padding: { + horizontal: 24, + vertical: 16, +} + +padding: { + top: 12, + right: 24, + bottom: 12, + left: 24, +} +``` + +Object keys omitted from an accepted form become zero. Symmetric and physical +edge keys cannot be mixed. Values must be finite and non-negative. ### `scrollable` **Type:** Boolean | **Default:** `false` @@ -41,6 +103,7 @@ Renders markdown content. @block { align: center flex: 2 + padding: 16 scrollable: true } @@ -62,6 +125,7 @@ Arranges child blocks horizontally. @section { align: topCenter flex: 1 + spacing: 24 } @block @@ -73,6 +137,12 @@ Right content **Properties:** - `blocks` - Child blocks (auto-populated) +- `flex` - Positive integer height weight relative to sibling sections +- `spacing` - Finite, non-negative logical pixels between sibling blocks only +- `align` - Default alignment inherited by children without explicit alignment + +When the requested total spacing exceeds the section width, SuperDeck clamps +the actual spacing so every child rectangle remains inside the section. Use for multi-column layouts and side-by-side content. @@ -136,7 +206,10 @@ SuperDeckApp( ); ``` -**Note:** Use explicit (`@widget { name: "chart" }`) or shorthand (`@chart`) syntax. All properties pass to your `WidgetFactory` as `Map`. +**Note:** Use explicit (`@widget { name: "chart" }`) or shorthand (`@chart`) +syntax. Widget-specific properties pass to your `WidgetFactory` as +`Map`; `name`, `align`, `flex`, `margin`, `padding`, and +`scrollable` are reserved and consumed by SuperDeck. ## Built-in widgets @@ -150,6 +223,7 @@ Use shorthand syntax for these widgets. fit: cover width: 400 height: 300 + scale: 1.25 } ``` @@ -157,6 +231,16 @@ Use shorthand syntax for these widgets. - `src` (required) - Asset path, absolute file path (`/Users/...` or `file:///...`), or URL - `fit` - `fill`, `contain`, `cover`, `fitWidth`, `fitHeight`, `none`, `scaleDown` (default: `contain`) - `width`, `height` - Fixed dimensions in pixels +- `scale` - Finite paint scale greater than zero (default: `1`) + +`width`, `height`, and `scale` accept integer or decimal numeric values (for +example, `width: 300` or `width: 300.5`); both forms validate under the same +finite-positive rule. + +`scale` changes painting, not layout. Scaled pixels clip to an explicit image +frame when `width` or `height` is supplied, otherwise to the effective content +rectangle. The transform origin uses the effective block alignment. `scale: 1` +uses the untransformed image path. ### dartpad @@ -255,6 +339,10 @@ Generates QR codes. @image { src: assets/hero.jpg fit: cover + width: 1280 + height: 720 + scale: 1.1 + padding: 0 align: center } ``` @@ -277,11 +365,38 @@ Generates QR codes. } ``` +## Overflow behavior + +Non-scrollable blocks are hard-clipped. Set `scrollable: true` when overflow is +intentional and should remain reachable. + +Static capture disables scrolling. For a scrollable custom widget, SuperDeck +still lays out the child with the same unbounded vertical constraint used by +the live scroll view, then clips the natural-height result to the block frame. + +`DeckOptions(debug: true)` diagnoses supported non-scrollable Markdown overflow +with a red indicator and a deduplicated console message. Messages include the +slide key, section/block runtime key, available and measured sizes, and the +overflow axis. The probe preserves Markdown wrapping. It does not intrinsically +measure or rebuild arbitrary custom widgets, and static capture does not display +or log diagnostics. + +## Next-major compatibility + +- Replace every zero or negative `flex` with a positive integer. +- Review section-level `align`: it now becomes the inherited default for child + blocks without explicit alignment. +- Old generated contracts remain readable because new fields are optional. + Downgrading to 1.x requires removing `spacing`/`padding`/`margin`/image + `scale` from authoring and rebuilding `.superdeck/superdeck.json` with the + 1.x toolchain. + ## Best practices 1. Use `@block` for most content -2. Combine blocks in `@section` for rich layouts -3. Set explicit image dimensions to prevent shifts -4. Test DartPad IDs before presenting -5. Document custom widget arguments -6. Use consistent alignment patterns +2. Combine blocks in `@section` for rich layouts and use `spacing` for gaps +3. Use block `padding`/`margin` for internal inset, not section spacing +4. Set explicit image dimensions to define predictable image frames +5. Test DartPad IDs before presenting +6. Document custom widget arguments +7. Use section alignment for shared defaults and block alignment for exceptions diff --git a/docs/reference/contracts.mdx b/docs/reference/contracts.mdx index 745b5bdb..f7690c5e 100644 --- a/docs/reference/contracts.mdx +++ b/docs/reference/contracts.mdx @@ -25,17 +25,29 @@ The compiled payload is a raw JSON array of slide objects — no root wrapper, n { "align": "center", "flex": 1, + "spacing": 40, "blocks": [ { "type": "block", "content": "# Hello, SuperDeck!", - "align": "center", - "flex": 2 + "flex": 2, + "margin": { + "top": 8, + "right": 12, + "bottom": 8, + "left": 12 + }, + "padding": { + "top": 16, + "right": 24, + "bottom": 16, + "left": 24 + } }, { "type": "widget", "name": "qrcode", - "args": { "text": "https://superdeck.dev" }, + "text": "https://superdeck.dev", "flex": 1 } ] @@ -72,12 +84,16 @@ Each section groups child blocks into a horizontal row. | Field | Type | Description | |-------|------|-------------| -| `align` | `enum` | Content alignment within the section | -| `flex` | `integer?` | Flex weight relative to sibling sections | +| `align` | `enum?` | Alignment inherited by children without explicit alignment | +| `flex` | `positive integer?` | Height weight relative to sibling sections | +| `spacing` | `non-negative number?` | Logical-pixel gap between sibling blocks | | `blocks` | `array` | Ordered list of content or widget blocks | The `align` enum accepts nine compass values: `topLeft`, `topCenter`, `topRight`, `centerLeft`, `center`, `centerRight`, `bottomLeft`, `bottomCenter`, `bottomRight`. +Spacing is applied only between blocks. The renderer clamps impossible total +spacing to keep all allocated rectangles inside the section. + ## Block types Blocks use a discriminated union on the `type` field. SuperDeck defines two block variants. @@ -90,8 +106,10 @@ Renders parsed Markdown content. |-------|------|-------------| | `type` | `"block"` | Discriminator | | `content` | `string` | Raw Markdown content | -| `align` | `enum?` | Content alignment | -| `flex` | `integer?` | Flex weight relative to sibling blocks | +| `align` | `enum?` | Content alignment; overrides inherited section alignment | +| `flex` | `positive integer?` | Width weight relative to sibling blocks | +| `margin` | `object?` | Normalized physical edges (`top`, `right`, `bottom`, `left`); space inside the block frame, outside its decoration | +| `padding` | `object?` | Normalized physical edges (`top`, `right`, `bottom`, `left`); space between the block decoration and its content | | `scrollable` | `boolean?` | Enable scrolling for overflow content | ### Widget block (`type: "widget"`) @@ -102,11 +120,60 @@ Renders a named Flutter widget with arguments. |-------|------|-------------| | `type` | `"widget"` | Discriminator | | `name` | `string` | Widget factory name (registered in `DeckOptions.widgets`) | -| `args` | `object` | Key-value arguments passed to the widget factory | -| `align` | `enum?` | Content alignment | -| `flex` | `integer?` | Flex weight relative to sibling blocks | +| Additional fields | JSON values | Widget arguments flattened into the block and passed to the factory | +| `align` | `enum?` | Content alignment; overrides inherited section alignment | +| `flex` | `positive integer?` | Width weight relative to sibling blocks | +| `margin` | `object?` | Normalized physical edges (`top`, `right`, `bottom`, `left`); space inside the block frame, outside its decoration | +| `padding` | `object?` | Normalized physical edges (`top`, `right`, `bottom`, `left`); space between the block decoration and its content | | `scrollable` | `boolean?` | Enable scrolling for overflow content | +`name`, `align`, `flex`, `margin`, `padding`, `scrollable`, and `type` are +reserved block fields and are not included in `WidgetBlock.args`. Built-in +widget fields such as image `scale` remain widget arguments and are validated +by the built-in DTO. + +## Inset normalization + +Markdown authoring accepts a scalar, a symmetric object, or a physical-edge +object for both `padding` and `margin`: + +```yaml +padding: 16 + +padding: { + horizontal: 24, + vertical: 16, +} + +padding: { + top: 12, + right: 24, + bottom: 12, + left: 24, +} +``` + +`margin` accepts the same three forms. This authoring shorthand exists only in +Markdown. In the compiled contract, `padding` and `margin` are always a closed +object with exactly the four physical-edge keys `top`, `right`, `bottom`, and +`left` — no scalar, symmetric, or partial-edge form is valid there, and +unknown keys are rejected. Symmetric and edge keys cannot be mixed while +authoring; empty/unknown-key objects and negative or non-finite values are +invalid. Omitted authored edges normalize to zero; an explicitly authored +`null` edge is invalid and reports its exact path, such as `padding.left`. + +## Contract evolution and downgrade + +`spacing`, `padding`, and `margin` are optional/defaulted, so the next-major +runtime reads existing 1.x contracts. A 1.x runtime is not required to read +contracts authored with these new fields. + +Before upgrading, replace non-positive flex values and review every section +alignment because section alignment now affects children without explicit block +alignment. After using any new authoring property, regenerate the contract. +To downgrade, remove the new properties, restore 1.x-compatible alignment/flex +input, and rebuild `.superdeck/superdeck.json` with the 1.x toolchain. + ## Machine-readable schema The canonical JSON Schema artifact is exported to: diff --git a/docs/reference/deck-options.mdx b/docs/reference/deck-options.mdx index 4ebce1d7..e2ef9d13 100644 --- a/docs/reference/deck-options.mdx +++ b/docs/reference/deck-options.mdx @@ -34,6 +34,17 @@ DeckOptions( ) ``` +#### `blockContainer` and `BlockStyler` + +`SlideStyler.blockContainer` accepts a `BlockStyler`, a constrained Mix styler +for the framework-owned block container. It supports only `padding`, `margin` +(plus Mix spacing convenience methods), `decoration`, `foregroundDecoration` +(color, gradient, border, radius, shadow, image helpers), `clipBehavior`, +context/`BlockVariant` variants, and animation. It deliberately cannot express +widget modifiers (`.wrap()`), width/height/constraints, transforms, or box +alignment — block/section `align` owns content placement, and section +`spacing` plus flex own geometry. + #### Named widget block styles Use `BlockVariant` in a `SlideStyler` stylesheet to target every named widget @@ -49,24 +60,36 @@ const webviewBlock = BlockVariant('webview'); final options = DeckOptions( baseStyle: SlideStyler( - blockContainer: BoxStyler( + blockContainer: BlockStyler( padding: EdgeInsetsGeometryMix.all(40), ).variants([ VariantStyle( webviewBlock, - BoxStyler(padding: EdgeInsetsGeometryMix.all(0)), + BlockStyler(padding: EdgeInsetsGeometryMix.all(0)), ), ]), ), ); ``` +Every `VariantStyle` inside a `BlockStyler` must itself contain a +`BlockStyler`. Passing a broader `BoxStyler` is rejected immediately so +constraints, transforms, alignment, and widget modifiers cannot re-enter the +framework-owned block frame through a raw variant list. + `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. +A Markdown block-level `padding` or `margin` value is applied after these Mix +variants resolve. It replaces only the matching resolved inset (`padding` +replaces padding, `margin` replaces margin); the other inset, border, +decoration, animation, and the active selector remain intact. An absent +(`null`) override inherits the resolved style value; an explicit `0` removes +that inset. + 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 @@ -176,7 +199,11 @@ Slides can set `layout: fullscreen` in front matter to suppress the resolved hea ### `debug` **Type:** `bool` | **Default:** `false` -Enables visual debugging aids. +Enables section/block boundaries and safe overflow diagnostics for supported +non-scrollable Markdown content. Overflow paints a red indicator and logs each +stable slide/block/axis issue once with available and measured sizes. The probe +preserves wrapping constraints, skips scrolling and static capture, and does +not rebuild or intrinsically measure arbitrary custom widgets. ```dart DeckOptions( diff --git a/docs/reference/markdown-syntax.mdx b/docs/reference/markdown-syntax.mdx index 76cc6663..eb624ddb 100644 --- a/docs/reference/markdown-syntax.mdx +++ b/docs/reference/markdown-syntax.mdx @@ -38,9 +38,9 @@ SuperDeck supports three core block types. | Block | Purpose | Key properties | |---|---|---| -| `@section` | Container for horizontal layout | `flex`, `align` | -| `@block` | Render markdown content | `flex`, `align`, `scrollable` | -| `@widget` | Embed custom Flutter widgets | `name`, `flex`, `align`, `scrollable` + custom args | +| `@section` | Container for horizontal layout | `flex`, `align`, `spacing` | +| `@block` | Render markdown content | `flex`, `align`, `margin`, `padding`, `scrollable` | +| `@widget` | Embed custom Flutter widgets | `name`, `flex`, `align`, `margin`, `padding`, `scrollable` + custom args | Built-in widgets (`image`, `dartpad`, `webview`, `qrcode`) use the same widget syntax as `@widget`. @@ -60,16 +60,49 @@ for the stylesheet declaration and limitations. ### `flex` -- Type: number +- Type: positive integer - Default: `1` - Meaning: relative size in the parent layout. ### `align` - Type: string -- Default: `centerLeft` +- Default: inherited section alignment, then `centerLeft` - Values: `topLeft`, `topCenter`, `topRight`, `centerLeft`, `center`, `centerRight`, `bottomLeft`, `bottomCenter`, `bottomRight` +### `spacing` + +- Type: finite non-negative number +- Default: `0` +- Applies to: `@section` +- Meaning: logical-pixel gaps between sibling blocks only. + +### `margin` + +- Type: finite non-negative number or object +- Default: resolved block-container style margin +- Applies to: `@block` and `@widget` +- Forms: scalar; `{ horizontal, vertical }`; or `{ top, right, bottom, left }`. +- Meaning: space consumed inside the block's allocated frame, outside its + decoration/border. It reduces only that block's own usable area — unlike CSS + margins, it never creates a shared gutter with sibling blocks and never + changes flex ratios. Use section `spacing` for gutters. + +### `padding` + +- Type: finite non-negative number or object +- Default: resolved block-container style padding +- Applies to: `@block` and `@widget` +- Forms: scalar; `{ horizontal, vertical }`; or `{ top, right, bottom, left }`. +- Meaning: space consumed inside the decorated block container, between the + border and the content. + +Do not mix symmetric and physical-edge keys. Omitted keys in an object become +zero. `margin` and `padding` are reserved and are not passed to custom widget +args. An absent (`null`) override inherits the resolved style value for that +inset; an explicit `0` removes it. A present override replaces only the +matching inset after style variants resolve. + ### `scrollable` - Type: boolean @@ -87,7 +120,9 @@ Example: @image { src: assets/value-loop.png fit: contain + width: 640 height: 420 + scale: 1.2 } ```` @@ -97,6 +132,11 @@ Arguments: - `fit`: `contain`, `cover`, `fill`, `fitWidth`, `fitHeight`, `none`, `scaleDown`. - `width`: number. - `height`: number. +- `scale`: finite number greater than zero (default `1`); changes painting, not layout, and clips to the image frame. + +`width`, `height`, and `scale` accept integer or decimal numeric values (for +example, `width: 300` or `scale: 1.2`); both forms validate under the same +finite-positive rule. ### `@dartpad` diff --git a/docs/tutorials/block-layouts.mdx b/docs/tutorials/block-layouts.mdx index bba86b68..93547487 100644 --- a/docs/tutorials/block-layouts.mdx +++ b/docs/tutorials/block-layouts.mdx @@ -1,147 +1,205 @@ --- title: Block layouts -description: Quick overview of SuperDeck's block-based layout system +description: Build predictable SuperDeck layouts with sections, spacing, padding, alignment, and image frames --- # Block layouts -SuperDeck uses a block-based layout system for flexible content arrangement. Blocks are containers that hold different content types. +SuperDeck lays out every slide on a logical `1280 x 720` canvas. Sections form +vertical rows; the blocks inside each section form horizontal columns. +The resolved `slideContainer` margin, border, padding, and constraints define +the actual frame used by those rows. Header and footer keep their preferred +heights when possible and shrink proportionally when their combined height is +larger than that frame. + +## The two layout axes + +```text +slide +├── section 1 vertical allocation between sections +│ ├── block A horizontal allocation inside the section +│ └── block B +└── section 2 + └── block C +``` -## Core concept +`flex` controls both axes: -Blocks automatically arrange themselves: +- A section's positive integer `flex` is its height relative to sibling sections. +- A block's positive integer `flex` is its width relative to sibling blocks. -- **Vertical stacking** - Blocks stack vertically by default -- **Horizontal sections** - Use `@section` for side-by-side layouts -- **Flexible sizing** - Control space with `flex` +```markdown +@section { flex: 1 } +@block +Top row -## Common blocks +@section { flex: 2 } +@block { flex: 2 } +Wide column -```markdown -@block # Markdown content (most common) -@section # Container for horizontal layouts -@image # Images with fit options -@dartpad # Interactive code examples -@qrcode # QR codes -@widget # Custom Flutter widgets +@block { flex: 1 } +Narrow column ``` -## Layout examples +If a slide has no directives, SuperDeck creates one section containing one +Markdown block. Multiple `@block` directives without `@section` share one +implicit section. -### Single column (default) -```markdown -# Title +## Five independent geometry controls -Regular markdown content flows vertically. +Keep these controls separate. They nest in this order: -More content below. -``` +1. The slide container style supplies the outer slide inset. +2. `@section { spacing: ... }` creates gaps between sibling blocks. +3. A block's `margin` creates an inset inside that block's already-allocated + frame, outside its decoration/border — it reduces only that block's own + usable area. Unlike CSS margins, it never creates a shared gutter with + sibling blocks and never changes flex ratios; use section `spacing` for + gutters. +4. A block's `padding` creates an inset inside the decorated container, + between the border and the content. +5. `align` places the content inside the remaining content rectangle. + +The decoration always fills the block frame left after margin; alignment never +shrinks or moves the decorated container itself. -### Two columns ```markdown -@section +@section { + spacing: 32 + align: center +} -@block -Left side content +@block { padding: 0 } +Edge to edge inside this column + +@block { padding: 16 } +Uniform internal inset -@block -Right side content +@block { + margin: { + horizontal: 16, + vertical: 8, + } + padding: { + horizontal: 48, + vertical: 24, + } + align: bottomRight +} +Independent margin, padding, and alignment override ``` -### Flexible sizing +Section spacing exists only between blocks—never before the first or after the +last. When a requested total gap is wider than the section, SuperDeck clamps +the actual gap so every block remains inside the section. + +## Padding and margin forms + +Content and widget blocks accept exactly three forms for `padding` and for +`margin`: + ```markdown -@section +@block { padding: 16 } @block { - flex: 2 + padding: { + horizontal: 24, + vertical: 16, + } } -Takes 2/3 of the space @block { - flex: 1 + padding: { + top: 12, + right: 24, + bottom: 12, + left: 24, + } } -Takes 1/3 of the space ``` -### Mixed content -```markdown -@section +`margin` accepts the same three forms. Omitted keys in an object are zero, but +an explicitly authored `null` edge is invalid and reports its exact path (for +example, `padding.left`). Do not mix `horizontal`/`vertical` with physical edge +keys. Empty objects, unknown keys, negative values, `NaN`, and infinity are +invalid. -@block -## Text Content -- Point one -- Point two +An omitted `padding`/`margin` override retains the resolved Mix +block-container value for that inset. A present override replaces only that +matching inset after active variants; the other inset, border, decoration, +animation, and variant behavior remain. -@image { - src: assets/screenshot.png - fit: contain -} -``` +## Alignment inheritance -## Block properties +The effective alignment for each block is: -Content blocks and widgets support these properties. Sections support `flex` -and `align`; set `scrollable` on the child block or widget that overflows. +```text +block align → section align → centerLeft +``` -- **`flex`** - Relative size (default: 1) -- **`align`** - Content alignment (center, topLeft, etc.) -- **`scrollable`** - Enable scrolling (default: false) +Use section alignment for a shared default and block alignment for exceptions: ```markdown -@block { - flex: 2 - align: center - scrollable: true -} +@section { align: topCenter } + +@block +Inherits topCenter + +@block { align: bottomRight } +Overrides the section ``` -## Layout patterns +All nine values are supported: `topLeft`, `topCenter`, `topRight`, +`centerLeft`, `center`, `centerRight`, `bottomLeft`, `bottomCenter`, and +`bottomRight`. -### Hero section -```markdown -@section +## Image framing -@block { +`fit` controls how image pixels fit their frame. `scale` enlarges or shrinks +the painted pixels without changing the frame or the block's flex geometry. + +```markdown +@image { + src: assets/screenshot.png + fit: cover + width: 640 + height: 360 + scale: 1.25 align: center } -# Big Title -## Subtitle ``` -### Content + visual -```markdown -@section +Scaled pixels are clipped to explicit `width`/`height` when supplied, otherwise +to the block's effective content rectangle. The transform origin uses the +effective block alignment. `scale` defaults to `1` and must be finite and +greater than zero. -@block { - flex: 2 -} -## Feature Description -Detailed explanation... +For a full-canvas image, use `layout: fullscreen`, one image block, an explicit +`1280 x 720` frame, `fit: cover`, and `padding: 0`. Fullscreen changes chrome; +it does not change the image framing rules. -@block -![Feature Screenshot](image.png) -``` +## Overflow and debugging -### Three columns -```markdown -@section +Non-scrollable content is hard-clipped. Use `scrollable: true` on a content or +widget block when the audience should be able to reach overflowed content. -@block -### Option A +With `DeckOptions(debug: true)`, supported non-scrollable Markdown overflow gets +a red indicator and a deduplicated console message containing the slide key, +block key, available size, measured size, and overflow axis. The live probe +observes the existing Markdown render; it does not change wrapping constraints. +It intentionally does not rebuild or intrinsically measure arbitrary custom +widgets, and static capture remains diagnostic-free. -@block -### Option B +## Next-major migration -@block -### Option C -``` +Two inputs change semantics in the next major release: -## Key takeaways +- `flex` must be a positive integer. Replace zero or negative values. +- Section alignment now applies to children without explicit block alignment. + Add an explicit child alignment where the old no-op behavior must remain. -- Start with `@block` blocks -- Use `@section` for side-by-side layouts -- Control sizing with `flex` -- Mix content types freely -- Layouts adapt to screen size +After adopting `spacing`, `padding`, or `margin`, rebuild +`.superdeck/superdeck.json`. Downgrading to 1.x requires removing the new +authoring properties and rebuilding the contract with the 1.x toolchain. -Next: [First Presentation Tutorial](/tutorials/first-presentation) +Next: [Block types reference](/reference/block-types) diff --git a/packages/builder/CHANGELOG.md b/packages/builder/CHANGELOG.md index 938e409e..738e2bd9 100644 --- a/packages/builder/CHANGELOG.md +++ b/packages/builder/CHANGELOG.md @@ -1,6 +1,13 @@ ## Unreleased - Round-trip the `layout` slide frontmatter option in Markdown serialization. +- Parse and round-trip section `spacing` plus all supported block `padding` + and `margin` authoring forms, serializing insets as four normalized physical + edges in clockwise order. +- Serialize nested directive maps in canonical multiline flow-map braces + (comma-separated entries, one key per line); scalar-only options stay + inline. Serializer output is idempotent. +- **Breaking:** reject non-positive flex values during Markdown compilation. - Add build-plugin `beginBuild` and `finishBuild` lifecycle hooks. - Make `DeckBuilder.dispose()` wait for queued builds before disposing plugins and reject new builds after disposal. diff --git a/packages/builder/lib/src/parsers/section_parser.dart b/packages/builder/lib/src/parsers/section_parser.dart index dbc8db64..9e221038 100644 --- a/packages/builder/lib/src/parsers/section_parser.dart +++ b/packages/builder/lib/src/parsers/section_parser.dart @@ -51,7 +51,9 @@ class SectionParser { if (parsedBlock.type == SectionBlock.key) { aggregator.addSection(SectionBlock.parse(parsedBlock.data)); } else { - aggregator.addBlock(Block.parse(parsedBlock.data)); + // Directive options are authored shorthand; normalize insets before + // the contract-only decoder sees them. + aggregator.addBlock(Block.parseAuthoring(parsedBlock.data)); } aggregator.addContent(blockContent); diff --git a/packages/builder/lib/src/parsers/slide_serializer.dart b/packages/builder/lib/src/parsers/slide_serializer.dart index 5395a7e7..e53cbf5e 100644 --- a/packages/builder/lib/src/parsers/slide_serializer.dart +++ b/packages/builder/lib/src/parsers/slide_serializer.dart @@ -8,7 +8,7 @@ import 'comment_parser.dart'; /// [SectionParser] + [BlockParser] + [TagTokenizer] + [CommentParser]). /// /// Round-tripping `parse(serialize(slides))` reproduces the slides -/// structurally — options, section/block layout (flex/align/scrollable), +/// structurally — options, section/block layout (flex/align/spacing/scrollable), /// widget names + args, markdown content, and comments — modulo the /// content-hash [Slide.key] (regenerated on parse) and insignificant /// whitespace inside content blocks. @@ -98,7 +98,7 @@ class SlideSerializer { final buffer = StringBuffer(); for (final entry in entries) { - buffer.writeln('${entry.key}: ${_yamlValue(entry.value)}'); + buffer.writeln('${entry.key}: ${_flowValue(entry.value)}'); } return buffer.toString(); } @@ -122,7 +122,10 @@ class SlideSerializer { final multipleSections = sections.length > 1; for (final section in sections) { final needsDirective = - multipleSections || section.flex != 1 || section.align != null; + multipleSections || + section.flex != 1 || + section.align != null || + section.spacing != 0; if (needsDirective) { buffer.writeln(_directive('section', _sectionOptions(section))); buffer.writeln(); @@ -137,12 +140,16 @@ class SlideSerializer { bool _isImplicitSection(List sections) { if (sections.length != 1) return false; final section = sections.first; - if (section.flex != 1 || section.align != null) return false; + if (section.flex != 1 || section.align != null || section.spacing != 0) { + return false; + } if (section.blocks.length != 1) return false; final block = section.blocks.first; return block is ContentBlock && block.flex == 1 && block.align == null && + block.margin == null && + block.padding == null && !block.scrollable; } @@ -173,6 +180,7 @@ class SlideSerializer { final options = {}; if (section.flex != 1) options['flex'] = section.flex; if (section.align != null) options['align'] = section.align!.name; + if (section.spacing != 0) options['spacing'] = section.spacing; return options; } @@ -180,6 +188,8 @@ class SlideSerializer { final options = {}; if (block.flex != 1) options['flex'] = block.flex; if (block.align != null) options['align'] = block.align!.name; + if (block.padding != null) options['padding'] = block.padding!.toMap(); + if (block.margin != null) options['margin'] = block.margin!.toMap(); if (block.scrollable) options['scrollable'] = true; return options; } @@ -190,38 +200,75 @@ class SlideSerializer { /// Renders a `@name` directive with an optional `{ ... }` options block. /// - /// A single option is rendered inline (`@block { align: center }`); multiple - /// options use block-style YAML (newline-separated), because comma-separated - /// `key: a, key2: b` without braces is not valid YAML. + /// A single non-map option is rendered inline (`@block { padding: 16 }`). + /// Any other shape uses multiline braces with one option per line, because + /// comma-separated `key: a, key2: b` on one line is not valid YAML inside + /// the directive braces. String _directive(String name, Map options) { if (options.isEmpty) return '@$name'; if (options.length == 1) { final entry = options.entries.first; - return '@$name { ${entry.key}: ${_yamlValue(entry.value)} }'; + final value = entry.value; + if (value is! Map || value.isEmpty) { + return '@$name { ${entry.key}: ${_directiveValue(value, 1)} }'; + } } final buffer = StringBuffer('@$name {\n'); for (final entry in options.entries) { - buffer.writeln(' ${entry.key}: ${_yamlValue(entry.value)}'); + buffer.writeln(' ${entry.key}: ${_directiveValue(entry.value, 1)}'); } buffer.write('}'); return buffer.toString(); } - String _yamlValue(Object? value) { + /// Formats one directive option value nested at [indentLevel]. + /// + /// Scalars and lists stay inline; non-empty maps become multiline YAML flow + /// mappings. Flow-map entries are comma-separated (with a trailing comma) + /// because a multiline flow mapping without commas is invalid YAML. + String _directiveValue(Object? value, int indentLevel) { + if (value is Map && value.isNotEmpty) { + final entryIndent = ' ' * (indentLevel + 1); + final closeIndent = ' ' * indentLevel; + final buffer = StringBuffer('{\n'); + for (final entry in value.entries) { + final nested = _directiveValue(entry.value, indentLevel + 1); + buffer.writeln('$entryIndent${entry.key}: $nested,'); + } + buffer.write('$closeIndent}'); + return buffer.toString(); + } + + return _flowValue(value); + } + + /// Formats a value inline in YAML flow style. + String _flowValue(Object? value) { if (value == null) return 'null'; - if (value is bool || value is num) return value.toString(); + if (value is num) return _numValue(value); + if (value is bool) return value.toString(); if (value is List) { - return '[${value.map(_yamlValue).join(', ')}]'; + return '[${value.map(_flowValue).join(', ')}]'; } if (value is Map) { final entries = value.entries - .map((e) => '${e.key}: ${_yamlValue(e.value)}') + .map((e) => '${e.key}: ${_flowValue(e.value)}') .join(', '); return '{$entries}'; } return _yamlString(value.toString()); } + /// Emits whole-number doubles (e.g. normalized inset edges) as integers so + /// canonical output reads `top: 12` instead of `top: 12.0`. Both re-parse to + /// the same normalized value. + String _numValue(num value) { + if (value is double && value.isFinite && value == value.truncateToDouble()) { + return value.truncate().toString(); + } + return value.toString(); + } + /// Quotes a string only when bare emission would be ambiguous or re-parse to /// a non-string value. Quoting never changes the parsed value, so this is /// purely for human-friendly output. diff --git a/packages/builder/test/src/parsers/block_parser_test.dart b/packages/builder/test/src/parsers/block_parser_test.dart index 10720a30..54ad60c6 100644 --- a/packages/builder/test/src/parsers/block_parser_test.dart +++ b/packages/builder/test/src/parsers/block_parser_test.dart @@ -287,6 +287,19 @@ More content expect(blocks[1].data['flex'], 2); }); + test('@block preserves structured padding input', () { + const text = ''' +@block { + padding: {horizontal: 24, vertical: 16} +} +Content +'''; + + final block = const BlockParser().parse(text).single; + + expect(block.data['padding'], {'horizontal': 24, 'vertical': 16}); + }); + test('rejects unsupported @column directives', () { const text = '@column{flex: 1}'; diff --git a/packages/builder/test/src/parsers/section_parser_test.dart b/packages/builder/test/src/parsers/section_parser_test.dart index f8f22240..415b34a2 100644 --- a/packages/builder/test/src/parsers/section_parser_test.dart +++ b/packages/builder/test/src/parsers/section_parser_test.dart @@ -165,6 +165,20 @@ Footer content column. // 4. Attribute Tests group('Attributes', () { + test('Section spacing is parsed', () { + const markdown = ''' +@section { spacing: 40 } +@block +Left +@block +Right +'''; + + final sections = sectionParser.parse(markdown); + + expect(sections.single.toMap()['spacing'], 40); + }); + group('Column Attributes', () { test('Header with columns and flex attribute', () { const markdown = ''' @@ -398,6 +412,26 @@ Footer content. }); group('Failure Cases', () { + for (final flex in [0, -1]) { + test('Non-positive block flex $flex is rejected', () { + expect( + () => sectionParser.parse( + '@section\n@block { flex: $flex }\nContent', + ), + throwsA(anything), + ); + }); + + test('Non-positive section flex $flex is rejected', () { + expect( + () => sectionParser.parse( + '@section { flex: $flex }\n@block\nContent', + ), + throwsA(anything), + ); + }); + } + test('Invalid flex attribute format', () { const markdown = ''' @section diff --git a/packages/builder/test/src/parsers/slide_serializer_test.dart b/packages/builder/test/src/parsers/slide_serializer_test.dart index 683b5247..1dd02151 100644 --- a/packages/builder/test/src/parsers/slide_serializer_test.dart +++ b/packages/builder/test/src/parsers/slide_serializer_test.dart @@ -42,6 +42,7 @@ Map canonicalSection(SectionBlock section) { return { 'flex': section.flex, 'align': section.align?.name, + 'spacing': section.spacing, 'blocks': section.blocks.map(canonicalBlock).toList(), }; } @@ -52,6 +53,8 @@ Map canonicalBlock(Block block) { 'type': 'block', 'flex': block.flex, 'align': block.align?.name, + 'margin': block.toMap()['margin'], + 'padding': block.toMap()['padding'], 'scrollable': block.scrollable, 'content': block.content.trim(), }, @@ -60,6 +63,8 @@ Map canonicalBlock(Block block) { 'name': block.name, 'flex': block.flex, 'align': block.align?.name, + 'margin': block.toMap()['margin'], + 'padding': block.toMap()['padding'], 'scrollable': block.scrollable, 'args': block.args, }, @@ -135,6 +140,47 @@ void main() { ); }); + test('section spacing', () { + final slides = parseDeck( + '@section { spacing: 40 }\n' + '@block\n' + 'Left\n' + '@block\n' + 'Right', + ); + + expect(slides.single.sections.single.spacing, 40); + expectRoundTrip(slides); + }); + + test('content and widget padding normalize and round-trip', () { + final slides = parseDeck( + '@section\n' + '@block { padding: 16 }\n' + 'Content\n' + '@image {\n' + ' src: photo.png\n' + ' padding: {horizontal: 24, vertical: 12}\n' + '}', + ); + + final blocks = slides.single.sections.single.blocks; + expect(blocks[0].toMap()['padding'], { + 'top': 16.0, + 'right': 16.0, + 'bottom': 16.0, + 'left': 16.0, + }); + expect((blocks[1] as WidgetBlock).args.containsKey('padding'), isFalse); + expect(blocks[1].toMap()['padding'], { + 'top': 12.0, + 'right': 24.0, + 'bottom': 12.0, + 'left': 24.0, + }); + expectRoundTrip(slides); + }); + test('multiple content blocks in one section stay separate', () { expectRoundTrip( parseDeck( @@ -192,6 +238,150 @@ void main() { }); }); + group('SlideSerializer canonical directive formatting', () { + test('normalized physical insets serialize one edge per line', () { + final slides = parseDeck( + '@block {\n' + ' padding: {\n' + ' top: 12,\n' + ' right: 24,\n' + ' bottom: 12,\n' + ' left: 24,\n' + ' }\n' + ' margin: {\n' + ' top: 8,\n' + ' right: 12,\n' + ' bottom: 8,\n' + ' left: 12,\n' + ' }\n' + '}\n\n' + 'Content', + ); + + const expected = + '---\n' + '\n' + '@block {\n' + ' padding: {\n' + ' top: 12,\n' + ' right: 24,\n' + ' bottom: 12,\n' + ' left: 24,\n' + ' }\n' + ' margin: {\n' + ' top: 8,\n' + ' right: 12,\n' + ' bottom: 8,\n' + ' left: 12,\n' + ' }\n' + '}\n' + '\n' + 'Content\n'; + + final once = const SlideSerializer().serialize(slides); + expect(once, expected); + expect(const SlideSerializer().serialize(parseDeck(once)), expected); + }); + + test('symmetric authoring normalizes to clockwise physical edges', () { + final slides = parseDeck( + '@block {\n' + ' padding: {\n' + ' horizontal: 32,\n' + ' vertical: 16,\n' + ' }\n' + '}\n\n' + 'Content', + ); + + expect( + const SlideSerializer().serialize(slides), + contains( + '@block {\n' + ' padding: {\n' + ' top: 16,\n' + ' right: 32,\n' + ' bottom: 16,\n' + ' left: 32,\n' + ' }\n' + '}', + ), + ); + }); + + test('scalar margin shorthand normalizes and round-trips', () { + final slides = parseDeck('@block { margin: 8 }\n\nContent'); + + final block = slides.single.sections.single.blocks.single; + expect(block.toMap()['margin'], { + 'top': 8.0, + 'right': 8.0, + 'bottom': 8.0, + 'left': 8.0, + }); + expectRoundTrip(slides); + }); + + test('single scalar option stays inline', () { + final slides = parseDeck('@block { flex: 2 }\n\nContent'); + + expect( + const SlideSerializer().serialize(slides), + contains('@block { flex: 2 }'), + ); + }); + + test('nested widget args, lists, and quoted braces serialize exactly', () { + final slides = parseDeck( + '@chart {\n' + ' title: "Sales: {Q1}, Q2"\n' + ' data: {\n' + ' series: {\n' + ' alpha: 1,\n' + ' beta: 2.5,\n' + ' },\n' + ' labels: [x, y],\n' + ' }\n' + '}', + ); + + const expected = + '---\n' + '\n' + '@chart {\n' + ' title: "Sales: {Q1}, Q2"\n' + ' data: {\n' + ' series: {\n' + ' alpha: 1,\n' + ' beta: 2.5,\n' + ' },\n' + ' labels: [x, y],\n' + ' }\n' + '}\n'; + + final once = const SlideSerializer().serialize(slides); + expect(once, expected); + expect(const SlideSerializer().serialize(parseDeck(once)), expected); + }); + + test('multiline flow maps without commas are invalid YAML', () { + // Inside the nested braces the value is a YAML flow mapping; entries on + // separate lines must be comma-separated or the document fails to parse. + expect( + () => parseDeck( + '@block {\n' + ' padding: {\n' + ' top: 12\n' + ' right: 24\n' + ' }\n' + '}\n\n' + 'Content', + ), + throwsA(isA()), + ); + }); + }); + group('SlideSerializer round-trips real decks', () { final demoSlides = File('../../demo/slides.md'); diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 6ec1538c..c2bf7ac2 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,6 +1,26 @@ ## Unreleased - Add `SlideLayout` and the `SlideOptions.layout` field to the slide contract. +- Add optional section `spacing`, block `padding` and `margin`, and inherited + section/block alignment resolution to the layout contract. `margin` is + reserved from widget args alongside the other layout keys. +- **Breaking:** split inset authoring from the compiled contract. Compiled + contracts accept only normalized four-edge `padding`/`margin` objects + (`top`, `right`, `bottom`, `left`); scalar and symmetric shorthand is + authoring-only and is normalized by the new `Block.parseAuthoring` entry + point (`BlockInsets.parseAuthoring` reports the exact field and edge on + invalid input). +- Add `aiSlideSchema`, a flattened structured-output projection of the slide + contract for AI generation (JSON-Schema unions are not consumable by + structured-output adapters). +- **Breaking:** require positive integer flex values in schemas and public Dart + constructors; zero and negative flex values are no longer accepted. +- **Breaking:** remove `Block.resolvedAlign` and + `SectionBlock.totalBlockFlex`. Renderers resolve alignment through + `SectionBlock.resolveBlockAlign`; layout engines own flex distribution. +- Reject explicitly authored `null` inset edges with their full field path + (for example, `padding.left`) while continuing to normalize omitted edges to + zero. ## 1.0.0 diff --git a/packages/core/lib/src/deck/block_insets.dart b/packages/core/lib/src/deck/block_insets.dart new file mode 100644 index 00000000..c8feeab7 --- /dev/null +++ b/packages/core/lib/src/deck/block_insets.dart @@ -0,0 +1,168 @@ +import 'package:ack/ack.dart'; +import 'package:dart_mappable/dart_mappable.dart'; + +part 'block_insets.mapper.dart'; + +String _authoringMessage(String field) => + '$field must use a finite non-negative scalar, a non-empty object with ' + 'horizontal and/or vertical, or a non-empty object with top, right, ' + 'bottom, and/or left. Symmetric and physical-edge keys cannot be mixed.'; + +const _symmetricKeys = {'horizontal', 'vertical'}; +const _edgeKeys = {'top', 'right', 'bottom', 'left'}; + +final _insetValueSchema = Ack.number().min(0).finite(); + +final _symmetricInsetsSchema = Ack.object( + { + 'horizontal': _insetValueSchema.optional(), + 'vertical': _insetValueSchema.optional(), + }, + additionalProperties: false, +).withConstraint(const _NonEmptyInsetsObjectConstraint()); + +final _partialEdgeInsetsSchema = Ack.object( + { + 'top': _insetValueSchema.optional(), + 'right': _insetValueSchema.optional(), + 'bottom': _insetValueSchema.optional(), + 'left': _insetValueSchema.optional(), + }, + additionalProperties: false, +).withConstraint(const _NonEmptyInsetsObjectConstraint()); + +/// Four normalized physical inset edges for a slide block. +/// +/// Used for both block `padding` and block `margin`. Authoring shorthand +/// (scalar, symmetric, or partial physical edges) is normalized by +/// [parseAuthoring]; compiled contracts only carry the normalized +/// four-edge form validated by [schema]. +@MappableClass() +final class BlockInsets with BlockInsetsMappable { + final double top; + final double right; + final double bottom; + final double left; + + BlockInsets({ + double top = 0, + double right = 0, + double bottom = 0, + double left = 0, + }) : top = _validateEdge(top, 'top'), + right = _validateEdge(right, 'right'), + bottom = _validateEdge(bottom, 'bottom'), + left = _validateEdge(left, 'left'); + + BlockInsets.all(double value) + : this(top: value, right: value, bottom: value, left: value); + + BlockInsets.symmetric({double horizontal = 0, double vertical = 0}) + : this( + top: vertical, + right: horizontal, + bottom: vertical, + left: horizontal, + ); + + /// Normalized contract schema: a closed object with all four physical edges. + static final schema = Ack.object({ + 'top': _insetValueSchema, + 'right': _insetValueSchema, + 'bottom': _insetValueSchema, + 'left': _insetValueSchema, + }, additionalProperties: false); + + /// Authoring schema: scalar, symmetric map, or physical-edge map. + static final authoringSchema = Ack.anyOf([ + _insetValueSchema, + _symmetricInsetsSchema, + _partialEdgeInsetsSchema, + ]); + + static final fromMap = BlockInsetsMapper.fromMap; + + /// Parses one supported authoring form and normalizes it to physical edges. + /// + /// [field] names the authored option (`padding` or `margin`) so errors + /// report the exact field and edge, e.g. `margin.left`. + static BlockInsets parseAuthoring(Object? value, {required String field}) { + if (value is num) { + return BlockInsets.all(_validateAuthoringValue(value, field)); + } + + if (value is Map) { + final map = { + for (final entry in value.entries) '${entry.key}': entry.value, + }; + final keys = map.keys.toSet(); + final isSymmetric = keys.isNotEmpty && _symmetricKeys.containsAll(keys); + final isEdges = keys.isNotEmpty && _edgeKeys.containsAll(keys); + if (!isSymmetric && !isEdges) { + throw ArgumentError.value(value, field, _authoringMessage(field)); + } + + double read(String key) { + if (!map.containsKey(key)) return 0; + final edgeValue = map[key]; + + return _validateAuthoringValue(edgeValue, '$field.$key'); + } + + if (isSymmetric) { + return BlockInsets.symmetric( + horizontal: read('horizontal'), + vertical: read('vertical'), + ); + } + + return BlockInsets( + top: read('top'), + right: read('right'), + bottom: read('bottom'), + left: read('left'), + ); + } + + throw ArgumentError.value(value, field, _authoringMessage(field)); + } + + static double _validateAuthoringValue(Object? value, String name) { + if (value is num && value.isFinite && value >= 0) return value.toDouble(); + throw ArgumentError.value( + value, + name, + 'Inset values must be finite non-negative numbers.', + ); + } + + static double _validateEdge(double value, String edge) { + if (value.isFinite && value >= 0) return value; + throw ArgumentError.value( + value, + edge, + 'Inset edges must be finite non-negative numbers.', + ); + } +} + +final class _NonEmptyInsetsObjectConstraint + extends Constraint> + with Validator>, JsonSchemaSpec> { + const _NonEmptyInsetsObjectConstraint() + : super( + constraintKey: 'insets_non_empty_object', + description: 'Inset objects must contain at least one property.', + ); + + @override + String buildMessage(Map value) { + return 'Inset objects must contain at least one property.'; + } + + @override + bool isValid(Map value) => value.isNotEmpty; + + @override + Map toJsonSchema() => const {'minProperties': 1}; +} diff --git a/packages/core/lib/src/deck/block_insets.mapper.dart b/packages/core/lib/src/deck/block_insets.mapper.dart new file mode 100644 index 00000000..b3c55cc9 --- /dev/null +++ b/packages/core/lib/src/deck/block_insets.mapper.dart @@ -0,0 +1,164 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +// ignore_for_file: type=lint +// ignore_for_file: invalid_use_of_protected_member +// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member +// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter + +part of 'block_insets.dart'; + +class BlockInsetsMapper extends ClassMapperBase { + BlockInsetsMapper._(); + + static BlockInsetsMapper? _instance; + static BlockInsetsMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = BlockInsetsMapper._()); + } + return _instance!; + } + + @override + final String id = 'BlockInsets'; + + static double _$top(BlockInsets v) => v.top; + static const Field _f$top = Field( + 'top', + _$top, + opt: true, + def: 0, + ); + static double _$right(BlockInsets v) => v.right; + static const Field _f$right = Field( + 'right', + _$right, + opt: true, + def: 0, + ); + static double _$bottom(BlockInsets v) => v.bottom; + static const Field _f$bottom = Field( + 'bottom', + _$bottom, + opt: true, + def: 0, + ); + static double _$left(BlockInsets v) => v.left; + static const Field _f$left = Field( + 'left', + _$left, + opt: true, + def: 0, + ); + + @override + final MappableFields fields = const { + #top: _f$top, + #right: _f$right, + #bottom: _f$bottom, + #left: _f$left, + }; + + static BlockInsets _instantiate(DecodingData data) { + return BlockInsets( + top: data.dec(_f$top), + right: data.dec(_f$right), + bottom: data.dec(_f$bottom), + left: data.dec(_f$left), + ); + } + + @override + final Function instantiate = _instantiate; + + static BlockInsets fromMap(Map map) { + return ensureInitialized().decodeMap(map); + } + + static BlockInsets fromJson(String json) { + return ensureInitialized().decodeJson(json); + } +} + +mixin BlockInsetsMappable { + String toJson() { + return BlockInsetsMapper.ensureInitialized().encodeJson( + this as BlockInsets, + ); + } + + Map toMap() { + return BlockInsetsMapper.ensureInitialized().encodeMap( + this as BlockInsets, + ); + } + + BlockInsetsCopyWith get copyWith => + _BlockInsetsCopyWithImpl( + this as BlockInsets, + $identity, + $identity, + ); + @override + String toString() { + return BlockInsetsMapper.ensureInitialized().stringifyValue( + this as BlockInsets, + ); + } + + @override + bool operator ==(Object other) { + return BlockInsetsMapper.ensureInitialized().equalsValue( + this as BlockInsets, + other, + ); + } + + @override + int get hashCode { + return BlockInsetsMapper.ensureInitialized().hashValue(this as BlockInsets); + } +} + +extension BlockInsetsValueCopy<$R, $Out> + on ObjectCopyWith<$R, BlockInsets, $Out> { + BlockInsetsCopyWith<$R, BlockInsets, $Out> get $asBlockInsets => + $base.as((v, t, t2) => _BlockInsetsCopyWithImpl<$R, $Out>(v, t, t2)); +} + +abstract class BlockInsetsCopyWith<$R, $In extends BlockInsets, $Out> + implements ClassCopyWith<$R, $In, $Out> { + $R call({double? top, double? right, double? bottom, double? left}); + BlockInsetsCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); +} + +class _BlockInsetsCopyWithImpl<$R, $Out> + extends ClassCopyWithBase<$R, BlockInsets, $Out> + implements BlockInsetsCopyWith<$R, BlockInsets, $Out> { + _BlockInsetsCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + BlockInsetsMapper.ensureInitialized(); + @override + $R call({double? top, double? right, double? bottom, double? left}) => $apply( + FieldCopyWithData({ + if (top != null) #top: top, + if (right != null) #right: right, + if (bottom != null) #bottom: bottom, + if (left != null) #left: left, + }), + ); + @override + BlockInsets $make(CopyWithData data) => BlockInsets( + top: data.get(#top, or: $value.top), + right: data.get(#right, or: $value.right), + bottom: data.get(#bottom, or: $value.bottom), + left: data.get(#left, or: $value.left), + ); + + @override + BlockInsetsCopyWith<$R2, BlockInsets, $Out2> $chain<$R2, $Out2>( + Then<$Out2, $R2> t, + ) => _BlockInsetsCopyWithImpl<$R2, $Out2>($value, $cast, t); +} diff --git a/packages/core/lib/src/deck/block_model.dart b/packages/core/lib/src/deck/block_model.dart index 61b0b5a8..b883f13a 100644 --- a/packages/core/lib/src/deck/block_model.dart +++ b/packages/core/lib/src/deck/block_model.dart @@ -1,8 +1,60 @@ import 'package:ack/ack.dart'; import 'package:dart_mappable/dart_mappable.dart'; +import 'block_insets.dart'; + part 'block_model.mapper.dart'; +/// Positive flex weight shared by the canonical block/section schemas and the +/// AI-generation projection in `slide_contract.dart`. +final positiveFlexSchema = Ack.integer().positive(); + +/// Finite non-negative number shared by section `spacing` and the +/// AI-generation projection in `slide_contract.dart`. +final nonNegativeSpacingSchema = Ack.number().min(0).finite(); + +int _validateFlex(int flex) { + if (flex > 0) return flex; + throw ArgumentError.value( + flex, + 'flex', + 'Flex must be an integer greater than zero.', + ); +} + +double _validateSpacing(double spacing) { + if (spacing.isFinite && spacing >= 0) return spacing; + throw ArgumentError.value( + spacing, + 'spacing', + 'Spacing must be a finite number greater than or equal to zero.', + ); +} + +void _validateFlexInput(Map map) { + if (map['flex'] case final int flex) { + _validateFlex(flex); + } +} + +void _validateSpacingInput(Map map) { + if (map['spacing'] case final num spacing) { + _validateSpacing(spacing.toDouble()); + } +} + +Map _normalizeAuthoringInsets(Map map) { + var normalized = map; + for (final field in const ['margin', 'padding']) { + final value = normalized[field]; + if (value == null) continue; + final insets = BlockInsets.parseAuthoring(value, field: field); + normalized = {...normalized, field: insets.toMap()}; + } + + return normalized; +} + /// Base class for renderable blocks in a slide section. /// /// Blocks are leaf content units inside sections. They support alignment, @@ -12,28 +64,46 @@ sealed class Block with BlockMappable { final String type; final ContentAlignment? align; final int flex; + final BlockInsets? margin; + final BlockInsets? padding; final bool scrollable; Block({ required this.type, this.align, - this.flex = 1, + int flex = 1, + this.margin, + this.padding, this.scrollable = false, - }); + }) : flex = _validateFlex(flex); /// Base schema for all block types static final schema = Ack.object({ 'type': Ack.string(), 'align': ContentAlignment.schema.optional(), - 'flex': Ack.integer().optional(), + 'flex': positiveFlexSchema.optional(), + 'margin': BlockInsets.schema.optional(), + 'padding': BlockInsets.schema.optional(), 'scrollable': Ack.boolean().optional(), }, additionalProperties: true); - /// Parses a block from a JSON map. + /// Parses a block from normalized contract data. /// /// Automatically determines the block type from the discriminator key. - static Block parse(Map map) => - fromMap(discriminatedSchema.parse(map)!); + /// Insets must already be normalized physical edges; use [parseAuthoring] + /// for Markdown directive input. + static Block parse(Map map) { + _validateFlexInput(map); + return fromMap(discriminatedSchema.parse(map)!); + } + + /// Parses a block from authored directive options. + /// + /// Normalizes `margin` and `padding` shorthand (scalar, symmetric, or + /// partial physical edges) before decoding through the contract schema. + static Block parseAuthoring(Map map) { + return parse(_normalizeAuthoringInsets(map)); + } /// Schema for discriminated union of block types. /// @@ -48,14 +118,6 @@ sealed class Block with BlockMappable { ); static final fromMap = BlockMapper.fromMap; - - /// The effective alignment for this block. - /// - /// When [align] is not set explicitly, defaults to - /// [ContentAlignment.centerLeft] because paragraph-like content is easier - /// to read and scan when left-aligned. Set `align: center` explicitly to - /// restore the previous default. - ContentAlignment get resolvedAlign => align ?? ContentAlignment.centerLeft; } /// A section that contains multiple child blocks arranged horizontally. @@ -66,6 +128,7 @@ class SectionBlock with SectionBlockMappable { final List blocks; final ContentAlignment? align; final int flex; + final double spacing; final String type; static const key = 'section'; @@ -73,21 +136,30 @@ class SectionBlock with SectionBlockMappable { SectionBlock( List? blocks, { this.align, - this.flex = 1, + int flex = 1, + double spacing = 0, String type = key, }) : blocks = List.unmodifiable(blocks ?? const []), + flex = _validateFlex(flex), + spacing = _validateSpacing(spacing), type = _validateType(type); - /// The total flex value of all child blocks. - int get totalBlockFlex { - return blocks.fold(0, (total, block) => total + block.flex); + /// Resolves a child's alignment in section context. + /// + /// Explicit block alignment wins, followed by section alignment, then the + /// platform-neutral [ContentAlignment.centerLeft] default. + ContentAlignment resolveBlockAlign(Block block) { + return block.align ?? align ?? ContentAlignment.centerLeft; } static final fromMap = SectionBlockMapper.fromMap; /// Parses a section block from a JSON map. - static SectionBlock parse(Map map) => - fromMap(schema.parse(map)!); + static SectionBlock parse(Map map) { + _validateFlexInput(map); + _validateSpacingInput(map); + return fromMap(schema.parse(map)!); + } /// Creates a section block with a single text block. static SectionBlock text(String content) { @@ -103,7 +175,8 @@ class SectionBlock with SectionBlockMappable { static final schema = Ack.object({ 'type': Ack.literal(key).optional(), 'align': ContentAlignment.schema.optional(), - 'flex': Ack.integer().optional(), + 'flex': positiveFlexSchema.optional(), + 'spacing': nonNegativeSpacingSchema.optional(), 'blocks': Ack.list(Block.discriminatedSchema).optional(), }, additionalProperties: false); } @@ -120,9 +193,15 @@ class ContentBlock extends Block with ContentBlockMappable { final String content; - ContentBlock(String? content, {super.align, super.flex, super.scrollable}) - : content = content ?? '', - super(type: key); + ContentBlock( + String? content, { + super.align, + super.flex, + super.margin, + super.padding, + super.scrollable, + }) : content = content ?? '', + super(type: key); static final fromMap = ContentBlockMapper.fromMap; @@ -130,14 +209,18 @@ class ContentBlock extends Block with ContentBlockMappable { static final schema = Ack.object({ 'type': Ack.literal(key).optional(), 'align': ContentAlignment.schema.optional(), - 'flex': Ack.integer().optional(), + 'flex': positiveFlexSchema.optional(), + 'margin': BlockInsets.schema.optional(), + 'padding': BlockInsets.schema.optional(), 'scrollable': Ack.boolean().optional(), 'content': Ack.string().optional(), }, additionalProperties: true); - /// Parses a content block from a JSON map with schema validation. - static ContentBlock parse(Map map) => - fromMap(schema.parse(map)!); + /// Parses a content block from normalized contract data. + static ContentBlock parse(Map map) { + _validateFlexInput(map); + return fromMap(schema.parse(map)!); + } } @MappableEnum() @@ -195,7 +278,14 @@ enum ImageFit { ) class WidgetBlock extends Block with WidgetBlockMappable { static const key = 'widget'; - static const _reservedKeys = {'name', 'align', 'flex', 'scrollable'}; + static const _reservedKeys = { + 'name', + 'align', + 'flex', + 'margin', + 'padding', + 'scrollable', + }; final Map args; final String name; @@ -205,6 +295,8 @@ class WidgetBlock extends Block with WidgetBlockMappable { Map? args, super.align, super.flex, + super.margin, + super.padding, super.scrollable, }) : args = _validateArgs(args), super(type: key); @@ -239,14 +331,18 @@ class WidgetBlock extends Block with WidgetBlockMappable { static final schema = Ack.object({ 'align': ContentAlignment.schema.optional(), - 'flex': Ack.integer().optional(), + 'flex': positiveFlexSchema.optional(), + 'margin': BlockInsets.schema.optional(), + 'padding': BlockInsets.schema.optional(), 'scrollable': Ack.boolean().optional(), 'name': Ack.string(), }, additionalProperties: true); - /// Parses a widget block from a JSON map with schema validation. - static WidgetBlock parse(Map map) => - fromMap(schema.parse(map)!); + /// Parses a widget block from normalized contract data. + static WidgetBlock parse(Map map) { + _validateFlexInput(map); + return fromMap(schema.parse(map)!); + } } @MappableEnum() diff --git a/packages/core/lib/src/deck/block_model.mapper.dart b/packages/core/lib/src/deck/block_model.mapper.dart index 4d1eb29a..3783adf0 100644 --- a/packages/core/lib/src/deck/block_model.mapper.dart +++ b/packages/core/lib/src/deck/block_model.mapper.dart @@ -204,6 +204,7 @@ class BlockMapper extends ClassMapperBase { ContentBlockMapper.ensureInitialized(); WidgetBlockMapper.ensureInitialized(); ContentAlignmentMapper.ensureInitialized(); + BlockInsetsMapper.ensureInitialized(); } return _instance!; } @@ -226,6 +227,18 @@ class BlockMapper extends ClassMapperBase { opt: true, def: 1, ); + static BlockInsets? _$margin(Block v) => v.margin; + static const Field _f$margin = Field( + 'margin', + _$margin, + opt: true, + ); + static BlockInsets? _$padding(Block v) => v.padding; + static const Field _f$padding = Field( + 'padding', + _$padding, + opt: true, + ); static bool _$scrollable(Block v) => v.scrollable; static const Field _f$scrollable = Field( 'scrollable', @@ -239,6 +252,8 @@ class BlockMapper extends ClassMapperBase { #type: _f$type, #align: _f$align, #flex: _f$flex, + #margin: _f$margin, + #padding: _f$padding, #scrollable: _f$scrollable, }; @override @@ -272,7 +287,15 @@ mixin BlockMappable { abstract class BlockCopyWith<$R, $In extends Block, $Out> implements ClassCopyWith<$R, $In, $Out> { - $R call({ContentAlignment? align, int? flex, bool? scrollable}); + BlockInsetsCopyWith<$R, BlockInsets, BlockInsets>? get margin; + BlockInsetsCopyWith<$R, BlockInsets, BlockInsets>? get padding; + $R call({ + ContentAlignment? align, + int? flex, + BlockInsets? margin, + BlockInsets? padding, + bool? scrollable, + }); BlockCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); } @@ -310,6 +333,13 @@ class SectionBlockMapper extends ClassMapperBase { opt: true, def: 1, ); + static double _$spacing(SectionBlock v) => v.spacing; + static const Field _f$spacing = Field( + 'spacing', + _$spacing, + opt: true, + def: 0, + ); static String _$type(SectionBlock v) => v.type; static const Field _f$type = Field( 'type', @@ -323,6 +353,7 @@ class SectionBlockMapper extends ClassMapperBase { #blocks: _f$blocks, #align: _f$align, #flex: _f$flex, + #spacing: _f$spacing, #type: _f$type, }; @override @@ -333,6 +364,7 @@ class SectionBlockMapper extends ClassMapperBase { data.dec(_f$blocks), align: data.dec(_f$align), flex: data.dec(_f$flex), + spacing: data.dec(_f$spacing), type: data.dec(_f$type), ); } @@ -404,6 +436,7 @@ abstract class SectionBlockCopyWith<$R, $In extends SectionBlock, $Out> List? blocks, ContentAlignment? align, int? flex, + double? spacing, String? type, }); SectionBlockCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); @@ -429,12 +462,14 @@ class _SectionBlockCopyWithImpl<$R, $Out> Object? blocks = $none, Object? align = $none, int? flex, + double? spacing, String? type, }) => $apply( FieldCopyWithData({ if (blocks != $none) #blocks: blocks, if (align != $none) #align: align, if (flex != null) #flex: flex, + if (spacing != null) #spacing: spacing, if (type != null) #type: type, }), ); @@ -443,6 +478,7 @@ class _SectionBlockCopyWithImpl<$R, $Out> data.get(#blocks, or: $value.blocks), align: data.get(#align, or: $value.align), flex: data.get(#flex, or: $value.flex), + spacing: data.get(#spacing, or: $value.spacing), type: data.get(#type, or: $value.type), ); @@ -461,6 +497,7 @@ class ContentBlockMapper extends SubClassMapperBase { MapperContainer.globals.use(_instance = ContentBlockMapper._()); BlockMapper.ensureInitialized().addSubMapper(_instance!); ContentAlignmentMapper.ensureInitialized(); + BlockInsetsMapper.ensureInitialized(); } return _instance!; } @@ -486,6 +523,18 @@ class ContentBlockMapper extends SubClassMapperBase { opt: true, def: 1, ); + static BlockInsets? _$margin(ContentBlock v) => v.margin; + static const Field _f$margin = Field( + 'margin', + _$margin, + opt: true, + ); + static BlockInsets? _$padding(ContentBlock v) => v.padding; + static const Field _f$padding = Field( + 'padding', + _$padding, + opt: true, + ); static bool _$scrollable(ContentBlock v) => v.scrollable; static const Field _f$scrollable = Field( 'scrollable', @@ -505,6 +554,8 @@ class ContentBlockMapper extends SubClassMapperBase { #content: _f$content, #align: _f$align, #flex: _f$flex, + #margin: _f$margin, + #padding: _f$padding, #scrollable: _f$scrollable, #type: _f$type, }; @@ -523,6 +574,8 @@ class ContentBlockMapper extends SubClassMapperBase { data.dec(_f$content), align: data.dec(_f$align), flex: data.dec(_f$flex), + margin: data.dec(_f$margin), + padding: data.dec(_f$padding), scrollable: data.dec(_f$scrollable), ); } @@ -590,10 +643,16 @@ extension ContentBlockValueCopy<$R, $Out> abstract class ContentBlockCopyWith<$R, $In extends ContentBlock, $Out> implements BlockCopyWith<$R, $In, $Out> { @override + BlockInsetsCopyWith<$R, BlockInsets, BlockInsets>? get margin; + @override + BlockInsetsCopyWith<$R, BlockInsets, BlockInsets>? get padding; + @override $R call({ String? content, ContentAlignment? align, int? flex, + BlockInsets? margin, + BlockInsets? padding, bool? scrollable, }); ContentBlockCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); @@ -608,16 +667,26 @@ class _ContentBlockCopyWithImpl<$R, $Out> late final ClassMapperBase $mapper = ContentBlockMapper.ensureInitialized(); @override + BlockInsetsCopyWith<$R, BlockInsets, BlockInsets>? get margin => + $value.margin?.copyWith.$chain((v) => call(margin: v)); + @override + BlockInsetsCopyWith<$R, BlockInsets, BlockInsets>? get padding => + $value.padding?.copyWith.$chain((v) => call(padding: v)); + @override $R call({ Object? content = $none, Object? align = $none, int? flex, + Object? margin = $none, + Object? padding = $none, bool? scrollable, }) => $apply( FieldCopyWithData({ if (content != $none) #content: content, if (align != $none) #align: align, if (flex != null) #flex: flex, + if (margin != $none) #margin: margin, + if (padding != $none) #padding: padding, if (scrollable != null) #scrollable: scrollable, }), ); @@ -626,6 +695,8 @@ class _ContentBlockCopyWithImpl<$R, $Out> data.get(#content, or: $value.content), align: data.get(#align, or: $value.align), flex: data.get(#flex, or: $value.flex), + margin: data.get(#margin, or: $value.margin), + padding: data.get(#padding, or: $value.padding), scrollable: data.get(#scrollable, or: $value.scrollable), ); @@ -644,6 +715,7 @@ class WidgetBlockMapper extends SubClassMapperBase { MapperContainer.globals.use(_instance = WidgetBlockMapper._()); BlockMapper.ensureInitialized().addSubMapper(_instance!); ContentAlignmentMapper.ensureInitialized(); + BlockInsetsMapper.ensureInitialized(); } return _instance!; } @@ -672,6 +744,18 @@ class WidgetBlockMapper extends SubClassMapperBase { opt: true, def: 1, ); + static BlockInsets? _$margin(WidgetBlock v) => v.margin; + static const Field _f$margin = Field( + 'margin', + _$margin, + opt: true, + ); + static BlockInsets? _$padding(WidgetBlock v) => v.padding; + static const Field _f$padding = Field( + 'padding', + _$padding, + opt: true, + ); static bool _$scrollable(WidgetBlock v) => v.scrollable; static const Field _f$scrollable = Field( 'scrollable', @@ -692,6 +776,8 @@ class WidgetBlockMapper extends SubClassMapperBase { #args: _f$args, #align: _f$align, #flex: _f$flex, + #margin: _f$margin, + #padding: _f$padding, #scrollable: _f$scrollable, #type: _f$type, }; @@ -713,6 +799,8 @@ class WidgetBlockMapper extends SubClassMapperBase { args: data.dec(_f$args), align: data.dec(_f$align), flex: data.dec(_f$flex), + margin: data.dec(_f$margin), + padding: data.dec(_f$padding), scrollable: data.dec(_f$scrollable), ); } @@ -780,11 +868,17 @@ abstract class WidgetBlockCopyWith<$R, $In extends WidgetBlock, $Out> MapCopyWith<$R, String, Object?, ObjectCopyWith<$R, Object?, Object?>?> get args; @override + BlockInsetsCopyWith<$R, BlockInsets, BlockInsets>? get margin; + @override + BlockInsetsCopyWith<$R, BlockInsets, BlockInsets>? get padding; + @override $R call({ String? name, Map? args, ContentAlignment? align, int? flex, + BlockInsets? margin, + BlockInsets? padding, bool? scrollable, }); WidgetBlockCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); @@ -806,11 +900,19 @@ class _WidgetBlockCopyWithImpl<$R, $Out> (v) => call(args: v), ); @override + BlockInsetsCopyWith<$R, BlockInsets, BlockInsets>? get margin => + $value.margin?.copyWith.$chain((v) => call(margin: v)); + @override + BlockInsetsCopyWith<$R, BlockInsets, BlockInsets>? get padding => + $value.padding?.copyWith.$chain((v) => call(padding: v)); + @override $R call({ String? name, Object? args = $none, Object? align = $none, int? flex, + Object? margin = $none, + Object? padding = $none, bool? scrollable, }) => $apply( FieldCopyWithData({ @@ -818,6 +920,8 @@ class _WidgetBlockCopyWithImpl<$R, $Out> if (args != $none) #args: args, if (align != $none) #align: align, if (flex != null) #flex: flex, + if (margin != $none) #margin: margin, + if (padding != $none) #padding: padding, if (scrollable != null) #scrollable: scrollable, }), ); @@ -827,6 +931,8 @@ class _WidgetBlockCopyWithImpl<$R, $Out> args: data.get(#args, or: $value.args), align: data.get(#align, or: $value.align), flex: data.get(#flex, or: $value.flex), + margin: data.get(#margin, or: $value.margin), + padding: data.get(#padding, or: $value.padding), scrollable: data.get(#scrollable, or: $value.scrollable), ); diff --git a/packages/core/lib/src/deck/slide_contract.dart b/packages/core/lib/src/deck/slide_contract.dart index d54b9989..6d58653b 100644 --- a/packages/core/lib/src/deck/slide_contract.dart +++ b/packages/core/lib/src/deck/slide_contract.dart @@ -1,5 +1,7 @@ import 'package:ack/ack.dart'; +import 'block_insets.dart'; +import 'block_model.dart'; import 'slide_model.dart'; /// Canonical top-level JSON contract for compiled slide payloads. @@ -13,3 +15,66 @@ List parseSlidesContract(Object? value) { .map((slide) => Slide.fromMap(Map.from(slide))) .toList(growable: false); } + +/// Flattened slide projection for structured-output AI generation. +/// +/// Structured-output schema adapters (e.g. Google Generative AI) cannot +/// consume the JSON Schema `anyOf` union produced by +/// [Block.discriminatedSchema], so the discriminated block union is flattened +/// into one object with a `type` enum. Every field vocabulary (alignment, +/// flex, spacing, normalized insets, slide options) is shared with the +/// canonical schemas, so a contract change here reaches AI generation +/// automatically. Data generated against this projection must still decode +/// through [Slide.parse] / [parseSlidesContract]. +final aiSlideSchema = Ack.object({ + 'key': Ack.string().describe('Unique slide identifier using kebab-case'), + 'options': SlideOptions.schema.optional().describe('Slide options'), + 'comments': Ack.list( + Ack.string().describe('A speaker note or talking point for this slide'), + ).optional().describe('Speaker notes'), + 'sections': Ack.list( + _aiSectionSchema, + ).describe('Horizontal sections in the slide'), +}).describe('A single slide'); + +final _aiSectionSchema = Ack.object({ + 'type': Ack.literal(SectionBlock.key).describe('Section type discriminator'), + 'align': ContentAlignment.schema.optional().describe( + 'Content alignment within the section', + ), + 'flex': positiveFlexSchema.optional().describe( + 'Flex weight for proportional sizing. Higher values take more space.', + ), + 'spacing': nonNegativeSpacingSchema.optional().describe( + 'Gap in logical pixels between sibling blocks', + ), + 'blocks': Ack.list(_aiBlockSchema).describe('Content blocks in this section'), +}).describe('A section containing blocks'); + +final _aiBlockSchema = Ack.object({ + 'type': Ack.enumString(const [ContentBlock.key, WidgetBlock.key]).describe( + 'Block type: "block" for markdown content, "widget" for a named widget ' + 'reference', + ), + 'content': Ack.string().optional().describe( + 'Markdown content (required for type "block")', + ), + 'name': Ack.string().optional().describe( + 'Widget name (required for type "widget")', + ), + 'align': ContentAlignment.schema.optional().describe('Content alignment'), + 'flex': positiveFlexSchema.optional().describe( + 'Flex weight for proportional sizing. Higher values take more space.', + ), + 'margin': BlockInsets.schema.optional().describe( + 'Space inside the block frame but outside its decoration, as normalized ' + 'physical edges', + ), + 'padding': BlockInsets.schema.optional().describe( + 'Space between the block decoration and its content, as normalized ' + 'physical edges', + ), + 'scrollable': Ack.boolean().optional().describe( + 'Whether overflowing block content scrolls', + ), +}).describe('A content or widget block'); diff --git a/packages/core/lib/superdeck_core.dart b/packages/core/lib/superdeck_core.dart index 3aac896f..338b5c73 100644 --- a/packages/core/lib/superdeck_core.dart +++ b/packages/core/lib/superdeck_core.dart @@ -7,6 +7,7 @@ export 'package:ack/ack.dart'; export 'src/cache/asset_cache_store.dart'; export 'src/cache/asset_cache_store_io.dart'; // Presentation +export 'src/deck/block_insets.dart'; export 'src/deck/block_model.dart'; export 'src/deck/deck_build_status.dart'; export 'src/deck/deck_build_store.dart'; diff --git a/packages/core/schema/superdeck.slides.schema.json b/packages/core/schema/superdeck.slides.schema.json index 3636641b..6741e7ae 100644 --- a/packages/core/schema/superdeck.slides.schema.json +++ b/packages/core/schema/superdeck.slides.schema.json @@ -77,8 +77,65 @@ "type": "string" }, "flex": { + "exclusiveMinimum": 0, "type": "integer" }, + "margin": { + "additionalProperties": false, + "properties": { + "bottom": { + "minimum": 0, + "type": "number" + }, + "left": { + "minimum": 0, + "type": "number" + }, + "right": { + "minimum": 0, + "type": "number" + }, + "top": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "top", + "right", + "bottom", + "left" + ], + "type": "object" + }, + "padding": { + "additionalProperties": false, + "properties": { + "bottom": { + "minimum": 0, + "type": "number" + }, + "left": { + "minimum": 0, + "type": "number" + }, + "right": { + "minimum": 0, + "type": "number" + }, + "top": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "top", + "right", + "bottom", + "left" + ], + "type": "object" + }, "scrollable": { "type": "boolean" }, @@ -110,11 +167,68 @@ "type": "string" }, "flex": { + "exclusiveMinimum": 0, "type": "integer" }, + "margin": { + "additionalProperties": false, + "properties": { + "bottom": { + "minimum": 0, + "type": "number" + }, + "left": { + "minimum": 0, + "type": "number" + }, + "right": { + "minimum": 0, + "type": "number" + }, + "top": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "top", + "right", + "bottom", + "left" + ], + "type": "object" + }, "name": { "type": "string" }, + "padding": { + "additionalProperties": false, + "properties": { + "bottom": { + "minimum": 0, + "type": "number" + }, + "left": { + "minimum": 0, + "type": "number" + }, + "right": { + "minimum": 0, + "type": "number" + }, + "top": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "top", + "right", + "bottom", + "left" + ], + "type": "object" + }, "scrollable": { "type": "boolean" }, @@ -134,8 +248,13 @@ "type": "array" }, "flex": { + "exclusiveMinimum": 0, "type": "integer" }, + "spacing": { + "minimum": 0, + "type": "number" + }, "type": { "const": "section", "type": "string" diff --git a/packages/core/test/public_api_test.dart b/packages/core/test/public_api_test.dart index ed0d48d3..0642638d 100644 --- a/packages/core/test/public_api_test.dart +++ b/packages/core/test/public_api_test.dart @@ -6,11 +6,13 @@ void main() { test('exports the supported runtime surface', () { final workspace = DeckWorkspace(projectDir: '/tmp/test-deck'); final slide = Slide(key: 'intro'); + final padding = BlockInsets.all(8); expect(workspace.deckJson.path, contains('superdeck.json')); expect(DeckPlugin, isNotNull); expect(slidesContractSchema, isNotNull); expect(parseSlidesContract([slide.toMap()]), hasLength(1)); + expect(padding.left, 8); }); }); } diff --git a/packages/core/test/src/deck/block_model_test.dart b/packages/core/test/src/deck/block_model_test.dart index c3e5f977..79903d5b 100644 --- a/packages/core/test/src/deck/block_model_test.dart +++ b/packages/core/test/src/deck/block_model_test.dart @@ -1,6 +1,59 @@ +import 'package:superdeck_core/src/deck/block_insets.dart'; import 'package:superdeck_core/src/deck/block_model.dart'; import 'package:test/test.dart'; +Matcher _throwsInvalidFlex() { + return throwsA( + isA() + .having((error) => error.name, 'name', 'flex') + .having( + (error) => error.message.toString(), + 'message', + contains('greater than zero'), + ), + ); +} + +Matcher _throwsMappedInvalidFlex() { + return throwsA( + predicate((error) { + final message = error.toString(); + return message.contains('flex') && message.contains('greater than zero'); + }, 'an error naming flex and requiring a value greater than zero'), + ); +} + +Matcher _throwsInvalidSpacing() { + return throwsA( + isA() + .having((error) => error.name, 'name', 'spacing') + .having( + (error) => error.message.toString(), + 'message', + allOf(contains('finite'), contains('greater than or equal to zero')), + ), + ); +} + +Matcher _throwsInvalidInsets(String name, {required bool structural}) { + return throwsA( + isA() + .having((error) => error.name, 'name', name) + .having( + (error) => error.message.toString(), + 'message', + structural + ? allOf( + contains('finite non-negative'), + contains('scalar'), + contains('horizontal'), + contains('top'), + ) + : contains('finite non-negative'), + ), + ); +} + void main() { group('Block Model', () { group('DartPadTheme', () { @@ -256,6 +309,237 @@ void main() { }); }); + group('block insets authoring', () { + final accepted = >{ + 16: {'top': 16.0, 'right': 16.0, 'bottom': 16.0, 'left': 16.0}, + {'horizontal': 24}: { + 'top': 0.0, + 'right': 24.0, + 'bottom': 0.0, + 'left': 24.0, + }, + {'vertical': 12}: { + 'top': 12.0, + 'right': 0.0, + 'bottom': 12.0, + 'left': 0.0, + }, + {'horizontal': 24, 'vertical': 16}: { + 'top': 16.0, + 'right': 24.0, + 'bottom': 16.0, + 'left': 24.0, + }, + {'top': 12, 'right': 24}: { + 'top': 12.0, + 'right': 24.0, + 'bottom': 0.0, + 'left': 0.0, + }, + }; + + for (final field in const ['padding', 'margin']) { + for (final MapEntry(key: input, value: normalized) + in accepted.entries) { + test('$field: $input normalizes to four physical edges', () { + final block = Block.parseAuthoring({'type': 'block', field: input}); + + expect(block.toMap()[field], normalized); + }); + } + } + + test('authoring schema accepts the three authoring forms', () { + for (final input in accepted.keys) { + expect( + BlockInsets.authoringSchema.safeParse(input).isOk, + isTrue, + reason: 'insets: $input', + ); + } + }); + + test('contract schema rejects authoring shorthand', () { + for (final field in const ['padding', 'margin']) { + for (final input in [ + 16, + {'horizontal': 24}, + {'top': 12, 'right': 24}, + ]) { + expect( + ContentBlock.schema.safeParse({ + 'type': 'block', + field: input, + }).isOk, + isFalse, + reason: '$field: $input', + ); + } + } + }); + + test('contract schema accepts normalized four-edge insets', () { + for (final field in const ['padding', 'margin']) { + expect( + ContentBlock.schema.safeParse({ + 'type': 'block', + field: {'top': 1, 'right': 2, 'bottom': 3, 'left': 4}, + }).isOk, + isTrue, + reason: field, + ); + } + }); + + final structurallyInvalid = [ + {}, + {'unknown': 1}, + {'horizontal': 8, 'top': 4}, + ]; + + for (final field in const ['padding', 'margin']) { + for (final input in structurallyInvalid) { + test('$field rejects $input naming the field', () { + expect( + () => Block.parseAuthoring({'type': 'block', field: input}), + _throwsInvalidInsets(field, structural: true), + ); + }); + } + + test('$field rejects invalid scalars naming the field', () { + for (final input in [-1, double.nan, double.infinity]) { + expect( + () => Block.parseAuthoring({'type': 'block', field: input}), + _throwsInvalidInsets(field, structural: false), + reason: '$field: $input', + ); + } + }); + + test('$field reports the exact invalid edge', () { + expect( + () => Block.parseAuthoring({ + 'type': 'block', + field: {'left': -1}, + }), + _throwsInvalidInsets('$field.left', structural: false), + ); + expect( + () => Block.parseAuthoring({ + 'type': 'block', + field: {'vertical': double.nan}, + }), + _throwsInvalidInsets('$field.vertical', structural: false), + ); + expect( + () => Block.parseAuthoring({ + 'type': 'block', + field: {'left': null}, + }), + _throwsInvalidInsets('$field.left', structural: false), + ); + }); + + test('$field omitted edges normalize to zero', () { + final block = Block.parseAuthoring({ + 'type': 'block', + field: {'top': 12}, + }); + + expect(block.toMap()[field], { + 'top': 12.0, + 'right': 0.0, + 'bottom': 0.0, + 'left': 0.0, + }); + }); + } + + test('authoring schema rejects invalid forms', () { + for (final input in [ + ...structurallyInvalid, + -1, + {'left': -1}, + double.nan, + double.infinity, + ]) { + expect( + BlockInsets.authoringSchema.safeParse(input).isOk, + isFalse, + reason: 'insets: $input', + ); + } + }); + + test('public constructors create normalized insets', () { + expect(BlockInsets.all(8).toMap(), { + 'top': 8.0, + 'right': 8.0, + 'bottom': 8.0, + 'left': 8.0, + }); + expect(BlockInsets.symmetric(horizontal: 12, vertical: 6).toMap(), { + 'top': 6.0, + 'right': 12.0, + 'bottom': 6.0, + 'left': 12.0, + }); + }); + + test('public constructor rejects invalid edges', () { + for (final value in [-1.0, double.nan, double.infinity]) { + expect( + () => BlockInsets(left: value), + throwsA( + isA() + .having((error) => error.name, 'name', 'left') + .having( + (error) => error.message.toString(), + 'message', + contains('finite non-negative'), + ), + ), + ); + } + }); + + test('generated map and copy paths preserve invariants', () { + final insets = BlockInsets.fromMap({ + 'top': 1, + 'right': 2, + 'bottom': 3, + 'left': 4, + }); + + expect(insets.toMap(), { + 'top': 1.0, + 'right': 2.0, + 'bottom': 3.0, + 'left': 4.0, + }); + expect(insets.copyWith(left: 5).left, 5); + expect( + () => insets.copyWith(left: double.nan), + throwsA(isA()), + ); + }); + + test('absent insets stay null; explicit zero stays representable', () { + final inherited = Block.parseAuthoring({'type': 'block'}); + expect(inherited.margin, isNull); + expect(inherited.padding, isNull); + + final removed = Block.parseAuthoring({ + 'type': 'block', + 'margin': 0, + 'padding': 0, + }); + expect(removed.margin, BlockInsets.all(0)); + expect(removed.padding, BlockInsets.all(0)); + }); + }); + group('ContentBlock', () { test('creates with default values', () { final block = ContentBlock('Hello'); @@ -263,6 +547,7 @@ void main() { expect(block.content, 'Hello'); expect(block.type, 'block'); expect(block.flex, 1); + expect(block.padding, isNull); expect(block.scrollable, false); expect(block.align, isNull); }); @@ -278,27 +563,45 @@ void main() { 'Content', align: ContentAlignment.center, flex: 2, + padding: BlockInsets.all(8), scrollable: true, ); expect(block.content, 'Content'); expect(block.align, ContentAlignment.center); expect(block.flex, 2); + expect(block.padding, BlockInsets.all(8)); expect(block.scrollable, true); }); - group('resolvedAlign', () { - test('defaults to centerLeft when align is not set', () { + group('constructor validation', () { + for (final flex in [0, -1]) { + test('rejects flex $flex', () { + expect( + () => ContentBlock('Content', flex: flex), + _throwsInvalidFlex(), + ); + }); + } + + test('copyWith rejects non-positive flex', () { final block = ContentBlock('Content'); - expect(block.align, isNull); - expect(block.resolvedAlign, ContentAlignment.centerLeft); + expect(() => block.copyWith(flex: 0), _throwsInvalidFlex()); }); - test('uses explicit align when set', () { - final block = ContentBlock('Content', align: ContentAlignment.center); + test('fromMap rejects non-positive flex', () { + expect( + () => ContentBlock.fromMap({'type': 'block', 'flex': -1}), + _throwsMappedInvalidFlex(), + ); + }); - expect(block.resolvedAlign, ContentAlignment.center); + test('parse reports the flex field and accepted range', () { + expect( + () => ContentBlock.parse({'type': 'block', 'flex': 0}), + _throwsInvalidFlex(), + ); }); }); @@ -338,6 +641,7 @@ void main() { 'Content', align: ContentAlignment.center, flex: 2, + padding: BlockInsets.all(8), scrollable: true, ); final copy = original.copyWith(); @@ -345,6 +649,7 @@ void main() { expect(copy.content, original.content); expect(copy.align, original.align); expect(copy.flex, original.flex); + expect(copy.padding, original.padding); expect(copy.scrollable, original.scrollable); }); }); @@ -366,6 +671,7 @@ void main() { 'Content', align: ContentAlignment.center, flex: 2, + padding: BlockInsets.symmetric(horizontal: 12, vertical: 8), scrollable: true, ); final map = block.toMap(); @@ -374,6 +680,12 @@ void main() { expect(map['content'], 'Content'); expect(map['align'], 'center'); expect(map['flex'], 2); + expect(map['padding'], { + 'top': 8.0, + 'right': 12.0, + 'bottom': 8.0, + 'left': 12.0, + }); expect(map['scrollable'], true); }); }); @@ -395,6 +707,7 @@ void main() { 'content': 'Content', 'align': 'center', 'flex': 2, + 'padding': {'top': 1, 'right': 2, 'bottom': 3, 'left': 4}, 'scrollable': true, }; final block = ContentBlock.fromMap(map); @@ -402,6 +715,10 @@ void main() { expect(block.content, 'Content'); expect(block.align, ContentAlignment.center); expect(block.flex, 2); + expect( + block.padding, + BlockInsets(top: 1, right: 2, bottom: 3, left: 4), + ); expect(block.scrollable, true); }); @@ -432,6 +749,7 @@ void main() { 'Test content', align: ContentAlignment.bottomRight, flex: 3, + padding: BlockInsets.all(6), scrollable: true, ); @@ -494,6 +812,17 @@ void main() { expect(result.isOk, isFalse); }); + + test('rejects non-positive flex', () { + for (final flex in [0, -1]) { + final result = ContentBlock.schema.safeParse({ + 'type': 'block', + 'flex': flex, + }); + + expect(result.isOk, isFalse, reason: 'flex: $flex'); + } + }); }); }); @@ -504,6 +833,7 @@ void main() { expect(section.blocks, isEmpty); expect(section.type, 'section'); expect(section.flex, 1); + expect(section.spacing, 0); }); test('creates with child blocks', () { @@ -528,26 +858,100 @@ void main() { [ContentBlock('Test')], align: ContentAlignment.center, flex: 2, + spacing: 24, ); expect(section.align, ContentAlignment.center); expect(section.flex, 2); + expect(section.spacing, 24); }); - group('totalBlockFlex', () { - test('returns 0 for empty section', () { + group('constructor validation', () { + for (final flex in [0, -1]) { + test('rejects flex $flex', () { + expect(() => SectionBlock([], flex: flex), _throwsInvalidFlex()); + }); + } + + test('copyWith rejects non-positive flex', () { final section = SectionBlock([]); - expect(section.totalBlockFlex, 0); + + expect(() => section.copyWith(flex: 0), _throwsInvalidFlex()); + }); + + test('fromMap rejects non-positive flex', () { + expect( + () => SectionBlock.fromMap({'type': 'section', 'flex': -1}), + _throwsMappedInvalidFlex(), + ); + }); + + test('parse reports the flex field and accepted range', () { + expect( + () => SectionBlock.parse({'type': 'section', 'flex': 0}), + _throwsInvalidFlex(), + ); + }); + + for (final spacing in [-1.0, double.nan, double.infinity]) { + test('rejects spacing $spacing', () { + expect( + () => SectionBlock([], spacing: spacing), + _throwsInvalidSpacing(), + ); + }); + } + + test('copyWith rejects invalid spacing', () { + final section = SectionBlock([]); + + expect( + () => section.copyWith(spacing: double.infinity), + _throwsInvalidSpacing(), + ); + }); + + test('parse reports the spacing field and accepted range', () { + for (final spacing in [-1.0, double.nan, double.infinity]) { + expect( + () => SectionBlock.parse({'spacing': spacing}), + _throwsInvalidSpacing(), + reason: 'spacing: $spacing', + ); + } + }); + }); + + group('resolveBlockAlign', () { + test('defaults to centerLeft without explicit alignment', () { + final block = ContentBlock('Content'); + final section = SectionBlock([block]); + + expect(section.resolveBlockAlign(block), ContentAlignment.centerLeft); + }); + + test('inherits section alignment', () { + final block = ContentBlock('Content'); + final section = SectionBlock([ + block, + ], align: ContentAlignment.bottomCenter); + + expect( + section.resolveBlockAlign(block), + ContentAlignment.bottomCenter, + ); }); - test('sums child flex values', () { + test('prefers explicit block alignment', () { + final block = ContentBlock( + 'Content', + align: ContentAlignment.topRight, + ); final section = SectionBlock([ - ContentBlock('A', flex: 1), - ContentBlock('B', flex: 2), - ContentBlock('C', flex: 3), - ]); + block, + ], align: ContentAlignment.bottomLeft); - expect(section.totalBlockFlex, 6); + expect(section.resolveBlockAlign(block), ContentAlignment.topRight); }); }); @@ -565,12 +969,20 @@ void main() { [ContentBlock('Test')], align: ContentAlignment.center, flex: 2, + spacing: 16, ); final copy = original.copyWith(); expect(copy.blocks.length, original.blocks.length); expect(copy.align, original.align); expect(copy.flex, original.flex); + expect(copy.spacing, original.spacing); + }); + + test('copies with new spacing', () { + final original = SectionBlock([]); + + expect(original.copyWith(spacing: 32).spacing, 32); }); }); @@ -581,16 +993,18 @@ void main() { expect(map['type'], 'section'); expect(map['blocks'], isEmpty); + expect(map['spacing'], 0); expect(map.containsKey('scrollable'), isFalse); }); test('serializes section with blocks', () { - final section = SectionBlock([ContentBlock('Test')]); + final section = SectionBlock([ContentBlock('Test')], spacing: 20); final map = section.toMap(); expect(map['type'], 'section'); expect(map['blocks'], isA()); expect((map['blocks'] as List).length, 1); + expect(map['spacing'], 20); expect(map.containsKey('scrollable'), isFalse); }); }); @@ -609,11 +1023,13 @@ void main() { 'blocks': [ {'type': 'block', 'content': 'Test'}, ], + 'spacing': 18, }; final section = SectionBlock.fromMap(map); expect(section.blocks.length, 1); expect((section.blocks[0] as ContentBlock).content, 'Test'); + expect(section.spacing, 18); }); }); @@ -651,6 +1067,34 @@ void main() { expect(result.isOk, isFalse); }); + + test('accepts finite non-negative spacing', () { + for (final spacing in [0, 12.5, 40]) { + final result = SectionBlock.schema.safeParse({ + 'spacing': spacing, + 'blocks': [], + }); + + expect(result.isOk, isTrue, reason: 'spacing: $spacing'); + } + }); + + test('rejects non-positive flex and invalid spacing', () { + for (final flex in [0, -1]) { + expect( + SectionBlock.schema.safeParse({'flex': flex}).isOk, + isFalse, + reason: 'flex: $flex', + ); + } + for (final spacing in [-1, double.nan, double.infinity]) { + expect( + SectionBlock.schema.safeParse({'spacing': spacing}).isOk, + isFalse, + reason: 'spacing: $spacing', + ); + } + }); }); group('text factory', () { @@ -708,12 +1152,23 @@ void main() { }); group('constructor validation', () { + for (final flex in [0, -1]) { + test('rejects flex $flex', () { + expect( + () => WidgetBlock(name: 'Test', flex: flex), + _throwsInvalidFlex(), + ); + }); + } + test('throws ArgumentError when args contain reserved keys', () { for (final reservedKey in const [ 'name', 'align', 'flex', 'scrollable', + 'margin', + 'padding', ]) { expect( () => WidgetBlock( @@ -743,23 +1198,33 @@ void main() { expect(widget.args, {'custom': 'value', 'count': 42, 'flag': true}); }); - }); - group('resolvedAlign', () { - test('defaults to centerLeft when align is not set', () { - final widget = WidgetBlock(name: 'Test'); - - expect(widget.align, isNull); - expect(widget.resolvedAlign, ContentAlignment.centerLeft); - }); + test('authored insets are normalized and excluded from args', () { + final widget = + Block.parseAuthoring({ + 'type': 'widget', + 'name': 'Test', + 'padding': {'horizontal': 12, 'vertical': 8}, + 'margin': 4, + 'custom': 'value', + }) + as WidgetBlock; - test('uses explicit align when set', () { - final widget = WidgetBlock( - name: 'Test', - align: ContentAlignment.topRight, - ); - - expect(widget.resolvedAlign, ContentAlignment.topRight); + expect(widget.args.containsKey('padding'), isFalse); + expect(widget.args.containsKey('margin'), isFalse); + expect(widget.args['custom'], 'value'); + expect(widget.toMap()['padding'], { + 'top': 8.0, + 'right': 12.0, + 'bottom': 8.0, + 'left': 12.0, + }); + expect(widget.toMap()['margin'], { + 'top': 4.0, + 'right': 4.0, + 'bottom': 4.0, + 'left': 4.0, + }); }); }); @@ -985,6 +1450,13 @@ void main() { expect(block, isA()); }); + + test('reports the flex field and accepted range', () { + expect( + () => Block.parse({'type': 'widget', 'name': 'Test', 'flex': -1}), + _throwsInvalidFlex(), + ); + }); }); group('schema', () { diff --git a/packages/core/test/src/deck/slide_contract_test.dart b/packages/core/test/src/deck/slide_contract_test.dart index 3a33868c..bb0c7a3e 100644 --- a/packages/core/test/src/deck/slide_contract_test.dart +++ b/packages/core/test/src/deck/slide_contract_test.dart @@ -1,3 +1,4 @@ +import 'package:superdeck_core/src/deck/block_insets.dart'; import 'package:superdeck_core/src/deck/block_model.dart'; import 'package:superdeck_core/src/deck/slide_contract.dart'; import 'package:superdeck_core/src/deck/slide_model.dart'; @@ -71,7 +72,9 @@ void main() { key: 'rt-slide', options: SlideOptions(title: 'RT Title'), sections: [ - SectionBlock([ContentBlock('Content')]), + SectionBlock([ + ContentBlock('Content', padding: BlockInsets.all(8)), + ], spacing: 12), ], comments: ['Note'], ), @@ -111,9 +114,80 @@ void main() { ); expect(sectionSchema['additionalProperties'], isFalse); - expect(sectionProperties.keys, containsAll(['type', 'align', 'flex'])); + expect( + sectionProperties.keys, + containsAll(['type', 'align', 'flex', 'spacing']), + ); expect(sectionProperties.containsKey('blocks'), isTrue); expect(sectionProperties.containsKey('scrollable'), isFalse); + + final flexSchema = Map.from( + sectionProperties['flex'] as Map, + ); + expect(flexSchema['exclusiveMinimum'], 0); + + final spacingSchema = Map.from( + sectionProperties['spacing'] as Map, + ); + expect(spacingSchema['minimum'], 0); + }); + + test('json schema exports normalized four-edge insets only', () { + for (final field in const ['padding', 'margin']) { + final insetsSchema = _propertySchema( + ContentBlock.schema.toJsonSchema(), + field, + ); + + expect(insetsSchema['type'], 'object', reason: field); + expect(insetsSchema['additionalProperties'], isFalse, reason: field); + expect( + insetsSchema['required'], + containsAll(['top', 'right', 'bottom', 'left']), + reason: field, + ); + } + }); + + test('contract rejects authoring shorthand insets', () { + final invalid = [ + { + 'key': 'shorthand', + 'sections': [ + { + 'type': 'section', + 'blocks': [ + {'type': 'block', 'content': '', 'padding': 16}, + ], + }, + ], + }, + ]; + + expect(slidesContractSchema.safeParse(invalid).isOk, isFalse); + expect(() => parseSlidesContract(invalid), throwsA(anything)); + }); + + test('round-trips normalized margin through the contract', () { + final original = [ + Slide( + key: 'margin-slide', + sections: [ + SectionBlock([ + ContentBlock( + 'Content', + margin: BlockInsets.symmetric(horizontal: 12, vertical: 8), + ), + ]), + ], + ), + ]; + + final restored = parseSlidesContract( + original.map((slide) => slide.toMap()).toList(), + ); + + expect(restored, original); }); }); } diff --git a/packages/playground/.planning/architecture.md b/packages/playground/.planning/architecture.md index 5a1b72c2..0b72085e 100644 --- a/packages/playground/.planning/architecture.md +++ b/packages/playground/.planning/architecture.md @@ -108,7 +108,7 @@ Every state construct found today maps to exactly one new pattern. | `DeckToolsAdapter` (`_activeInvocations`, `isIdle`) | `features/ai/core/tools/deck_tools_adapter.dart` | fold into **DeckToolsStore** | `features/ai/domain/stores/` | | `DeckStore` / `InMemoryDeckStore` (in-memory deck document) | `features/ai/core/tools/{deck_store,in_memory_deck_store}.dart` | **DeckRepository** interface + **InMemoryDeckRepository** impl (returns `Result`) | `core/{domain/repositories,data/repositories}/` | | `DeckEditCoordinator` (session orchestration) | `features/ai/deck_edit/deck_edit_coordinator.dart` | **ApplyDeckEditCommand** + reads DeckDocumentStore | `features/ai/domain/commands/` | -| `DeckCustomizationStore` (background, per-`TextLevel` signals, computed SlideStyle) | `stores/deck_customization_store.dart` | **DeckCustomizationStore** as `ChangeNotifier`; computed `slideStyle` → plain getter | `core/domain/stores/` (shared: editor + AI) | +| `DeckCustomizationStore` (background, per-`TextLevel` signals, computed SlideStyler) | `stores/deck_customization_store.dart` | **DeckCustomizationStore** as `ChangeNotifier`; computed `slideStyle` → plain getter | `core/domain/stores/` (shared: editor + AI) | | `EditorState` (`activeSlideIndex`) | `stores/editor_state.dart` | **EditorStore** (`activeSlideIndex`, setters) | `features/editor/domain/stores/` | | `SlideConfigurationStore` (already `ChangeNotifier`) | `stores/slide_configuration_store.dart` | Keep as `ChangeNotifier`; move to core domain | `core/domain/stores/` | | `TextEditorController` | `utils/text_editor_controller.dart` | **Ephemeral** — owned by editor `StatefulWidget`, disposed | `features/editor/presentation/widgets/` | @@ -201,7 +201,7 @@ No feature-specific store — the page reads the shared `DeckDocumentStore` and - **`DeckDocumentStore`** (`ChangeNotifier`) — single source of truth for the live deck (slides/markdown). Replaces `InMemoryDeckStore` as *state*; IO becomes `DeckRepository`. - **`DeckCustomizationStore`** (`ChangeNotifier`) — background color, per-`TextLevel` - color/size/weight/family; `SlideStyle get slideStyle` becomes a computed getter. + color/size/weight/family; `SlideStyler get slideStyle` becomes a computed getter. - **`SlideConfigurationStore`** — already a `ChangeNotifier`, moved here as-is. `lib/core/data`: diff --git a/packages/playground/.planning/editor-slice-plan.md b/packages/playground/.planning/editor-slice-plan.md index d45640b8..078fb092 100644 --- a/packages/playground/.planning/editor-slice-plan.md +++ b/packages/playground/.planning/editor-slice-plan.md @@ -70,7 +70,7 @@ Widget-ephemeral (StatefulWidget, disposed locally) - **`DeckStore`** — subscribe to `controller.slides`; expose `List slides`; `notifyListeners()` on change. Single read-surface for slides (replaces scattered `controller.slides.value` reads and the unused `SlideConfigurationStore`). - **`ThumbnailStore`** — on each `slides` change, reconcile subscriptions to every current `getThumbnail(key).status` signal (subscribe new keys, drop removed); expose `ThumbnailStatus statusFor(SlideKey)`; `notifyListeners()` on any status transition. Owns the dynamic subscription lifecycle. -- **`DeckCustomizationStore`** — `ChangeNotifier`; owns `background` + per-`TextLevel` `{color,size,weight,family}`; on any setter, recompute `SlideStyle` and write `controller.options.value = DeckOptions(...)`, then `notifyListeners()`. Seeds options in constructor. `applyFromAiStyle` removed. +- **`DeckCustomizationStore`** — `ChangeNotifier`; owns `background` + per-`TextLevel` `{color,size,weight,family}`; on any setter, recompute `SlideStyler` and write `controller.options.value = DeckOptions(...)`, then `notifyListeners()`. Seeds options in constructor. `applyFromAiStyle` removed. ## Status diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/deck_schemas.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/deck_schemas.dart index 8c64d25f..820a2085 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/deck_schemas.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/deck_schemas.dart @@ -1,5 +1,6 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; +import 'package:superdeck_core/superdeck_core.dart' show aiSlideSchema; import '../prompts/font_styles.dart'; @@ -7,8 +8,10 @@ part 'deck_schemas.g.dart'; /// Schema definitions for SuperDeck presentation generation. /// -/// These schemas use the ACK fluent API for consistency with catalog schemas. -/// They are compatible with Google Generative AI via `.toJsonSchemaBuilder()`. +/// The slide portion comes from `superdeck_core`'s [aiSlideSchema], the +/// AI-compatible projection of the canonical slide contract — Playground owns +/// only the style schema and prompt guidance. Schemas are compatible with +/// Google Generative AI via `.toJsonSchemaBuilder()`. // ============================================================================ // STYLE SCHEMAS @@ -54,140 +57,21 @@ final styleSchema = Ack.object({ 'fonts': _deckFontsSchema, }).describe('Global style configuration for the deck'); -// ============================================================================ -// SLIDE CONTENT SCHEMAS -// ============================================================================ - -const _alignmentValues = [ - 'topLeft', - 'topCenter', - 'topRight', - 'centerLeft', - 'center', - 'centerRight', - 'bottomLeft', - 'bottomCenter', - 'bottomRight', -]; - -/// Schema for a content or widget block. -/// -/// Blocks can be either: -/// - Content blocks (type: "block"): Contain markdown content -/// - Widget blocks (type: "widget"): Reference named widgets -@AckType(name: 'SlideBlock') -final _slideBlockSchema = Ack.object({ - 'type': Ack.enumString(['block', 'widget']).describe( - 'Block type: "block" for markdown content, "widget" for named widget reference', - ), - 'content': Ack.string().optional().describe( - 'Markdown content (required for type "block")', - ), - 'name': Ack.string().optional().describe( - 'Widget name (required for type "widget")', - ), - 'flex': Ack.integer().optional().describe( - 'Flex weight for proportional sizing. Higher values take more space.', - ), - 'align': Ack.enumString( - _alignmentValues, - ).optional().describe('Content alignment'), - 'scrollable': Ack.boolean().optional().describe( - 'Whether this block is scrollable', - ), -}).describe('A content or widget block'); -final blockSchema = _slideBlockSchema; - -/// Schema for a section containing blocks. -/// -/// Sections represent horizontal rows in a slide, containing one or more -/// blocks laid out as columns. -@AckType(name: 'SlideSection') -final _slideSectionSchema = Ack.object({ - 'type': Ack.literal('section').describe('Section type discriminator'), - 'flex': Ack.integer().optional().describe( - 'Flex weight for proportional sizing. Higher values take more space.', - ), - 'align': Ack.enumString( - _alignmentValues, - ).optional().describe('Content alignment within section'), - 'scrollable': Ack.boolean().optional().describe( - 'Whether this section is scrollable', - ), - 'blocks': Ack.list( - _slideBlockSchema, - ).describe('Content blocks in this section'), -}).describe('A section containing blocks'); -final sectionSchema = _slideSectionSchema; - -// ============================================================================ -// SLIDE SCHEMAS -// ============================================================================ - -/// Schema for slide options. -/// -/// Contains metadata about the slide such as title and style reference. -@AckType(name: 'SlideOptions') -final _slideOptionsSchema = Ack.object({ - 'title': Ack.string().optional().describe( - 'Slide title displayed in navigation', - ), - 'style': Ack.string().optional().describe( - 'Style name reference matching a defined style', - ), -}).describe('Slide options'); -final slideOptionsSchema = _slideOptionsSchema; - -/// Schema for a single slide. -/// -/// Each slide contains a unique key, optional metadata, and a vertical stack -/// of sections. -@AckType(name: 'Slide') -final slideSchema = Ack.object({ - 'key': Ack.string().describe('Unique slide identifier using kebab-case'), - 'options': _slideOptionsSchema.optional(), - 'comments': Ack.list( - Ack.string().describe('A speaker note or talking point for this slide'), - ).optional().describe('Speaker notes'), - 'sections': Ack.list( - _slideSectionSchema, - ).describe('Horizontal sections in the slide'), -}).describe('A single slide'); - -/// Schema for slide creation payloads. -/// -/// Matches [slideSchema], but allows omitting `key` so callers can request -/// automatic key generation. -@AckType(name: 'CreateSlide') -final createSlideSchema = Ack.object({ - 'key': Ack.string().optional().describe( - 'Optional slide identifier. When missing, a key can be generated.', - ), - 'options': _slideOptionsSchema.optional(), - 'comments': Ack.list( - Ack.string().describe('A speaker note or talking point for this slide'), - ).optional().describe('Speaker notes'), - 'sections': Ack.list( - _slideSectionSchema, - ).describe('Horizontal sections in the slide'), -}).describe('A slide payload accepted by createSlide'); - // ============================================================================ // ROOT SCHEMA // ============================================================================ /// Schema for the complete slide generation output. /// -/// Root schema for SuperDeck presentation generation, containing the slides -/// array and global style configuration. -@AckType(name: 'SlideGeneration') -final _slideGenerationSchema = Ack.object({ +/// Root schema for SuperDeck presentation generation: the slides array uses +/// the canonical AI projection exported by `superdeck_core`, so any layout +/// contract change in core reaches AI generation automatically. +final slideGenerationSchema = Ack.object({ 'slides': Ack.list( - slideSchema, + aiSlideSchema, ).describe('Array of slides in the presentation'), 'style': styleSchema, }).describe('A SuperDeck presentation with slides and style'); -final slideGenerationSchema = _slideGenerationSchema; // ============================================================================ // PROMPT GUIDANCE diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/deck_schemas.g.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/deck_schemas.g.dart index bc55a5e3..d1488194 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/deck_schemas.g.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/deck_schemas.g.dart @@ -7,8 +7,6 @@ part of 'deck_schemas.dart'; -List _$ackListCast(Object? value) => (value as List).cast(); - /// Extension type for DeckColors extension type DeckColorsType(Map _data) implements Map { @@ -80,174 +78,3 @@ extension type DeckStyleType(Map _data) DeckFontsType get fonts => DeckFontsType(_data['fonts'] as Map); } - -/// Extension type for SlideBlock -extension type SlideBlockType(Map _data) - implements Map { - static SlideBlockType parse(Object? data) { - return _slideBlockSchema.parseAs( - data, - (validated) => SlideBlockType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return _slideBlockSchema.safeParseAs( - data, - (validated) => SlideBlockType(validated as Map), - ); - } - - String get type => _data['type'] as String; - - String? get content => _data['content'] as String?; - - String? get name => _data['name'] as String?; - - int? get flex => _data['flex'] as int?; - - String? get align => _data['align'] as String?; - - bool? get scrollable => _data['scrollable'] as bool?; -} - -/// Extension type for SlideSection -extension type SlideSectionType(Map _data) - implements Map { - static SlideSectionType parse(Object? data) { - return _slideSectionSchema.parseAs( - data, - (validated) => SlideSectionType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return _slideSectionSchema.safeParseAs( - data, - (validated) => SlideSectionType(validated as Map), - ); - } - - String get type => _data['type'] as String; - - int? get flex => _data['flex'] as int?; - - String? get align => _data['align'] as String?; - - bool? get scrollable => _data['scrollable'] as bool?; - - List get blocks => (_data['blocks'] as List) - .map((e) => SlideBlockType(e as Map)) - .toList(); -} - -/// Extension type for SlideOptions -extension type SlideOptionsType(Map _data) - implements Map { - static SlideOptionsType parse(Object? data) { - return _slideOptionsSchema.parseAs( - data, - (validated) => SlideOptionsType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return _slideOptionsSchema.safeParseAs( - data, - (validated) => SlideOptionsType(validated as Map), - ); - } - - String? get title => _data['title'] as String?; - - String? get style => _data['style'] as String?; -} - -/// Extension type for Slide -extension type SlideType(Map _data) - implements Map { - static SlideType parse(Object? data) { - return slideSchema.parseAs( - data, - (validated) => SlideType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return slideSchema.safeParseAs( - data, - (validated) => SlideType(validated as Map), - ); - } - - String get key => _data['key'] as String; - - SlideOptionsType? get options => _data['options'] != null - ? SlideOptionsType(_data['options'] as Map) - : null; - - List? get comments => _data['comments'] != null - ? _$ackListCast(_data['comments']) - : null; - - List get sections => (_data['sections'] as List) - .map((e) => SlideSectionType(e as Map)) - .toList(); -} - -/// Extension type for CreateSlide -extension type CreateSlideType(Map _data) - implements Map { - static CreateSlideType parse(Object? data) { - return createSlideSchema.parseAs( - data, - (validated) => CreateSlideType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return createSlideSchema.safeParseAs( - data, - (validated) => CreateSlideType(validated as Map), - ); - } - - String? get key => _data['key'] as String?; - - SlideOptionsType? get options => _data['options'] != null - ? SlideOptionsType(_data['options'] as Map) - : null; - - List? get comments => _data['comments'] != null - ? _$ackListCast(_data['comments']) - : null; - - List get sections => (_data['sections'] as List) - .map((e) => SlideSectionType(e as Map)) - .toList(); -} - -/// Extension type for SlideGeneration -extension type SlideGenerationType(Map _data) - implements Map { - static SlideGenerationType parse(Object? data) { - return _slideGenerationSchema.parseAs( - data, - (validated) => SlideGenerationType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return _slideGenerationSchema.safeParseAs( - data, - (validated) => SlideGenerationType(validated as Map), - ); - } - - List get slides => (_data['slides'] as List) - .map((e) => SlideType(e as Map)) - .toList(); - - DeckStyleType get style => - DeckStyleType(_data['style'] as Map); -} diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline_helpers.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline_helpers.dart index dbb8f947..ed467da6 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline_helpers.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline_helpers.dart @@ -1,6 +1,9 @@ part of 'deck_generator_service.dart'; -List> _sanitizeSlides(List> slides) { +@visibleForTesting +List> sanitizeGeneratedSlides( + List> slides, +) { return slides.map(_sanitizeSlide).nonNulls.toList(); } diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart index 23f6c23d..63372481 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart @@ -86,7 +86,7 @@ Future _finalizeDeck( final slides = _extractSlidesWithKeys(deckJson); debugLog.log('DECK_GEN', 'Pre-sanitize: ${slides.length} slides'); - final sanitizedSlides = _sanitizeSlides(slides); + final sanitizedSlides = sanitizeGeneratedSlides(slides); debugLog.log( 'DECK_GEN', 'Post-sanitize: ${sanitizedSlides.length} slides ' diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/google_schema_adapter.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/google_schema_adapter.dart index f3e2238b..6966431e 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/google_schema_adapter.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/google_schema_adapter.dart @@ -38,9 +38,9 @@ class GoogleSchemaAdapter { 'object' => _adaptObject(schema, path), 'array' => _adaptArray(schema, path), 'string' => _adaptString(schema), - 'number' => _adaptScalar(google_ai.Type.number, schema), - 'integer' => _adaptScalar(google_ai.Type.integer, schema), - 'boolean' => _adaptScalar(google_ai.Type.boolean, schema), + 'number' => _adaptScalar(google_ai.Type.number, schema, path), + 'integer' => _adaptScalar(google_ai.Type.integer, schema, path), + 'boolean' => _adaptScalar(google_ai.Type.boolean, schema, path), 'null' => google_ai.Schema( type: google_ai.Type.object, nullable: true, @@ -150,10 +150,51 @@ class GoogleSchemaAdapter { google_ai.Schema _adaptScalar( google_ai.Type type, Map schema, + List path, ) { - return google_ai.Schema(type: type, description: _description(schema)); + final isInteger = type == google_ai.Type.integer; + var minimum = _numericBound(schema['minimum']); + var maximum = _numericBound(schema['maximum']); + + if (schema['exclusiveMinimum'] case final num exclusiveMinimum) { + if (isInteger) { + final inclusiveMinimum = exclusiveMinimum.floorToDouble() + 1; + minimum = minimum == null + ? inclusiveMinimum + : minimum > inclusiveMinimum + ? minimum + : inclusiveMinimum; + } else { + _reportUnsupportedKeyword('exclusiveMinimum', path); + } + } + + if (schema['exclusiveMaximum'] case final num exclusiveMaximum) { + if (isInteger) { + final inclusiveMaximum = exclusiveMaximum.ceilToDouble() - 1; + maximum = maximum == null + ? inclusiveMaximum + : maximum < inclusiveMaximum + ? maximum + : inclusiveMaximum; + } else { + _reportUnsupportedKeyword('exclusiveMaximum', path); + } + } + + return google_ai.Schema( + type: type, + description: _description(schema), + minimum: minimum, + maximum: maximum, + ); } + double? _numericBound(Object? value) => switch (value) { + num value => value.toDouble(), + _ => null, + }; + google_ai.Schema? _unsupportedType(String type, List path) { _errors.add( GoogleSchemaAdapterError('Unsupported schema type "$type".', path: path), @@ -205,11 +246,7 @@ class GoogleSchemaAdapter { 'minLength', 'maxLength', 'pattern', - 'exclusiveMinimum', - 'exclusiveMaximum', 'multipleOf', - 'minimum', - 'maximum', }; for (final keyword in unsupportedKeywords) { @@ -224,6 +261,15 @@ class GoogleSchemaAdapter { } } + void _reportUnsupportedKeyword(String keyword, List path) { + _errors.add( + GoogleSchemaAdapterError( + 'Unsupported keyword "$keyword". It will be ignored.', + path: path, + ), + ); + } + List _stringList(Object? value) { if (value is! List) return []; return value.map((e) => e.toString()).toList(); diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/schemas/deck_schemas_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/deck_schemas_test.dart new file mode 100644 index 00000000..b06015dd --- /dev/null +++ b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/deck_schemas_test.dart @@ -0,0 +1,222 @@ +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_cloud_ai_generativelanguage_v1beta/generativelanguage.dart' + as google_ai; +import 'package:json_schema_builder/json_schema_builder.dart' as dsb; +import 'package:playground/features/ai/quick_agent/core/engine/schemas/deck_schemas.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/google_schema_adapter.dart'; +import 'package:superdeck_core/superdeck_core.dart' + show Slide, aiSlideSchema; + +Map validSlide({ + Map? block, + Map? section, +}) { + return { + 'key': 'slide-intro', + 'sections': [ + { + 'type': 'section', + ...?section, + 'blocks': [ + {'type': 'block', 'content': '# Hello', ...?block}, + ], + }, + ], + }; +} + +void main() { + group('slideGenerationSchema (Google adapter)', () { + test('adapts with the full canonical block surface intact', () { + final result = GoogleSchemaAdapter().adapt( + slideGenerationSchema.toJsonSchemaBuilder(), + ); + + expect(result.schema, isNotNull); + + google_ai.Schema property(google_ai.Schema schema, String key) { + final value = schema.properties[key]; + expect(value, isNotNull, reason: 'missing property "$key"'); + return value!; + } + + final slides = property(result.schema!, 'slides').items!; + final sections = property(slides, 'sections').items!; + expect( + sections.properties.keys, + containsAll(['type', 'align', 'flex', 'spacing', 'blocks']), + ); + expect(sections.properties.containsKey('scrollable'), isFalse); + expect(property(sections, 'flex').minimum, 1); + expect(property(sections, 'spacing').minimum, 0); + + final blocks = property(sections, 'blocks').items!; + expect( + blocks.properties.keys, + containsAll([ + 'type', + 'content', + 'name', + 'align', + 'flex', + 'margin', + 'padding', + 'scrollable', + ]), + ); + expect( + property(blocks, 'margin').properties.keys, + containsAll(['top', 'right', 'bottom', 'left']), + ); + expect(property(blocks, 'align').enum$, contains('bottomRight')); + expect(property(blocks, 'flex').minimum, 1); + expect( + result.errors.where( + (error) => + error.message.contains('minimum') || + error.message.contains('exclusiveMinimum'), + ), + isEmpty, + ); + }); + + test('preserves supported numeric bounds exactly', () { + final result = GoogleSchemaAdapter().adapt( + dsb.Schema.fromMap({ + 'type': 'object', + 'properties': { + 'boundedNumber': {'type': 'number', 'minimum': 1.5, 'maximum': 9.5}, + 'boundedInteger': { + 'type': 'integer', + 'exclusiveMinimum': 0, + 'exclusiveMaximum': 10, + }, + }, + }), + ); + + final boundedNumber = result.schema!.properties['boundedNumber']!; + final boundedInteger = result.schema!.properties['boundedInteger']!; + + expect(boundedNumber.minimum, 1.5); + expect(boundedNumber.maximum, 9.5); + expect(boundedInteger.minimum, 1); + expect(boundedInteger.maximum, 9); + expect(result.errors, isEmpty); + }); + + test('reports unsupported exclusive bounds on non-integer schemas', () { + final result = GoogleSchemaAdapter().adapt( + dsb.Schema.fromMap({ + 'type': 'number', + 'exclusiveMinimum': 0.5, + 'exclusiveMaximum': 10.5, + }), + ); + + expect(result.schema, isNotNull); + expect(result.schema!.minimum, isNull); + expect(result.schema!.maximum, isNull); + expect( + result.errors.map((error) => error.message), + containsAll([ + contains('exclusiveMinimum'), + contains('exclusiveMaximum'), + ]), + ); + }); + }); + + group('aiSlideSchema parity with the canonical contract', () { + test('valid layout data passes the projection and the canonical parser', + () { + final slide = validSlide( + section: {'flex': 2, 'align': 'center', 'spacing': 24}, + block: { + 'flex': 3, + 'align': 'topLeft', + 'padding': {'top': 12, 'right': 24, 'bottom': 12, 'left': 24}, + 'margin': {'top': 8, 'right': 8, 'bottom': 8, 'left': 8}, + 'scrollable': true, + }, + ); + + expect(aiSlideSchema.safeParse(slide).isOk, isTrue); + final parsed = Slide.parse(slide); + final block = parsed.sections.single.blocks.single; + expect(block.margin?.top, 8); + expect(block.padding?.right, 24); + expect(parsed.sections.single.spacing, 24); + }); + + test('rejects non-positive flex like core', () { + for (final flex in [0, -1]) { + expect( + aiSlideSchema.safeParse(validSlide(block: {'flex': flex})).isOk, + isFalse, + reason: 'block flex: $flex', + ); + expect( + aiSlideSchema.safeParse(validSlide(section: {'flex': flex})).isOk, + isFalse, + reason: 'section flex: $flex', + ); + } + }); + + test('spacing is section-only like core', () { + expect( + aiSlideSchema.safeParse(validSlide(section: {'spacing': 40})).isOk, + isTrue, + ); + expect( + aiSlideSchema.safeParse(validSlide(block: {'spacing': 40})).isOk, + isFalse, + ); + }); + + test('rejects authoring inset shorthand like the compiled contract', () { + for (final shorthand in [ + 16, + {'horizontal': 24}, + ]) { + expect( + aiSlideSchema + .safeParse(validSlide(block: {'padding': shorthand})) + .isOk, + isFalse, + reason: 'padding: $shorthand', + ); + } + }); + + test('invalid layout fails the canonical parser with a schema error', () { + final slide = validSlide(block: {'padding': 16}); + expect(() => Slide.parse(slide), throwsA(anything)); + }); + + test('block surface matches the core reserved widget fields', () { + final blockProperties = + ((((aiSlideSchema.toJsonSchema()['properties'] + as Map)['sections'] + as Map)['items'] + as Map)['properties'] + as Map)['blocks'] as Map; + final properties = + ((blockProperties['items'] as Map)['properties'] as Map).keys.toSet(); + + // Reserved WidgetBlock keys plus the discriminator and content field. + expect(properties, { + 'type', + 'content', + 'name', + 'align', + 'flex', + 'margin', + 'padding', + 'scrollable', + }); + }); + }); +} diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_pipeline_helpers_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_pipeline_helpers_test.dart index 22beafc3..20872adc 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_pipeline_helpers_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_pipeline_helpers_test.dart @@ -1,7 +1,63 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generator_service.dart'; +import 'package:superdeck_core/superdeck_core.dart' show Slide; void main() { + group('sanitizeGeneratedSlides', () { + test('preserves spacing, padding, and margin for the canonical parser', + () { + final sanitized = sanitizeGeneratedSlides([ + { + 'key': 'slide-layout', + 'sections': [ + { + 'type': 'section', + 'spacing': 24, + 'blocks': [ + { + 'type': 'block', + 'content': '# Hello', + 'padding': {'top': 12, 'right': 24, 'bottom': 12, 'left': 24}, + 'margin': {'top': 8, 'right': 8, 'bottom': 8, 'left': 8}, + }, + ], + }, + ], + }, + ]); + + expect(sanitized, hasLength(1)); + final parsed = Slide.parse(Map.from(sanitized.single)); + final section = parsed.sections.single; + expect(section.spacing, 24); + expect(section.blocks.single.padding?.left, 24); + expect(section.blocks.single.margin?.top, 8); + }); + + test('drops empty blocks and sections but keeps usable ones', () { + final sanitized = sanitizeGeneratedSlides([ + { + 'key': 'slide-partial', + 'sections': [ + { + 'blocks': [ + {'type': 'block', 'content': ''}, + ], + }, + { + 'blocks': [ + {'type': 'block', 'content': 'Kept'}, + ], + }, + ], + }, + ]); + + expect(sanitized, hasLength(1)); + expect(sanitized.single['sections'], hasLength(1)); + }); + }); + group('minimumUsableSlideCount', () { test('passes through counts of 0 and 1 unchanged', () { expect(minimumUsableSlideCount(0), 0); diff --git a/packages/superdeck/CHANGELOG.md b/packages/superdeck/CHANGELOG.md index 83d5f317..cc2a4b78 100644 --- a/packages/superdeck/CHANGELOG.md +++ b/packages/superdeck/CHANGELOG.md @@ -1,9 +1,46 @@ ## Unreleased +- Add clamped gaps between sibling blocks with section `spacing`. +- Replace manual `Stack`/`Positioned` geometry with constraint-driven + `Column`/`Row`/`Expanded` layout. Slide-container insets and constraints now + define the real content frame, and oversized header/footer chrome is reduced + proportionally. +- Make each allocated block cell the sizing source: decoration fills the frame, + margin sits outside decoration, padding sits inside it, and alignment moves + only content. `BlockConfiguration.size` now comes from the resolved inner + constraints. +- Apply alignment with `block align → section align → centerLeft` precedence. +- Add per-block `margin` and `padding` overrides that replace only the + matching resolved inset after variants resolve, preserving decoration, + foreground decoration, clipping, and animation. Margin is consumed inside a + block's allocated frame and never changes section spacing or flex ratios. +- **Breaking:** `SlideStyler.blockContainer` now takes a `BlockStyler` instead + of a `BoxStyler`. `BlockStyler` exposes only padding, margin, decoration, + foreground decoration, clipping, variants, and animation — widget modifiers, + constraints, transforms, and box alignment are unrepresentable for the + framework-owned block frame. Raw variant lists reject non-`BlockStyler` + values eagerly and are snapshotted to preserve that invariant; low-level + low-level `SlideStyler.create` input is reduced to the same allow-list at the + render boundary. +- Add image paint scaling with aligned clipping and an unchanged `scale: 1` + rendering path. Image `width`, `height`, and `scale` accept integers or + doubles through one finite-positive rule. +- Add deduplicated debug overflow diagnostics for non-scrollable Markdown and + custom widgets without changing wrapping or rebuilding content; the visual + indicator outlines the overflowing frame instead of covering its corner. +- Preserve live unbounded child layout for scrollable custom widgets during + static capture, then clip the natural-height result to the block frame. +- Make WebView and DartPad surfaces, including static placeholders, fill their + received constraints without a copied `Size` parameter. +- **Breaking:** remove `Block.resolvedAlign`, `SectionBlock.totalBlockFlex`, + and internal redundant geometry helpers. `SectionBlock.resolveBlockAlign` + remains the alignment-resolution contract. +- **Breaking:** section alignment now affects children without an explicit block + alignment, and non-positive flex values are invalid. - **BREAKING**: Generate the Mix styling layer with `mix_generator` instead of hand-writing it. Styler classes are now canonically named `*Styler` (`SlideStyler`, `MarkdownAlertStyler`, ...) following the Mix 2.x convention; - the previous `*Style` names remain available as typedef aliases. + the previous `*Style` names are no longer exported. - **BREAKING**: Styler constructor parameters for `TextStyle`-typed fields now take `TextStyleMix` (e.g. `SlideStyler(strong:)`, `MarkdownCodeblockStyler(textStyle:)`). Wrap existing values with diff --git a/packages/superdeck/lib/src/builtins/dartpad_widget.dart b/packages/superdeck/lib/src/builtins/dartpad_widget.dart index e4f6f40f..e6eff00b 100644 --- a/packages/superdeck/lib/src/builtins/dartpad_widget.dart +++ b/packages/superdeck/lib/src/builtins/dartpad_widget.dart @@ -1,7 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:superdeck_core/superdeck_core.dart'; -import '../rendering/blocks/block_provider.dart'; import '../ui/widgets/webview_wrapper.dart'; /// Strongly-typed data transfer object for DartPad widget. @@ -88,9 +87,7 @@ class DartPadWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final data = BlockConfiguration.of(context); return WebViewWrapper( - size: data.size, url: _data.toUrl(), cacheKey: _data.cacheKey, title: 'DartPad', diff --git a/packages/superdeck/lib/src/builtins/image_widget.dart b/packages/superdeck/lib/src/builtins/image_widget.dart index b4664f10..ea54efef 100644 --- a/packages/superdeck/lib/src/builtins/image_widget.dart +++ b/packages/superdeck/lib/src/builtins/image_widget.dart @@ -10,6 +10,8 @@ import '../ui/widgets/provider.dart'; import '../ui/widgets/resolved_asset_image.dart'; import '../utils/converters.dart'; +final _finitePositiveNumberSchema = Ack.number().finite().positive(); + /// Strongly-typed data transfer object for image widget. class ImageDto { /// Image source. @@ -29,19 +31,27 @@ class ImageDto { /// Optional explicit height. final double? height; - const ImageDto({ + /// Paint scale applied within the image frame. + final double scale; + + ImageDto({ required this.src, this.fit = ImageFit.contain, this.width, this.height, - }); + double scale = 1, + }) : scale = _validateScale(scale); /// Schema for validating image arguments. + /// + /// `width`, `height`, and `scale` share one finite-positive numeric rule so + /// integer authoring (`width: 300`, `scale: 1`) and doubles both validate. static final schema = Ack.object({ 'src': Ack.string().notEmpty(), 'fit': ImageFit.schema.nullable().optional(), - 'width': Ack.double().positive().nullable().optional(), - 'height': Ack.double().positive().nullable().optional(), + 'width': _finitePositiveNumberSchema.nullable().optional(), + 'height': _finitePositiveNumberSchema.nullable().optional(), + 'scale': _finitePositiveNumberSchema.nullable().optional(), }); /// Parses and validates raw map into typed ImageDto. @@ -52,6 +62,10 @@ class ImageDto { ...map, if (trimmedSrc is String) 'src': trimmedSrc, }; + final rawScale = normalizedMap['scale']; + if (rawScale is num) { + _validateScale(rawScale.toDouble()); + } schema.parse(normalizedMap); // Validate first final src = (normalizedMap['src'] as String).trim(); @@ -70,9 +84,21 @@ class ImageDto { fit: fit, width: (normalizedMap['width'] as num?)?.toDouble(), height: (normalizedMap['height'] as num?)?.toDouble(), + scale: (normalizedMap['scale'] as num?)?.toDouble() ?? 1, ); } + static double _validateScale(double scale) { + if (!scale.isFinite || scale <= 0) { + throw ArgumentError.value( + scale, + 'scale', + 'must be a finite number greater than zero', + ); + } + return scale; + } + /// Parses image source into a [Uri]. /// /// Security: This method handles `@image` blocks from YAML configuration, @@ -105,6 +131,7 @@ class ImageDto { /// - `fit` (optional): ImageFit enum value (cover, contain, fill, etc.) - default: contain /// - `width` (optional): Image width in logical pixels /// - `height` (optional): Image height in logical pixels +/// - `scale` (optional): Paint scale within the image frame - default: 1 class ImageWidget extends StatelessWidget { final ImageDto _data; @@ -116,18 +143,20 @@ class ImageWidget extends StatelessWidget { final data = BlockConfiguration.of(context); final spec = data.spec; final alignment = data.align; + final flutterAlignment = alignment.toAlignment; final styleSpec = StyleSpec( spec: spec.image.spec.copyWith( fit: _data.fit.toBoxFit, - alignment: alignment?.toAlignment ?? Alignment.centerLeft, + alignment: flutterAlignment, ), ); // Resolve a bare-key src (e.g. an in-memory AI-generated image) through the // slide's asset cache when bound; otherwise render the source directly. - final assetCacheStore = - InheritedData.maybeOf(context)?.assetCacheStore; + final assetCacheStore = InheritedData.maybeOf( + context, + )?.assetCacheStore; final Widget image = (assetCacheStore != null && isBareAssetKey(_data.src)) ? ResolvedAssetImage( assetKey: _data.src.path, @@ -142,13 +171,30 @@ class ImageWidget extends StatelessWidget { styleSpec: styleSpec, ); - final constrained = (_data.width != null || _data.height != null) - ? SizedBox(width: _data.width, height: _data.height, child: image) - : image; + final hasExplicitSize = _data.width != null || _data.height != null; + + if (_data.scale == 1) { + final constrained = hasExplicitSize + ? SizedBox(width: _data.width, height: _data.height, child: image) + : image; + return Align(alignment: flutterAlignment, child: constrained); + } + + final transformed = Transform.scale( + scale: _data.scale, + alignment: flutterAlignment, + child: image, + ); + final Widget frame = hasExplicitSize + ? SizedBox(width: _data.width, height: _data.height, child: transformed) + : SizedBox.fromSize( + size: data.size, + child: Align(alignment: flutterAlignment, child: transformed), + ); return Align( - alignment: alignment?.toAlignment ?? Alignment.centerLeft, - child: constrained, + alignment: flutterAlignment, + child: ClipRect(child: frame), ); } } diff --git a/packages/superdeck/lib/src/builtins/webview_widget.dart b/packages/superdeck/lib/src/builtins/webview_widget.dart index 031a1927..3279f461 100644 --- a/packages/superdeck/lib/src/builtins/webview_widget.dart +++ b/packages/superdeck/lib/src/builtins/webview_widget.dart @@ -1,7 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:superdeck_core/superdeck_core.dart'; -import '../rendering/blocks/block_provider.dart'; import '../ui/widgets/webview_wrapper.dart'; /// Strongly-typed data transfer object for the webview widget. @@ -98,9 +97,7 @@ class WebViewWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final data = BlockConfiguration.of(context); return WebViewWrapper( - size: data.size, url: _data.url, cacheKey: _data.cacheKey, title: _data.title, diff --git a/packages/superdeck/lib/src/markdown/builders/code_element_builder.dart b/packages/superdeck/lib/src/markdown/builders/code_element_builder.dart index 7d1e64be..27d0cae3 100644 --- a/packages/superdeck/lib/src/markdown/builders/code_element_builder.dart +++ b/packages/superdeck/lib/src/markdown/builders/code_element_builder.dart @@ -7,7 +7,6 @@ import '../../rendering/blocks/block_provider.dart'; import '../../styling/components/markdown_codeblock.dart'; import '../../ui/widgets/hero_element.dart'; import '../../ui/widgets/overflow_clip.dart'; -import '../../utils/converters.dart'; import '../../utils/syntax_highlighter.dart'; import '../markdown_helpers.dart'; import '../markdown_hero_mixin.dart'; @@ -126,16 +125,6 @@ class CodeElementBuilder extends MarkdownElementBuilder with MarkdownHeroMixin { ], ); - final containerSpec = spec.container?.spec; - final codeOffset = containerSpec != null - ? containerSpec.calculateBlockOffset - : Offset.zero; - - final totalSize = Size( - blockData.size.width - codeOffset.dx, - blockData.size.height - codeOffset.dy, - ); - return applyHeroIfNeeded( context: builderContext, child: codeWidget, @@ -144,7 +133,7 @@ class CodeElementBuilder extends MarkdownElementBuilder with MarkdownHeroMixin { text: tagAndContent.content.trim(), language: language, spec: spec, - size: totalSize, + size: blockData.size, ), buildFlight: (context, from, to, t) { final fromSize = from.size; diff --git a/packages/superdeck/lib/src/markdown/builders/text_element_builder.dart b/packages/superdeck/lib/src/markdown/builders/text_element_builder.dart index 0e6e3ca2..e51488cb 100644 --- a/packages/superdeck/lib/src/markdown/builders/text_element_builder.dart +++ b/packages/superdeck/lib/src/markdown/builders/text_element_builder.dart @@ -63,11 +63,7 @@ class TextElementBuilder extends MarkdownElementBuilder with MarkdownHeroMixin { context: builderContext, child: child, heroTag: heroTag, - heroData: TextElement( - text: transformed, - spec: spec, - size: blockData.size, - ), + heroData: TextElement(text: transformed, spec: spec), buildFlight: _buildStableFlight, ); }, @@ -97,11 +93,7 @@ class TextElementBuilder extends MarkdownElementBuilder with MarkdownHeroMixin { context: context, child: child, heroTag: tag, - heroData: TextElement( - text: transformed, - spec: spec, - size: BlockConfiguration.of(context).size, - ), + heroData: TextElement(text: transformed, spec: spec), buildFlight: _buildStableFlight, ); }, diff --git a/packages/superdeck/lib/src/rendering/blocks/block_provider.dart b/packages/superdeck/lib/src/rendering/blocks/block_provider.dart index 3835ce68..789ed3d9 100644 --- a/packages/superdeck/lib/src/rendering/blocks/block_provider.dart +++ b/packages/superdeck/lib/src/rendering/blocks/block_provider.dart @@ -22,7 +22,7 @@ class BlockConfiguration { final SlideSpec spec; final Size size; - final ContentAlignment? align; + final ContentAlignment align; /// Slide-local identity derived from slide key + section/block indices. final String runtimeKey; @@ -46,9 +46,4 @@ class BlockConfiguration { } return data; } - - // ignore: unused-code - static BlockConfiguration? maybeOf(BuildContext context) { - return InheritedData.maybeOf(context); - } } diff --git a/packages/superdeck/lib/src/rendering/blocks/block_widget.dart b/packages/superdeck/lib/src/rendering/blocks/block_widget.dart index 60a0dc93..4a982ff5 100644 --- a/packages/superdeck/lib/src/rendering/blocks/block_widget.dart +++ b/packages/superdeck/lib/src/rendering/blocks/block_widget.dart @@ -21,14 +21,14 @@ import 'markdown_viewer.dart'; class _BlockContainer extends StatefulWidget { const _BlockContainer({ required this.block, - required this.size, + required this.align, required this.configuration, required this.runtimeKey, required this.child, }); final Block block; - final Size size; + final ContentAlignment align; final SlideConfiguration configuration; final String runtimeKey; final Widget child; @@ -38,53 +38,113 @@ class _BlockContainer extends StatefulWidget { } class _BlockContainerState extends State<_BlockContainer> { + /// Removes geometry that the framework-owned block frame does not delegate + /// to styles. + /// + /// `BlockStyler` makes these properties unavailable through the public + /// authoring API. This render-boundary guard also covers low-level + /// [SlideStyler.create] and direct [SlideSpec] construction. + SlideSpec _sanitizeBlockContainer(SlideSpec spec) { + final container = spec.blockContainer; + final box = container.spec; + final isAlreadySafe = + box.alignment == null && + box.constraints == null && + box.transform == null && + box.transformAlignment == null && + container.widgetModifiers == null; + if (isAlreadySafe) return spec; + + return spec.copyWith( + blockContainer: StyleSpec( + spec: BoxSpec( + padding: box.padding, + margin: box.margin, + decoration: box.decoration, + foregroundDecoration: box.foregroundDecoration, + clipBehavior: box.clipBehavior, + ), + animation: container.animation, + ), + ); + } + + /// Applies per-block `margin`/`padding` overrides onto the resolved block + /// container after variants resolve. + /// + /// An absent override retains the resolved style inset; an explicit zero + /// removes it. Only the matching inset is replaced — decoration, foreground + /// decoration, clipping, and animation are preserved. + SlideSpec _applyBlockInsets(SlideSpec spec, Block block) { + if (block.margin == null && block.padding == null) return spec; + + final container = spec.blockContainer; + + return spec.copyWith( + blockContainer: container.copyWith( + spec: container.spec.copyWith( + margin: block.margin?.toEdgeInsets, + padding: block.padding?.toEdgeInsets, + ), + ), + ); + } + @override Widget build(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) { + final resolvedSpec = switch (widget.block) { WidgetBlock() => widget.configuration.style.resolve(context).spec, ContentBlock() => SlideSpec.of(context), }; - - final blockOffset = spec.blockContainer.spec.calculateBlockOffset; - - final blockData = BlockConfiguration( - align: widget.block.align, - spec: spec, - size: Size( - math.max(0.0, widget.size.width - blockOffset.dx), - math.max(0.0, widget.size.height - blockOffset.dy), - ), - runtimeKey: widget.runtimeKey, - ); - - Widget content = InheritedData( - data: blockData, - child: Box(styleSpec: spec.blockContainer, child: widget.child), + final spec = _applyBlockInsets( + _sanitizeBlockContainer(resolvedSpec), + widget.block, ); - content = OverflowClip( - scrollable: - widget.block.scrollable && !widget.configuration.isStaticRendering, - child: content, - ); - - // Apply alignment, falling back to the block's default when unset. - content = Align( - alignment: widget.block.resolvedAlign.toAlignment, - child: content, - ); - - // Apply size constraints - content = ConstrainedBox( - constraints: BoxConstraints.loose(widget.size), - child: content, + Widget content = Box( + styleSpec: spec.blockContainer, + child: LayoutBuilder( + builder: (context, constraints) { + final blockData = BlockConfiguration( + align: widget.align, + spec: spec, + size: constraints.biggest, + runtimeKey: widget.runtimeKey, + ); + + Widget innerContent = widget.child; + final diagnosticsEnabled = + widget.configuration.debug && + !widget.configuration.isStaticRendering && + !widget.block.scrollable; + if (diagnosticsEnabled) { + innerContent = OverflowDiagnosticProbe( + slideKey: widget.configuration.key, + runtimeKey: widget.runtimeKey, + availableSize: blockData.size, + child: innerContent, + ); + } + + innerContent = _BlockContentFrame( + align: widget.align, + size: blockData.size, + scrollable: widget.block.scrollable, + isStaticRendering: widget.configuration.isStaticRendering, + child: innerContent, + ); + + return InheritedData(data: blockData, child: innerContent); + }, + ), ); // Add debug border if needed if (widget.configuration.debug) { - return Container( + content = DecoratedBox( + position: DecorationPosition.foreground, decoration: BoxDecoration( border: Border.all(color: Colors.cyan, width: 2), ), @@ -96,23 +156,71 @@ class _BlockContainerState extends State<_BlockContainer> { } } +class _BlockContentFrame extends StatelessWidget { + const _BlockContentFrame({ + required this.align, + required this.size, + required this.scrollable, + required this.isStaticRendering, + required this.child, + }); + + final ContentAlignment align; + final Size size; + final bool scrollable; + final bool isStaticRendering; + final Widget child; + + @override + Widget build(BuildContext context) { + final alignment = align.toAlignment; + + if (scrollable && !isStaticRendering) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: size.height), + child: Align(alignment: alignment, child: child), + ), + ); + } + + return ClipRect( + child: Align(alignment: alignment, child: child), + ); + } +} + /// Helper widget for content block children to access BlockConfiguration context. class _ContentBlockChild extends StatelessWidget { - const _ContentBlockChild({required this.content}); + const _ContentBlockChild({ + required this.content, + required this.allowVerticalOverflow, + }); final String content; + final bool allowVerticalOverflow; @override Widget build(BuildContext context) { final data = BlockConfiguration.of(context); - final isStaticRendering = SlideConfiguration.of(context).isStaticRendering; - return MarkdownViewer( + final slide = SlideConfiguration.of(context); + final markdown = MarkdownViewer( content: content, spec: data.spec, - duration: isStaticRendering + duration: slide.isStaticRendering ? Duration.zero : const Duration(milliseconds: 250), ); + + if (!allowVerticalOverflow) return markdown; + return OverflowBox( + alignment: data.align.toAlignment, + minWidth: 0, + maxWidth: data.size.width, + minHeight: 0, + maxHeight: double.infinity, + child: markdown, + ); } } @@ -125,7 +233,6 @@ class _CustomBlockChild extends StatelessWidget { @override Widget build(BuildContext context) { final slide = SlideConfiguration.of(context); - final data = BlockConfiguration.of(context); final factory = slide.getWidgetFactory(block.name); if (factory == null) { @@ -134,28 +241,20 @@ class _CustomBlockChild extends StatelessWidget { try { final child = factory(block.args); - if (block.scrollable && !slide.isStaticRendering) { - return ConstrainedBox( + if (!block.scrollable || !slide.isStaticRendering) return child; + + final data = BlockConfiguration.of(context); + return OverflowBox( + alignment: Alignment.topCenter, + minWidth: data.size.width, + maxWidth: data.size.width, + minHeight: data.size.height, + maxHeight: double.infinity, + child: ConstrainedBox( constraints: BoxConstraints(minHeight: data.size.height), - child: child, - ); - } - - if (block.scrollable && slide.isStaticRendering) { - return SizedBox( - height: data.size.height, - child: ClipRect( - child: OverflowBox( - alignment: Alignment.topCenter, - minHeight: data.size.height, - maxHeight: double.infinity, - child: child, - ), - ), - ); - } - - return SizedBox(height: data.size.height, child: child); + child: Align(alignment: data.align.toAlignment, child: child), + ), + ); } catch (e, stackTrace) { return ErrorWidgets.detailed( 'Error building widget: ${block.name}', @@ -170,13 +269,13 @@ class BlockWidget extends StatelessWidget { const BlockWidget({ super.key, required this.block, - required this.size, + required this.align, required this.configuration, required this.runtimeKey, }); final ContentBlock block; - final Size size; + final ContentAlignment align; final SlideConfiguration configuration; final String runtimeKey; @@ -184,10 +283,14 @@ class BlockWidget extends StatelessWidget { Widget build(BuildContext context) { return _BlockContainer( block: block, - size: size, + align: align, configuration: configuration, runtimeKey: runtimeKey, - child: _ContentBlockChild(content: block.content), + child: _ContentBlockChild( + content: block.content, + allowVerticalOverflow: + configuration.isStaticRendering || !block.scrollable, + ), ); } } @@ -197,13 +300,13 @@ class CustomBlockWidget extends StatelessWidget { const CustomBlockWidget({ super.key, required this.block, - required this.size, + required this.align, required this.configuration, required this.runtimeKey, }); final WidgetBlock block; - final Size size; + final ContentAlignment align; final SlideConfiguration configuration; final String runtimeKey; @@ -213,7 +316,7 @@ class CustomBlockWidget extends StatelessWidget { name: block.name, child: _BlockContainer( block: block, - size: size, + align: align, configuration: configuration, runtimeKey: runtimeKey, child: _CustomBlockChild(block: block), @@ -227,12 +330,10 @@ class SectionWidget extends StatelessWidget { const SectionWidget({ super.key, required this.section, - required this.size, required this.sectionIndex, }); final SectionBlock section; - final Size size; final int sectionIndex; Positioned _renderDebugInfo(Block block, Size size) { @@ -256,56 +357,69 @@ ${size.width.toStringAsFixed(2)} x ${size.height.toStringAsFixed(2)}'''; @override Widget build(context) { final configuration = SlideConfiguration.of(context); - final flexUnit = size.width / section.totalBlockFlex; - - double leftOffset = 0; - final children = []; - - for (var blockIndex = 0; blockIndex < section.blocks.length; blockIndex++) { - final block = section.blocks[blockIndex]; - final blockWidth = flexUnit * block.flex; - final blockSize = Size(blockWidth, size.height); - final runtimeKey = buildBlockRuntimeKey( - configuration.key, - sectionIndex, - blockIndex, - ); - - Widget blockWidget = switch (block) { - WidgetBlock b => CustomBlockWidget( - block: b, - size: blockSize, - configuration: configuration, - runtimeKey: runtimeKey, - ), - ContentBlock b => BlockWidget( - block: b, - size: blockSize, - configuration: configuration, - runtimeKey: runtimeKey, - ), - }; - - // Add debug info overlay if needed - if (configuration.debug) { - blockWidget = Stack( - children: [blockWidget, _renderDebugInfo(block, blockSize)], + final gapCount = math.max(0, section.blocks.length - 1); + if (section.blocks.isEmpty) return const SizedBox.expand(); + + return LayoutBuilder( + builder: (context, constraints) { + final actualSpacing = gapCount == 0 + ? 0.0 + : math.min(section.spacing, constraints.maxWidth / gapCount); + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for ( + var blockIndex = 0; + blockIndex < section.blocks.length; + blockIndex++ + ) ...[ + Expanded( + flex: section.blocks[blockIndex].flex, + child: Builder( + builder: (context) { + final block = section.blocks[blockIndex]; + final align = section.resolveBlockAlign(block); + final runtimeKey = buildBlockRuntimeKey( + configuration.key, + sectionIndex, + blockIndex, + ); + + Widget blockWidget = switch (block) { + WidgetBlock b => CustomBlockWidget( + block: b, + align: align, + configuration: configuration, + runtimeKey: runtimeKey, + ), + ContentBlock b => BlockWidget( + block: b, + align: align, + configuration: configuration, + runtimeKey: runtimeKey, + ), + }; + + if (!configuration.debug) return blockWidget; + return LayoutBuilder( + builder: (context, constraints) => Stack( + fit: StackFit.expand, + children: [ + blockWidget, + _renderDebugInfo(block, constraints.biggest), + ], + ), + ); + }, + ), + ), + if (blockIndex < section.blocks.length - 1) + SizedBox(width: actualSpacing), + ], + ], ); - } - - children.add( - Positioned( - left: leftOffset, - top: 0, - width: blockSize.width, - height: blockSize.height, - child: blockWidget, - ), - ); - - leftOffset += blockWidth; - } - - return Stack(children: children); + }, + ); } } diff --git a/packages/superdeck/lib/src/rendering/slides/slide_view.dart b/packages/superdeck/lib/src/rendering/slides/slide_view.dart index 220d8057..6df779c4 100644 --- a/packages/superdeck/lib/src/rendering/slides/slide_view.dart +++ b/packages/superdeck/lib/src/rendering/slides/slide_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' show Colors; +import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; import 'package:superdeck_core/superdeck_core.dart'; @@ -6,15 +7,14 @@ import '../../deck/slide_configuration.dart'; import '../../styling/components/slide.dart'; import '../../utils/constants.dart'; import '../blocks/block_widget.dart'; -import 'package:flutter/widgets.dart'; class SlideView extends StatelessWidget { final SlideConfiguration slide; const SlideView(this.slide, {super.key}); - Widget _renderPreferredSize(PreferredSizeWidget? widget) { + Widget _renderPreferredSize(PreferredSizeWidget? widget, double height) { return widget != null - ? SizedBox.fromSize(size: widget.preferredSize, child: widget) + ? SizedBox(height: height, child: widget) : const SizedBox.shrink(); } @@ -34,47 +34,39 @@ class SlideView extends StatelessWidget { ); } - Widget _renderSections(SlideConfiguration configuration, Size slideSize) { + Widget _renderSections(SlideConfiguration configuration) { final sections = configuration.sections; if (sections.isEmpty) { - return const SizedBox.shrink(); + return const SizedBox.expand(); } - final totalSectionsFlex = sections.fold( - 0, - (previous, section) => previous + section.flex, - ); - var topOffset = 0.0; - final sectionWidgets = []; - for (var sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { - final section = sections[sectionIndex]; - final heightPercentage = section.flex / totalSectionsFlex; - final sectionSize = Size( - slideSize.width, - slideSize.height * heightPercentage, - ); - sectionWidgets.add( - Positioned( - left: 0, - top: topOffset, - width: sectionSize.width, - height: sectionSize.height, - child: Stack( - children: [ - SectionWidget( - section: section, - size: sectionSize, - sectionIndex: sectionIndex, - ), - if (configuration.debug) _renderDebugInfo(section, sectionSize), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for ( + var sectionIndex = 0; + sectionIndex < sections.length; + sectionIndex++ + ) + Expanded( + flex: sections[sectionIndex].flex, + child: LayoutBuilder( + builder: (context, constraints) { + final section = sections[sectionIndex]; + final sectionSize = constraints.biggest; + return Stack( + fit: StackFit.expand, + children: [ + SectionWidget(section: section, sectionIndex: sectionIndex), + if (configuration.debug) + _renderDebugInfo(section, sectionSize), + ], + ); + }, + ), ), - ), - ); - topOffset += sectionSize.height; - } - - return Stack(children: sectionWidgets); + ], + ); } @override @@ -82,20 +74,8 @@ class SlideView extends StatelessWidget { final header = slide.parts?.header; final footer = slide.parts?.footer; - final headerHeight = header != null ? header.preferredSize.height : 0.0; - final footerHeight = footer != null ? footer.preferredSize.height : 0.0; - - final footerWidget = _renderPreferredSize(footer); - final headerWidget = _renderPreferredSize(header); final backgroundWidget = slide.parts?.background ?? const SizedBox.shrink(); - final slideSize = Size( - kResolution.width, - kResolution.height - headerHeight - footerHeight, - ); - - final sectionsWidget = _renderSections(slide, slideSize); - // Background should be outside the modifier to fill entire viewport return SizedBox.fromSize( size: kResolution, @@ -114,32 +94,40 @@ class SlideView extends StatelessWidget { child: StyleBuilder( style: slide.style, builder: (context, spec) { - return Box( - styleSpec: spec.slideContainer, - child: Stack( - children: [ - Positioned( - top: 0, - left: 0, - right: 0, - height: headerHeight, - child: headerWidget, - ), - Positioned( - top: headerHeight, - left: 0, - right: 0, - height: slideSize.height, - child: sectionsWidget, - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - height: footerHeight, - child: footerWidget, - ), - ], + return Align( + child: Box( + styleSpec: spec.slideContainer, + child: LayoutBuilder( + builder: (context, constraints) { + final availableHeight = constraints.hasBoundedHeight + ? constraints.maxHeight + : kResolution.height; + final preferredHeaderHeight = + header?.preferredSize.height ?? 0.0; + final preferredFooterHeight = + footer?.preferredSize.height ?? 0.0; + final preferredChromeHeight = + preferredHeaderHeight + preferredFooterHeight; + final chromeScale = + preferredChromeHeight > 0 && + preferredChromeHeight > availableHeight + ? availableHeight / preferredChromeHeight + : 1.0; + final headerHeight = + preferredHeaderHeight * chromeScale; + final footerHeight = + preferredFooterHeight * chromeScale; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _renderPreferredSize(header, headerHeight), + Expanded(child: _renderSections(slide)), + _renderPreferredSize(footer, footerHeight), + ], + ); + }, + ), ), ); }, diff --git a/packages/superdeck/lib/src/styling/components/block_styler.dart b/packages/superdeck/lib/src/styling/components/block_styler.dart new file mode 100644 index 00000000..c5e69db5 --- /dev/null +++ b/packages/superdeck/lib/src/styling/components/block_styler.dart @@ -0,0 +1,187 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; + +/// Constrained style for the framework-owned block container. +/// +/// Resolves to a Mix [BoxSpec] so the existing `Box` renderer and +/// interpolation path are reused, but deliberately exposes only the surface a +/// block container owns: +/// +/// - `padding` and `margin` (plus Mix spacing convenience methods); +/// - `decoration` and `foregroundDecoration` (color, gradient, border, +/// radius, shadow, and image helpers); +/// - `clipBehavior`; +/// - context and `BlockVariant` variants; +/// - Mix animation metadata. +/// +/// It intentionally cannot express widget modifiers, width/height or other +/// constraints, transforms, box alignment, or widget-state variants — those +/// would create competing geometry owners with section `spacing`, block +/// `flex`, and block/section `align`. Extending this allow-list is a design +/// decision, not a convenience patch; see `.planning` notes for PR #99. +final class BlockStyler extends Style + with + Diagnosticable, + SpacingStyleMixin, + DecorationStyleMixin, + BorderStyleMixin, + BorderRadiusStyleMixin, + ShadowStyleMixin, + VariantStyleMixin, + AnimationStyleMixin { + final Prop? $padding; + final Prop? $margin; + final Prop? $decoration; + final Prop? $foregroundDecoration; + final Prop? $clipBehavior; + + const BlockStyler._create({ + Prop? padding, + Prop? margin, + Prop? decoration, + Prop? foregroundDecoration, + Prop? clipBehavior, + super.variants, + super.animation, + }) : $padding = padding, + $margin = margin, + $decoration = decoration, + $foregroundDecoration = foregroundDecoration, + $clipBehavior = clipBehavior, + // The block frame is framework-owned; widget modifiers stay + // unrepresentable so no wrapper can change block geometry. + super(modifier: null); + + BlockStyler({ + EdgeInsetsGeometryMix? padding, + EdgeInsetsGeometryMix? margin, + DecorationMix? decoration, + DecorationMix? foregroundDecoration, + Clip? clipBehavior, + AnimationConfig? animation, + List>? variants, + }) : this._create( + padding: Prop.maybeMix(padding), + margin: Prop.maybeMix(margin), + decoration: Prop.maybeMix(decoration), + foregroundDecoration: Prop.maybeMix(foregroundDecoration), + clipBehavior: Prop.maybe(clipBehavior), + variants: _validateVariants(variants), + animation: animation, + ); + + static List>? _validateVariants( + List>? variants, + ) { + if (variants == null) return null; + + for (final variant in variants) { + if (variant.value is! BlockStyler) { + throw ArgumentError.value( + variant.value, + 'variants', + 'BlockStyler variants must contain only BlockStyler values.', + ); + } + } + + return List.unmodifiable(variants); + } + + /// Sets the padding. + @override + BlockStyler padding(EdgeInsetsGeometryMix value) { + return merge(BlockStyler(padding: value)); + } + + /// Sets the margin. + @override + BlockStyler margin(EdgeInsetsGeometryMix value) { + return merge(BlockStyler(margin: value)); + } + + /// Sets the decoration. + @override + BlockStyler decoration(DecorationMix value) { + return merge(BlockStyler(decoration: value)); + } + + /// Sets the foregroundDecoration. + @override + BlockStyler foregroundDecoration(DecorationMix value) { + return merge(BlockStyler(foregroundDecoration: value)); + } + + /// Sets the clipBehavior. + BlockStyler clipBehavior(Clip value) { + return merge(BlockStyler(clipBehavior: value)); + } + + /// Sets the style variants. + @override + BlockStyler variants(List> value) { + return merge(BlockStyler(variants: value)); + } + + /// Sets the animation configuration. + @override + BlockStyler animate(AnimationConfig value) { + return merge(BlockStyler(animation: value)); + } + + @override + BlockStyler merge(BlockStyler? other) { + if (other == null) return this; + + return BlockStyler._create( + padding: MixOps.merge($padding, other.$padding), + margin: MixOps.merge($margin, other.$margin), + decoration: MixOps.merge($decoration, other.$decoration), + foregroundDecoration: MixOps.merge( + $foregroundDecoration, + other.$foregroundDecoration, + ), + clipBehavior: MixOps.merge($clipBehavior, other.$clipBehavior), + variants: MixOps.mergeVariants($variants, other.$variants), + animation: MixOps.mergeAnimation($animation, other.$animation), + ); + } + + @override + StyleSpec resolve(BuildContext context) { + return StyleSpec( + spec: BoxSpec( + padding: MixOps.resolve(context, $padding), + margin: MixOps.resolve(context, $margin), + decoration: MixOps.resolve(context, $decoration), + foregroundDecoration: MixOps.resolve(context, $foregroundDecoration), + clipBehavior: MixOps.resolve(context, $clipBehavior), + ), + animation: $animation, + // widgetModifiers intentionally stays null for block containers. + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('padding', $padding)) + ..add(DiagnosticsProperty('margin', $margin)) + ..add(DiagnosticsProperty('decoration', $decoration)) + ..add(DiagnosticsProperty('foregroundDecoration', $foregroundDecoration)) + ..add(DiagnosticsProperty('clipBehavior', $clipBehavior)); + } + + @override + List get props => [ + $padding, + $margin, + $decoration, + $foregroundDecoration, + $clipBehavior, + $animation, + $variants, + ]; +} diff --git a/packages/superdeck/lib/src/styling/components/slide.dart b/packages/superdeck/lib/src/styling/components/slide.dart index 6ea92f8d..e12ccccb 100644 --- a/packages/superdeck/lib/src/styling/components/slide.dart +++ b/packages/superdeck/lib/src/styling/components/slide.dart @@ -4,6 +4,7 @@ import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:mix/mix.dart'; import 'package:mix_annotations/mix_annotations.dart'; +import 'block_styler.dart'; import 'markdown_alert.dart'; import 'markdown_blockquote.dart'; import 'markdown_checkbox.dart'; @@ -76,6 +77,7 @@ final class SlideSpec with _$SlideSpec { @override final StyleSpec? checkbox; + @MixableField(setterType: BlockStyler) @override final StyleSpec blockContainer; @override diff --git a/packages/superdeck/lib/src/styling/components/slide.g.dart b/packages/superdeck/lib/src/styling/components/slide.g.dart index 3b5df8d9..cc724f41 100644 --- a/packages/superdeck/lib/src/styling/components/slide.g.dart +++ b/packages/superdeck/lib/src/styling/components/slide.g.dart @@ -339,7 +339,7 @@ class SlideStyler extends MixStyler { MarkdownTableStyler? table, MarkdownCodeblockStyler? code, MarkdownCheckboxStyler? checkbox, - BoxStyler? blockContainer, + BlockStyler? blockContainer, BoxStyler? slideContainer, ImageStyler? image, AnimationConfig? animation, @@ -404,7 +404,7 @@ class SlideStyler extends MixStyler { SlideStyler().code(value); factory SlideStyler.checkbox(MarkdownCheckboxStyler value) => SlideStyler().checkbox(value); - factory SlideStyler.blockContainer(BoxStyler value) => + factory SlideStyler.blockContainer(BlockStyler value) => SlideStyler().blockContainer(value); factory SlideStyler.slideContainer(BoxStyler value) => SlideStyler().slideContainer(value); @@ -516,7 +516,7 @@ class SlideStyler extends MixStyler { } /// Sets the blockContainer. - SlideStyler blockContainer(BoxStyler value) { + SlideStyler blockContainer(BlockStyler value) { return merge(SlideStyler(blockContainer: value)); } diff --git a/packages/superdeck/lib/src/styling/default_style.dart b/packages/superdeck/lib/src/styling/default_style.dart index 79f55a5d..f46acb95 100644 --- a/packages/superdeck/lib/src/styling/default_style.dart +++ b/packages/superdeck/lib/src/styling/default_style.dart @@ -4,6 +4,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:mix/mix.dart'; import 'block_variant.dart'; +import 'components/block_styler.dart'; import 'components/markdown_alert.dart'; import 'components/markdown_alert_type.dart'; import 'components/markdown_blockquote.dart'; @@ -235,23 +236,27 @@ SlideStyler _createDefaultSlideStyle() { textStyle: TextStyleMix.value(_baseTextStyle), ), - blockContainer: BoxStyler(padding: EdgeInsetsGeometryMix.all(40)).variants([ - VariantStyle(onImage, BoxStyler(padding: EdgeInsetsGeometryMix.all(0))), - VariantStyle( - onGist, - BoxStyler( - padding: EdgeInsetsGeometryMix.all(0), - margin: EdgeInsetsGeometryMix.all(0), - ), - ), - VariantStyle( - onWebview, - BoxStyler( - padding: EdgeInsetsGeometryMix.all(0), - margin: EdgeInsetsGeometryMix.all(0), - ), - ), - ]), + blockContainer: BlockStyler(padding: EdgeInsetsGeometryMix.all(40)) + .variants([ + VariantStyle( + onImage, + BlockStyler(padding: EdgeInsetsGeometryMix.all(0)), + ), + VariantStyle( + onGist, + BlockStyler( + padding: EdgeInsetsGeometryMix.all(0), + margin: EdgeInsetsGeometryMix.all(0), + ), + ), + VariantStyle( + onWebview, + BlockStyler( + padding: EdgeInsetsGeometryMix.all(0), + margin: EdgeInsetsGeometryMix.all(0), + ), + ), + ]), slideContainer: BoxStyler(), diff --git a/packages/superdeck/lib/src/ui/widgets/hero_element.dart b/packages/superdeck/lib/src/ui/widgets/hero_element.dart index c8af5e6a..394abba0 100644 --- a/packages/superdeck/lib/src/ui/widgets/hero_element.dart +++ b/packages/superdeck/lib/src/ui/widgets/hero_element.dart @@ -7,13 +7,8 @@ import '../../styling/components/markdown_codeblock.dart'; class TextElement { final String text; final TextSpec spec; - final Size size; // ignore: unused-code - const TextElement({ - required this.text, - required this.spec, - required this.size, - }); + const TextElement({required this.text, required this.spec}); } /// Immutable data holder for code elements in Hero animations @@ -54,7 +49,7 @@ class ImageElement { /// Usage in builders: /// ```dart /// return HeroElement( -/// data: TextElement(text: content, spec: spec, size: size), +/// data: TextElement(text: content, spec: spec), /// child: widget, /// ); /// ``` diff --git a/packages/superdeck/lib/src/ui/widgets/overflow_clip.dart b/packages/superdeck/lib/src/ui/widgets/overflow_clip.dart index fae451f9..b0d03d3e 100644 --- a/packages/superdeck/lib/src/ui/widgets/overflow_clip.dart +++ b/packages/superdeck/lib/src/ui/widgets/overflow_clip.dart @@ -1,5 +1,318 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; +/// A debug-only description of content that paints outside its assigned frame. +@immutable +class OverflowDiagnosticIssue { + OverflowDiagnosticIssue({ + required this.slideKey, + required this.runtimeKey, + required this.availableSize, + required this.measuredSize, + required Set axes, + }) : axes = Set.unmodifiable(axes); + + final String slideKey; + final String runtimeKey; + final Size availableSize; + final Size measuredSize; + final Set axes; + + String get stableSignature => [ + for (final axis in Axis.values) + if (axes.contains(axis)) axis.name, + ].join(','); + + String get message { + final axis = stableSignature; + return 'SuperDeck overflow: slide=$slideKey block=$runtimeKey ' + 'available=${_formatSize(availableSize)} ' + 'measured=${_formatSize(measuredSize)} axis=$axis'; + } +} + +String _formatSize(Size size) { + return '${size.width.toStringAsFixed(1)}x${size.height.toStringAsFixed(1)}'; +} + +/// Internal debug reporter that emits each stable block/axis issue once. +/// +/// This is intentionally not exported from the public package API. The logger +/// and active-issue view exist only to make diagnostics deterministic in tests. +class OverflowDiagnostics { + OverflowDiagnostics._(); + + static final _activeIssues = {}; + + @visibleForTesting + static ValueChanged? logger; + + @visibleForTesting + static Map get activeIssuesForTesting => + Map.unmodifiable(_activeIssues); + + static void report(OverflowDiagnosticIssue issue) { + final previous = _activeIssues[issue.runtimeKey]; + _activeIssues[issue.runtimeKey] = issue; + if (previous?.stableSignature == issue.stableSignature) return; + + final activeLogger = logger; + if (activeLogger != null) { + activeLogger(issue.message); + } else { + debugPrint(issue.message); + } + } + + static void clear(String runtimeKey) { + _activeIssues.remove(runtimeKey); + } + + @visibleForTesting + static void resetForTesting() { + _activeIssues.clear(); + logger = null; + } +} + +/// Observes an already-laid-out Markdown subtree for debug overflow. +/// +/// The render proxy forwards the exact incoming constraints to [child]. It +/// never performs an intrinsic pass, a second layout, or a duplicate build. +class OverflowDiagnosticProbe extends SingleChildRenderObjectWidget { + const OverflowDiagnosticProbe({ + super.key, + required this.slideKey, + required this.runtimeKey, + required this.availableSize, + this.indicatorColor = const Color(0xFFFF3B30), + required super.child, + }); + + final String slideKey; + final String runtimeKey; + final Size availableSize; + final Color indicatorColor; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderOverflowDiagnosticProbe( + slideKey: slideKey, + runtimeKey: runtimeKey, + availableSize: availableSize, + indicatorColor: indicatorColor, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderObject renderObject) { + (renderObject as _RenderOverflowDiagnosticProbe) + ..slideKey = slideKey + ..runtimeKey = runtimeKey + ..availableSize = availableSize + ..indicatorColor = indicatorColor; + } +} + +class _RenderOverflowDiagnosticProbe extends RenderProxyBox { + _RenderOverflowDiagnosticProbe({ + required String slideKey, + required String runtimeKey, + required Size availableSize, + required Color indicatorColor, + }) : _slideKey = slideKey, + _runtimeKey = runtimeKey, + _availableSize = availableSize, + _indicatorColor = indicatorColor; + + static const _overflowTolerance = 0.5; + static const _indicatorStrokeWidth = 2.0; + + String _slideKey; + String _runtimeKey; + Size _availableSize; + Color _indicatorColor; + OverflowDiagnosticIssue? _issue; + int _notificationSerial = 0; + + set slideKey(String value) { + if (_slideKey == value) return; + _slideKey = value; + markNeedsPaint(); + } + + set runtimeKey(String value) { + if (_runtimeKey == value) return; + OverflowDiagnostics.clear(_runtimeKey); + _runtimeKey = value; + markNeedsPaint(); + } + + set availableSize(Size value) { + if (_availableSize == value) return; + _availableSize = value; + markNeedsPaint(); + } + + set indicatorColor(Color value) { + if (_indicatorColor == value) return; + _indicatorColor = value; + markNeedsPaint(); + } + + OverflowDiagnosticIssue? _measureIssue() { + final renderChild = child; + if (renderChild == null || !renderChild.hasSize) return null; + final availableSize = _effectiveAvailableSize; + + var paintedBounds = Offset.zero & renderChild.size; + + void includeBox(RenderBox box) { + if (!box.hasSize) return; + final transform = box.getTransformTo(this); + final bounds = MatrixUtils.transformRect(transform, box.paintBounds); + if (_isFinite(bounds)) { + paintedBounds = paintedBounds.expandToInclude(bounds); + } + + if (box is RenderParagraph) { + final textLength = box.text.toPlainText().length; + if (textLength > 0) { + try { + final textBoxes = box.getBoxesForSelection( + TextSelection(baseOffset: 0, extentOffset: textLength), + ); + for (final textBox in textBoxes) { + final textBounds = MatrixUtils.transformRect( + transform, + Rect.fromLTRB( + textBox.left, + textBox.top, + textBox.right, + textBox.bottom, + ), + ); + if (_isFinite(textBounds)) { + paintedBounds = paintedBounds.expandToInclude(textBounds); + } + } + } on FlutterError { + // A transient paragraph without text layout is not diagnosable yet. + } + } + } + } + + void visit(RenderObject object) { + object.visitChildren((descendant) { + if (descendant case final RenderBox box) includeBox(box); + visit(descendant); + }); + } + + includeBox(renderChild); + visit(renderChild); + + final axes = {}; + if (paintedBounds.left < -_overflowTolerance || + paintedBounds.right > availableSize.width + _overflowTolerance) { + axes.add(Axis.horizontal); + } + if (paintedBounds.top < -_overflowTolerance || + paintedBounds.bottom > availableSize.height + _overflowTolerance) { + axes.add(Axis.vertical); + } + if (axes.isEmpty) return null; + + final measuredWidth = + math.max(renderChild.size.width, paintedBounds.right) - + math.min(0.0, paintedBounds.left); + final measuredHeight = + math.max(renderChild.size.height, paintedBounds.bottom) - + math.min(0.0, paintedBounds.top); + + return OverflowDiagnosticIssue( + slideKey: _slideKey, + runtimeKey: _runtimeKey, + availableSize: availableSize, + measuredSize: Size(measuredWidth, measuredHeight), + axes: axes, + ); + } + + bool _isFinite(Rect rect) { + return rect.left.isFinite && + rect.top.isFinite && + rect.right.isFinite && + rect.bottom.isFinite; + } + + Size get _effectiveAvailableSize { + return Size( + constraints.hasBoundedWidth + ? math.min(_availableSize.width, constraints.maxWidth) + : _availableSize.width, + constraints.hasBoundedHeight + ? math.min(_availableSize.height, constraints.maxHeight) + : _availableSize.height, + ); + } + + void _scheduleNotification(OverflowDiagnosticIssue? issue) { + final serial = ++_notificationSerial; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!attached || serial != _notificationSerial) return; + if (issue == null) { + OverflowDiagnostics.clear(_runtimeKey); + } else { + OverflowDiagnostics.report(issue); + } + }); + } + + @override + void paint(PaintingContext context, Offset offset) { + _issue = _measureIssue(); + _scheduleNotification(_issue); + super.paint(context, offset); + if (_issue == null) return; + + final availableSize = _effectiveAvailableSize; + final visibleWidth = math.min(size.width, availableSize.width); + final visibleHeight = math.min(size.height, availableSize.height); + if (visibleWidth <= 0 || visibleHeight <= 0) return; + + // Outline the visible frame instead of filling a corner so the indicator + // never obscures content aligned to any frame edge. + final strokeWidth = math.min( + _indicatorStrokeWidth, + math.min(visibleWidth, visibleHeight) / 2, + ); + context.canvas.drawRect( + Rect.fromLTWH( + offset.dx, + offset.dy, + visibleWidth, + visibleHeight, + ).deflate(strokeWidth / 2), + Paint() + ..color = _indicatorColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth, + ); + } + + @override + void detach() { + _notificationSerial++; + OverflowDiagnostics.clear(_runtimeKey); + super.detach(); + } +} + /// Clips overflow in a way that avoids layout assertions during size changes. /// /// We intentionally use `Wrap` with `clipBehavior` for non-scrollable content diff --git a/packages/superdeck/lib/src/ui/widgets/webview_wrapper.dart b/packages/superdeck/lib/src/ui/widgets/webview_wrapper.dart index 8da81d74..8dbcf4e8 100644 --- a/packages/superdeck/lib/src/ui/widgets/webview_wrapper.dart +++ b/packages/superdeck/lib/src/ui/widgets/webview_wrapper.dart @@ -24,7 +24,6 @@ import 'webview_controller_cache.dart'; /// and never creates a controller. class WebViewWrapper extends StatefulWidget { final String url; - final Size size; final String? cacheKey; final String? title; final List? allowedHosts; @@ -35,7 +34,6 @@ class WebViewWrapper extends StatefulWidget { const WebViewWrapper({ super.key, required this.url, - required this.size, this.cacheKey, this.title, this.allowedHosts, @@ -337,9 +335,7 @@ class _WebViewWrapperState extends State { final label = (title != null && title.isNotEmpty) ? title : 'WebView unavailable in static capture'; - return SizedBox( - width: widget.size.width, - height: widget.size.height, + return SizedBox.expand( child: Center( child: Text( label, @@ -350,21 +346,19 @@ class _WebViewWrapperState extends State { ); } - @override - Widget build(BuildContext context) { + Widget _buildSurface() { if (_isStaticRendering) return _buildPlaceholder(); final controller = _controller; if (controller == null) { - return SizedBox(width: widget.size.width, height: widget.size.height); + return const SizedBox.expand(); } final showToolbar = widget.showControls || widget.showClearControl; - return SizedBox( - width: widget.size.width, - height: widget.size.height, + return SizedBox.expand( child: Stack( + fit: StackFit.expand, children: [ AnimatedOpacity( opacity: _hide ? 0 : 1, @@ -372,19 +366,40 @@ class _WebViewWrapperState extends State { child: WebViewWidget(controller: controller), ), if (showToolbar) - Row( - children: [ - if (widget.showControls) - SDIconButton(onPressed: _reload, icon: Icons.refresh), - if (widget.showClearControl) - SDIconButton( - onPressed: _clearDartPadEditor, - icon: Icons.clear, - ), - ], + Align( + alignment: Alignment.topLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showControls) + SDIconButton(onPressed: _reload, icon: Icons.refresh), + if (widget.showClearControl) + SDIconButton( + onPressed: _clearDartPadEditor, + icon: Icons.clear, + ), + ], + ), ), ], ), ); } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final blockSize = BlockConfiguration.of(context).size; + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : blockSize.width; + final height = constraints.hasBoundedHeight + ? constraints.maxHeight + : blockSize.height; + + return SizedBox(width: width, height: height, child: _buildSurface()); + }, + ); + } } diff --git a/packages/superdeck/lib/src/utils/converters.dart b/packages/superdeck/lib/src/utils/converters.dart index f2d3dffd..91408332 100644 --- a/packages/superdeck/lib/src/utils/converters.dart +++ b/packages/superdeck/lib/src/utils/converters.dart @@ -1,24 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:mix/mix.dart'; import 'package:superdeck_core/superdeck_core.dart'; -extension BoxSpecOffsetExtension on BoxSpec { - /// Calculates the total spacing offset from padding, margin, and border. - Offset get calculateBlockOffset { - final paddingX = padding?.horizontal ?? 0.0; - final paddingY = padding?.vertical ?? 0.0; - final marginX = margin?.horizontal ?? 0.0; - final marginY = margin?.vertical ?? 0.0; - - final borderDimensions = decoration is BoxDecoration - ? (decoration as BoxDecoration).border?.dimensions - : null; - - final borderX = borderDimensions?.horizontal ?? 0.0; - final borderY = borderDimensions?.vertical ?? 0.0; - - return Offset(paddingX + marginX + borderX, paddingY + marginY + borderY); - } +extension BlockInsetsExtension on BlockInsets { + EdgeInsets get toEdgeInsets => EdgeInsets.fromLTRB(left, top, right, bottom); } extension ImageFitExtension on ImageFit { @@ -49,93 +33,6 @@ extension ContentAlignmentExtension on ContentAlignment { ContentAlignment.bottomRight => Alignment.bottomRight, }; } - - // ignore: unused-code - (MainAxisAlignment mainAxis, CrossAxisAlignment crossAxis) toFlexAlignment( - Axis axis, - ) { - final isHorizontal = axis == Axis.horizontal; - - if (isHorizontal) { - return switch (this) { - ContentAlignment.topLeft => ( - MainAxisAlignment.start, - CrossAxisAlignment.start, - ), - ContentAlignment.topCenter => ( - MainAxisAlignment.center, - CrossAxisAlignment.start, - ), - ContentAlignment.topRight => ( - MainAxisAlignment.end, - CrossAxisAlignment.start, - ), - ContentAlignment.centerLeft => ( - MainAxisAlignment.start, - CrossAxisAlignment.center, - ), - ContentAlignment.center => ( - MainAxisAlignment.center, - CrossAxisAlignment.center, - ), - ContentAlignment.centerRight => ( - MainAxisAlignment.end, - CrossAxisAlignment.center, - ), - ContentAlignment.bottomLeft => ( - MainAxisAlignment.start, - CrossAxisAlignment.end, - ), - ContentAlignment.bottomCenter => ( - MainAxisAlignment.center, - CrossAxisAlignment.end, - ), - ContentAlignment.bottomRight => ( - MainAxisAlignment.end, - CrossAxisAlignment.end, - ), - }; - } else { - return switch (this) { - ContentAlignment.topLeft => ( - MainAxisAlignment.start, - CrossAxisAlignment.start, - ), - ContentAlignment.topCenter => ( - MainAxisAlignment.start, - CrossAxisAlignment.center, - ), - ContentAlignment.topRight => ( - MainAxisAlignment.start, - CrossAxisAlignment.end, - ), - ContentAlignment.centerLeft => ( - MainAxisAlignment.center, - CrossAxisAlignment.start, - ), - ContentAlignment.center => ( - MainAxisAlignment.center, - CrossAxisAlignment.center, - ), - ContentAlignment.centerRight => ( - MainAxisAlignment.center, - CrossAxisAlignment.end, - ), - ContentAlignment.bottomLeft => ( - MainAxisAlignment.end, - CrossAxisAlignment.start, - ), - ContentAlignment.bottomCenter => ( - MainAxisAlignment.end, - CrossAxisAlignment.center, - ), - ContentAlignment.bottomRight => ( - MainAxisAlignment.end, - CrossAxisAlignment.end, - ), - }; - } - } } /// Converts a hex color string to a [Color]. diff --git a/packages/superdeck/lib/superdeck.dart b/packages/superdeck/lib/superdeck.dart index b752900b..b6481903 100644 --- a/packages/superdeck/lib/superdeck.dart +++ b/packages/superdeck/lib/superdeck.dart @@ -13,6 +13,7 @@ export 'src/thumbnails/thumbnail_service.dart'; // Styling export 'src/styling/block_variant.dart' show BlockVariant; +export 'src/styling/components/block_styler.dart'; 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/goldens/block_aligned_center.png b/packages/superdeck/test/goldens/block_aligned_center.png index 3b125eb8..4d0bb2d1 100644 Binary files a/packages/superdeck/test/goldens/block_aligned_center.png and b/packages/superdeck/test/goldens/block_aligned_center.png differ diff --git a/packages/superdeck/test/goldens/decorated_block_frame.png b/packages/superdeck/test/goldens/decorated_block_frame.png new file mode 100644 index 00000000..91f7d6dc Binary files /dev/null and b/packages/superdeck/test/goldens/decorated_block_frame.png differ diff --git a/packages/superdeck/test/goldens/image_scale_framing.png b/packages/superdeck/test/goldens/image_scale_framing.png new file mode 100644 index 00000000..72e3f978 Binary files /dev/null and b/packages/superdeck/test/goldens/image_scale_framing.png differ diff --git a/packages/superdeck/test/goldens/inset_slide_container.png b/packages/superdeck/test/goldens/inset_slide_container.png new file mode 100644 index 00000000..25d1fc21 Binary files /dev/null and b/packages/superdeck/test/goldens/inset_slide_container.png differ diff --git a/packages/superdeck/test/goldens/plain_markdown_slide.png b/packages/superdeck/test/goldens/plain_markdown_slide.png index f46bbe01..6abd62b6 100644 Binary files a/packages/superdeck/test/goldens/plain_markdown_slide.png and b/packages/superdeck/test/goldens/plain_markdown_slide.png differ diff --git a/packages/superdeck/test/goldens/section_two_blocks.png b/packages/superdeck/test/goldens/section_two_blocks.png index 2eede924..e7a27830 100644 Binary files a/packages/superdeck/test/goldens/section_two_blocks.png and b/packages/superdeck/test/goldens/section_two_blocks.png differ diff --git a/packages/superdeck/test/goldens/slide_goldens_test.dart b/packages/superdeck/test/goldens/slide_goldens_test.dart index 97db1327..9f7afd2a 100644 --- a/packages/superdeck/test/goldens/slide_goldens_test.dart +++ b/packages/superdeck/test/goldens/slide_goldens_test.dart @@ -1,8 +1,14 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mix/mix.dart'; +import 'package:superdeck/superdeck.dart' show BlockStyler, SlideStyler; +import 'package:superdeck/src/builtins/image_widget.dart'; +import 'package:superdeck/src/builtins/widgets.dart'; import 'package:superdeck/src/rendering/slides/slide_parts.dart'; import 'package:superdeck/src/rendering/slides/slide_view.dart'; +import 'package:superdeck/src/styling/default_style.dart'; import 'package:superdeck/src/ui/tokens/colors.dart'; import 'package:superdeck/src/ui/widgets/provider.dart'; import 'package:superdeck/src/utils/constants.dart'; @@ -76,9 +82,171 @@ Author text in the second column. matchesGoldenFile('block_aligned_center.png'), ); }, tags: ['ci-excluded', 'golden']); + + testWidgets('image fit and scale framing matrix', (tester) async { + final sourceFile = File('../../demo/web/icons/Icon-512.png').absolute; + final source = Uri.dataFromBytes( + sourceFile.readAsBytesSync(), + mimeType: 'image/png', + ).toString(); + const frameWidth = 420.0; + const frameHeight = 240.0; + + WidgetBlock image({required String fit, required double scale}) { + return WidgetBlock( + name: 'image', + padding: BlockInsets.all(0), + args: { + 'src': source, + 'fit': fit, + 'width': frameWidth, + 'height': frameHeight, + 'scale': scale, + }, + ); + } + + await _pumpGoldenSlide( + tester, + Slide( + key: 'golden-image-scale-framing', + sections: [ + SectionBlock( + [ + image(fit: 'contain', scale: 1), + image(fit: 'contain', scale: 1.35), + ], + spacing: 40, + align: ContentAlignment.center, + ), + SectionBlock( + [image(fit: 'cover', scale: 1), image(fit: 'cover', scale: 1.35)], + spacing: 40, + align: ContentAlignment.center, + ), + ], + ), + ); + + expect(sourceFile.existsSync(), isTrue); + expect(find.byType(ImageWidget), findsNWidgets(4)); + expect(find.byType(Image), findsNWidgets(4)); + for (var index = 0; index < 4; index++) { + expect( + tester.getSize(find.byType(Image).at(index)), + const Size(420, 240), + ); + } + expect(find.textContaining('Error loading image'), findsNothing); + expect(tester.takeException(), isNull); + + final imageElements = find.byType(Image).evaluate().toList(growable: false); + await tester.runAsync(() async { + for (final element in imageElements) { + final image = element.widget as Image; + await precacheImage(image.image, element); + } + }); + await tester.pumpAndSettle(); + + await expectLater( + find.byType(SlideView), + matchesGoldenFile('image_scale_framing.png'), + ); + }, tags: ['ci-excluded', 'golden']); + + testWidgets( + 'decorated block frame fills its allocated cell', + (tester) async { + await _pumpGoldenSlide( + tester, + Slide( + key: 'golden-decorated-block-frame', + sections: [ + SectionBlock([ + ContentBlock( + '## Decoration fills the frame\n\nContent alignment stays inside.', + align: ContentAlignment.bottomRight, + ), + ]), + ], + ), + style: SlideStyler( + blockContainer: BlockStyler( + margin: EdgeInsetsGeometryMix.all(56), + padding: EdgeInsetsGeometryMix.all(36), + decoration: BoxDecorationMix( + color: const Color(0xFF172A46), + border: BorderMix.all( + BorderSideMix(color: const Color(0xFF45D4FF), width: 6), + ), + borderRadius: BorderRadiusMix.circular(24), + ), + ), + ), + ); + + await expectLater( + find.byType(SlideView), + matchesGoldenFile('decorated_block_frame.png'), + ); + }, + tags: ['ci-excluded', 'golden'], + ); + + testWidgets( + 'inset slide container defines the section frame', + (tester) async { + await _pumpGoldenSlide( + tester, + Slide( + key: 'golden-inset-slide-container', + sections: [ + SectionBlock([ + ContentBlock( + '## Inset slide container\n\nThe section uses the inner frame.', + ), + ]), + ], + ), + style: SlideStyler( + slideContainer: BoxStyler( + margin: EdgeInsetsGeometryMix.all(48), + padding: EdgeInsetsGeometryMix.all(32), + decoration: BoxDecorationMix( + color: const Color(0xFF111A2A), + border: BorderMix.all( + BorderSideMix(color: const Color(0xFFB26BFF), width: 8), + ), + ), + ), + blockContainer: BlockStyler( + padding: EdgeInsetsGeometryMix.all(24), + decoration: BoxDecorationMix(color: const Color(0xFF233554)), + ), + ), + parts: const SlideParts( + header: null, + footer: null, + background: ColoredBox(color: Color(0xFF090909)), + ), + ); + + await expectLater( + find.byType(SlideView), + matchesGoldenFile('inset_slide_container.png'), + ); + }, + tags: ['ci-excluded', 'golden'], + ); } -Future _pumpGoldenSlide(WidgetTester tester, Slide slide) async { +Future _pumpGoldenSlide( + WidgetTester tester, + Slide slide, { + SlideStyler? style, + SlideParts? parts, +}) async { tester.view.physicalSize = kResolution; tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); @@ -86,7 +254,11 @@ Future _pumpGoldenSlide(WidgetTester tester, Slide slide) async { final configuration = SlideTestHarness.createConfiguration( slide, - parts: const SlideParts(background: ColoredBox(color: Color(0xFF090909))), + style: style == null ? null : defaultSlideStyle.merge(style), + widgets: builtInWidgets, + parts: + parts ?? + const SlideParts(background: ColoredBox(color: Color(0xFF090909))), ); await tester.pumpWidget( diff --git a/packages/superdeck/test/helpers/slide_test_harness.dart b/packages/superdeck/test/helpers/slide_test_harness.dart index 6c57bf68..d9671520 100644 --- a/packages/superdeck/test/helpers/slide_test_harness.dart +++ b/packages/superdeck/test/helpers/slide_test_harness.dart @@ -22,6 +22,7 @@ class SlideTestHarness { SlideParts? parts, }) async { await SyntaxHighlight.initialize(); + _setLogicalViewport(tester, resolution ?? kResolution); final configuration = createConfiguration( slide, style: style, @@ -78,6 +79,7 @@ class SlideTestHarness { Size? resolution, }) async { await SyntaxHighlight.initialize(); + _setLogicalViewport(tester, resolution ?? kResolution); await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -95,4 +97,12 @@ class SlideTestHarness { await tester.pumpAndSettle(); } + + static void _setLogicalViewport(WidgetTester tester, Size size) { + tester.view + ..physicalSize = size + ..devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } } diff --git a/packages/superdeck/test/public_api_test.dart b/packages/superdeck/test/public_api_test.dart index 80a91f4b..04f396d3 100644 --- a/packages/superdeck/test/public_api_test.dart +++ b/packages/superdeck/test/public_api_test.dart @@ -20,6 +20,7 @@ void main() { expect(DeckPlugin, isNotNull); expect(DeckAction, isNotNull); expect(DeckRuntimePlugin, isNotNull); + expect(BlockStyler, isNotNull); }); }); } diff --git a/packages/superdeck/test/src/behavior/alignment_behavior_test.dart b/packages/superdeck/test/src/behavior/alignment_behavior_test.dart index d0c56124..7e817527 100644 --- a/packages/superdeck/test/src/behavior/alignment_behavior_test.dart +++ b/packages/superdeck/test/src/behavior/alignment_behavior_test.dart @@ -31,7 +31,12 @@ void main() { SlideFixtures.withAlignment(contentAlign), ); - final alignFinder = find.byType(Align).first; + final alignFinder = find + .descendant( + of: find.byType(BlockWidget), + matching: find.byType(Align), + ) + .first; final alignWidget = tester.widget(alignFinder); expect(alignWidget.alignment, expected); }); @@ -97,7 +102,14 @@ void main() { ], ); await SlideTestHarness.pumpSlide(tester, slide); - final alignWidget = tester.widget(find.byType(Align).first); + final alignWidget = tester.widget( + find + .descendant( + of: find.byType(BlockWidget), + matching: find.byType(Align), + ) + .first, + ); expect(alignWidget.alignment, Alignment.topLeft); }); }); diff --git a/packages/superdeck/test/src/builtins/dartpad_widget_test.dart b/packages/superdeck/test/src/builtins/dartpad_widget_test.dart index 8aefb2f6..b6c903a7 100644 --- a/packages/superdeck/test/src/builtins/dartpad_widget_test.dart +++ b/packages/superdeck/test/src/builtins/dartpad_widget_test.dart @@ -183,7 +183,7 @@ void main() { find.byType(WebViewWrapper), ); - expect(wrapper.size, size); + expect(tester.getSize(find.byType(WebViewWrapper)), size); expect( wrapper.url, 'https://dartpad.dev/?id=snippet&theme=light&embed=true&run=false', @@ -432,14 +432,22 @@ class _DartPadHarness extends StatelessWidget { data: deckController, child: InheritedData( data: slide, - child: InheritedData( - data: BlockConfiguration( - spec: const SlideSpec(), - size: size, - align: null, - runtimeKey: runtimeKey, + child: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox.fromSize( + size: size, + child: InheritedData( + data: BlockConfiguration( + spec: const SlideSpec(), + size: size, + align: ContentAlignment.centerLeft, + runtimeKey: runtimeKey, + ), + child: DartPadWidget(args), + ), + ), ), - child: Scaffold(body: DartPadWidget(args)), ), ), ), diff --git a/packages/superdeck/test/src/builtins/image_widget_test.dart b/packages/superdeck/test/src/builtins/image_widget_test.dart index d7c63db9..f4d98ee7 100644 --- a/packages/superdeck/test/src/builtins/image_widget_test.dart +++ b/packages/superdeck/test/src/builtins/image_widget_test.dart @@ -2,10 +2,12 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:superdeck/src/builtins/image_widget.dart'; +import 'package:superdeck/src/deck/slide_configuration.dart'; import 'package:superdeck/src/rendering/blocks/block_provider.dart'; import 'package:superdeck/src/styling/components/slide.dart'; import 'package:superdeck/src/ui/widgets/cache_image_widget.dart'; import 'package:superdeck/src/ui/widgets/provider.dart'; +import 'package:superdeck/src/ui/widgets/resolved_asset_image.dart'; import 'package:superdeck/src/utils/converters.dart'; import 'package:superdeck_core/superdeck_core.dart'; @@ -20,12 +22,14 @@ void main() { 'fit': 'cover', 'width': 300.0, 'height': 200.0, + 'scale': 1.25, }); expect(dto.src, Uri.parse('assets/logo.png')); expect(dto.fit, ImageFit.cover); expect(dto.width, 300.0); expect(dto.height, 200.0); + expect(dto.scale, 1.25); }); test('uses defaults when optional fields are omitted or null', () { @@ -35,14 +39,42 @@ void main() { 'fit': null, 'width': null, 'height': null, + 'scale': null, }); expect(omitted.fit, ImageFit.contain); expect(omitted.width, isNull); expect(omitted.height, isNull); + expect(omitted.scale, 1.0); expect(explicitNull.fit, ImageFit.contain); expect(explicitNull.width, isNull); expect(explicitNull.height, isNull); + expect(explicitNull.scale, 1.0); + }); + + test('accepts integer width, height, and scale authoring', () { + final dto = ImageDto.parse({ + 'src': 'assets/logo.png', + 'width': 300, + 'height': 300, + 'scale': 1, + }); + + expect(dto.width, 300.0); + expect(dto.height, 300.0); + expect(dto.scale, 1.0); + }); + + test('rejects non-positive dimensions with one numeric rule', () { + for (final field in const ['width', 'height', 'scale']) { + for (final value in [0, -1, double.nan, double.infinity]) { + expect( + () => ImageDto.parse({'src': 'a.png', field: value}), + throwsA(anything), + reason: '$field: $value', + ); + } + } }); test('trims whitespace from src', () { @@ -96,6 +128,47 @@ void main() { expect(dto.fit, fit); } }); + + test('rejects non-positive and non-finite scale with field context', () { + for (final scale in [ + 0.0, + -1.0, + double.nan, + double.infinity, + double.negativeInfinity, + ]) { + expect( + () => ImageDto.parse({'src': 'a.png', 'scale': scale}), + throwsA( + isA() + .having((error) => error.name, 'name', 'scale') + .having( + (error) => error.message.toString(), + 'message', + contains('finite number greater than zero'), + ), + ), + ); + } + }); + + test('public construction enforces the same scale invariant', () { + final dto = ImageDto(src: Uri.parse('a.png'), scale: 1.25); + expect(dto.scale, 1.25); + + expect( + () => ImageDto(src: Uri.parse('a.png'), scale: 0), + throwsA( + isA() + .having((error) => error.name, 'name', 'scale') + .having( + (error) => error.message.toString(), + 'message', + contains('finite number greater than zero'), + ), + ), + ); + }); }); }); @@ -164,6 +237,8 @@ void main() { ); expect(explicitSize.width, 120.0); expect(explicitSize.height, 80.0); + expect(_insideImageWidget(Transform), findsNothing); + expect(_insideImageWidget(ClipRect), findsNothing); }); for (final fit in ImageFit.values) { @@ -181,6 +256,102 @@ void main() { expect(tester.takeException(), isNull); }); } + + testWidgets('scale one retains the current widget path exactly', ( + tester, + ) async { + await tester.pumpWidget( + const _ImageHarness( + size: Size(640, 480), + args: {'src': _transparentPixelDataUri, 'scale': 1.0}, + ), + ); + await tester.pump(); + + expect(find.byType(CachedImage), findsOneWidget); + expect(_insideImageWidget(Transform), findsNothing); + expect(_insideImageWidget(ClipRect), findsNothing); + }); + + testWidgets('scale paints inside the content frame using block alignment', ( + tester, + ) async { + const frameSize = Size(640, 480); + await tester.pumpWidget( + const _ImageHarness( + size: frameSize, + align: ContentAlignment.bottomRight, + args: {'src': _transparentPixelDataUri, 'scale': 1.25}, + ), + ); + await tester.pump(); + + expect(find.byType(CachedImage), findsOneWidget); + final transformFinder = _insideImageWidget(Transform); + final clipFinder = _insideImageWidget(ClipRect); + expect(transformFinder, findsOneWidget); + expect(clipFinder, findsOneWidget); + + final transform = tester.widget(transformFinder); + expect(transform.alignment, Alignment.bottomRight); + expect(transform.transform.storage[0], 1.25); + expect(transform.transform.storage[5], 1.25); + expect(tester.getSize(clipFinder), frameSize); + expect( + tester.getSize(transformFinder), + tester.getSize(find.byType(CachedImage)), + ); + + final image = tester.widget(find.byType(CachedImage)); + expect(image.styleSpec.spec.alignment, Alignment.bottomRight); + }); + + testWidgets('explicit dimensions define the scaled clipping frame', ( + tester, + ) async { + await tester.pumpWidget( + const _ImageHarness( + size: Size(640, 480), + args: { + 'src': _transparentPixelDataUri, + 'width': 120.0, + 'height': 80.0, + 'scale': 1.25, + }, + ), + ); + await tester.pump(); + + expect(tester.getSize(_insideImageWidget(ClipRect)), const Size(120, 80)); + expect( + tester.getSize(_insideImageWidget(Transform)), + const Size(120, 80), + ); + expect(tester.getSize(find.byType(CachedImage)), const Size(120, 80)); + }); + + testWidgets('scaled bare-key images reuse the existing resolver path', ( + tester, + ) async { + final store = _CountingCacheStore(); + final harness = _ImageHarness( + size: const Size(640, 480), + assetCacheStore: store, + args: const {'src': 'generated-image.png', 'scale': 1.25}, + ); + + await tester.pumpWidget(harness); + await tester.pumpAndSettle(); + + expect(store.resolveCount, 1); + expect(find.byType(ResolvedAssetImage), findsOneWidget); + expect(find.byType(CachedImage), findsOneWidget); + expect(_insideImageWidget(Transform), findsOneWidget); + + await tester.pumpWidget(harness); + await tester.pumpAndSettle(); + expect(store.resolveCount, 1); + }); }); } @@ -191,21 +362,64 @@ const _transparentPixelDataUri = class _ImageHarness extends StatelessWidget { final Map args; final Size size; + final ContentAlignment align; + final AssetCacheStore? assetCacheStore; - const _ImageHarness({required this.args, required this.size}); + const _ImageHarness({ + required this.args, + required this.size, + this.align = ContentAlignment.center, + this.assetCacheStore, + }); @override Widget build(BuildContext context) { - return MaterialApp( - home: InheritedData( - data: BlockConfiguration( - spec: const SlideSpec(), - size: size, - align: ContentAlignment.center, - runtimeKey: 'test-slide:s0:b0', - ), - child: Scaffold(body: ImageWidget(args)), + Widget tree = InheritedData( + data: BlockConfiguration( + spec: const SlideSpec(), + size: size, + align: align, + runtimeKey: 'test-slide:s0:b0', ), + child: Scaffold(body: ImageWidget(args)), ); + + if (assetCacheStore case final store?) { + tree = InheritedData( + data: SlideConfiguration( + slideIndex: 0, + style: SlideStyler(), + slide: Slide(key: 'image-test'), + thumbnailKey: 'image-test-thumbnail.png', + assetCacheStore: store, + ), + child: tree, + ); + } + + return MaterialApp(home: tree); } } + +class _CountingCacheStore implements AssetCacheStore { + int resolveCount = 0; + + @override + Future delete(String assetKey) async {} + + @override + Future resolve(String assetKey) async { + resolveCount += 1; + return Uri.parse(_transparentPixelDataUri); + } + + @override + Future write(String assetKey, List bytes) async => null; +} + +Finder _insideImageWidget(Type type) { + return find.descendant( + of: find.byType(ImageWidget), + matching: find.byType(type), + ); +} diff --git a/packages/superdeck/test/src/builtins/webview_widget_test.dart b/packages/superdeck/test/src/builtins/webview_widget_test.dart index 33665812..9fec947c 100644 --- a/packages/superdeck/test/src/builtins/webview_widget_test.dart +++ b/packages/superdeck/test/src/builtins/webview_widget_test.dart @@ -138,7 +138,7 @@ void main() { final wrapper = tester.widget( find.byType(WebViewWrapper), ); - expect(wrapper.size, size); + expect(tester.getSize(find.byType(WebViewWrapper)), size); expect(wrapper.url, 'https://example.com/demo'); expect(wrapper.showControls, isFalse); expect(find.byKey(const ValueKey('fake-web-view')), findsOneWidget); @@ -186,6 +186,24 @@ void main() { ); }); + testWidgets('fills the block viewport under scrollable constraints', ( + tester, + ) async { + await tester.pumpWidget( + _WebViewHarness( + deckController: deckController, + size: const Size(640, 480), + runtimeKey: 'slide-0:s0:b0', + unboundedHeight: true, + args: {'url': 'https://example.com/scrollable'}, + ), + ); + await tester.pump(); + + expect(tester.getSize(find.byType(WebViewWrapper)), const Size(640, 480)); + expect(tester.takeException(), isNull); + }); + testWidgets('reuses controller on remount with same cache identity', ( tester, ) async { @@ -631,6 +649,7 @@ void main() { await tester.pump(); expect(find.text('Static Title'), findsOneWidget); + expect(tester.getSize(find.byType(WebViewWrapper)), const Size(640, 480)); expect(find.byKey(const ValueKey('fake-web-view')), findsNothing); expect(webViewPlatform.controllers, isEmpty); expect(deckController.webViewControllerCache.length, 0); @@ -710,14 +729,17 @@ class _TwoWebViewsHarness extends StatelessWidget { ); Widget webView(String runtimeKey) { - return InheritedData( - data: BlockConfiguration( - spec: const SlideSpec(), - size: size, - align: null, - runtimeKey: runtimeKey, + return SizedBox.fromSize( + size: size, + child: InheritedData( + data: BlockConfiguration( + spec: const SlideSpec(), + size: size, + align: ContentAlignment.centerLeft, + runtimeKey: runtimeKey, + ), + child: WebViewWidget(args), ), - child: WebViewWidget(args), ); } @@ -743,6 +765,7 @@ class _WebViewHarness extends StatelessWidget { final Size size; final String runtimeKey; final bool isStaticRendering; + final bool unboundedHeight; const _WebViewHarness({ required this.deckController, @@ -750,6 +773,7 @@ class _WebViewHarness extends StatelessWidget { required this.size, required this.runtimeKey, this.isStaticRendering = false, + this.unboundedHeight = false, }); @override @@ -767,19 +791,30 @@ class _WebViewHarness extends StatelessWidget { isStaticRendering: isStaticRendering, ); + final webView = InheritedData( + data: BlockConfiguration( + spec: const SlideSpec(), + size: size, + align: ContentAlignment.centerLeft, + runtimeKey: runtimeKey, + ), + child: WebViewWidget(args), + ); + return MaterialApp( home: InheritedData( data: deckController, child: InheritedData( data: slide, - child: InheritedData( - data: BlockConfiguration( - spec: const SlideSpec(), - size: size, - align: null, - runtimeKey: runtimeKey, - ), - child: Scaffold(body: WebViewWidget(args)), + child: Scaffold( + body: unboundedHeight + ? SingleChildScrollView( + child: SizedBox(width: size.width, child: webView), + ) + : Align( + alignment: Alignment.topLeft, + child: SizedBox.fromSize(size: size, child: webView), + ), ), ), ), diff --git a/packages/superdeck/test/src/deck/template_resolver_test.dart b/packages/superdeck/test/src/deck/template_resolver_test.dart index 56018e44..d6301d9b 100644 --- a/packages/superdeck/test/src/deck/template_resolver_test.dart +++ b/packages/superdeck/test/src/deck/template_resolver_test.dart @@ -718,7 +718,7 @@ void main() { test('preserves resolved style without layout-specific overrides', () { final baseStyle = SlideStyler( - blockContainer: BoxStyler( + blockContainer: BlockStyler( padding: EdgeInsetsGeometryMix.all(32), margin: EdgeInsetsGeometryMix.all(16), ), diff --git a/packages/superdeck/test/src/markdown/builders/text_element_builder_widget_test.dart b/packages/superdeck/test/src/markdown/builders/text_element_builder_widget_test.dart index bc4045f7..6ec97542 100644 --- a/packages/superdeck/test/src/markdown/builders/text_element_builder_widget_test.dart +++ b/packages/superdeck/test/src/markdown/builders/text_element_builder_widget_test.dart @@ -10,6 +10,7 @@ import 'package:superdeck/src/rendering/blocks/markdown_render_scope.dart'; import 'package:superdeck/src/styling/components/markdown_codeblock.dart'; import 'package:superdeck/src/styling/components/markdown_list.dart'; import 'package:superdeck/src/styling/components/slide.dart'; +import 'package:superdeck/src/ui/widgets/hero_element.dart'; import 'package:superdeck/src/ui/widgets/provider.dart'; import 'package:superdeck_core/superdeck_core.dart'; @@ -396,7 +397,7 @@ void main() { ); testWidgets( - 'code blocks with Hero tag access BlockConfiguration for size calculation', + 'code hero uses the real inner block frame without subtracting again', (tester) async { const markdown = ''' ```dart {.code-hero} @@ -404,16 +405,34 @@ void main() {} ``` '''; - await tester.pumpWidget(_MarkdownHarness(markdown: markdown)); + await tester.pumpWidget( + _MarkdownHarness( + markdown: markdown, + slideSpec: SlideSpec( + code: StyleSpec( + spec: MarkdownCodeblockSpec( + container: StyleSpec( + spec: BoxSpec( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(border: Border.all(width: 2)), + ), + ), + ), + ), + ), + ), + ); await tester.pumpAndSettle(); // Verify code is rendered (uses RichText for syntax highlighting) expect(find.byType(RichText), findsWidgets); - // Verify no BlockConfiguration access errors occurred during size calculation - // If BlockConfiguration.of(builderContext) failed, rendering would have thrown - final allWidgets = tester.allWidgets.toList(); - expect(allWidgets, isNotEmpty); + final heroElement = tester.widget>( + find.byWidgetPredicate( + (widget) => widget is HeroElement, + ), + ); + expect(heroElement.data.size, const Size(800, 600)); }, ); }); @@ -584,7 +603,7 @@ class _MarkdownHarness extends StatelessWidget { // Provide BlockConfiguration with a reasonable slide size for testing final blockData = BlockConfiguration( - align: ContentBlock(markdown).align, + align: ContentAlignment.centerLeft, spec: slideSpec, size: const Size(800, 600), runtimeKey: 'test-slide:s0:b0', diff --git a/packages/superdeck/test/src/markdown/hero_transition_test.dart b/packages/superdeck/test/src/markdown/hero_transition_test.dart new file mode 100644 index 00000000..99706688 --- /dev/null +++ b/packages/superdeck/test/src/markdown/hero_transition_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck/src/deck/slide_configuration.dart'; +import 'package:superdeck/src/rendering/slides/slide_view.dart'; +import 'package:superdeck/src/ui/widgets/cache_image_widget.dart'; +import 'package:superdeck/src/ui/widgets/hero_element.dart'; +import 'package:superdeck/src/ui/widgets/provider.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +import '../../helpers/slide_test_harness.dart'; + +const _heroText = 'Constraint driven Hero'; +const _imageUri = 'https://example.com/hero.png'; +const _transitionDuration = Duration(seconds: 1); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'text Hero interpolates typography between different block frames', + (tester) async { + _setSlideViewport(tester); + final from = _slide( + key: 'text-hero-from', + heroContent: '# $_heroText {.shared-text}', + ); + final to = _slide( + key: 'text-hero-to', + heroContent: '### $_heroText {.shared-text}', + constrainHero: true, + ); + + await _pumpHeroRoutes(tester, from: from, to: to); + + final routeText = _routeTextFinder(_heroText); + final fromSize = tester.getSize(routeText); + final fromFontSize = tester.widget(routeText).style!.fontSize!; + + _navigateToNextSlide(tester); + await tester.pump(); + await tester.pump(_transitionDuration ~/ 2); + + expect(tester.takeException(), isNull); + final shuttleText = _shuttleTextFinder(_heroText); + expect(shuttleText, findsOneWidget); + final shuttleSize = tester.getSize(shuttleText); + final shuttleFontSize = tester + .widget(shuttleText) + .textSpan! + .style! + .fontSize!; + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + final toSize = tester.getSize(routeText); + final toFontSize = tester.widget(routeText).style!.fontSize!; + expect(toSize.width, lessThan(fromSize.width)); + expect(toSize.height, lessThan(fromSize.height)); + expect(shuttleSize.width, inExclusiveRange(toSize.width, fromSize.width)); + expect( + shuttleSize.height, + inExclusiveRange(toSize.height, fromSize.height), + ); + expect(shuttleFontSize, inExclusiveRange(toFontSize, fromFontSize)); + }, + ); + + testWidgets('image Hero interpolates its constraint-derived block size', ( + tester, + ) async { + _setSlideViewport(tester); + final from = _slide( + key: 'image-hero-from', + heroContent: '![Hero image]($_imageUri){.shared-image}', + ); + final to = _slide( + key: 'image-hero-to', + heroContent: '![Hero image]($_imageUri){.shared-image}', + constrainHero: true, + ); + + await _pumpHeroRoutes(tester, from: from, to: to); + + final fromData = _imageHeroData(tester).single; + + _navigateToNextSlide(tester); + await tester.pump(); + + final routeData = _imageHeroData(tester, skipOffstage: false); + final toData = routeData.singleWhere((data) => data.size != fromData.size); + expect(toData.size.width, lessThan(fromData.size.width)); + expect(toData.size.height, lessThan(fromData.size.height)); + + await tester.pump(_transitionDuration ~/ 2); + + expect(tester.takeException(), isNull); + final targetSizes = tester + .widgetList(find.byType(CachedImage)) + .map((image) => image.targetSize) + .whereType(); + final shuttleSize = targetSizes.singleWhere( + (size) => + size.width > toData.size.width && + size.width < fromData.size.width && + size.height > toData.size.height && + size.height < fromData.size.height, + ); + expect(shuttleSize.width.isFinite, isTrue); + expect(shuttleSize.height.isFinite, isTrue); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(_imageHeroData(tester).single.size, toData.size); + }); +} + +Slide _slide({ + required String key, + required String heroContent, + bool constrainHero = false, +}) { + final heroBlock = ContentBlock(heroContent, align: ContentAlignment.center); + if (!constrainHero) { + return Slide( + key: key, + sections: [ + SectionBlock([heroBlock]), + ], + ); + } + + return Slide( + key: key, + sections: [ + SectionBlock([heroBlock, ContentBlock('Side')]), + SectionBlock([ContentBlock('Below')]), + ], + ); +} + +Future _pumpHeroRoutes( + WidgetTester tester, { + required Slide from, + required Slide to, +}) async { + final fromConfiguration = SlideTestHarness.createConfiguration(from); + final toConfiguration = SlideTestHarness.createConfiguration(to); + + await tester.pumpWidget( + MaterialApp( + home: _SlideRoute(configuration: fromConfiguration), + onGenerateRoute: (settings) => PageRouteBuilder( + settings: settings, + transitionDuration: _transitionDuration, + pageBuilder: (context, animation, secondaryAnimation) => + _SlideRoute(configuration: toConfiguration), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ), + ); + await tester.pumpAndSettle(); +} + +void _navigateToNextSlide(WidgetTester tester) { + tester.state(find.byType(Navigator)).pushNamed('/next'); +} + +Finder _routeTextFinder(String text) => + find.byWidgetPredicate((widget) => widget is Text && widget.data == text); + +Finder _shuttleTextFinder(String text) => find.byWidgetPredicate( + (widget) => widget is Text && widget.textSpan?.toPlainText() == text, +); + +List _imageHeroData( + WidgetTester tester, { + bool skipOffstage = true, +}) { + return tester + .widgetList>( + find.byWidgetPredicate( + (widget) => widget is HeroElement, + skipOffstage: skipOffstage, + ), + ) + .map((element) => element.data) + .toList(); +} + +void _setSlideViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); +} + +class _SlideRoute extends StatelessWidget { + const _SlideRoute({required this.configuration}); + + final SlideConfiguration configuration; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: InheritedData(data: configuration, child: SlideView(configuration)), + ); + } +} diff --git a/packages/superdeck/test/src/markdown/image_cache_resolution_test.dart b/packages/superdeck/test/src/markdown/image_cache_resolution_test.dart index a5be560b..39821750 100644 --- a/packages/superdeck/test/src/markdown/image_cache_resolution_test.dart +++ b/packages/superdeck/test/src/markdown/image_cache_resolution_test.dart @@ -117,7 +117,7 @@ class _MarkdownHarness extends StatelessWidget { assetCacheStore: assetCacheStore, ); final blockData = BlockConfiguration( - align: ContentBlock(markdown).align, + align: ContentAlignment.centerLeft, spec: slideSpec, size: const Size(800, 600), runtimeKey: 'slide:s0:b0', diff --git a/packages/superdeck/test/src/markdown/image_element_rendering_test.dart b/packages/superdeck/test/src/markdown/image_element_rendering_test.dart index 341959b1..4f13c828 100644 --- a/packages/superdeck/test/src/markdown/image_element_rendering_test.dart +++ b/packages/superdeck/test/src/markdown/image_element_rendering_test.dart @@ -245,7 +245,7 @@ class _MarkdownHarness extends StatelessWidget { // Provide BlockConfiguration with a reasonable slide size for testing final blockData = BlockConfiguration( - align: ContentBlock(markdown).align, + align: ContentAlignment.centerLeft, spec: slideSpec, size: const Size(800, 600), runtimeKey: 'slide:s0:b0', diff --git a/packages/superdeck/test/src/markdown/markdown_builders_test.dart b/packages/superdeck/test/src/markdown/markdown_builders_test.dart index 6c5dcc81..0340774e 100644 --- a/packages/superdeck/test/src/markdown/markdown_builders_test.dart +++ b/packages/superdeck/test/src/markdown/markdown_builders_test.dart @@ -243,7 +243,7 @@ class _MarkdownHarness extends StatelessWidget { ); final blockData = BlockConfiguration( - align: ContentBlock(markdown).align, + align: ContentAlignment.centerLeft, spec: slideSpec, size: const Size(800, 600), runtimeKey: 'slide:s0:b0', diff --git a/packages/superdeck/test/src/rendering/block_widget_test.dart b/packages/superdeck/test/src/rendering/block_widget_test.dart index 85bc6617..b378469c 100644 --- a/packages/superdeck/test/src/rendering/block_widget_test.dart +++ b/packages/superdeck/test/src/rendering/block_widget_test.dart @@ -1,9 +1,15 @@ -import 'package:flutter/widgets.dart'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mix/mix.dart'; -import 'package:superdeck/superdeck.dart' show BlockVariant, SlideStyler; +import 'package:superdeck/superdeck.dart' + show BlockStyler, BlockVariant, SlideParts, SlideStyler; import 'package:superdeck/src/rendering/blocks/block_provider.dart'; import 'package:superdeck/src/rendering/blocks/block_widget.dart'; +import 'package:superdeck/src/ui/widgets/provider.dart'; +import 'package:superdeck/src/ui/widgets/overflow_clip.dart'; import 'package:superdeck_core/superdeck_core.dart'; import '../../fixtures/slide_fixtures.dart'; @@ -44,7 +50,12 @@ void main() { SlideFixtures.withAlignment(alignment), ); - final alignWidget = find.byType(Align).first; + final alignWidget = find + .descendant( + of: find.byType(BlockWidget), + matching: find.byType(Align), + ) + .first; final align = tester.widget(alignWidget); expect(align.alignment, _toAlignment(alignment)); }); @@ -69,9 +80,143 @@ void main() { ), ); - final align = tester.widget(find.byType(Align).first); + final align = tester.widget( + find + .descendant( + of: find.byType(BlockWidget), + matching: find.byType(Align), + ) + .first, + ); expect(align.alignment, Alignment.centerLeft); }); + + testWidgets('block inherits section alignment', (tester) async { + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'section-align', + sections: [ + SectionBlock([ + ContentBlock('# Inherited'), + ], align: ContentAlignment.bottomRight), + ], + ), + ); + + final align = tester.widget( + find + .descendant( + of: find.byType(BlockWidget), + matching: find.byType(Align), + ) + .first, + ); + expect(align.alignment, Alignment.bottomRight); + }); + + testWidgets('resolved alignment reaches BlockConfiguration', ( + tester, + ) async { + ContentAlignment? observedAlignment; + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'section-configuration-align', + sections: [ + SectionBlock([ + WidgetBlock(name: 'alignment-probe'), + ], align: ContentAlignment.topCenter), + ], + ), + widgets: { + 'alignment-probe': (_) => _AlignmentProbe( + onBuild: (alignment) => observedAlignment = alignment, + ), + }, + ); + + expect(observedAlignment, ContentAlignment.topCenter); + }); + + testWidgets('explicit block alignment overrides section alignment', ( + tester, + ) async { + ContentAlignment? observedAlignment; + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'block-configuration-align', + sections: [ + SectionBlock([ + WidgetBlock( + name: 'alignment-probe', + align: ContentAlignment.centerRight, + ), + ], align: ContentAlignment.topLeft), + ], + ), + widgets: { + 'alignment-probe': (_) => _AlignmentProbe( + onBuild: (alignment) => observedAlignment = alignment, + ), + }, + ); + + final align = tester.widget( + find + .descendant( + of: find.byType(CustomBlockWidget), + matching: find.byType(Align), + ) + .first, + ); + expect(align.alignment, Alignment.centerRight); + expect(observedAlignment, ContentAlignment.centerRight); + }); + + for (final alignment in ContentAlignment.values) { + testWidgets('$alignment positions content inside a full block frame', ( + tester, + ) async { + const markerKey = ValueKey('alignment-marker'); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'frame-${alignment.name}', + sections: [ + SectionBlock([WidgetBlock(name: 'fixed', align: alignment)]), + ], + ), + parts: const SlideParts(header: null, footer: null), + widgets: { + 'fixed': (_) => + const SizedBox(key: markerKey, width: 20, height: 10), + }, + ); + + final blockRect = tester.getRect(find.byType(CustomBlockWidget)); + final boxRect = tester.getRect( + find.descendant( + of: find.byType(CustomBlockWidget), + matching: find.byType(Box), + ), + ); + final markerRect = tester.getRect(find.byKey(markerKey)); + final innerRect = Rect.fromLTRB( + blockRect.left + 40, + blockRect.top + 40, + blockRect.right - 40, + blockRect.bottom - 40, + ); + + expect(boxRect, blockRect); + expect( + markerRect, + _toAlignment(alignment).inscribe(markerRect.size, innerRect), + ); + }); + } }); group('scrollable behavior', () { @@ -152,8 +297,9 @@ void main() { ); testWidgets( - 'scrollable widget block is NOT scrollable during static rendering', + 'static scrollable widget lays out naturally and clips to its frame', (tester) async { + const childKey = ValueKey('static-scrollable-child'); await SlideTestHarness.pumpSlide( tester, Slide( @@ -165,17 +311,403 @@ void main() { ], ), widgets: { - 'short-widget': (_) => - const SizedBox(height: 80, child: Text('Static widget')), + 'short-widget': (_) => const SizedBox( + key: childKey, + height: 1000, + child: Text('Static widget'), + ), }, isStaticRendering: true, ); tester.expectNotScrollable(find.byType(CustomBlockWidget)); + expect( + tester.getSize(find.byKey(childKey)).height, + greaterThan(tester.getSize(find.byType(CustomBlockWidget)).height), + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'static scrollable widget preserves live unbounded child layout', + (tester) async { + const markerKey = ValueKey('scrollable-widget-top-marker'); + final observedMaxHeights = []; + final slide = Slide( + key: 'scrollable-widget-layout-parity', + sections: [ + SectionBlock([ + WidgetBlock( + name: 'constraint-probe', + align: ContentAlignment.bottomRight, + scrollable: true, + ), + ]), + ], + ); + final widgets = { + 'constraint-probe': (_) => LayoutBuilder( + builder: (context, constraints) { + observedMaxHeights.add(constraints.maxHeight); + return const SizedBox( + height: 1000, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(key: markerKey, width: 10, height: 10), + ), + ); + }, + ), + }; + + await SlideTestHarness.pumpSlide(tester, slide, widgets: widgets); + final liveMaxHeight = observedMaxHeights.last; + final liveMarkerOffset = + tester.getTopLeft(find.byKey(markerKey)) - + tester.getTopLeft(find.byType(CustomBlockWidget)); + + observedMaxHeights.clear(); + await SlideTestHarness.pumpSlide( + tester, + slide, + widgets: widgets, + isStaticRendering: true, + ); + final staticMaxHeight = observedMaxHeights.last; + final staticMarkerOffset = + tester.getTopLeft(find.byKey(markerKey)) - + tester.getTopLeft(find.byType(CustomBlockWidget)); + + expect(liveMaxHeight.isInfinite, isTrue); + expect(staticMaxHeight, liveMaxHeight); + expect(staticMarkerOffset, liveMarkerOffset); + tester.expectNotScrollable(find.byType(CustomBlockWidget)); }, ); }); + group('overflow diagnostics', () { + late List logs; + + setUp(() { + logs = []; + OverflowDiagnostics.resetForTesting(); + OverflowDiagnostics.logger = logs.add; + }); + + tearDown(OverflowDiagnostics.resetForTesting); + + testWidgets('does not report content that fits', (tester) async { + await SlideTestHarness.pumpSlide( + tester, + SlideFixtures.singleColumn(content: 'Content that fits.'), + debug: true, + ); + + expect(find.byType(OverflowDiagnosticProbe), findsOneWidget); + expect(OverflowDiagnostics.activeIssuesForTesting, isEmpty); + expect(logs, isEmpty); + }); + + testWidgets('preserves the assigned markdown wrapping width', ( + tester, + ) async { + final content = List.filled(60, 'wrapping sentinel').join(' '); + final slide = Slide( + key: 'wrapping-width', + sections: [ + SectionBlock([ContentBlock(content)]), + ], + ); + final textFinder = find.byWidgetPredicate( + (widget) => widget is Text && widget.data == content, + ); + + await SlideTestHarness.pumpSlide( + tester, + slide, + debug: true, + isStaticRendering: true, + ); + final withoutDiagnostics = tester.getSize(textFinder); + + await SlideTestHarness.pumpSlide(tester, slide, debug: true); + final withDiagnostics = tester.getSize(textFinder); + + expect(withDiagnostics, withoutDiagnostics); + }); + + testWidgets('reports vertical markdown overflow with stable identity', ( + tester, + ) async { + await SlideTestHarness.pumpSlide( + tester, + SlideFixtures.nonScrollableBlock(lineCount: 100), + debug: true, + ); + + expect(logs, hasLength(1)); + expect( + logs.single, + allOf( + contains('slide=fixture-non-scrollable-block'), + contains('block=fixture-non-scrollable-block:s0:b0'), + contains('available='), + contains('measured='), + contains('axis=vertical'), + ), + ); + final issue = OverflowDiagnostics.activeIssuesForTesting.values.single; + expect(issue.axes, contains(Axis.vertical)); + }); + + testWidgets('reports unwrapped horizontal markdown overflow', ( + tester, + ) async { + final content = List.filled(400, 'W').join(); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'horizontal-overflow', + sections: [ + SectionBlock([ContentBlock(content)]), + ], + ), + style: SlideStyler(p: TextStyler(softWrap: false)), + debug: true, + ); + + expect(logs, hasLength(1)); + expect(logs.single, contains('axis=horizontal')); + final issue = OverflowDiagnostics.activeIssuesForTesting.values.single; + expect(issue.axes, contains(Axis.horizontal)); + }); + + testWidgets( + 'deduplicates stable issues and logs again after resolution', + (tester) async { + Slide slide(String content) => Slide( + key: 'resolving-overflow', + sections: [ + SectionBlock([ContentBlock(content)]), + ], + ); + final overflow = List.filled(100, 'Overflow line').join('\n'); + + await SlideTestHarness.pumpSlide( + tester, + slide(overflow), + debug: true, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(logs, hasLength(1)); + + await SlideTestHarness.pumpSlide( + tester, + slide('Resolved content.'), + debug: true, + ); + expect(OverflowDiagnostics.activeIssuesForTesting, isEmpty); + + await SlideTestHarness.pumpSlide( + tester, + slide(overflow), + debug: true, + ); + expect(logs, hasLength(2)); + }, + ); + + testWidgets('logs when the overflow axis changes', (tester) async { + final vertical = List.filled(100, 'Overflow line').join('\n'); + final horizontal = List.filled(400, 'W').join(); + + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'changing-overflow', + sections: [ + SectionBlock([ContentBlock(vertical)]), + ], + ), + debug: true, + ); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'changing-overflow', + sections: [ + SectionBlock([ContentBlock(horizontal)]), + ], + ), + style: SlideStyler(p: TextStyler(softWrap: false)), + debug: true, + ); + + expect(logs, hasLength(2)); + expect(logs.first, contains('axis=vertical')); + expect(logs.last, contains('axis=horizontal')); + }); + + testWidgets('skips scrollable and static content', (tester) async { + await SlideTestHarness.pumpSlide( + tester, + SlideFixtures.scrollableBlock(lineCount: 100), + debug: true, + ); + expect(find.byType(OverflowDiagnosticProbe), findsNothing); + + await SlideTestHarness.pumpSlide( + tester, + SlideFixtures.nonScrollableBlock(lineCount: 100), + debug: true, + isStaticRendering: true, + ); + expect(find.byType(OverflowDiagnosticProbe), findsNothing); + expect(logs, isEmpty); + }); + + testWidgets('probes custom widgets once through the shared frame', ( + tester, + ) async { + var buildCount = 0; + await SlideTestHarness.pumpSlide( + tester, + SlideFixtures.withCustomWidget(widgetName: 'counting-widget'), + widgets: { + 'counting-widget': (_) => Builder( + builder: (context) { + buildCount += 1; + return const OverflowBox( + maxWidth: 2400, + maxHeight: 2400, + child: SizedBox(width: 2400, height: 2400), + ); + }, + ), + }, + debug: true, + ); + + expect(buildCount, 1); + expect(find.byType(OverflowDiagnosticProbe), findsOneWidget); + expect(logs, hasLength(1)); + expect( + logs.single, + contains('block=fixture-custom-widget-counting-widget:s0:b0'), + ); + }); + + testWidgets( + 'outlines an overflowing frame without covering aligned content', + (tester) async { + tester.view.physicalSize = const Size(100, 100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + const boundaryKey = ValueKey('overflow-indicator-boundary'); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: RepaintBoundary( + key: boundaryKey, + child: SizedBox( + width: 100, + height: 100, + child: OverflowDiagnosticProbe( + slideKey: 'indicator-slide', + runtimeKey: 'indicator-slide:s0:b0', + availableSize: Size(100, 100), + child: OverflowBox( + maxWidth: 200, + maxHeight: 200, + child: SizedBox( + width: 200, + height: 200, + child: ColoredBox(color: Color(0xFF0000FF)), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect(OverflowDiagnostics.activeIssuesForTesting, isNotEmpty); + + final boundary = tester.renderObject( + find.byKey(boundaryKey), + ); + final pixels = await tester.runAsync(() async { + final image = await boundary.toImage(); + try { + final bytes = await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); + List pixel(int x, int y) { + final pixelOffset = (y * image.width + x) * 4; + return bytes!.buffer.asUint8List(pixelOffset, 4).toList(); + } + + return { + 'topEdge': pixel(50, 0), + 'rightEdge': pixel(99, 50), + 'topRightInterior': pixel(95, 5), + }; + } finally { + image.dispose(); + } + }); + + // The frame outline paints on the edges... + expect(pixels!['topEdge'], [255, 59, 48, 255]); + expect(pixels['rightEdge'], [255, 59, 48, 255]); + // ...but content aligned to the top-right corner stays visible. + expect(pixels['topRightInterior'], [0, 0, 255, 255]); + }, + ); + + testWidgets('allows Hero flights between changing constraints', ( + tester, + ) async { + Widget route(Size size, Color color) { + return Scaffold( + body: Center( + child: SizedBox.fromSize( + size: size, + child: OverflowDiagnosticProbe( + slideKey: 'hero-slide', + runtimeKey: 'hero-slide:s0:b0', + availableSize: size, + child: Hero( + tag: 'diagnostic-hero', + child: ColoredBox(color: color), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget( + MaterialApp( + home: route(const Size(320, 200), Colors.blue), + routes: {'/next': (_) => route(const Size(180, 120), Colors.green)}, + ), + ); + + tester.state(find.byType(Navigator)).pushNamed('/next'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + }); + group('error handling', () { testWidgets('CustomBlockWidget shows error for unknown widget', ( tester, @@ -217,6 +749,375 @@ void main() { }); }); + group('block container safety', () { + testWidgets('sanitizes low-level raw styles at the render boundary', ( + tester, + ) async { + _setSlideViewport(tester); + late BlockConfiguration blockData; + final animation = AnimationConfig.linear( + const Duration(milliseconds: 100), + ); + + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'raw-block-container-style', + sections: [ + SectionBlock([WidgetBlock(name: 'custom')]), + ], + ), + style: SlideStyler.create( + blockContainer: Prop.value( + StyleSpec( + spec: BoxSpec( + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(maxWidth: 100), + transform: Matrix4.diagonal3Values(2, 2, 1), + alignment: Alignment.center, + ), + animation: animation, + widgetModifiers: const [], + ), + ), + ), + widgets: { + 'custom': (_) => + _BlockDataProbe(onBuild: (data) => blockData = data), + }, + ); + + final container = blockData.spec.blockContainer; + expect(container.spec.padding, const EdgeInsets.all(12)); + expect(container.spec.constraints, isNull); + expect(container.spec.transform, isNull); + expect(container.spec.alignment, isNull); + expect(container.widgetModifiers, isNull); + expect(container.animation, same(animation)); + }); + }); + + group('inset overrides', () { + testWidgets('absent override retains resolved style padding', ( + tester, + ) async { + _setSlideViewport(tester); + late Size blockSize; + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'default-padding', + sections: [ + SectionBlock([WidgetBlock(name: 'custom')]), + ], + ), + widgets: { + 'custom': (_) => + _BlockSizeProbe(onBuild: (size) => blockSize = size), + }, + ); + + expect(blockSize, const Size(1200, 540)); + }); + + testWidgets('padding zero makes a block edge-to-edge', (tester) async { + _setSlideViewport(tester); + late Size blockSize; + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'zero-padding', + sections: [ + SectionBlock([ + WidgetBlock.fromMap({ + 'type': 'widget', + 'name': 'custom', + 'padding': {'top': 0, 'right': 0, 'bottom': 0, 'left': 0}, + }), + ]), + ], + ), + widgets: { + 'custom': (_) => + _BlockSizeProbe(onBuild: (size) => blockSize = size), + }, + ); + + expect(blockSize, const Size(1280, 620)); + }); + + testWidgets('padding zero makes markdown content edge-to-edge', ( + tester, + ) async { + _setSlideViewport(tester); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'zero-content-padding', + sections: [ + SectionBlock([ + ContentBlock('Content', padding: BlockInsets.all(0)), + ]), + ], + ), + ); + + final provider = tester.widget>( + find.byWidgetPredicate( + (widget) => widget is InheritedData, + ), + ); + expect(provider.data.size, const Size(1280, 620)); + }); + + testWidgets('asymmetric padding determines exact usable size', ( + tester, + ) async { + _setSlideViewport(tester); + late Size blockSize; + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'asymmetric-padding', + sections: [ + SectionBlock([ + WidgetBlock.fromMap({ + 'type': 'widget', + 'name': 'custom', + 'padding': {'top': 30, 'right': 20, 'bottom': 40, 'left': 10}, + }), + ]), + ], + ), + widgets: { + 'custom': (_) => + _BlockSizeProbe(onBuild: (size) => blockSize = size), + }, + ); + + expect(blockSize, const Size(1250, 550)); + }); + + testWidgets( + 'overrides replace only matching insets after variants resolve', + (tester) async { + _setSlideViewport(tester); + late BlockConfiguration blockData; + const variantColor = Color(0xFFCC3344); + final animation = AnimationConfig.ease( + const Duration(milliseconds: 120), + ); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'preserved-variant-geometry', + sections: [ + SectionBlock([ + WidgetBlock.fromMap({ + 'type': 'widget', + 'name': 'chart', + 'padding': {'top': 4, 'right': 4, 'bottom': 4, 'left': 4}, + 'margin': {'top': 6, 'right': 6, 'bottom': 6, 'left': 6}, + }), + ]), + ], + ), + style: SlideStyler( + blockContainer: + BlockStyler( + padding: EdgeInsetsGeometryMix.all(10), + margin: EdgeInsetsGeometryMix.all(5), + decoration: BoxDecorationMix( + color: const Color(0xFF224466), + border: BorderMix.all(BorderSideMix(width: 2)), + ), + ).animate(animation).variants([ + VariantStyle( + const BlockVariant('chart'), + BlockStyler( + padding: EdgeInsetsGeometryMix.all(20), + margin: EdgeInsetsGeometryMix.all(10), + decoration: BoxDecorationMix( + color: variantColor, + border: BorderMix.all(BorderSideMix(width: 3)), + ), + ), + ), + ]), + ), + widgets: { + 'chart': (_) => + _BlockDataProbe(onBuild: (data) => blockData = data), + }, + ); + + final container = blockData.spec.blockContainer.spec; + final decoration = container.decoration! as BoxDecoration; + // 1280/620 minus padding 4*2, margin 6*2, border 3*2. + expect(blockData.size, const Size(1254, 594)); + expect(container.padding, const EdgeInsets.all(4)); + expect(container.margin, const EdgeInsets.all(6)); + expect(decoration.color, variantColor); + expect(decoration.border!.dimensions, const EdgeInsets.all(3)); + expect(blockData.spec.blockContainer.animation, same(animation)); + // No modifier can wrap the framework-owned block container. + expect(blockData.spec.blockContainer.widgetModifiers, isNull); + }, + ); + + for (final name in ['image', 'gist', 'webview', 'custom']) { + testWidgets('$name padding overrides its resolved variant', ( + tester, + ) async { + _setSlideViewport(tester); + late Size blockSize; + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: '$name-padding', + sections: [ + SectionBlock([ + WidgetBlock.fromMap({ + 'type': 'widget', + 'name': name, + 'padding': { + 'top': 12, + 'right': 12, + 'bottom': 12, + 'left': 12, + }, + }), + ]), + ], + ), + widgets: { + name: (_) => _BlockSizeProbe(onBuild: (size) => blockSize = size), + }, + ); + + expect(blockSize, const Size(1256, 596)); + }); + } + + testWidgets('asymmetric margin determines exact usable size', ( + tester, + ) async { + _setSlideViewport(tester); + late Size blockSize; + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'asymmetric-margin', + sections: [ + SectionBlock([ + WidgetBlock.fromMap({ + 'type': 'widget', + 'name': 'custom', + 'margin': {'top': 10, 'right': 20, 'bottom': 30, 'left': 40}, + }), + ]), + ], + ), + widgets: { + 'custom': (_) => + _BlockSizeProbe(onBuild: (size) => blockSize = size), + }, + ); + + // Default style padding 40 plus the authored margin edges. + expect(blockSize, const Size(1140, 500)); + }); + + testWidgets('symmetric content-block margin reduces usable size', ( + tester, + ) async { + _setSlideViewport(tester); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'content-margin', + sections: [ + SectionBlock([ + ContentBlock( + 'Content', + margin: BlockInsets.symmetric(horizontal: 10, vertical: 10), + ), + ]), + ], + ), + ); + + final provider = tester.widget>( + find.byWidgetPredicate( + (widget) => widget is InheritedData, + ), + ); + // Default style padding 40 plus margin 10 on every edge. + expect(provider.data.size, const Size(1180, 520)); + }); + + testWidgets('margin zero with zero padding stays edge-to-edge', ( + tester, + ) async { + _setSlideViewport(tester); + late Size blockSize; + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'zero-insets', + sections: [ + SectionBlock([ + WidgetBlock( + name: 'custom', + margin: BlockInsets.all(0), + padding: BlockInsets.all(0), + ), + ]), + ], + ), + widgets: { + 'custom': (_) => + _BlockSizeProbe(onBuild: (size) => blockSize = size), + }, + ); + + expect(blockSize, const Size(1280, 620)); + }); + + testWidgets( + 'margin consumes only its own frame; spacing and flex are unchanged', + (tester) async { + _setSlideViewport(tester); + late Size marginedSize; + late Size siblingSize; + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'spacing-plus-margin', + sections: [ + SectionBlock([ + WidgetBlock(name: 'margined', margin: BlockInsets.all(10)), + WidgetBlock(name: 'sibling'), + ], spacing: 40), + ], + ), + widgets: { + 'margined': (_) => + _BlockSizeProbe(onBuild: (size) => marginedSize = size), + 'sibling': (_) => + _BlockSizeProbe(onBuild: (size) => siblingSize = size), + }, + ); + + // Equal flex splits (1280 - 40 spacing) into 620-wide frames. The + // margin reduces only the margined block's usable area; the sibling + // keeps the full frame minus default padding. + expect(marginedSize, const Size(520, 520)); + expect(siblingSize, const Size(540, 540)); + }, + ); + }); + group('named widget block variants', () { testWidgets( 'changes only the matching custom widget container and its usable size', @@ -237,11 +1138,11 @@ void main() { ], ), style: SlideStyler( - blockContainer: BoxStyler(padding: EdgeInsetsGeometryMix.all(40)) - .variants([ + blockContainer: + BlockStyler(padding: EdgeInsetsGeometryMix.all(40)).variants([ VariantStyle( const BlockVariant('chart'), - BoxStyler(padding: EdgeInsetsGeometryMix.all(0)), + BlockStyler(padding: EdgeInsetsGeometryMix.all(0)), ), ]), ), @@ -282,11 +1183,11 @@ void main() { ], ), style: SlideStyler( - blockContainer: BoxStyler(padding: EdgeInsetsGeometryMix.all(40)) - .variants([ + blockContainer: + BlockStyler(padding: EdgeInsetsGeometryMix.all(40)).variants([ VariantStyle( const BlockVariant('webview'), - BoxStyler(padding: EdgeInsetsGeometryMix.all(0)), + BlockStyler(padding: EdgeInsetsGeometryMix.all(0)), ), ]), ), @@ -319,11 +1220,11 @@ void main() { ], ), style: SlideStyler( - blockContainer: BoxStyler(padding: EdgeInsetsGeometryMix.all(40)) + blockContainer: BlockStyler(padding: EdgeInsetsGeometryMix.all(40)) .variants([ VariantStyle( const NamedVariant('image'), - BoxStyler(padding: EdgeInsetsGeometryMix.all(0)), + BlockStyler(padding: EdgeInsetsGeometryMix.all(0)), ), ]), ), @@ -430,13 +1331,13 @@ void main() { ), style: SlideStyler( blockContainer: - BoxStyler( + BlockStyler( padding: EdgeInsetsGeometryMix.all(10), margin: EdgeInsetsGeometryMix.all(5), ).variants([ VariantStyle( const BlockVariant('webview'), - BoxStyler( + BlockStyler( padding: EdgeInsetsGeometryMix.all(20), margin: EdgeInsetsGeometryMix.all(10), ), @@ -508,6 +1409,30 @@ class _BlockSizeProbe extends StatelessWidget { } } +class _BlockDataProbe extends StatelessWidget { + const _BlockDataProbe({required this.onBuild}); + + final ValueChanged onBuild; + + @override + Widget build(BuildContext context) { + onBuild(BlockConfiguration.of(context)); + return const SizedBox.shrink(); + } +} + +class _AlignmentProbe extends StatelessWidget { + const _AlignmentProbe({required this.onBuild}); + + final ValueChanged onBuild; + + @override + Widget build(BuildContext context) { + onBuild(BlockConfiguration.of(context).align); + return const SizedBox.shrink(); + } +} + class _VariantAwareWidget extends StatelessWidget { const _VariantAwareWidget(); diff --git a/packages/superdeck/test/src/rendering/section_widget_test.dart b/packages/superdeck/test/src/rendering/section_widget_test.dart index 863b00f9..7ce85131 100644 --- a/packages/superdeck/test/src/rendering/section_widget_test.dart +++ b/packages/superdeck/test/src/rendering/section_widget_test.dart @@ -38,6 +38,149 @@ void main() { }); group('horizontal flex distribution', () { + testWidgets('spacing zero preserves existing equal rectangles', ( + tester, + ) async { + _setSlideViewport(tester); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'zero-spacing', + sections: [ + SectionBlock([ContentBlock('Left'), ContentBlock('Right')]), + ], + ), + ); + + final sectionRect = tester.getRect(find.byType(SectionWidget)); + final blocks = find.byType(BlockWidget); + final firstRect = tester.getRect(blocks.at(0)); + final secondRect = tester.getRect(blocks.at(1)); + + expect(sectionRect.width, 1280); + expect(firstRect.width, 640); + expect(secondRect.width, 640); + expect(firstRect.left, sectionRect.left); + expect(secondRect.left, sectionRect.left + 640); + }); + + testWidgets('one block ignores spacing and fills the section', ( + tester, + ) async { + _setSlideViewport(tester); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'single-block-spacing', + sections: [ + SectionBlock([ContentBlock('Only')], spacing: 40), + ], + ), + ); + + final sectionRect = tester.getRect(find.byType(SectionWidget)); + final blockRect = tester.getRect(find.byType(BlockWidget)); + + expect(blockRect, sectionRect); + }); + + testWidgets('two equal blocks reserve requested spacing', (tester) async { + _setSlideViewport(tester); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'two-block-spacing', + sections: [ + SectionBlock([ + ContentBlock('Left'), + ContentBlock('Right'), + ], spacing: 40), + ], + ), + ); + + final sectionRect = tester.getRect(find.byType(SectionWidget)); + final blocks = find.byType(BlockWidget); + final firstRect = tester.getRect(blocks.at(0)); + final secondRect = tester.getRect(blocks.at(1)); + + expect(firstRect.width, 620); + expect(secondRect.width, 620); + expect(firstRect.left, sectionRect.left); + expect(secondRect.left, sectionRect.left + 660); + }); + + testWidgets('three weighted blocks preserve flex after gaps', ( + tester, + ) async { + _setSlideViewport(tester); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'weighted-spacing', + sections: [ + SectionBlock([ + ContentBlock('One', flex: 1), + ContentBlock('Two', flex: 2), + ContentBlock('Three', flex: 1), + ], spacing: 20), + ], + ), + ); + + final sectionRect = tester.getRect(find.byType(SectionWidget)); + final blocks = find.byType(BlockWidget); + final firstRect = tester.getRect(blocks.at(0)); + final secondRect = tester.getRect(blocks.at(1)); + final thirdRect = tester.getRect(blocks.at(2)); + + expect(firstRect.width, 310); + expect(secondRect.width, 620); + expect(thirdRect.width, 310); + expect(firstRect.left, sectionRect.left); + expect(secondRect.left, sectionRect.left + 330); + expect(thirdRect.left, sectionRect.left + 970); + }); + + testWidgets('oversized spacing clamps children inside the section', ( + tester, + ) async { + _setSlideViewport(tester); + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'oversized-spacing', + sections: [ + SectionBlock([ + ContentBlock('One'), + ContentBlock('Two'), + ContentBlock('Three'), + ], spacing: 1000), + ], + ), + ); + + final sectionRect = tester.getRect(find.byType(SectionWidget)); + final blocks = find.byType(BlockWidget); + final rects = [ + tester.getRect(blocks.at(0)), + tester.getRect(blocks.at(1)), + tester.getRect(blocks.at(2)), + ]; + + for (final rect in rects) { + expect(rect.width.isFinite, isTrue); + expect(rect.width, greaterThanOrEqualTo(0)); + expect(rect.left, greaterThanOrEqualTo(sectionRect.left)); + expect(rect.right, lessThanOrEqualTo(sectionRect.right)); + } + expect(rects.map((rect) => rect.left - sectionRect.left), [ + 0, + 640, + 1280, + ]); + }); + testWidgets('two blocks with equal flex have equal widths', ( tester, ) async { @@ -159,3 +302,10 @@ void main() { }); }); } + +void _setSlideViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); +} diff --git a/packages/superdeck/test/src/rendering/slide_view_test.dart b/packages/superdeck/test/src/rendering/slide_view_test.dart index b1b26ef1..05db280d 100644 --- a/packages/superdeck/test/src/rendering/slide_view_test.dart +++ b/packages/superdeck/test/src/rendering/slide_view_test.dart @@ -246,12 +246,12 @@ void main() { DeckOptions( baseStyle: SlideStyler( blockContainer: - BoxStyler( + BlockStyler( padding: EdgeInsetsGeometryMix.all(40), ).variants([ VariantStyle( const BlockVariant('webview'), - BoxStyler(padding: EdgeInsetsGeometryMix.all(0)), + BlockStyler(padding: EdgeInsetsGeometryMix.all(0)), ), ]), ), @@ -328,6 +328,85 @@ void main() { expect(size.width, 1280); }); + + testWidgets('slide container insets reduce the real section frame', ( + tester, + ) async { + await SlideTestHarness.pumpSlide( + tester, + SlideFixtures.singleColumn(), + parts: const SlideParts(header: null, footer: null), + style: SlideStyler( + slideContainer: BoxStyler( + margin: EdgeInsetsGeometryMix.all(10), + padding: EdgeInsetsGeometryMix.all(20), + decoration: BoxDecorationMix( + border: BorderMix.all(BorderSideMix(width: 5)), + ), + ), + ), + ); + + expect( + tester.getRect(find.byType(SectionWidget)), + const Rect.fromLTWH(35, 35, 1210, 650), + ); + }); + + testWidgets('slide container constraints define the layout frame', ( + tester, + ) async { + await SlideTestHarness.pumpSlide( + tester, + SlideFixtures.singleColumn(), + parts: const SlideParts(header: null, footer: null), + style: SlideStyler( + slideContainer: BoxStyler( + constraints: BoxConstraintsMix(maxWidth: 1000, maxHeight: 600), + ), + ), + ); + + expect( + tester.getRect(find.byType(SectionWidget)), + const Rect.fromLTWH(140, 60, 1000, 600), + ); + }); + + testWidgets( + 'oversized header and footer are constrained proportionally', + (tester) async { + await SlideTestHarness.pumpSlide( + tester, + SlideFixtures.singleColumn(), + parts: const SlideParts( + header: PreferredSize( + preferredSize: Size.fromHeight(600), + child: SizedBox(key: ValueKey('oversized-header')), + ), + footer: PreferredSize( + preferredSize: Size.fromHeight(300), + child: SizedBox(key: ValueKey('oversized-footer')), + ), + ), + ); + + expect( + tester + .getSize(find.byKey(const ValueKey('oversized-header'))) + .height, + 480, + ); + expect( + tester + .getSize(find.byKey(const ValueKey('oversized-footer'))) + .height, + 240, + ); + expect(tester.getSize(find.byType(SectionWidget)).height, 0); + expect(tester.takeException(), isNull); + }, + ); }); }); } diff --git a/packages/superdeck/test/src/styling/components/block_styler_test.dart b/packages/superdeck/test/src/styling/components/block_styler_test.dart new file mode 100644 index 00000000..4de6ebd3 --- /dev/null +++ b/packages/superdeck/test/src/styling/components/block_styler_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; +import 'package:superdeck/src/styling/block_variant.dart' + show BlockVariantScope; +import 'package:superdeck/superdeck.dart' show BlockStyler, BlockVariant; + +/// Resolves [style] inside a widget tree, optionally under a +/// [BlockVariantScope] named [blockName]. +Future> resolveStyle( + WidgetTester tester, + BlockStyler style, { + String? blockName, +}) async { + late StyleSpec resolved; + Widget child = Builder( + builder: (context) { + resolved = style.build(context); + return const SizedBox.shrink(); + }, + ); + if (blockName != null) { + child = BlockVariantScope(name: blockName, child: child); + } + await tester.pumpWidget( + Directionality(textDirection: TextDirection.ltr, child: child), + ); + return resolved; +} + +void main() { + group('BlockStyler', () { + testWidgets('resolves the supported surface to a BoxSpec', (tester) async { + final style = BlockStyler( + padding: EdgeInsetsGeometryMix.all(40), + margin: EdgeInsetsGeometryMix.symmetric(horizontal: 8), + decoration: BoxDecorationMix( + color: const Color(0xFF112233), + borderRadius: BorderRadiusMix.circular(10), + ), + foregroundDecoration: BoxDecorationMix(color: const Color(0x22000000)), + clipBehavior: Clip.antiAlias, + ); + + final resolved = await resolveStyle(tester, style); + final spec = resolved.spec; + + expect(spec.padding, const EdgeInsets.all(40)); + expect(spec.margin, const EdgeInsets.symmetric(horizontal: 8)); + final decoration = spec.decoration as BoxDecoration; + expect(decoration.color, const Color(0xFF112233)); + expect(decoration.borderRadius, BorderRadius.circular(10)); + expect( + (spec.foregroundDecoration as BoxDecoration).color, + const Color(0x22000000), + ); + expect(spec.clipBehavior, Clip.antiAlias); + }); + + testWidgets('never resolves widget modifiers or competing geometry', ( + tester, + ) async { + final style = BlockStyler( + padding: EdgeInsetsGeometryMix.all(16), + ).merge(BlockStyler(margin: EdgeInsetsGeometryMix.all(4))); + + final resolved = await resolveStyle(tester, style); + + expect(resolved.widgetModifiers, isNull); + expect(resolved.spec.constraints, isNull); + expect(resolved.spec.transform, isNull); + expect(resolved.spec.alignment, isNull); + }); + + testWidgets('merge overrides matching insets and keeps decoration', ( + tester, + ) async { + final base = BlockStyler( + padding: EdgeInsetsGeometryMix.all(40), + decoration: BoxDecorationMix(color: const Color(0xFF445566)), + ); + final override = BlockStyler(padding: EdgeInsetsGeometryMix.all(12)); + + final resolved = await resolveStyle(tester, base.merge(override)); + + expect(resolved.spec.padding, const EdgeInsets.all(12)); + expect( + (resolved.spec.decoration as BoxDecoration).color, + const Color(0xFF445566), + ); + }); + + testWidgets('applies a matching BlockVariant after the base style', ( + tester, + ) async { + final style = BlockStyler(padding: EdgeInsetsGeometryMix.all(40)).variant( + const BlockVariant('image'), + BlockStyler(padding: EdgeInsetsGeometryMix.all(0)), + ); + + final inScope = await resolveStyle(tester, style, blockName: 'image'); + expect(inScope.spec.padding, EdgeInsets.zero); + + final outOfScope = await resolveStyle(tester, style, blockName: 'chart'); + expect(outOfScope.spec.padding, const EdgeInsets.all(40)); + + final noScope = await resolveStyle(tester, style); + expect(noScope.spec.padding, const EdgeInsets.all(40)); + }); + + test('rejects variant styles from the wider BoxStyler surface', () { + expect( + () => BlockStyler( + variants: [ + VariantStyle( + const BlockVariant('image'), + BoxStyler(constraints: BoxConstraintsMix(maxWidth: 100)), + ), + ], + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('BlockStyler'), + ), + ), + ); + }); + + testWidgets('snapshots a validated variant list', (tester) async { + const image = BlockVariant('image'); + final variants = >[ + VariantStyle(image, BlockStyler(padding: EdgeInsetsGeometryMix.all(0))), + ]; + final style = BlockStyler( + padding: EdgeInsetsGeometryMix.all(40), + variants: variants, + ); + + variants[0] = VariantStyle( + image, + BoxStyler(constraints: BoxConstraintsMix(maxWidth: 100)), + ); + + final resolved = await resolveStyle(tester, style, blockName: 'image'); + expect(resolved.spec.padding, EdgeInsets.zero); + expect(resolved.spec.constraints, isNull); + }); + + testWidgets('supports Mix spacing convenience methods', (tester) async { + final style = BlockStyler().paddingAll(24).marginOnly(top: 8, bottom: 4); + + final resolved = await resolveStyle(tester, style); + + expect(resolved.spec.padding, const EdgeInsets.all(24)); + expect(resolved.spec.margin, const EdgeInsets.only(top: 8, bottom: 4)); + }); + + testWidgets('keeps animation metadata through merge', (tester) async { + final style = BlockStyler( + padding: EdgeInsetsGeometryMix.all(8), + ).animate(AnimationConfig.linear(const Duration(milliseconds: 100))); + + final resolved = await resolveStyle( + tester, + style.merge(BlockStyler(margin: EdgeInsetsGeometryMix.all(2))), + ); + + expect(resolved.animation, isNotNull); + expect(resolved.spec.padding, const EdgeInsets.all(8)); + expect(resolved.spec.margin, const EdgeInsets.all(2)); + }); + + test('equal configurations are equal', () { + BlockStyler build() => BlockStyler( + padding: EdgeInsetsGeometryMix.all(40), + decoration: BoxDecorationMix(color: const Color(0xFF112233)), + clipBehavior: Clip.hardEdge, + ); + + expect(build(), build()); + expect(build().hashCode, build().hashCode); + expect( + build(), + isNot(BlockStyler(padding: EdgeInsetsGeometryMix.all(1))), + ); + }); + }); +} diff --git a/packages/superdeck/test/src/utils/converters_test.dart b/packages/superdeck/test/src/utils/converters_test.dart index d6bfa9b7..ab5a21ca 100644 --- a/packages/superdeck/test/src/utils/converters_test.dart +++ b/packages/superdeck/test/src/utils/converters_test.dart @@ -1,10 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mix/mix.dart'; import 'package:superdeck/src/utils/converters.dart'; import 'package:superdeck_core/superdeck_core.dart'; void main() { + group('BlockInsets.toEdgeInsets', () { + test('maps normalized physical edges', () { + final insets = BlockInsets(top: 1, right: 2, bottom: 3, left: 4); + + expect(insets.toEdgeInsets, const EdgeInsets.fromLTRB(4, 1, 2, 3)); + }); + }); + group('hexToColor', () { group('6-digit hex', () { test('parses with # prefix', () { @@ -145,188 +152,4 @@ void main() { expect(ImageFit.scaleDown.toBoxFit, BoxFit.scaleDown); }); }); - - group('ContentAlignment.toFlexAlignment', () { - group('Axis.horizontal (Row)', () { - const axis = Axis.horizontal; - - test('topLeft maps to (start, start)', () { - final result = ContentAlignment.topLeft.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.start); - expect(result.$2, CrossAxisAlignment.start); - }); - - test('topCenter maps to (center, start)', () { - final result = ContentAlignment.topCenter.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.center); - expect(result.$2, CrossAxisAlignment.start); - }); - - test('topRight maps to (end, start)', () { - final result = ContentAlignment.topRight.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.end); - expect(result.$2, CrossAxisAlignment.start); - }); - - test('centerLeft maps to (start, center)', () { - final result = ContentAlignment.centerLeft.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.start); - expect(result.$2, CrossAxisAlignment.center); - }); - - test('center maps to (center, center)', () { - final result = ContentAlignment.center.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.center); - expect(result.$2, CrossAxisAlignment.center); - }); - - test('centerRight maps to (end, center)', () { - final result = ContentAlignment.centerRight.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.end); - expect(result.$2, CrossAxisAlignment.center); - }); - - test('bottomLeft maps to (start, end)', () { - final result = ContentAlignment.bottomLeft.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.start); - expect(result.$2, CrossAxisAlignment.end); - }); - - test('bottomCenter maps to (center, end)', () { - final result = ContentAlignment.bottomCenter.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.center); - expect(result.$2, CrossAxisAlignment.end); - }); - - test('bottomRight maps to (end, end)', () { - final result = ContentAlignment.bottomRight.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.end); - expect(result.$2, CrossAxisAlignment.end); - }); - }); - - group('Axis.vertical (Column)', () { - const axis = Axis.vertical; - - test('topLeft maps to (start, start)', () { - final result = ContentAlignment.topLeft.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.start); - expect(result.$2, CrossAxisAlignment.start); - }); - - test('topCenter maps to (start, center)', () { - final result = ContentAlignment.topCenter.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.start); - expect(result.$2, CrossAxisAlignment.center); - }); - - test('topRight maps to (start, end)', () { - final result = ContentAlignment.topRight.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.start); - expect(result.$2, CrossAxisAlignment.end); - }); - - test('centerLeft maps to (center, start)', () { - final result = ContentAlignment.centerLeft.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.center); - expect(result.$2, CrossAxisAlignment.start); - }); - - test('center maps to (center, center)', () { - final result = ContentAlignment.center.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.center); - expect(result.$2, CrossAxisAlignment.center); - }); - - test('centerRight maps to (center, end)', () { - final result = ContentAlignment.centerRight.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.center); - expect(result.$2, CrossAxisAlignment.end); - }); - - test('bottomLeft maps to (end, start)', () { - final result = ContentAlignment.bottomLeft.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.end); - expect(result.$2, CrossAxisAlignment.start); - }); - - test('bottomCenter maps to (end, center)', () { - final result = ContentAlignment.bottomCenter.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.end); - expect(result.$2, CrossAxisAlignment.center); - }); - - test('bottomRight maps to (end, end)', () { - final result = ContentAlignment.bottomRight.toFlexAlignment(axis); - expect(result.$1, MainAxisAlignment.end); - expect(result.$2, CrossAxisAlignment.end); - }); - }); - }); - - group('BoxSpec.calculateBlockOffset', () { - test('empty spec returns Offset.zero', () { - final spec = BoxSpec(); - expect(spec.calculateBlockOffset, Offset.zero); - }); - - test('padding only calculates horizontal and vertical', () { - final spec = BoxSpec(padding: const EdgeInsets.all(10)); - expect(spec.calculateBlockOffset.dx, 20.0); - expect(spec.calculateBlockOffset.dy, 20.0); - }); - - test('margin only calculates horizontal and vertical', () { - final spec = BoxSpec(margin: const EdgeInsets.all(5)); - expect(spec.calculateBlockOffset.dx, 10.0); - expect(spec.calculateBlockOffset.dy, 10.0); - }); - - test('asymmetric padding calculates correctly', () { - final spec = BoxSpec( - padding: const EdgeInsets.only(left: 10, right: 20, top: 5, bottom: 15), - ); - expect(spec.calculateBlockOffset.dx, 30.0); - expect(spec.calculateBlockOffset.dy, 20.0); - }); - - test('combined padding and margin sums correctly', () { - final spec = BoxSpec( - padding: const EdgeInsets.all(10), - margin: const EdgeInsets.all(5), - ); - expect(spec.calculateBlockOffset.dx, 30.0); - expect(spec.calculateBlockOffset.dy, 30.0); - }); - - test('decoration with border adds border dimensions', () { - final spec = BoxSpec( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration(border: Border.all(width: 2)), - ); - expect(spec.calculateBlockOffset.dx, 24.0); - expect(spec.calculateBlockOffset.dy, 24.0); - }); - - test('decoration without border does not add extra offset', () { - final spec = BoxSpec( - padding: const EdgeInsets.all(10), - decoration: const BoxDecoration(color: Colors.red), - ); - expect(spec.calculateBlockOffset.dx, 20.0); - expect(spec.calculateBlockOffset.dy, 20.0); - }); - - test('symmetric horizontal padding', () { - final spec = BoxSpec(padding: const EdgeInsets.symmetric(horizontal: 15)); - expect(spec.calculateBlockOffset.dx, 30.0); - expect(spec.calculateBlockOffset.dy, 0.0); - }); - - test('symmetric vertical margin', () { - final spec = BoxSpec(margin: const EdgeInsets.symmetric(vertical: 8)); - expect(spec.calculateBlockOffset.dx, 0.0); - expect(spec.calculateBlockOffset.dy, 16.0); - }); - }); }