diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0a0ee..6fe6be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,12 @@ All notable changes to this project will be documented in this file. - [ ] `example/` updated when the change touches the canonical consumer scaffold - [ ] `flutter test` green; `dart analyze` clean; `dart format` no diff; `dart pub publish --dry-run` no blocking errors +## [0.0.4] - 2026-07-08 + ### Added +- **`design:sync` + `design:lint` commands make DESIGN.md the single source of truth for the app theme.** Two new commands join `MagicArtisanProvider`. `design:sync` parses a `DESIGN.md` (YAML front matter: color roles with a single-file `dark:` overlay, typography, `rounded`, `spacing`, and `components` carrying `{colors.x}` / `{rounded.x}` / `{spacing.x}` references; the markdown body is ignored), resolves the references against a dotted-path symbol table with a cycle guard, and emits a wind theme source file (`--output`, default `lib/config/wind_theme.g.dart`). The generated file exposes `Map designAliases` carrying the 17 property-prefixed semantic keys (`bg-surface`, `text-fg`, `border-color-border`, ...) with arbitrary-hex light + `dark:` pairs (`'bg-surface': 'bg-[#f9f9ff] dark:bg-[#0f1419]'`), drop-in for `WindThemeData(aliases: ...)` and matching the `MagicStarterTokens.defaultAliases` contract, plus a brand `primary` `MaterialColor` with a generated 50-900 ramp (seeded from the DESIGN.md `primary` light hex) for `WindThemeData.toThemeData()` Material interop. It writes atomically via `.tmp` + rename and is idempotent (byte-identical output on re-run for an unchanged DESIGN.md). `design:lint` validates a DESIGN.md against six rules ported from the open design.md reference linter and adapted to the wind-flavored superset: broken-ref (error), missing-primary (warning), unknown-key (warning; the `dark:` overlay lives inside `colors` and is structurally never a top-level key, so it is never flagged), section-order (warning), missing-sections (info), orphaned-tokens (warning, with Material Design 3 baseline families exempt), and contrast-ratio (warning; a greenfield WCAG relative-luminance helper does sRGB channel linearization + the 4.5:1 ratio check on each component `backgroundColor` / `textColor` pair). The Tailwind/DTCG export-conformance and rem-based spacing/rounded rules from the reference linter are intentionally dropped (wind uses 4px logical spacing and arbitrary-hex aliases). The command exits nonzero only on an error-severity finding. Touches `lib/src/cli/commands/design_sync_command.dart`, `lib/src/cli/commands/design_lint_command.dart`, `lib/src/cli/helpers/design_md_parser.dart`, `lib/src/cli/magic_artisan_provider.dart`; adds `test/cli/commands/{design_sync,design_lint}_command_test.dart` + `test/cli/helpers/design_md_parser_test.dart`; documented in `doc/packages/magic-cli.md` (including the DESIGN.md format page). +- **`previews:refresh` + `make:component` codegen commands for the design-first preview catalog.** Two new commands join `MagicArtisanProvider`. `previews:refresh` scans a configurable target directory (`--path`, default `lib`) for `*.preview.dart` files, extracts the single public `*Preview` class from each via regex (the private `_*State` companion of a stateful preview is ignored), validates the class name is a clean PascalCase identifier before interpolation, fails fast on a slug collision, sorts deterministically, and renders `/_previews.g.dart` through an atomic `.tmp` + rename. The generated file returns a freshly-built `List` from the `previewEntries()` FUNCTION (never a top-level const list) so the dev-only catalog tree-shakes from release builds (dart-lang/sdk#33920); it imports `PreviewEntry` from `package:magic_devtools/preview.dart` and each preview widget by its path relative to the generated file (preview files are not exported from any barrel). Re-running the command produces a byte-identical file. `make:component [--variants=intent,size] [--slots]` extends `ArtisanGeneratorCommand` and scaffolds the canonical 4-file atomic component folder under `lib/ui/components//` (`.dart`, `.recipe.dart`, `.preview.dart`, `index.dart`): the class is unprefixed PascalCase, the recipe is seeded with the requested variant axes (or a `WindSlotRecipe` shape under `--slots`), the index re-exports the component + recipe but not the preview, then the command chains `previews:refresh` so the new preview lands in `_previews.g.dart`. Touches `lib/src/cli/commands/previews_refresh_command.dart`, `lib/src/cli/commands/make_component_command.dart`, `lib/src/cli/helpers/previews_index_writer.dart`, `lib/src/cli/helpers/magic_stub_loader.dart` (adds `loadFrom`), `lib/src/cli/magic_artisan_provider.dart`, and six stubs under `assets/stubs/`; adds `test/cli/commands/{previews_refresh,make_component}_command_test.dart`. - **`magic:install --with-devtools` wires the debug trio in one step.** Installing the optional debug tooling (`magic_devtools` + `fluttersdk_dusk` + `fluttersdk_telescope`) previously meant a manual multi-step bootstrap: add three deps, run `plugin:install` twice, then `dusk:install` + `telescope:install`. The new `--with-devtools` flag does all of it after the core install: it adds the three packages to `dependencies` (regular, not `dev_dependencies`, because `lib/main.dart` imports them and the `kDebugMode` gate tree-shakes the subsystem from release builds, so `dev_dependencies` would trip `depend_on_referenced_packages`) and wires `lib/main.dart` under `kDebugMode` exactly as `dusk:install` / `telescope:install` do: `DuskPlugin.install()` and `TelescopePlugin.install()` (plus `ExceptionWatcher` + `DumpWatcher`) before `Magic.init()`, then `MagicDuskIntegration.install()` and `MagicTelescopeIntegration.install()` after it. The wiring is a pure-functional, idempotent transform (`buildDevtoolsWiring`) over the generated main.dart, and the dep-add rides the same `installer.addDependency` mechanism the install already uses, so re-running `magic:install --with-devtools` never duplicates a wiring block or a dependency entry. The injected package imports are placed within the existing package-import group (before the relative `config/...` imports, with `package:flutter/foundation.dart` ordered before `package:flutter/material.dart`), so the generated main.dart stays `directives_ordering`-clean and a freshly installed app emits no analyzer warnings. Absent the flag, nothing changes for the existing install path. Touches `lib/src/cli/commands/magic_install_command.dart`; adds the `MagicInstallCommand.buildDevtoolsWiring` test group plus a real-FS full-install group to `test/cli/commands/magic_install_command_test.dart`. - **`MagicMiddleware.redirectTarget(String location)` for pre-build redirect guards.** Redirect-style guards (auth / guest) can now return a redirect target synchronously, evaluated inside the router's `redirect` callback BEFORE any page builds. Previously the only way to redirect was an imperative `MagicRoute.to()` inside `handle()`, which runs post-mount and remounts the destination view, recreating its form state on every mount (the login-double-mount bug). `_handleRedirect` now evaluates every matched route's global + route middleware `redirectTarget` and returns the first non-null target. The default returns `null`, and `handle()` now defaults to `next()`, so a redirect-only guard overrides just `redirectTarget`. Fully backward compatible: existing `handle()`-based guards keep working. Touches `lib/src/http/middleware/magic_middleware.dart`, `lib/src/routing/magic_router.dart`; adds `test/routing/redirect_guard_mount_test.dart` (asserts the destination mounts exactly once, including through a layout ShellRoute). @@ -28,7 +32,7 @@ All notable changes to this project will be documented in this file. ### Changed -- **`fluttersdk_wind` constraint bumped to `^1.1.2`.** Picks up wind 1.1.0's Material-free `WInput`/`WText` rewrite, 1.1.1's two fixes that magic's W-widget UI depends on (`WInput` native text selection restored (mouse drag-select, double-tap word, long-press), and `WText` now inherits an ancestor `DefaultTextStyle` color (the CSS text-color cascade) before falling back to the OS-brightness baseline; the latter fixes invisible labels on magic's W-rendered surfaces (`Magic*View`, `MagicFeedback`, dialog buttons whose color lives on the container) when the app theme disagrees with the OS theme), and 1.1.2's `WPopover` fix: a popover with an interactive trigger (a `WButton`/`WAnchor` with its own `onTap`) now opens reliably and no longer dismisses itself on the opening gesture, with the trigger kept accessible via a `Semantics` tap action. This is the primitive behind magic_starter's team selector and user/notification dropdowns. Touches `pubspec.yaml`. +- **`fluttersdk_wind` constraint bumped to `^1.2.0`.** Requires wind 1.2.0's `WindRecipe`/`WindSlotRecipe` (the `tv()`-equivalent recipe API, NEW in 1.2.0 and re-exported by `package:magic/magic.dart` โ€” the design-first component layer and `make:component` scaffolds depend on it), plus the intrinsic-safe flex, the seeded `primary` token, the min-width-stretch scroll, and the `h-full`-inside-vertical-scroll dev assert. Also picks up wind 1.1.0's Material-free `WInput`/`WText` rewrite, 1.1.1's two fixes that magic's W-widget UI depends on (`WInput` native text selection restored (mouse drag-select, double-tap word, long-press), and `WText` now inherits an ancestor `DefaultTextStyle` color (the CSS text-color cascade) before falling back to the OS-brightness baseline; the latter fixes invisible labels on magic's W-rendered surfaces (`Magic*View`, `MagicFeedback`, dialog buttons whose color lives on the container) when the app theme disagrees with the OS theme), and 1.1.2's `WPopover` fix: a popover with an interactive trigger (a `WButton`/`WAnchor` with its own `onTap`) now opens reliably and no longer dismisses itself on the opening gesture, with the trigger kept accessible via a `Semantics` tap action. This is the primitive behind magic_starter's team selector and user/notification dropdowns. Touches `pubspec.yaml`. - **Debug-tooling install guidance corrected to regular `dependencies`.** The `magic:install` post-install message recommended adding `magic_devtools` / `fluttersdk_dusk` / `fluttersdk_telescope` to `dev_dependencies`, but the install commands wire them into `lib/main.dart` (under `kDebugMode`), which trips the `depend_on_referenced_packages` lint. They are now documented as regular `dependencies` (tree-shaken from release via `kDebugMode`), matching dusk/telescope's own install docs. Also bumps the message's stale `fluttersdk_dusk ^0.0.7` to `^0.0.8`. Touches `install.yaml`. ## [0.0.3] - 2026-06-17 diff --git a/README.md b/README.md index 894fb99..ca2a779 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The same Facades, the same Eloquent syntax, the same Service Provider lifecycle | ๐ŸŽจ | **Wind UI** | Built-in [Wind](https://wind.fluttersdk.com) Tailwind-syntax styling with `className` strings. | | ๐Ÿ“ก | **Broadcasting** | Laravel Echo equivalent: real-time WebSocket channels via the `Echo` facade with presence support and `Echo.fake()`. | | ๐Ÿงช | **Testing** | First-class fakes: `Http.fake()`, `Auth.fake()`, `Cache.fake()`, `Vault.fake()`, `Log.fake()`, `Echo.fake()`. No mockito needed. | -| ๐Ÿงฐ | **Magic CLI** | Artisan-style scaffolding via `dart run magic:artisan make:model`, `make:controller`, and 14 generators. | +| ๐Ÿงฐ | **Magic CLI** | Artisan-style scaffolding via `dart run magic:artisan make:model`, `make:controller`, 15 generators, plus `make:component` for design-first component workflows and `design:sync` / `design:lint` to drive the Wind theme from a `DESIGN.md`. | ## A taste of Magic diff --git a/assets/stubs/component.recipe.stub b/assets/stubs/component.recipe.stub new file mode 100644 index 0000000..9cfb142 --- /dev/null +++ b/assets/stubs/component.recipe.stub @@ -0,0 +1,15 @@ +import 'package:magic/magic.dart'; + +/// Builds the [WindRecipe] for the {{ className }} component. +/// +/// Each variant axis maps a value to a className built from semantic tokens +/// (`bg-surface`, `text-fg`, `border-color-border`, ...). Fill in the per-value +/// classNames; the emission order is `base ++ variant ++ compound ++ caller` +/// (never sorted or deduped) so state-prefixed tokens survive to parse time. +WindRecipe {{ camelName }}Recipe() { + return WindRecipe( + base: 'flex flex-col', + variants: { +{{ variantAxes }} }, +{{ defaultVariants }} ); +} diff --git a/assets/stubs/component.slot_recipe.stub b/assets/stubs/component.slot_recipe.stub new file mode 100644 index 0000000..af7b564 --- /dev/null +++ b/assets/stubs/component.slot_recipe.stub @@ -0,0 +1,17 @@ +import 'package:magic/magic.dart'; + +/// Builds the [WindSlotRecipe] for the {{ className }} component. +/// +/// Each slot maps to its own className, built from semantic tokens +/// (`bg-surface`, `text-fg`, `border-color-border`, ...). Variant axes carry a +/// per-slot className map; the emission order per slot is +/// `base ++ variant ++ compound ++ caller` (never sorted or deduped). +WindSlotRecipe {{ camelName }}Recipe() { + return WindSlotRecipe( + slots: { + 'root': 'flex flex-col', + }, + variants: { +{{ slotVariantAxes }} }, +{{ defaultVariants }} ); +} diff --git a/assets/stubs/component.slots.stub b/assets/stubs/component.slots.stub new file mode 100644 index 0000000..b10c18c --- /dev/null +++ b/assets/stubs/component.slots.stub @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import '{{ snakeName }}.recipe.dart'; + +/// {{ className }} component (slot-based). +/// +/// Slot styling lives in `{{ snakeName }}.recipe.dart` (a [WindSlotRecipe] +/// reading semantic tokens); this widget resolves the recipe to a per-slot +/// className map and threads each slot into its [WDiv]. +@immutable +class {{ className }} extends StatelessWidget { + /// The main content placed in the `root` slot. + final Widget child; + + /// Creates a [{{ className }}]. + const {{ className }}({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final slots = {{ camelName }}Recipe()(); + return WDiv( + className: slots['root'], + child: child, + ); + } +} diff --git a/assets/stubs/component.stub b/assets/stubs/component.stub new file mode 100644 index 0000000..bb8a1e7 --- /dev/null +++ b/assets/stubs/component.stub @@ -0,0 +1,33 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import '{{ snakeName }}.recipe.dart'; + +/// {{ className }} component. +/// +/// Variant logic lives in `{{ snakeName }}.recipe.dart` (a [WindRecipe] reading +/// semantic tokens); this widget threads the resolved className into a [WDiv]. +/// Pass [className] to override the recipe output entirely (escape hatch). +@immutable +class {{ className }} extends StatelessWidget { + /// The main content. + final Widget child; + + /// Optional className that overrides the recipe output entirely. + final String? className; + + /// Creates a [{{ className }}]. + const {{ className }}({ + super.key, + required this.child, + this.className, + }); + + @override + Widget build(BuildContext context) { + return WDiv( + className: className ?? {{ camelName }}Recipe()(), + child: child, + ); + } +} diff --git a/assets/stubs/component_index.stub b/assets/stubs/component_index.stub new file mode 100644 index 0000000..1153a41 --- /dev/null +++ b/assets/stubs/component_index.stub @@ -0,0 +1,8 @@ +// {{ className }} component folder-local barrel. +// +// Re-exports the public surface (component + recipe). The preview is +// intentionally NOT re-exported: `previews:refresh` discovers `*.preview.dart` +// files directly and the preview must stay out of the release barrel. + +export '{{ snakeName }}.dart'; +export '{{ snakeName }}.recipe.dart'; diff --git a/assets/stubs/install/app_config.stub b/assets/stubs/install/app_config.stub index 4f96a3d..b0ae235 100644 --- a/assets/stubs/install/app_config.stub +++ b/assets/stubs/install/app_config.stub @@ -4,6 +4,7 @@ Map get appConfig => { 'app': { 'name': env('APP_NAME', 'My App'), + 'title_separator': ' - ', 'env': env('APP_ENV', 'production'), 'debug': env('APP_DEBUG', false), 'key': env('APP_KEY'), diff --git a/assets/stubs/install/main.stub b/assets/stubs/install/main.stub index 584a9d4..98d88a6 100644 --- a/assets/stubs/install/main.stub +++ b/assets/stubs/install/main.stub @@ -12,6 +12,6 @@ void main() async { ); runApp( - MagicApplication(title: '{{ appName }}'), + MagicApplication(title: '{{ appName }}', titleSuffix: '{{ appName }}'), ); } diff --git a/assets/stubs/preview.stub b/assets/stubs/preview.stub new file mode 100644 index 0000000..292b5af --- /dev/null +++ b/assets/stubs/preview.stub @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; +import 'package:magic/magic.dart'; + +import '{{ snakeName }}.dart'; + +/// Static variant-matrix preview for [{{ className }}]. +/// +/// Renders the component so the dev-only `/preview` catalog can show it in both +/// light and dark. One public preview class per file is the discovery contract +/// `previews:refresh` enforces. +class {{ className }}Preview extends StatelessWidget { + /// Creates the {{ className }} preview. + const {{ className }}Preview({super.key}); + + @override + Widget build(BuildContext context) { + return const WDiv( + className: 'flex flex-col gap-6 p-6', + child: {{ className }}( + child: WText('{{ className }}', className: 'text-sm text-fg'), + ), + ); + } +} diff --git a/doc/packages/magic-cli.md b/doc/packages/magic-cli.md index 36afae5..101379d 100644 --- a/doc/packages/magic-cli.md +++ b/doc/packages/magic-cli.md @@ -22,6 +22,11 @@ The Magic CLI is an `fluttersdk_artisan` plugin that ships as part of the magic - [make:listener](#makelistener) - [make:request](#makerequest) - [make:lang](#makelang) + - [make:component](#makecomponent) + - [previews:refresh](#previewsrefresh) + - [design:sync](#designsync) + - [design:lint](#designlint) + - [DESIGN.md format](#designmd-format) ## Introduction @@ -353,3 +358,135 @@ dart run magic:artisan make:lang de ``` **Output:** `assets/lang/.json` + + +### make:component + +Scaffolds an atomic 4-file component folder under `lib/ui/components//`: + +```bash +dart run magic:artisan make:component Avatar +dart run magic:artisan make:component Avatar --variants=intent,size +dart run magic:artisan make:component Panel --slots +``` + +**Output** (for `Avatar`): + +- `lib/ui/components/avatar/avatar.dart` (`class Avatar`, unprefixed PascalCase) +- `lib/ui/components/avatar/avatar.recipe.dart` (a `WindRecipe`, or a `WindSlotRecipe` under `--slots`, seeded with the requested `--variants` axes) +- `lib/ui/components/avatar/avatar.preview.dart` (a single public `AvatarPreview` matrix) +- `lib/ui/components/avatar/index.dart` (re-exports the component + recipe, NOT the preview) + +After scaffolding, `make:component` chains `previews:refresh` so the new preview lands in `_previews.g.dart` automatically. + +#### Options + +- `--variants=a,b`: seed the named variant axes into the recipe (values left empty to fill in). +- `--slots`: scaffold a multi-part `WindSlotRecipe` instead of a single-element `WindRecipe`. +- `--force`: overwrite an existing component. + + +### previews:refresh + +Regenerates the dev-only preview catalog index from `*.preview.dart` files: + +```bash +dart run magic:artisan previews:refresh +dart run magic:artisan previews:refresh --path=lib/ui/components +``` + +**Output:** `/_previews.g.dart` (default scan dir `lib`). + +Each `*.preview.dart` file must declare exactly ONE public `*Preview` class. The command validates the class name, fails fast on a slug collision, sorts deterministically, and writes atomically. The generated file returns a `List` from the `previewEntries()` function (never a top-level const list) so the catalog tree-shakes from release builds. Feed it to the catalog via `MagicPreview.register(previewEntries())`. + +#### Options + +- `--path=DIR`: directory to scan for `*.preview.dart` files (default `lib`). + + +### design:sync + +Generates the wind theme (semantic aliases + brand seed) from a `DESIGN.md`: + +```bash +dart run magic:artisan design:sync +dart run magic:artisan design:sync --input=DESIGN.md --output=lib/config/wind_theme.g.dart +``` + +**Output:** a Dart source file exposing a `Map designAliases` and a `Map designColors`. The aliases map carries the 17 property-prefixed semantic keys (`bg-surface`, `text-fg`, `border-color-border`, ...) with arbitrary-hex light + `dark:` pairs (`'bg-surface': 'bg-[#f9f9ff] dark:bg-[#0f1419]'`), drop-in for `WindThemeData(aliases: designAliases)`. The brand `primary` `MaterialColor` carries a generated 50-900 ramp (seeded from the DESIGN.md `primary` light hex) for `WindThemeData.toThemeData()` Material interop. + +The command is idempotent (byte-identical output on re-run for an unchanged DESIGN.md) and writes atomically via `.tmp` + rename. Do not hand-edit the generated file; re-run `design:sync` instead. + +#### Options + +- `--input=PATH`: path to the DESIGN.md source, relative to the project root (default `DESIGN.md`). +- `--output=PATH`: path for the generated wind theme file (default `lib/config/wind_theme.g.dart`). + + +### design:lint + +Validates a `DESIGN.md` against the design rules: + +```bash +dart run magic:artisan design:lint +dart run magic:artisan design:lint --input=DESIGN.md +``` + +The command exits nonzero only on an error-severity finding (a broken reference). Warnings and info notes are reported but do not fail the lint. Rules: + +- **broken-ref** (error): a component references a token (`{colors.x}`, `{rounded.x}`, `{spacing.x}`) that does not resolve. +- **missing-primary** (warning): colors are defined but `primary` is absent. +- **unknown-key** (warning): a top-level YAML key looks like a typo of a schema key. The single-file `dark:` overlay lives inside `colors`, so it is never flagged. +- **section-order** (warning): markdown `##` sections appear out of canonical order. +- **missing-sections** (info): the optional `spacing` / `rounded` groups are absent. +- **orphaned-tokens** (warning): a custom color token is defined but never referenced by any component (Material Design 3 baseline families are exempt). +- **contrast-ratio** (warning): a component `backgroundColor` / `textColor` pair falls below the WCAG AA minimum of 4.5:1. + +#### Options + +- `--input=PATH`: path to the DESIGN.md to validate, relative to the project root (default `DESIGN.md`). + + +### DESIGN.md format + +`DESIGN.md` is the single source of truth for the app theme: a YAML front matter (machine-readable design tokens) followed by a markdown body (human-readable rationale; ignored by `design:sync`). + +```markdown +--- +name: Acme +colors: + surface: + light: "#f9f9ff" # single-file dark: overlay per role + dark: "#0f1419" + fg: + light: "#151c27" + dark: "#e6e9f0" + primary: + light: "#7c3aed" # the brand seed for the MaterialColor ramp + dark: "#a78bfa" + on-primary: "#ffffff" # a bare hex is light-only (dark mirrors light) +typography: + body-md: + fontFamily: Plus Jakarta Sans + fontSize: 16px +rounded: + md: 8px +spacing: + md: 16px +components: + button-primary: + backgroundColor: "{colors.primary}" # {group.token} reference + textColor: "{colors.on-primary}" + rounded: "{rounded.md}" + padding: "{spacing.md}" +--- + +## Overview +... +## Colors +... +``` + +Each color role is either a bare hex scalar (light only; dark mirrors light) or a `{ light, dark }` overlay map. The 17 semantic roles map onto the wind alias keys as: `surface`/`surface-container`/`surface-container-high` -> `bg-surface*`; `fg` (or `on-surface`)/`fg-muted`/`fg-disabled` -> `text-fg*`; `primary`/`on-primary`/`primary-container` -> `bg-primary` / `text-on-primary` / `bg-primary-container`; `accent` -> `bg-accent`; `border`/`border-subtle` -> `border-color-border*`; `destructive`/`on-destructive`/`destructive-container` -> `bg-destructive` / `text-on-destructive` / `bg-destructive-container`; `success`/`warning` -> `bg-success` / `bg-warning`. + +This is a wind-flavored superset of the open DESIGN.md format: the single-file `dark:` overlay is the deliberate divergence that lets one document drive both light and dark wind aliases. diff --git a/lib/src/cli/commands/design_lint_command.dart b/lib/src/cli/commands/design_lint_command.dart new file mode 100644 index 0000000..4cd68ac --- /dev/null +++ b/lib/src/cli/commands/design_lint_command.dart @@ -0,0 +1,385 @@ +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:path/path.dart' as p; + +import '../helpers/design_md_parser.dart'; + +/// The severity of a [_Finding]. +enum _Severity { + /// A correctness violation; fails the lint (nonzero exit). + error, + + /// A quality concern; reported but does not fail the lint. + warning, + + /// An informational note. + info, +} + +/// A single lint finding. +class _Finding { + const _Finding(this.severity, this.path, this.message); + + final _Severity severity; + final String path; + final String message; +} + +/// `design:lint`: validates a `DESIGN.md` against six rules ported from the +/// design.md reference linter, adapted to the wind-flavored superset. +/// +/// Rules: +/// - **broken-ref** (error): a component references a token that does not +/// resolve. +/// - **missing-primary** (warning): colors are defined but `primary` is absent. +/// - **unknown-key** (warning): a top-level YAML key looks like a typo of a +/// schema key. The single-file `dark:` overlay lives inside `colors`, so it +/// is structurally never a top-level key and is never flagged. +/// - **section-order** (warning): markdown sections appear out of canonical +/// order. +/// - **missing-sections** (info): the optional `spacing` / `rounded` groups are +/// absent. +/// - **orphaned-tokens** (warning): a custom color token is defined but never +/// referenced (MD3 baseline families are exempt). +/// - **contrast-ratio** (warning): a component `backgroundColor` /`textColor` +/// pair falls below WCAG AA 4.5:1. +/// +/// The Tailwind/DTCG export-conformance and rem-based spacing/rounded rules +/// from the reference linter are intentionally dropped; wind uses 4px logical +/// spacing and arbitrary-hex aliases, so those checks do not apply. +class DesignLintCommand extends ArtisanCommand { + /// Creates a [DesignLintCommand]. + /// + /// @param projectRoot Override for project-root resolution; defaults to + /// [FileHelper.findProjectRoot]. + DesignLintCommand({String? projectRoot}) : _projectRootOverride = projectRoot; + + final String? _projectRootOverride; + + /// Canonical DESIGN.md section order, with accepted aliases resolved. + static const List _canonicalOrder = [ + 'Overview', + 'Colors', + 'Typography', + 'Layout', + 'Elevation & Depth', + 'Shapes', + 'Components', + "Do's and Don'ts", + ]; + + /// Section heading aliases that resolve to a canonical name. + static const Map _sectionAliases = { + 'Brand & Style': 'Overview', + 'Layout & Spacing': 'Layout', + 'Elevation': 'Elevation & Depth', + }; + + /// MD3 baseline color families never flagged as orphaned even when unused. + static const Set _md3Families = { + 'primary', + 'secondary', + 'tertiary', + 'error', + 'surface', + 'background', + 'outline', + 'fg', + 'accent', + 'border', + 'destructive', + 'success', + 'warning', + }; + + /// Maximum edit distance for the unknown-key typo heuristic. + static const int _maxTypoDistance = 2; + + @override + String get signature => + 'design:lint ' + '{--input=DESIGN.md : Path to the DESIGN.md to validate, relative to the project root}'; + + @override + String get description => 'Validate a DESIGN.md against the design rules.'; + + @override + CommandBoot get boot => CommandBoot.none; + + @override + Future handle(ArtisanContext ctx) async { + // 1. Resolve the input path and read it. + final root = _projectRootOverride ?? FileHelper.findProjectRoot(); + final inputPath = p.join( + root, + (ctx.input.option('input') as String?) ?? 'DESIGN.md', + ); + if (!FileHelper.fileExists(inputPath)) { + ctx.output.error('DESIGN.md not found at $inputPath.'); + return 1; + } + + final spec = DesignMdParser.parse(FileHelper.readFile(inputPath)); + + // 2. Run every rule and collect findings. + final findings = <_Finding>[ + if (spec.parseWarning != null) + _Finding(_Severity.warning, '', spec.parseWarning!), + ..._brokenRef(spec), + ..._missingPrimary(spec), + ..._unknownKey(spec), + ..._sectionOrder(spec), + ..._missingSections(spec), + ..._orphanedTokens(spec), + ..._contrastRatio(spec), + ]; + + // 3. Report and decide the exit code on error-severity findings. + return _report(ctx, findings); + } + + /// broken-ref: every unresolved component reference is an error. + List<_Finding> _brokenRef(DesignSpec spec) { + final findings = <_Finding>[]; + for (final entry in spec.components.entries) { + for (final ref in entry.value.unresolvedRefs) { + findings.add( + _Finding( + _Severity.error, + 'components.${entry.key}', + 'Reference $ref does not resolve to any defined token.', + ), + ); + } + } + return findings; + } + + /// missing-primary: colors defined but no `primary`. + List<_Finding> _missingPrimary(DesignSpec spec) { + if (spec.colors.isNotEmpty && !spec.colors.containsKey('primary')) { + return <_Finding>[ + const _Finding( + _Severity.warning, + 'colors', + "No 'primary' color defined. The agent will auto-generate key " + 'colors, reducing your control over the palette.', + ), + ]; + } + return const <_Finding>[]; + } + + /// unknown-key: a top-level key within edit distance 2 of a schema key. + List<_Finding> _unknownKey(DesignSpec spec) { + final findings = <_Finding>[]; + for (final key in spec.unknownTopLevelKeys) { + var bestMatch = ''; + var bestDistance = 1 << 30; + for (final known in DesignMdParser.schemaKeys) { + final distance = _levenshtein(key.toLowerCase(), known.toLowerCase()); + if (distance < bestDistance) { + bestDistance = distance; + bestMatch = known; + } + } + if (bestDistance <= _maxTypoDistance && bestMatch.isNotEmpty) { + findings.add( + _Finding( + _Severity.warning, + key, + 'Unknown key "$key" - did you mean "$bestMatch"?', + ), + ); + } + } + return findings; + } + + /// section-order: the first out-of-order known section pair. + List<_Finding> _sectionOrder(DesignSpec spec) { + final orderIndex = { + for (var i = 0; i < _canonicalOrder.length; i++) _canonicalOrder[i]: i, + }; + final known = spec.sections + .map((s) => _sectionAliases[s] ?? s) + .where(orderIndex.containsKey) + .toList(); + + for (var i = 0; i < known.length - 1; i++) { + final current = orderIndex[known[i]]!; + final next = orderIndex[known[i + 1]]!; + if (current > next) { + return <_Finding>[ + _Finding( + _Severity.warning, + 'sections', + "Section '${known[i]}' appears before '${known[i + 1]}', which " + 'is out of order. Expected order: ' + '${_canonicalOrder.join(', ')}', + ), + ]; + } + } + return const <_Finding>[]; + } + + /// missing-sections: optional spacing / rounded groups absent. + List<_Finding> _missingSections(DesignSpec spec) { + final findings = <_Finding>[]; + if (spec.colors.isEmpty) return findings; + if (spec.spacing.isEmpty) { + findings.add( + const _Finding( + _Severity.info, + 'spacing', + "No 'spacing' section defined. Layout spacing will fall back to " + 'agent defaults.', + ), + ); + } + if (spec.rounded.isEmpty) { + findings.add( + const _Finding( + _Severity.info, + 'rounded', + "No 'rounded' section defined. Corner rounding will fall back to " + 'agent defaults.', + ), + ); + } + return findings; + } + + /// orphaned-tokens: a custom color token never referenced by a component. + List<_Finding> _orphanedTokens(DesignSpec spec) { + if (spec.components.isEmpty) return const <_Finding>[]; + + // Collect every color value a component resolved to. A reference resolves + // to the role's light hex (see DesignMdParser symbol table), so match on + // that to discover which roles are actually in use. + final referencedHexes = {}; + for (final component in spec.components.values) { + referencedHexes.addAll(component.resolved.values); + } + + final referencedFamilies = {}; + for (final entry in spec.colors.entries) { + if (referencedHexes.contains(entry.value.light)) { + referencedFamilies.add(_colorFamily(entry.key)); + } + } + + final findings = <_Finding>[]; + for (final entry in spec.colors.entries) { + if (referencedHexes.contains(entry.value.light)) continue; + final family = _colorFamily(entry.key); + if (referencedFamilies.contains(family)) continue; + if (_md3Families.contains(family)) continue; + findings.add( + _Finding( + _Severity.warning, + 'colors.${entry.key}', + "'${entry.key}' is defined but never referenced by any component.", + ), + ); + } + return findings; + } + + /// contrast-ratio: a component bg/text pair below WCAG AA 4.5:1. + List<_Finding> _contrastRatio(DesignSpec spec) { + final findings = <_Finding>[]; + for (final entry in spec.components.entries) { + final bg = entry.value.resolved['backgroundColor']; + final text = entry.value.resolved['textColor']; + if (bg == null || text == null) continue; + + final ratio = DesignContrast.ratio(bg, text); + if (ratio < DesignContrast.wcagAaMinimum) { + findings.add( + _Finding( + _Severity.warning, + 'components.${entry.key}', + 'textColor ($text) on backgroundColor ($bg) has contrast ratio ' + '${ratio.toStringAsFixed(2)}:1, below the WCAG AA minimum of ' + '${DesignContrast.wcagAaMinimum}:1.', + ), + ); + } + } + return findings; + } + + /// Reduces an MD3-style color token name to its family root. + String _colorFamily(String name) { + var n = name; + n = n.replaceFirst(RegExp(r'^on-'), ''); + n = n.replaceFirst(RegExp(r'^inverse-'), ''); + n = n.replaceFirst(RegExp(r'^on-'), ''); + n = n.replaceFirst(RegExp(r'-container.*$'), ''); + n = n.replaceFirst(RegExp(r'-fixed.*$'), ''); + n = n.replaceFirst( + RegExp(r'-(dim|bright|tint|variant|muted|disabled|subtle)$'), + '', + ); + return n; + } + + /// The classic dynamic-programming Levenshtein edit distance. + int _levenshtein(String a, String b) { + if (a == b) return 0; + if (a.isEmpty) return b.length; + if (b.isEmpty) return a.length; + + var previous = List.generate(b.length + 1, (i) => i); + var current = List.filled(b.length + 1, 0); + + for (var i = 0; i < a.length; i++) { + current[0] = i + 1; + for (var j = 0; j < b.length; j++) { + final cost = a[i] == b[j] ? 0 : 1; + current[j + 1] = [ + current[j] + 1, + previous[j + 1] + 1, + previous[j] + cost, + ].reduce((x, y) => x < y ? x : y); + } + final swap = previous; + previous = current; + current = swap; + } + return previous[b.length]; + } + + /// Prints findings grouped by severity and returns the exit code (nonzero + /// when any error-severity finding is present). + int _report(ArtisanContext ctx, List<_Finding> findings) { + if (findings.isEmpty) { + ctx.output.success('DESIGN.md passed all design rules.'); + return 0; + } + + var errors = 0; + for (final finding in findings) { + final prefix = finding.path.isEmpty ? '' : '${finding.path}: '; + switch (finding.severity) { + case _Severity.error: + errors++; + ctx.output.error('$prefix${finding.message}'); + case _Severity.warning: + ctx.output.warning('$prefix${finding.message}'); + case _Severity.info: + ctx.output.info('$prefix${finding.message}'); + } + } + + if (errors > 0) { + ctx.output.error('DESIGN.md failed with $errors error(s).'); + return 1; + } + ctx.output.info( + 'DESIGN.md passed (with ${findings.length} non-error finding(s)).', + ); + return 0; + } +} diff --git a/lib/src/cli/commands/design_sync_command.dart b/lib/src/cli/commands/design_sync_command.dart new file mode 100644 index 0000000..a315a23 --- /dev/null +++ b/lib/src/cli/commands/design_sync_command.dart @@ -0,0 +1,264 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:path/path.dart' as p; + +import '../helpers/design_md_parser.dart'; + +/// `design:sync`: regenerates a wind theme source file from a `DESIGN.md`. +/// +/// DESIGN.md is the single source of truth for the app theme. This command +/// parses its front-matter color roles (each carrying a single-file `dark:` +/// overlay), maps them onto the 17 property-prefixed wind alias keys (the +/// `MagicStarterTokens.defaultAliases` contract), and emits a Dart source file +/// exposing: +/// +/// - a `Map` of arbitrary-hex light+dark alias pairs +/// (`'bg-surface': 'bg-[#f9f9ff] dark:bg-[#0f1419]'`), drop-in compatible +/// with `WindThemeData(aliases: ...)`, and +/// - a brand `primary` [MaterialColor] with a generated 50-900 ramp seeded from +/// the DESIGN.md `primary` light hex, for `WindThemeData.toThemeData()` +/// Material interop. +/// +/// The emission keys are property-prefixed (`bg-surface`, `text-fg`, +/// `border-color-border`) and NOT bare role names: wind's alias expander only +/// expands a whole unprefixed token that exactly matches an alias key. +/// +/// The generated file is written atomically (`.tmp` + rename) and is a pure +/// function of the DESIGN.md, so re-running on an unchanged input is +/// byte-identical (idempotent). +class DesignSyncCommand extends ArtisanCommand { + /// Creates a [DesignSyncCommand]. + /// + /// @param projectRoot Override for project-root resolution; defaults to + /// [FileHelper.findProjectRoot]. Used by tests to run + /// against a temp directory. + DesignSyncCommand({String? projectRoot}) : _projectRootOverride = projectRoot; + + final String? _projectRootOverride; + + /// DESIGN.md color role name -> (wind alias key, wind utility prefix). + /// + /// The order is the stable emission order of the generated alias map. A role + /// absent from the DESIGN.md is simply skipped (the consumer's + /// [DesignSyncCommand] does not fabricate values for missing roles). + static const List<_AliasMapping> _aliasMappings = <_AliasMapping>[ + _AliasMapping('surface', 'bg-surface', 'bg'), + _AliasMapping('surface-container', 'bg-surface-container', 'bg'), + _AliasMapping('surface-container-high', 'bg-surface-container-high', 'bg'), + _AliasMapping('fg', 'text-fg', 'text', altRole: 'on-surface'), + _AliasMapping('fg-muted', 'text-fg-muted', 'text'), + _AliasMapping('fg-disabled', 'text-fg-disabled', 'text'), + _AliasMapping('primary', 'bg-primary', 'bg'), + _AliasMapping('on-primary', 'text-on-primary', 'text'), + _AliasMapping('primary-container', 'bg-primary-container', 'bg'), + _AliasMapping('accent', 'bg-accent', 'bg'), + _AliasMapping('border', 'border-color-border', 'border'), + _AliasMapping('border-subtle', 'border-color-border-subtle', 'border'), + _AliasMapping('destructive', 'bg-destructive', 'bg'), + _AliasMapping('on-destructive', 'text-on-destructive', 'text'), + _AliasMapping('destructive-container', 'bg-destructive-container', 'bg'), + _AliasMapping('success', 'bg-success', 'bg'), + _AliasMapping('warning', 'bg-warning', 'bg'), + ]; + + @override + String get signature => + 'design:sync ' + '{--input=DESIGN.md : Path to the DESIGN.md source, relative to the project root} ' + '{--output=lib/config/wind_theme.g.dart : Path for the generated wind theme file}'; + + @override + String get description => + 'Generate the wind theme (semantic aliases + brand seed) from DESIGN.md.'; + + @override + CommandBoot get boot => CommandBoot.none; + + @override + Future handle(ArtisanContext ctx) async { + // 1. Resolve the input/output paths under the host project root. + final root = _projectRootOverride ?? FileHelper.findProjectRoot(); + final inputPath = p.join( + root, + (ctx.input.option('input') as String?) ?? 'DESIGN.md', + ); + final outputPath = p.join( + root, + (ctx.input.option('output') as String?) ?? 'lib/config/wind_theme.g.dart', + ); + + if (!FileHelper.fileExists(inputPath)) { + ctx.output.error('DESIGN.md not found at $inputPath.'); + return 1; + } + + // 2. Parse the DESIGN.md into the shared spec model. + final spec = DesignMdParser.parse(FileHelper.readFile(inputPath)); + if (!spec.hasFrontMatter) { + ctx.output.error( + 'DESIGN.md has no parseable YAML front matter; nothing to emit.', + ); + return 1; + } + if (!spec.colors.containsKey('primary')) { + ctx.output.error( + "DESIGN.md defines no 'primary' color; a brand seed is required.", + ); + return 1; + } + + // 3. Render the theme source (pure function of the spec) and write it + // atomically so open editors never observe a partial file. + final source = _renderTheme(spec); + final outFile = File(outputPath); + if (!outFile.parent.existsSync()) { + outFile.parent.createSync(recursive: true); + } + final tmpPath = '$outputPath.tmp'; + File(tmpPath).writeAsStringSync(source); + File(tmpPath).renameSync(outputPath); + + ctx.output.success( + 'Synced wind theme from ${p.relative(inputPath, from: root)} -> ' + '${p.relative(outputPath, from: root)}', + ); + return 0; + } + + /// Renders the generated wind theme Dart source. + /// + /// Pure: no I/O, no clock reads, so the output is fully determined by [spec] + /// (the idempotence guarantee). + String _renderTheme(DesignSpec spec) { + final buf = StringBuffer() + ..writeln('// GENERATED: do not edit by hand.') + ..writeln('// Regenerate via: dart run magic:artisan design:sync') + ..writeln('//') + ..writeln('// Source of truth: DESIGN.md') + ..writeln() + ..writeln("import 'package:flutter/material.dart';") + ..writeln() + ..writeln('/// Semantic wind alias map generated from DESIGN.md.') + ..writeln('///') + ..writeln('/// Drop-in for `WindThemeData(aliases: designAliases)`; the') + ..writeln('/// keys match the magic_starter token contract.') + ..writeln('const Map designAliases = {'); + + for (final mapping in _aliasMappings) { + final color = + spec.colors[mapping.role] ?? + (mapping.altRole != null ? spec.colors[mapping.altRole!] : null); + if (color == null) continue; + + final light = '${mapping.prefix}-[${color.light}]'; + final dark = 'dark:${mapping.prefix}-[${color.dark}]'; + buf.writeln(" '${mapping.aliasKey}': '$light $dark',"); + } + buf + ..writeln('};') + ..writeln() + ..writeln('/// The brand `primary` color with a generated 50-900 ramp.') + ..writeln('///') + ..writeln( + '/// Seeded from the DESIGN.md `primary` light hex; consumed by', + ) + ..writeln('/// `WindThemeData.toThemeData()` Material interop.') + ..writeln('final Map designColors ='); + + final primary = spec.colors['primary']!.light; + buf + ..writeln(' {') + ..writeln(" 'primary': ${_renderMaterialColor(primary)},") + ..writeln('};'); + + return buf.toString(); + } + + /// Renders a [MaterialColor] literal with a 50-900 ramp generated from the + /// seed [hex], mirroring the ramp shape used by the starter install command. + String _renderMaterialColor(String hex) { + final rgb = _hexToRgb(hex); + final seed = 0xFF000000 | (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; + + // Tint towards white for light shades, shade towards black for dark ones. + // 500 is the seed itself; this keeps the brand hue recognizable across the + // ramp without depending on a color-science package. + const ramp = { + 50: 0.92, + 100: 0.84, + 200: 0.66, + 300: 0.48, + 400: 0.26, + 500: 0.0, + 600: -0.12, + 700: -0.24, + 800: -0.36, + 900: -0.48, + }; + + final buf = StringBuffer() + ..writeln('MaterialColor(') + ..writeln( + ' 0x${seed.toRadixString(16).toUpperCase().padLeft(8, '0')},', + ) + ..writeln(' {'); + for (final entry in ramp.entries) { + final shade = _applyTint(rgb, entry.value); + final argb = 0xFF000000 | (shade[0] << 16) | (shade[1] << 8) | shade[2]; + buf.writeln( + ' ${entry.key}: Color(0x${argb.toRadixString(16).toUpperCase().padLeft(8, '0')}),', + ); + } + buf.write(' })'); + return buf.toString(); + } + + /// Tints [rgb] towards white (positive [factor]) or black (negative). + List _applyTint(List rgb, double factor) { + int channel(int c) { + if (factor >= 0) { + return (c + (255 - c) * factor).round().clamp(0, 255); + } + return (c * (1 + factor)).round().clamp(0, 255); + } + + return [channel(rgb[0]), channel(rgb[1]), channel(rgb[2])]; + } + + /// Parses a `#RGB` / `#RRGGBB` string into `[r, g, b]`; defaults to black on + /// a malformed value (the lint pass is responsible for rejecting bad hex). + List _hexToRgb(String hex) { + var body = hex.trim().toLowerCase().replaceFirst('#', ''); + if (body.length == 3) { + body = '${body[0]}${body[0]}${body[1]}${body[1]}${body[2]}${body[2]}'; + } + if (body.length != 6 || !RegExp(r'^[0-9a-f]{6}$').hasMatch(body)) { + return [0, 0, 0]; + } + return [ + int.parse(body.substring(0, 2), radix: 16), + int.parse(body.substring(2, 4), radix: 16), + int.parse(body.substring(4, 6), radix: 16), + ]; + } +} + +/// Maps a DESIGN.md color role onto a wind alias key + utility prefix. +class _AliasMapping { + const _AliasMapping(this.role, this.aliasKey, this.prefix, {this.altRole}); + + /// The DESIGN.md role name (e.g. `surface`). + final String role; + + /// An alternative role name accepted when [role] is absent (e.g. `on-surface` + /// satisfies the `text-fg` key). + final String? altRole; + + /// The wind alias key (e.g. `bg-surface`). + final String aliasKey; + + /// The wind utility prefix used for both the light and `dark:` token + /// (`bg`, `text`, or `border`). + final String prefix; +} diff --git a/lib/src/cli/commands/make_component_command.dart b/lib/src/cli/commands/make_component_command.dart new file mode 100644 index 0000000..1da4748 --- /dev/null +++ b/lib/src/cli/commands/make_component_command.dart @@ -0,0 +1,236 @@ +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:path/path.dart' as path; + +import '../helpers/magic_stub_loader.dart'; +import 'previews_refresh_command.dart'; + +/// `make:component [--variants=intent,size] [--slots]`: scaffolds an +/// atomic 4-file component folder (`.dart`, `.recipe.dart`, +/// `.preview.dart`, `index.dart`) under `lib/ui/components//`, then +/// chains `previews:refresh` so the new preview lands in `_previews.g.dart`. +/// +/// The component class is unprefixed PascalCase (`make:component Avatar` -> +/// `class Avatar`); the folder + files are `lower_snake_case`. The recipe is +/// seeded with the requested `--variants` axes (each value left empty for the +/// author to fill with token classNames). `--slots` seeds a [WindSlotRecipe] +/// shape instead of a single-element [WindRecipe]. +/// +/// Chaining follows the `make:model --all` pattern: a child command is parsed +/// against its own [ArgParser] and handled with a bare context that reuses the +/// parent output, so the operator sees one uninterrupted feedback stream. +class MakeComponentCommand extends ArtisanGeneratorCommand { + /// Creates a [MakeComponentCommand]. + /// + /// [testRoot] overrides the project root resolution, used in tests only. + MakeComponentCommand({String? testRoot}) : _testRoot = testRoot; + + final String? _testRoot; + + @override + CommandBoot get boot => CommandBoot.none; + + @override + String get name => 'make:component'; + + @override + String get description => + 'Scaffold an atomic component folder (recipe + component + preview + index)'; + + @override + String getDefaultNamespace() => 'lib/ui/components'; + + @override + String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + + /// Stub names resolve through the standard loader; this command writes four + /// files directly so [getStub] is unused. + @override + String getStub() => ''; + + @override + void configure(ArgParser parser) { + super.configure(parser); + parser.addOption( + 'variants', + help: 'Comma-separated variant axis names (e.g. intent,size).', + ); + parser.addFlag( + 'slots', + help: 'Scaffold a multi-part WindSlotRecipe instead of a single recipe.', + negatable: false, + ); + // Test seam: point the stub loader at a checkout-local assets/stubs dir + // without setting a process-wide env var. + parser.addOption( + 'stubs-dir', + help: 'Override the stubs directory (testing only).', + ); + } + + @override + Future handle(ArtisanContext ctx) async { + final rawName = ctx.input.argument(0); + if (rawName == null || rawName.isEmpty) { + ctx.output.error('Not enough arguments (missing: "name").'); + return 1; + } + + // 1. Resolve names: unprefixed PascalCase class, snake_case folder + files. + final parsed = StringHelper.parseName(rawName); + final className = StringHelper.toPascalCase(parsed.className); + final snakeName = StringHelper.toSnakeCase(parsed.className); + final camelName = StringHelper.toCamelCase(parsed.className); + + final componentDir = path.join( + getProjectRoot(), + getDefaultNamespace(), + snakeName, + ); + + // 2. Abort early if the folder already holds the component (unless --force). + final componentFile = path.join(componentDir, '$snakeName.dart'); + if (FileHelper.fileExists(componentFile) && !ctx.input.hasOption('force')) { + ctx.output.error('Component already exists at $componentFile'); + return 1; + } + + // 3. Parse the requested variant axes and the recipe shape (--slots). + final variantAxes = _parseVariants(ctx.input.option('variants') as String?); + final stubsDir = ctx.input.option('stubs-dir') as String?; + final slots = ctx.input.hasOption('slots'); + + final replacements = { + '{{ className }}': className, + '{{ snakeName }}': snakeName, + '{{ camelName }}': camelName, + '{{ variantAxes }}': _renderVariantAxes(variantAxes), + '{{ slotVariantAxes }}': _renderSlotVariantAxes(variantAxes), + '{{ defaultVariants }}': _renderDefaultVariants(variantAxes), + }; + + // 4. Write the four atomic files from their stubs. --slots swaps the + // single-element recipe + component for the WindSlotRecipe variants. + _writeStub( + slots ? 'component.slots' : 'component', + '$snakeName.dart', + componentDir, + replacements, + stubsDir, + ); + _writeStub( + slots ? 'component.slot_recipe' : 'component.recipe', + '$snakeName.recipe.dart', + componentDir, + replacements, + stubsDir, + ); + _writeStub( + 'preview', + '$snakeName.preview.dart', + componentDir, + replacements, + stubsDir, + ); + _writeStub( + 'component_index', + 'index.dart', + componentDir, + replacements, + stubsDir, + ); + + ctx.output.success('Created component: $componentDir'); + + // 5. Chain previews:refresh so the new preview lands in _previews.g.dart. + await _runChild( + PreviewsRefreshCommand(projectRoot: getProjectRoot()), + const [], + ctx, + ); + + return 0; + } + + /// Loads [stubName], applies [replacements], and writes the rendered content + /// to `/`. + void _writeStub( + String stubName, + String fileName, + String dir, + Map replacements, + String? stubsDir, + ) { + var content = stubsDir != null + ? MagicStubLoader.loadFrom(stubName, stubsDir) + : MagicStubLoader.load(stubName); + for (final entry in replacements.entries) { + content = content.replaceAll(entry.key, entry.value); + } + FileHelper.writeFile(path.join(dir, fileName), content); + } + + /// Splits the `--variants` option into trimmed, non-empty axis names. + List _parseVariants(String? raw) { + if (raw == null || raw.trim().isEmpty) return const []; + return raw + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + } + + /// Renders the `variants:` map body for the recipe stub. Each axis gets a + /// single placeholder value the author fills in with token classNames. + String _renderVariantAxes(List axes) { + if (axes.isEmpty) return ''; + final buf = StringBuffer(); + for (final axis in axes) { + buf + ..writeln(" '$axis': {") + ..writeln(" 'default': '',") + ..writeln(' },'); + } + return buf.toString(); + } + + /// Renders the `variants:` map body for the slot recipe stub. Each axis value + /// carries a per-slot className map (only the `root` slot is seeded). + String _renderSlotVariantAxes(List axes) { + if (axes.isEmpty) return ''; + final buf = StringBuffer(); + for (final axis in axes) { + buf + ..writeln(" '$axis': {") + ..writeln(" 'default': {'root': ''},") + ..writeln(' },'); + } + return buf.toString(); + } + + /// Renders the `defaultVariants:` block, or an empty string when there are + /// no axes (so the recipe stub stays analyzer-clean). + String _renderDefaultVariants(List axes) { + if (axes.isEmpty) return ''; + final buf = StringBuffer()..writeln(' defaultVariants: {'); + for (final axis in axes) { + buf.writeln(" '$axis': 'default',"); + } + buf.writeln(' },'); + return buf.toString(); + } + + /// Runs a sibling artisan command programmatically (the `make:model --all` + /// chaining pattern): parse [args] against the child's own [ArgParser], wrap + /// in an [ArgvInput], reuse the parent [ArtisanOutput] so the user sees one + /// uninterrupted feedback stream. + Future _runChild( + ArtisanCommand command, + List args, + ArtisanContext parentCtx, + ) async { + final parser = ArgParser(); + command.configure(parser); + final input = ArgvInput.parse(parser, args); + return command.handle(ArtisanContext.bare(input, parentCtx.output)); + } +} diff --git a/lib/src/cli/commands/previews_refresh_command.dart b/lib/src/cli/commands/previews_refresh_command.dart new file mode 100644 index 0000000..cdd7954 --- /dev/null +++ b/lib/src/cli/commands/previews_refresh_command.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:path/path.dart' as p; + +import '../helpers/previews_index_writer.dart'; + +/// `previews:refresh`: regenerates `/_previews.g.dart` from the +/// `*.preview.dart` files discovered under a configurable scan directory. +/// +/// The generated file is the dev-only preview catalog index consumed by +/// `MagicPreview.register(previewEntries())` (magic_devtools). It returns a +/// freshly-built `List` from a FUNCTION (never a top-level const +/// list) so the release-mode tree-shaker can prove the catalog unreachable. +/// +/// Mirrors the deterministic codegen primitives in fluttersdk_artisan +/// (`commands:refresh` / `plugins:refresh`): scan source of truth, extract via +/// regex, validate identifiers, collision-check, sort deterministically, +/// render a pure function of the inputs, write atomically via `.tmp` + rename. +/// Unlike those, it does NOT purge any AOT cli-bundle: the Flutter app consuming +/// this generated file has no out-of-isolate cli-bundle to invalidate. +/// +/// ## Failure modes +/// +/// - A `*.preview.dart` file with zero or multiple public preview classes -> +/// [FormatException]. +/// - A malformed preview class name -> [FormatException]. +/// - Two previews resolving to the same slug -> [FormatException]. +/// +/// ## Testing seam +/// +/// The constructor accepts an optional `projectRoot` override so the command +/// can run against a temp directory without walking up for `pubspec.yaml`. +class PreviewsRefreshCommand extends ArtisanCommand { + /// Creates a [PreviewsRefreshCommand]. + /// + /// @param projectRoot Override for project root resolution; defaults to + /// [FileHelper.findProjectRoot]. + PreviewsRefreshCommand({String? projectRoot}) + : _projectRootOverride = projectRoot; + + final String? _projectRootOverride; + + @override + String get signature => + 'previews:refresh {--path=lib : Directory to scan for *.preview.dart files}'; + + @override + String get description => + 'Regenerate /_previews.g.dart from discovered *.preview.dart files.'; + + @override + CommandBoot get boot => CommandBoot.none; + + @override + Future handle(ArtisanContext ctx) async { + // 1. Resolve the scan directory under the host project root. The path is + // consumer-configurable because the app decides which directory holds + // its preview files. + final root = _projectRootOverride ?? FileHelper.findProjectRoot(); + final scanPath = (ctx.input.option('path') as String?) ?? 'lib'; + final previewsDir = Directory(p.join(root, scanPath)); + + // 2. Scan, validate, collision-check, sort, render, atomic-write. + final discovered = writePreviewsIndex(previewsDir); + + // 3. Summarise so the operator sees what landed. + ctx.output.success( + 'Refreshed ${discovered.length} preview(s) -> ' + '${p.join(scanPath, '_previews.g.dart')}', + ); + return 0; + } +} diff --git a/lib/src/cli/helpers/design_md_parser.dart b/lib/src/cli/helpers/design_md_parser.dart new file mode 100644 index 0000000..7d66a4e --- /dev/null +++ b/lib/src/cli/helpers/design_md_parser.dart @@ -0,0 +1,447 @@ +import 'dart:math' as math; + +import 'package:yaml/yaml.dart'; + +/// A single color role resolved into its light and dark sRGB hex values. +/// +/// DESIGN.md models dark mode as a single-file overlay: a role is either a bare +/// hex scalar (light only, [dark] mirrors [light]) or a `{ light, dark }` map. +/// This diverges from the upstream Google design.md format, which has no +/// in-file dark overlay; the divergence is the deliberate "wind-flavored +/// superset" that lets one DESIGN.md drive a wind light+dark alias map. +class DesignColor { + /// Creates a color role with explicit light and dark hex values. + const DesignColor({required this.light, required this.dark}); + + /// The light-mode sRGB hex value (e.g. `#f9f9ff`). + final String light; + + /// The dark-mode sRGB hex value (e.g. `#0f1419`); equals [light] when the + /// role declared no overlay. + final String dark; +} + +/// A component token group with its references already resolved. +class DesignComponent { + /// Creates a component from its [resolved] sub-token values and the set of + /// references that could not be resolved. + const DesignComponent({required this.resolved, required this.unresolvedRefs}); + + /// Sub-token name to its resolved literal value (references followed to the + /// underlying primitive). An unresolved reference is omitted from this map + /// and recorded in [unresolvedRefs]. + final Map resolved; + + /// The raw `{...}` reference strings that did not resolve (broken or + /// circular). Empty when every reference resolved. + final List unresolvedRefs; +} + +/// The parsed, fully-resolved DESIGN.md model. +/// +/// Pure data: both `design:sync` (emission) and `design:lint` (validation) +/// consume this single representation so the two commands never drift on what +/// a DESIGN.md means. +class DesignSpec { + /// Creates a design spec. + const DesignSpec({ + required this.name, + required this.hasFrontMatter, + required this.colors, + required this.typography, + required this.rounded, + required this.spacing, + required this.components, + required this.sections, + required this.unknownTopLevelKeys, + required this.parseWarning, + }); + + /// The `name` front-matter field, or an empty string when absent. + final String name; + + /// Whether a YAML front-matter block was present and parsed. + final bool hasFrontMatter; + + /// Color roles keyed by their DESIGN.md role name (`surface`, `primary`...). + final Map colors; + + /// Typography groups keyed by level name; each is a property map. + final Map> typography; + + /// Corner-radius scale keyed by level name (`sm`, `md`, ...). + final Map rounded; + + /// Spacing scale keyed by level name. + final Map spacing; + + /// Component token groups keyed by component name. + final Map components; + + /// Markdown `##` section headings in document order. + final List sections; + + /// Top-level YAML keys that are not part of the schema (used by the + /// unknown-key lint rule). The `dark:` overlay lives inside `colors`, so it + /// never surfaces here. + final List unknownTopLevelKeys; + + /// A recoverable parse warning (e.g. missing front matter), or null. + final String? parseWarning; +} + +/// Parses a DESIGN.md document into a [DesignSpec]. +/// +/// Parsing is total: malformed references, missing front matter, and circular +/// references never throw; they surface as [DesignComponent.unresolvedRefs] or +/// [DesignSpec.parseWarning] so the linter can report them as findings. +class DesignMdParser { + DesignMdParser._(); + + /// Canonical top-level YAML keys per the DESIGN.md schema. + static const List schemaKeys = [ + 'version', + 'name', + 'description', + 'colors', + 'typography', + 'rounded', + 'spacing', + 'components', + ]; + + /// Maximum reference-chain depth before a reference is treated as unresolved. + static const int _maxReferenceDepth = 10; + + /// Matches a `{group.token}` reference. + static final RegExp _referencePattern = RegExp(r'^\{([a-zA-Z0-9_.-]+)\}$'); + + /// Parses [content] into a [DesignSpec]. + /// + /// @param content The raw DESIGN.md text (YAML front matter + markdown). + /// @return The fully-resolved spec. + static DesignSpec parse(String content) { + // 1. Split the front matter from the markdown body. + final frontMatter = _extractFrontMatter(content); + final sections = _extractSections(content); + + if (frontMatter == null) { + return DesignSpec( + name: '', + hasFrontMatter: false, + colors: const {}, + typography: const >{}, + rounded: const {}, + spacing: const {}, + components: const {}, + sections: sections, + unknownTopLevelKeys: const [], + parseWarning: + 'No YAML front matter found. Design tokens cannot be validated.', + ); + } + + final yaml = loadYaml(frontMatter); + if (yaml is! YamlMap) { + return DesignSpec( + name: '', + hasFrontMatter: false, + colors: const {}, + typography: const >{}, + rounded: const {}, + spacing: const {}, + components: const {}, + sections: sections, + unknownTopLevelKeys: const [], + parseWarning: 'Front matter is not a YAML mapping.', + ); + } + + // 2. Map the typed token groups. + final colors = _parseColors(yaml['colors']); + final typography = _parseTypography(yaml['typography']); + final rounded = _parseDimensionMap(yaml['rounded']); + final spacing = _parseDimensionMap(yaml['spacing']); + + // 3. Build the symbol table for reference resolution, then resolve every + // component sub-token against it. + final symbols = _buildSymbolTable(colors, rounded, spacing); + final components = _parseComponents(yaml['components'], symbols); + + // 4. Collect unknown top-level keys for the unknown-key rule. + final unknownKeys = [ + for (final key in yaml.keys) + if (key is String && !schemaKeys.contains(key)) key, + ]; + + return DesignSpec( + name: (yaml['name'] as String?) ?? '', + hasFrontMatter: true, + colors: colors, + typography: typography, + rounded: rounded, + spacing: spacing, + components: components, + sections: sections, + unknownTopLevelKeys: unknownKeys, + parseWarning: null, + ); + } + + /// Extracts the YAML front-matter block delimited by leading/trailing `---`. + static String? _extractFrontMatter(String content) { + final lines = content.split('\n'); + if (lines.isEmpty || lines.first.trim() != '---') return null; + + final buffer = StringBuffer(); + for (var i = 1; i < lines.length; i++) { + if (lines[i].trim() == '---') { + return buffer.toString(); + } + buffer.writeln(lines[i]); + } + return null; + } + + /// Extracts `##` section headings in document order, skipping any heading + /// inside the front-matter block. + static List _extractSections(String content) { + final lines = content.split('\n'); + final headings = []; + var inFrontMatter = false; + var seenFirstDelimiter = false; + + for (final line in lines) { + final trimmed = line.trim(); + if (trimmed == '---' && !seenFirstDelimiter && headings.isEmpty) { + inFrontMatter = !inFrontMatter; + seenFirstDelimiter = false; + continue; + } + if (trimmed == '---' && inFrontMatter) { + inFrontMatter = false; + continue; + } + if (inFrontMatter) continue; + + final match = RegExp(r'^##\s+(.+)$').firstMatch(line); + if (match != null) headings.add(match.group(1)!.trim()); + } + return headings; + } + + /// Parses the `colors` group, accepting either a bare hex scalar (light only) + /// or a `{ light, dark }` overlay map per role. + static Map _parseColors(dynamic node) { + if (node is! YamlMap) return const {}; + + final result = {}; + for (final entry in node.entries) { + final key = entry.key; + if (key is! String) continue; + final value = entry.value; + + if (value is String) { + result[key] = DesignColor(light: value, dark: value); + continue; + } + if (value is YamlMap) { + final light = (value['light'] ?? value['default']) as String?; + final dark = (value['dark'] ?? light) as String?; + if (light == null) continue; + result[key] = DesignColor(light: light, dark: dark ?? light); + } + } + return result; + } + + /// Parses the `typography` group into a name -> property-map structure. + static Map> _parseTypography(dynamic node) { + if (node is! YamlMap) return const >{}; + + final result = >{}; + for (final entry in node.entries) { + final key = entry.key; + final value = entry.value; + if (key is! String || value is! YamlMap) continue; + + final props = {}; + for (final prop in value.entries) { + if (prop.key is String) { + props[prop.key as String] = prop.value.toString(); + } + } + result[key] = props; + } + return result; + } + + /// Parses a flat `name -> dimension` group (`rounded`, `spacing`). + static Map _parseDimensionMap(dynamic node) { + if (node is! YamlMap) return const {}; + + final result = {}; + for (final entry in node.entries) { + if (entry.key is String) { + result[entry.key as String] = entry.value.toString(); + } + } + return result; + } + + /// Builds the dotted-path symbol table (`colors.primary`, `rounded.md`, ...) + /// against which component references resolve. + static Map _buildSymbolTable( + Map colors, + Map rounded, + Map spacing, + ) { + final symbols = {}; + for (final entry in colors.entries) { + // References resolve to the light value; dark is carried separately into + // the alias map by the emitter, not through the reference graph. + symbols['colors.${entry.key}'] = entry.value.light; + } + for (final entry in rounded.entries) { + symbols['rounded.${entry.key}'] = entry.value; + } + for (final entry in spacing.entries) { + symbols['spacing.${entry.key}'] = entry.value; + } + return symbols; + } + + /// Parses the `components` group and resolves each sub-token reference. + static Map _parseComponents( + dynamic node, + Map symbols, + ) { + if (node is! YamlMap) return const {}; + + final result = {}; + for (final entry in node.entries) { + final name = entry.key; + final body = entry.value; + if (name is! String || body is! YamlMap) continue; + + final resolved = {}; + final unresolved = []; + for (final prop in body.entries) { + final propName = prop.key; + if (propName is! String) continue; + final raw = prop.value.toString(); + + final match = _referencePattern.firstMatch(raw); + if (match == null) { + resolved[propName] = raw; + continue; + } + + final value = _resolveReference( + symbols, + match.group(1)!, + {}, + 0, + ); + if (value == null) { + unresolved.add(raw); + } else { + resolved[propName] = value; + } + } + result[name] = DesignComponent( + resolved: resolved, + unresolvedRefs: unresolved, + ); + } + return result; + } + + /// Resolves a `group.token` path with chained resolution and cycle detection. + /// Returns null when the path is missing, circular, or exceeds the depth cap. + static String? _resolveReference( + Map symbols, + String path, + Set visited, + int depth, + ) { + if (depth > _maxReferenceDepth) return null; + if (visited.contains(path)) return null; + visited.add(path); + + final value = symbols[path]; + if (value == null) return null; + + final match = _referencePattern.firstMatch(value); + if (match != null) { + return _resolveReference(symbols, match.group(1)!, visited, depth + 1); + } + return value; + } +} + +/// Greenfield WCAG relative-luminance + contrast-ratio helper. +/// +/// There is no existing magic luminance utility to reuse, so this implements +/// the WCAG 2.x definition directly: sRGB channel linearization, the +/// 0.2126/0.7152/0.0722 luminance weights, and the `(L1 + 0.05) / (L2 + 0.05)` +/// ratio. Input is sRGB hex (`#RGB` or `#RRGGBB`); other CSS color formats are +/// out of scope because DESIGN.md recommends hex as the normative default. +class DesignContrast { + DesignContrast._(); + + /// The WCAG AA minimum contrast ratio for normal text. + static const double wcagAaMinimum = 4.5; + + /// Computes the relative luminance of an sRGB hex color, or null when the + /// value is not a valid `#RGB` / `#RRGGBB` string. + static double? relativeLuminance(String hex) { + final rgb = _parseHex(hex); + if (rgb == null) return null; + + double linearize(int channel) { + final s = channel / 255.0; + return s <= 0.03928 + ? s / 12.92 + : math.pow((s + 0.055) / 1.055, 2.4) as double; + } + + return 0.2126 * linearize(rgb[0]) + + 0.7152 * linearize(rgb[1]) + + 0.0722 * linearize(rgb[2]); + } + + /// Computes the WCAG contrast ratio between two sRGB hex colors. + /// + /// Returns 1.0 (the no-contrast floor) when either color is unparseable, so + /// callers treat malformed input as a failed pair rather than crashing. + static double ratio(String a, String b) { + final la = relativeLuminance(a); + final lb = relativeLuminance(b); + if (la == null || lb == null) return 1.0; + + final lighter = math.max(la, lb); + final darker = math.min(la, lb); + return (lighter + 0.05) / (darker + 0.05); + } + + /// Parses a `#RGB` or `#RRGGBB` string into `[r, g, b]` (0-255), or null. + static List? _parseHex(String hex) { + final clean = hex.trim().toLowerCase(); + if (!clean.startsWith('#')) return null; + + var body = clean.substring(1); + if (body.length == 3) { + body = '${body[0]}${body[0]}${body[1]}${body[1]}${body[2]}${body[2]}'; + } + if (body.length != 6 || !RegExp(r'^[0-9a-f]{6}$').hasMatch(body)) { + return null; + } + + return [ + int.parse(body.substring(0, 2), radix: 16), + int.parse(body.substring(2, 4), radix: 16), + int.parse(body.substring(4, 6), radix: 16), + ]; + } +} diff --git a/lib/src/cli/helpers/magic_stub_loader.dart b/lib/src/cli/helpers/magic_stub_loader.dart index 361a9be..2218951 100644 --- a/lib/src/cli/helpers/magic_stub_loader.dart +++ b/lib/src/cli/helpers/magic_stub_loader.dart @@ -54,6 +54,20 @@ class MagicStubLoader { return file.readAsStringSync(); } + /// Loads the named stub from an explicit [stubsDir] (bypassing + /// package_config resolution). Used when the caller already knows the stubs + /// directory, e.g. a generator under test pointing at a checkout-local + /// `assets/stubs/`. + /// + /// Throws [StateError] when the stub file does not exist under [stubsDir]. + static String loadFrom(String name, String stubsDir) { + final file = File(p.join(stubsDir, '$name.stub')); + if (!file.existsSync()) { + throw StateError('Stub file not found: $name.stub at ${file.path}.'); + } + return file.readAsStringSync(); + } + /// Resolves the absolute path to magic's `assets/stubs/` directory. /// /// Strategy: diff --git a/lib/src/cli/helpers/previews_index_writer.dart b/lib/src/cli/helpers/previews_index_writer.dart new file mode 100644 index 0000000..7e24436 --- /dev/null +++ b/lib/src/cli/helpers/previews_index_writer.dart @@ -0,0 +1,224 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// One discovered preview entry from a `*.preview.dart` filesystem scan. +/// +/// Pairs the Dart class name with the URL-safe [slug] and human [label] the +/// catalog uses, plus the [importPath] relative to the generated +/// `_previews.g.dart` so the emitted file can reach the preview widget (preview +/// files are intentionally not exported from any barrel). +class DiscoveredPreview { + /// Creates a discovered preview. + const DiscoveredPreview({ + required this.className, + required this.slug, + required this.label, + required this.importPath, + }); + + /// The public preview class name (e.g. `ButtonPreview`). + final String className; + + /// The URL-safe `:component` slug (e.g. `button`). + final String slug; + + /// The catalog sidebar label (the class name minus the `Preview` suffix). + final String label; + + /// The import path relative to the generated file's directory. + final String importPath; +} + +/// Matches a PUBLIC preview class declaration: `class XxxPreview extends ...`. +/// +/// The leading `(? discoverPreviews(Directory previewsDir) { + if (!previewsDir.existsSync()) return const []; + + // 1. Collect candidate files in a stable order so the scan itself never + // depends on filesystem enumeration order. + final files = + previewsDir + .listSync(recursive: true) + .whereType() + .where((f) => p.basename(f.path).endsWith('.preview.dart')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + + // 2. Extract + validate one preview class per file. + final discovered = []; + for (final file in files) { + final source = file.readAsStringSync(); + final matches = _previewClassPattern.allMatches(source).toList(); + if (matches.length != 1) { + throw FormatException( + 'Expected exactly one public *Preview class in ' + '${p.basename(file.path)}, found ${matches.length}. Each ' + '*.preview.dart file must declare a single public preview widget.', + ); + } + + final className = matches.single.group(1)!; + if (!_identifierPattern.hasMatch(className)) { + throw FormatException( + 'Invalid preview class name "$className" in ' + '${p.basename(file.path)}. Expected a PascalCase Dart identifier ' + r'matching ^[A-Z][a-zA-Z0-9_]*$.', + ); + } + + final label = className.substring(0, className.length - 'Preview'.length); + discovered.add( + DiscoveredPreview( + className: className, + slug: _slugFor(label), + label: label, + importPath: _importPathFor(previewsDir, file), + ), + ); + } + + // 3. Fail fast on a slug collision so two previews never silently overwrite + // each other's `:component` route segment. + _validateNoSlugCollisions(discovered); + + // 4. Deterministic sort by slug keeps the emitted file byte-identical across + // runs regardless of discovery order. + discovered.sort((a, b) => a.slug.compareTo(b.slug)); + return discovered; +} + +/// Renders the `_previews.g.dart` source from an already-sorted [previews] +/// list. Pure function (no I/O, no clock reads) so its output is fully +/// determined by the inputs, which is the idempotence guarantee. +/// +/// The output is a FUNCTION returning a freshly-built `List`, +/// never a top-level const list: a const list of widget refs would defeat the +/// release-mode tree-shaker (dart-lang/sdk#33920) and pin the dev catalog into +/// production builds. +String renderPreviewsIndex(List previews) { + final buf = StringBuffer() + ..writeln('// GENERATED: do not edit by hand.') + ..writeln('// Regenerate via: dart run magic:artisan previews:refresh') + ..writeln('//') + ..writeln('// Source: *.preview.dart files discovered under the scan dir.') + ..writeln() + ..writeln("import 'package:magic_devtools/preview.dart';"); + + if (previews.isEmpty) { + buf + ..writeln() + ..writeln('List previewEntries() {') + ..writeln(' return [];') + ..writeln('}') + ..writeln(); + return buf.toString(); + } + + // Imports sorted by path for deterministic ordering. Slugs are already + // collision-checked, so two previews never share an import alias. + final imports = previews.map((e) => e.importPath).toSet().toList()..sort(); + for (final importPath in imports) { + buf.writeln("import '$importPath';"); + } + + buf + ..writeln() + ..writeln('List previewEntries() {') + ..writeln(' return ['); + for (final preview in previews) { + buf + ..writeln(' PreviewEntry(') + ..writeln(" label: '${preview.label}',") + ..writeln(" slug: '${preview.slug}',") + ..writeln(' builder: (_) => const ${preview.className}(),') + ..writeln(' ),'); + } + buf + ..writeln(' ];') + ..writeln('}') + ..writeln(); + return buf.toString(); +} + +/// End-to-end: scan [previewsDir], render, and atomically write +/// `_previews.g.dart` into that directory via `.tmp` + rename so concurrent +/// readers (open editors, lint daemons) never observe a partial file. +/// +/// @return The discovered previews so the caller can report what was wired. +List writePreviewsIndex(Directory previewsDir) { + final discovered = discoverPreviews(previewsDir); + if (!previewsDir.existsSync()) { + previewsDir.createSync(recursive: true); + } + final outputPath = p.join(previewsDir.path, '_previews.g.dart'); + final tmpPath = '$outputPath.tmp'; + File(tmpPath).writeAsStringSync(renderPreviewsIndex(discovered)); + File(tmpPath).renameSync(outputPath); + return discovered; +} + +/// Converts a PascalCase [label] to a `snake_case` slug +/// (`SegmentedControl` -> `segmented_control`). +String _slugFor(String label) { + final withUnderscores = label.replaceAllMapped( + RegExp(r'(?<=[a-z0-9])([A-Z])'), + (m) => '_${m.group(1)}', + ); + return withUnderscores.toLowerCase(); +} + +/// Computes the import path of [file] relative to [previewsDir] (where the +/// generated `_previews.g.dart` lives), using forward slashes so the emitted +/// Dart is platform-stable. +String _importPathFor(Directory previewsDir, File file) { + final relative = p.relative(file.path, from: previewsDir.path); + return p.split(relative).join('/'); +} + +/// Throws [FormatException] when two discovered previews claim the same slug, +/// naming every colliding class so the operator can fix the source without +/// guessing which files clashed. +void _validateNoSlugCollisions(List previews) { + final bySlug = >{}; + for (final preview in previews) { + bySlug.putIfAbsent(preview.slug, () => []).add(preview.className); + } + for (final entry in bySlug.entries) { + if (entry.value.length > 1) { + final classes = (entry.value.toList()..sort()).join(', '); + throw FormatException( + 'Duplicate preview slug "${entry.key}" claimed by: $classes. ' + 'Each preview must resolve to a unique slug.', + ); + } + } +} diff --git a/lib/src/cli/magic_artisan_provider.dart b/lib/src/cli/magic_artisan_provider.dart index b6c1f76..7b20b0d 100644 --- a/lib/src/cli/magic_artisan_provider.dart +++ b/lib/src/cli/magic_artisan_provider.dart @@ -1,7 +1,10 @@ import 'package:fluttersdk_artisan/artisan.dart'; +import 'commands/design_lint_command.dart'; +import 'commands/design_sync_command.dart'; import 'commands/key_generate_command.dart'; import 'commands/magic_install_command.dart'; +import 'commands/make_component_command.dart'; import 'commands/make_controller_command.dart'; import 'commands/make_enum_command.dart'; import 'commands/make_event_command.dart'; @@ -16,6 +19,7 @@ import 'commands/make_provider_command.dart'; import 'commands/make_request_command.dart'; import 'commands/make_seeder_command.dart'; import 'commands/make_view_command.dart'; +import 'commands/previews_refresh_command.dart'; /// Contributes magic:* (and make:* / key:generate) commands to the artisan /// dispatcher. @@ -30,8 +34,8 @@ import 'commands/make_view_command.dart'; /// }; /// ``` /// -/// Ships the full 16-command magic code-gen surface that used to live in the -/// `magic_cli` package: 14 make:* generators + `magic:install` + `key:generate`. +/// Ships the magic code-gen surface: 15 make:* generators + `previews:refresh` +/// + `design:sync` + `design:lint` + `magic:install` + `key:generate`. class MagicArtisanProvider extends ArtisanServiceProvider { @override String get providerName => 'magic'; @@ -52,6 +56,10 @@ class MagicArtisanProvider extends ArtisanServiceProvider { MakePolicyCommand(), MakeRequestCommand(), MakeModelCommand(), + MakeComponentCommand(), + PreviewsRefreshCommand(), + DesignSyncCommand(), + DesignLintCommand(), MagicInstallCommand(), KeyGenerateCommand(), ]; diff --git a/lib/src/foundation/magic_app_widget.dart b/lib/src/foundation/magic_app_widget.dart index b0cacf6..3bb6e11 100644 --- a/lib/src/foundation/magic_app_widget.dart +++ b/lib/src/foundation/magic_app_widget.dart @@ -80,6 +80,12 @@ class MagicApplication extends StatefulWidget { /// Optional title suffix appended to page titles (e.g. '- My App'). final String? titleSuffix; + /// Optional glue inserted between a page title and the suffix (e.g. ' | '). + /// + /// When `null`, the separator is sourced from `app.title_separator` in config + /// and defaults to `' - '`. + final String? titleSeparator; + /// Wind theme data for styling. /// MaterialApp theme will be derived from this using controller.toThemeData(). final WindThemeData? windTheme; @@ -118,6 +124,7 @@ class MagicApplication extends StatefulWidget { super.key, this.title = 'Magic App', this.titleSuffix, + this.titleSeparator, this.windTheme, this.themeMode = ThemeMode.system, this.initialRoute = '/', @@ -139,6 +146,12 @@ class _MagicApplicationState extends State { bool _initialized = false; bool _hasError = false; + /// Whether the locale-change title listener has been attached to [Lang]. + /// + /// Tracked so [dispose] removes exactly what [_initialize] added, and never + /// touches [Lang] when the translator was unbound at init time. + bool _titleListenerAttached = false; + /// Saved brightness preference loaded from Vault. /// /// - `null` means no preference saved (follow system). @@ -151,6 +164,20 @@ class _MagicApplicationState extends State { _initialize(); } + @override + void dispose() { + if (_titleListenerAttached) { + Lang.removeListener(_reapplyTitle); + } + super.dispose(); + } + + /// Re-emit the current page title through [TitleManager]. + /// + /// Invoked on every locale change so a stored translation key re-resolves + /// against the new locale without rebuilding the widget tree. + void _reapplyTitle() => TitleManager.instance.reapply(); + /// Initialize the application and load saved theme preference. /// /// Loads theme preference from Vault before marking as initialized. @@ -169,9 +196,28 @@ class _MagicApplicationState extends State { // 3. Call the app's onInit callback. widget.onInit?.call(); - // 4. Configure TitleManager with app title and optional suffix. + // 4. Configure TitleManager with app title, suffix, and separator. + // Suffix and separator fall back to config when their params are null, + // so the app name and glue can be set once in config/app.dart. TitleManager.instance.setAppTitle(widget.title); - TitleManager.instance.setSuffix(widget.titleSuffix); + TitleManager.instance.setSuffix( + widget.titleSuffix ?? Config.get('app.name', null), + ); + TitleManager.instance.setSeparator( + widget.titleSeparator ?? + Config.get('app.title_separator', ' - ')!, + ); + + // 5. Re-apply the title on locale change so translation keys re-resolve. + // Magic.reload() already refreshes the MaterialApp title when a locale + // switch runs with reload:true; this listener covers the reload:false + // path, the OS-level locale change, and the app-switcher label, none + // of which rebuild MagicApplication. Guarded by an explicit bound + // check so it never runs when the translator is not registered. + if (Magic.bound('translator')) { + Lang.addListener(_reapplyTitle); + _titleListenerAttached = true; + } setState(() => _initialized = true); } catch (e) { diff --git a/lib/src/routing/title_manager.dart b/lib/src/routing/title_manager.dart index c188f95..bc5a6ed 100644 --- a/lib/src/routing/title_manager.dart +++ b/lib/src/routing/title_manager.dart @@ -1,5 +1,7 @@ import 'package:flutter/services.dart'; +import '../facades/lang.dart'; + /// Manages page titles across routing, widgets, and facades. /// /// Follows the singleton pattern used by [MagicRouter]. Computes the effective @@ -48,6 +50,11 @@ class TitleManager { String? _routeTitle; String? _overrideTitle; + /// The glue inserted between a resolved title and the suffix. + /// + /// Defaults to `' - '` and is configurable via [setSeparator]. + String _separator = ' - '; + /// Callback invoked whenever the effective title changes. /// /// Defaults to [SystemChrome.setApplicationSwitcherDescription] when not @@ -96,35 +103,56 @@ class TitleManager { return this; } + /// Set the separator placed between the title and the suffix. + /// + /// Only affects how the suffix is glued to a title (e.g. `' | '` yields + /// `"Home | Site"`). Triggers a title recomputation. + TitleManager setSeparator(String separator) { + _separator = separator; + _applyTitle(); + return this; + } + // --------------------------------------------------------------------------- // Accessors // --------------------------------------------------------------------------- /// Returns the effective title **without** the suffix applied. /// - /// Resolution order: override โ†’ routeTitle โ†’ appTitle. - String? get currentTitle => _overrideTitle ?? _routeTitle ?? _appTitle; + /// Resolution order: override โ†’ routeTitle โ†’ appTitle. The chosen title is + /// resolved through [trans] lazily at read time, so a stored translation key + /// reflects the current locale. A plain literal (no matching key) passes + /// through unchanged. + String? get currentTitle { + final title = _overrideTitle ?? _routeTitle ?? _appTitle; + return title != null ? trans(title) : null; + } /// Returns the computed title **with** the suffix applied. /// - /// When an override or route title is active and a suffix is present, the - /// result is `"$title - $_suffix"`. When the resolved title falls back to - /// the application title, the suffix is omitted and the app title is - /// returned as-is. When no title is available, an empty string is returned. + /// The resolved title is passed through [trans] lazily at read time (a plain + /// literal passes through unchanged); the suffix stays literal and is never + /// translated. When an override or route title is active and a suffix is + /// present, the result is `"$title$_separator$suffix"`. When the resolved + /// title falls back to the application title, the suffix is omitted. When no + /// title is available, an empty string is returned. String get effectiveTitle { final overrideTitle = _overrideTitle; if (overrideTitle != null) { + final resolved = trans(overrideTitle); final suffix = _suffix; - return suffix != null ? '$overrideTitle - $suffix' : overrideTitle; + return suffix != null ? '$resolved$_separator$suffix' : resolved; } final routeTitle = _routeTitle; if (routeTitle != null) { + final resolved = trans(routeTitle); final suffix = _suffix; - return suffix != null ? '$routeTitle - $suffix' : routeTitle; + return suffix != null ? '$resolved$_separator$suffix' : resolved; } - return _appTitle ?? ''; + final appTitle = _appTitle; + return appTitle != null ? trans(appTitle) : ''; } // --------------------------------------------------------------------------- @@ -142,6 +170,13 @@ class TitleManager { // Internal // --------------------------------------------------------------------------- + /// Re-push the current [effectiveTitle] through the change callback. + /// + /// Lets the foundation layer re-emit the title without a state change (e.g. + /// on a locale switch, so translation keys re-resolve). [TitleManager] does + /// not subscribe to locale changes itself; the caller triggers this. + void reapply() => _applyTitle(); + /// Compute [effectiveTitle] and invoke [_onTitleChanged]. /// /// Falls back to [SystemChrome.setApplicationSwitcherDescription] when no diff --git a/pubspec.yaml b/pubspec.yaml index a22fde1..a3fe1a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: magic description: "A Laravel-inspired Flutter framework with Eloquent ORM, routing, and MVC architecture." -version: 0.0.3 +version: 0.0.4 homepage: https://magic.fluttersdk.com repository: https://github.com/fluttersdk/magic issue_tracker: https://github.com/fluttersdk/magic/issues @@ -23,7 +23,7 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter - fluttersdk_wind: ^1.1.2 + fluttersdk_wind: ^1.2.0 meta: ^1.16.0 more: ^4.7.0 dio: ^5.9.0 diff --git a/skills/magic-framework/SKILL.md b/skills/magic-framework/SKILL.md index 3293ad9..0b1f348 100644 --- a/skills/magic-framework/SKILL.md +++ b/skills/magic-framework/SKILL.md @@ -2,10 +2,10 @@ name: magic-framework description: "Write correct, idiomatic code in a Flutter app that depends on the `magic` framework (Laravel-inspired: IoC container, 18 facades, Eloquent-style ORM, service providers, reactive controllers, GoRouter routing, validation, auth, broadcasting). Use whenever code imports `package:magic/magic.dart` or `package:magic/testing.dart`, or the work touches Magic.init, MagicApp, a facade (Auth/Http/Cache/DB/Echo/Event/Gate/Config/Lang/Launch/Log/Pick/MagicRoute/Schema/Session/Storage/Vault/Crypt), a Model, MagicController, a MagicView, MagicFormData, FormRequest, a ServiceProvider, a migration, or the artisan make:* CLI. UI styling is Wind (separate wind-ui skill). Do NOT use for plain Flutter or Wind-only work with no magic import." when_to_use: "Use proactively when editing or scaffolding a magic app: Magic.init / a facade / a Model / a MagicController or MagicView / a form (MagicFormData, FormRequest, Validator) / a ServiceProvider / a route or MagicMiddleware / a migration / MagicStateMixin + RxStatus + fetchList / Session flash + old() + trans() / testing with MagicTest + Http.fake/Auth.fake / the artisan make:* CLI / the magic_deeplink, magic_notifications, magic_social_auth, magic_starter, or magic_devtools plugins. Trigger even when the user does not say the word 'magic'. Do NOT trigger for plain Flutter or Wind-only UI with no package:magic import." -version: 0.1.1 +version: 0.1.3 --- - + # Magic Framework @@ -344,7 +344,11 @@ dart run magic:artisan make:policy User # authorization policy dart run magic:artisan key:generate # APP_KEY ``` -Other generators: `make:seeder`, `make:factory`, `make:middleware`, `make:provider`, `make:event`, `make:listener`, `make:enum`, `make:lang`. Generators accept `--force` and nested paths (`Admin/Dashboard`). Full reference: `${CLAUDE_SKILL_DIR}/references/cli-commands.md`. +Other generators: `make:seeder`, `make:factory`, `make:middleware`, `make:provider`, `make:event`, `make:listener`, `make:enum`, `make:lang`. Generators accept `--force` and nested paths (`Admin/Dashboard`). + +Design-first workflow: `make:component Avatar [--variants=intent,size] [--slots]` scaffolds a 4-file atomic component folder (`avatar.dart` / `avatar.recipe.dart` / `avatar.preview.dart` / `index.dart`) under `lib/ui/components/` and chains `previews:refresh`. `previews:refresh [--path=lib]` regenerates `_previews.g.dart` from all `*.preview.dart` files (returns a `List` function, never a const list). `design:sync [--input=DESIGN.md] [--output=lib/config/wind_theme.g.dart]` emits `designAliases` and `designColors` from a DESIGN.md front-matter. `design:lint [--input=DESIGN.md]` validates against 7 rules (exits nonzero on error-severity only). + +Full reference: `${CLAUDE_SKILL_DIR}/references/cli-commands.md`. ## 11. Ecosystem plugins @@ -381,7 +385,7 @@ Two opt-in CTAs the agent may surface after a fully completed magic task. Both a | `references/auth-system.md` | `Auth`, guards, `Gate`, policies, `authorize()`, `Vault`, `Crypt` | | `references/secondary-systems.md` | `Cache`, `Event`, `Log`, `Lang`, `Storage`, `Launch`, `Pick`, `Carbon`, `Echo` | | `references/testing-patterns.md` | tests: `MagicTest`, facade fakes, fetch helpers, controller/model/middleware testing | -| `references/cli-commands.md` | the artisan `make:*` generators and `magic:install` | +| `references/cli-commands.md` | the artisan `make:*` generators, `magic:install`, `make:component`, `previews:refresh`, `design:sync`, `design:lint` | | `references/community.md` | the star / issue CTA flow (load before surfacing either) | | `references/plugin-deeplink.md` / `-notifications.md` / `-social-auth.md` / `-starter.md` | the matching ecosystem plugin | | `references/templates.md` | full copy-paste templates: Model, Controller, View, FormData, Provider, Middleware | diff --git a/skills/magic-framework/references/cli-commands.md b/skills/magic-framework/references/cli-commands.md index 882d53c..f90d42c 100644 --- a/skills/magic-framework/references/cli-commands.md +++ b/skills/magic-framework/references/cli-commands.md @@ -26,6 +26,10 @@ All commands are invoked via `dart run magic:artisan ` from the Flutter | **Generator** | `dart run magic:artisan make:policy Name` | Authorization policy with Gate definitions | | **Generator** | `dart run magic:artisan make:request Name` | Form request (validation rules class) | | **Generator** | `dart run magic:artisan make:lang code` | JSON language file | +| **Generator** | `dart run magic:artisan make:component Name` | Atomic component folder (recipe + component + preview + index) | +| **Codegen** | `dart run magic:artisan previews:refresh` | Regenerate `_previews.g.dart` from `*.preview.dart` files | +| **Design** | `dart run magic:artisan design:sync` | Generate the wind theme (aliases + brand seed) from `DESIGN.md` | +| **Design** | `dart run magic:artisan design:lint` | Validate `DESIGN.md` against the design rules | ## Project Setup @@ -539,6 +543,73 @@ flutter: ``` +### `dart run magic:artisan make:component` + +Scaffolds an atomic 4-file component folder under `lib/ui/components//`. + +```bash +dart run magic:artisan make:component Avatar +dart run magic:artisan make:component Avatar --variants=intent,size +dart run magic:artisan make:component Panel --slots +``` + +**Output** (for `Avatar`): `lib/ui/components/avatar/avatar.dart` (`class Avatar`, unprefixed PascalCase), `avatar.recipe.dart` (a `WindRecipe`, or a `WindSlotRecipe` under `--slots`, seeded with the requested `--variants` axes), `avatar.preview.dart` (a single public `AvatarPreview` matrix), and `index.dart` (re-exports the component + recipe, NOT the preview). + +After scaffolding, the command chains `previews:refresh` so the new preview lands in `_previews.g.dart` automatically. + +| Flag | Effect | +|:-----|:-------| +| `--variants=a,b` | Seeds the named variant axes into the recipe (values left empty to fill in). | +| `--slots` | Scaffolds a multi-part `WindSlotRecipe` instead of a single-element `WindRecipe`. | +| `--force` | Overwrite an existing component. | + + +### `dart run magic:artisan previews:refresh` + +Regenerates the dev-only preview catalog index from `*.preview.dart` files. + +```bash +dart run magic:artisan previews:refresh +dart run magic:artisan previews:refresh --path=lib/ui/components +``` + +**Output**: `/_previews.g.dart` (default scan dir `lib`). Each `*.preview.dart` file must declare exactly ONE public `*Preview` class; the command validates the name, fails fast on a slug collision, sorts deterministically, and writes atomically. The generated file returns a `List` from the `previewEntries()` function (never a top-level const list) so the catalog tree-shakes from release builds. Feed it to the catalog via `MagicPreview.register(previewEntries())`. + +| Flag | Effect | +|:-----|:-------| +| `--path=DIR` | Directory to scan for `*.preview.dart` files (default `lib`). | + + +### `dart run magic:artisan design:sync` + +Generates the wind theme (semantic aliases + brand seed) from a `DESIGN.md`. + +```bash +dart run magic:artisan design:sync +dart run magic:artisan design:sync --input=DESIGN.md --output=lib/config/wind_theme.g.dart +``` + +**Output**: a Dart source file exposing `Map designAliases` (17 property-prefixed semantic keys with arbitrary-hex light + `dark:` pairs, drop-in for `WindThemeData(aliases: ...)`) and `Map designColors` (the brand `primary` with a generated 50-900 ramp seeded from the DESIGN.md `primary` light hex). Idempotent (byte-identical on re-run) and written atomically. Do not hand-edit the generated file; re-run `design:sync`. + +| Flag | Effect | +|:-----|:-------| +| `--input=PATH` | DESIGN.md source, relative to the project root (default `DESIGN.md`). | +| `--output=PATH` | Generated wind theme file (default `lib/config/wind_theme.g.dart`). | + + +### `dart run magic:artisan design:lint` + +Validates a `DESIGN.md` against six rules: broken-ref (error), missing-primary, unknown-key (the `dark:` overlay is never flagged), section-order, missing-sections, orphaned-tokens, and contrast-ratio (WCAG AA 4.5:1 on component bg/text pairs). Exits nonzero only on an error-severity finding. + +```bash +dart run magic:artisan design:lint --input=DESIGN.md +``` + +| Flag | Effect | +|:-----|:-------| +| `--input=PATH` | DESIGN.md to validate, relative to the project root (default `DESIGN.md`). | + + ## Common Patterns ### Scaffolding a Full Resource diff --git a/test/cli/commands/design_lint_command_test.dart b/test/cli/commands/design_lint_command_test.dart new file mode 100644 index 0000000..af34c45 --- /dev/null +++ b/test/cli/commands/design_lint_command_test.dart @@ -0,0 +1,216 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/cli/commands/design_lint_command.dart'; +import 'package:path/path.dart' as p; + +/// A clean, well-ordered DESIGN.md that the linter must pass (exit 0), with the +/// single-file `dark:` overlay accepted (never flagged as an unknown key) and a +/// high-contrast component pair. +const String _clean = ''' +--- +name: Acme +colors: + primary: + light: "#5b21b6" + dark: "#a78bfa" + on-primary: "#ffffff" + surface: + light: "#f9f9ff" + dark: "#0f1419" +typography: + body-md: + fontFamily: Plus Jakarta Sans + fontSize: 16px +rounded: + md: 8px +spacing: + md: 16px +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + rounded: "{rounded.md}" + padding: "{spacing.md}" +--- + +## Overview + +Acme system. + +## Colors + +Colors. + +## Typography + +Type. + +## Layout + +Layout. + +## Components + +Components. +'''; + +ArtisanContext _ctx(BufferedOutput output, List args) { + final parser = ArgParser()..addOption('input'); + final input = ArgvInput.parse(parser, args); + return ArtisanContext.bare(input, output); +} + +void main() { + group('DesignLintCommand metadata', () { + final cmd = DesignLintCommand(); + + test('declares name design:lint', () { + expect(cmd.name, 'design:lint'); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('DesignLintCommand.handle()', () { + late Directory root; + late BufferedOutput output; + + setUp(() { + root = Directory.systemTemp.createTempSync('design_lint_'); + output = BufferedOutput(); + }); + + tearDown(() { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + + Future lint(String content) { + File(p.join(root.path, 'DESIGN.md')).writeAsStringSync(content); + final cmd = DesignLintCommand(projectRoot: root.path); + return cmd.handle(_ctx(output, ['--input=DESIGN.md'])); + } + + test('passes a clean file with the dark: overlay accepted', () async { + final code = await lint(_clean); + + expect(code, 0); + // The dark: overlay key must NOT trigger an unknown-key finding. + expect(output.content, isNot(contains('Unknown key'))); + }); + + test('flags a broken reference as an error and exits nonzero', () async { + const broken = ''' +--- +name: Acme +colors: + primary: "#5b21b6" +components: + card: + backgroundColor: "{colors.does-not-exist}" +--- + +## Colors +'''; + final code = await lint(broken); + + expect(code, isNot(0)); + expect(output.content, contains('does-not-exist')); + }); + + test('flags a WCAG contrast failure on a component bg/text pair', () async { + const lowContrast = ''' +--- +name: Acme +colors: + primary: "#aaaaaa" + on-primary: "#cccccc" +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" +--- + +## Colors +'''; + await lint(lowContrast); + + expect(output.content.toLowerCase(), contains('contrast')); + }); + + test('warns when the primary color is missing', () async { + const noPrimary = ''' +--- +name: Acme +colors: + surface: "#f9f9ff" +--- + +## Colors +'''; + await lint(noPrimary); + + expect(output.content.toLowerCase(), contains('primary')); + }); + + test('warns on a top-level key that is a typo of a schema key', () async { + const typo = ''' +--- +name: Acme +colers: + primary: "#5b21b6" +--- + +## Colors +'''; + await lint(typo); + + expect(output.content, contains('colers')); + }); + + test('warns when sections appear out of canonical order', () async { + const outOfOrder = ''' +--- +name: Acme +colors: + primary: "#5b21b6" +--- + +## Typography + +Type. + +## Colors + +Colors. +'''; + await lint(outOfOrder); + + expect(output.content.toLowerCase(), contains('order')); + }); + + test('flags an orphaned custom token never referenced', () async { + const orphan = ''' +--- +name: Acme +colors: + primary: "#5b21b6" + on-primary: "#ffffff" + brand-magenta: "#ff00ff" +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" +--- + +## Colors +'''; + await lint(orphan); + + expect(output.content, contains('brand-magenta')); + }); + }); +} diff --git a/test/cli/commands/design_sync_command_test.dart b/test/cli/commands/design_sync_command_test.dart new file mode 100644 index 0000000..8c69099 --- /dev/null +++ b/test/cli/commands/design_sync_command_test.dart @@ -0,0 +1,207 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/cli/commands/design_sync_command.dart'; +import 'package:path/path.dart' as p; + +/// A clean DESIGN.md covering every semantic role the emitter maps, plus the +/// brand `primary` color the MaterialColor seed is derived from. +const String _design = ''' +--- +name: Acme +colors: + surface: + light: "#f9f9ff" + dark: "#0f1419" + surface-container: + light: "#f0f3ff" + dark: "#161b22" + surface-container-high: + light: "#e2e8f8" + dark: "#1d242d" + fg: + light: "#151c27" + dark: "#e6e9f0" + fg-muted: + light: "#534434" + dark: "#9aa4b2" + fg-disabled: + light: "#867461" + dark: "#5b6470" + primary: + light: "#7c3aed" + dark: "#a78bfa" + on-primary: "#ffffff" + primary-container: + light: "#ede9fe" + dark: "#4c1d95" + accent: + light: "#4f46e5" + dark: "#6366f1" + border: + light: "#d8c3ad" + dark: "#30363d" + border-subtle: + light: "#e7eefe" + dark: "#21262d" + destructive: + light: "#ba1a1a" + dark: "#f87171" + on-destructive: "#ffffff" + destructive-container: + light: "#ffdad6" + dark: "#7f1d1d" + success: + light: "#15803d" + dark: "#22c55e" + warning: + light: "#b45309" + dark: "#f59e0b" +--- + +## Colors +'''; + +ArtisanContext _ctx(List args) { + final parser = ArgParser() + ..addOption('input') + ..addOption('output'); + final input = ArgvInput.parse(parser, args); + return ArtisanContext.bare(input, BufferedOutput()); +} + +void main() { + group('DesignSyncCommand metadata', () { + final cmd = DesignSyncCommand(); + + test('declares name design:sync', () { + expect(cmd.name, 'design:sync'); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + + test('extends ArtisanCommand', () { + expect(cmd, isA()); + }); + }); + + group('DesignSyncCommand.handle()', () { + late Directory root; + late String inputPath; + late String outputPath; + + setUp(() { + root = Directory.systemTemp.createTempSync('design_sync_'); + inputPath = p.join(root.path, 'DESIGN.md'); + outputPath = p.join(root.path, 'lib', 'config', 'wind_theme.g.dart'); + File(inputPath).writeAsStringSync(_design); + }); + + tearDown(() { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + + Future run() async { + final cmd = DesignSyncCommand(projectRoot: root.path); + final code = await cmd.handle( + _ctx([ + '--input=DESIGN.md', + '--output=lib/config/wind_theme.g.dart', + ]), + ); + expect(code, 0); + return File(outputPath).readAsStringSync(); + } + + test('emits the 17 property-prefixed alias keys from Step 7', () async { + final generated = await run(); + + const keys = [ + 'bg-surface', + 'bg-surface-container', + 'bg-surface-container-high', + 'text-fg', + 'text-fg-muted', + 'text-fg-disabled', + 'bg-primary', + 'text-on-primary', + 'bg-primary-container', + 'bg-accent', + 'border-color-border', + 'border-color-border-subtle', + 'bg-destructive', + 'text-on-destructive', + 'bg-destructive-container', + 'bg-success', + 'bg-warning', + ]; + for (final key in keys) { + expect(generated, contains("'$key':"), reason: 'missing alias $key'); + } + }); + + test('emits arbitrary-hex light+dark pairs for a surface role', () async { + final generated = await run(); + + expect( + generated, + contains("'bg-surface': 'bg-[#f9f9ff] dark:bg-[#0f1419]'"), + ); + expect( + generated, + contains("'text-fg': 'text-[#151c27] dark:text-[#e6e9f0]'"), + ); + expect( + generated, + contains( + "'border-color-border': 'border-[#d8c3ad] dark:border-[#30363d]'", + ), + ); + }); + + test('emits a brand primary MaterialColor with a 50-900 ramp', () async { + final generated = await run(); + + expect(generated, contains('MaterialColor(')); + for (final shade in [ + 50, + 100, + 200, + 300, + 400, + 500, + 600, + 700, + 800, + 900, + ]) { + expect(generated, contains('$shade:'), reason: 'missing shade $shade'); + } + // The 500 shade is seeded from the primary light hex (#7c3aed). + expect(generated, contains('0xFF7C3AED')); + }); + + test('exposes the aliases map and brand color as a public API', () async { + final generated = await run(); + + expect(generated, contains('Map')); + expect(generated, contains("'primary'")); + }); + + test('is idempotent: byte-identical output on re-run', () async { + final first = await run(); + final second = await run(); + + expect(second, first); + }); + + test('writes atomically with no .tmp leftover', () async { + await run(); + + expect(File('$outputPath.tmp').existsSync(), isFalse); + }); + }); +} diff --git a/test/cli/commands/magic_install_command_test.dart b/test/cli/commands/magic_install_command_test.dart index de6cf35..bda88a8 100644 --- a/test/cli/commands/magic_install_command_test.dart +++ b/test/cli/commands/magic_install_command_test.dart @@ -803,8 +803,8 @@ void main() { // still complete successfully. expect(exit, 0, reason: (ctx.output as BufferedOutput).content); final main = fs.readAsString('/proj/lib/main.dart'); - // The appName placeholder resolves to '' โ€” MagicApplication title is blank. - expect(main, contains("MagicApplication(title: '')")); + // The appName placeholder resolves to '' โ€” title and brand suffix blank. + expect(main, contains("MagicApplication(title: '', titleSuffix: '')")); }, ); @@ -817,7 +817,12 @@ void main() { await cmd.handle(ctx); final main = fs.readAsString('/proj/lib/main.dart'); - expect(main, contains("MagicApplication(title: 'My Cool App')")); + expect( + main, + contains( + "MagicApplication(title: 'My Cool App', titleSuffix: 'My Cool App')", + ), + ); }, ); }); diff --git a/test/cli/commands/make_component_command_test.dart b/test/cli/commands/make_component_command_test.dart new file mode 100644 index 0000000..54cd3f0 --- /dev/null +++ b/test/cli/commands/make_component_command_test.dart @@ -0,0 +1,218 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/cli/commands/make_component_command.dart'; +import 'package:path/path.dart' as p; + +/// A bare [ArtisanContext] driving the command with [args]. +ArtisanContext _ctx(MakeComponentCommand cmd, List args) { + final parser = ArgParser(); + cmd.configure(parser); + final input = ArgvInput.parse(parser, args); + return ArtisanContext.bare(input, BufferedOutput()); +} + +void main() { + group('MakeComponentCommand metadata', () { + final cmd = MakeComponentCommand(); + + test('declares name make:component', () { + expect(cmd.name, 'make:component'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeComponentCommand.handle()', () { + late Directory projectRoot; + late String stubsDir; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_component_'); + // The real stubs ship in magic's assets/stubs/; point the generator at + // them via the env override the StubLoader honours. + stubsDir = p.join(Directory.current.path, 'assets', 'stubs'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test('scaffolds the 4-file atomic component folder', () async { + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + _ctx(cmd, ['Avatar', '--stubs-dir=$stubsDir']), + ); + + expect(code, 0); + final dir = p.join(projectRoot.path, 'lib', 'ui', 'components', 'avatar'); + expect(File(p.join(dir, 'avatar.dart')).existsSync(), isTrue); + expect(File(p.join(dir, 'avatar.recipe.dart')).existsSync(), isTrue); + expect(File(p.join(dir, 'avatar.preview.dart')).existsSync(), isTrue); + expect(File(p.join(dir, 'index.dart')).existsSync(), isTrue); + }); + + test('names the component class unprefixed PascalCase', () async { + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + await cmd.handle(_ctx(cmd, ['Avatar', '--stubs-dir=$stubsDir'])); + + final component = File( + p.join( + projectRoot.path, + 'lib', + 'ui', + 'components', + 'avatar', + 'avatar.dart', + ), + ).readAsStringSync(); + expect(component, contains('class Avatar')); + + final preview = File( + p.join( + projectRoot.path, + 'lib', + 'ui', + 'components', + 'avatar', + 'avatar.preview.dart', + ), + ).readAsStringSync(); + expect(preview, contains('class AvatarPreview')); + }); + + test('emits the requested variant axes into the recipe', () async { + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + await cmd.handle( + _ctx(cmd, [ + 'Avatar', + '--variants=intent,size', + '--stubs-dir=$stubsDir', + ]), + ); + + final recipe = File( + p.join( + projectRoot.path, + 'lib', + 'ui', + 'components', + 'avatar', + 'avatar.recipe.dart', + ), + ).readAsStringSync(); + expect(recipe, contains("'intent':")); + expect(recipe, contains("'size':")); + }); + + test('the index re-exports the component but not the preview', () async { + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + await cmd.handle(_ctx(cmd, ['Avatar', '--stubs-dir=$stubsDir'])); + + final index = File( + p.join( + projectRoot.path, + 'lib', + 'ui', + 'components', + 'avatar', + 'index.dart', + ), + ).readAsStringSync(); + expect(index, contains("export 'avatar.dart'")); + expect(index, contains("export 'avatar.recipe.dart'")); + expect(index, isNot(contains("export 'avatar.preview.dart'"))); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + _ctx(cmd, ['--stubs-dir=$stubsDir']), + ); + expect(code, 1); + }); + + test( + 'returns 1 when the component already exists without --force', + () async { + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + await cmd.handle( + _ctx(cmd, ['Avatar', '--stubs-dir=$stubsDir']), + ); + + final second = MakeComponentCommand(testRoot: projectRoot.path); + final code = await second.handle( + _ctx(second, ['Avatar', '--stubs-dir=$stubsDir']), + ); + expect(code, 1); + }, + ); + + test('--slots scaffolds a WindSlotRecipe shape', () async { + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + await cmd.handle( + _ctx(cmd, ['Avatar', '--slots', '--stubs-dir=$stubsDir']), + ); + + final recipe = File( + p.join( + projectRoot.path, + 'lib', + 'ui', + 'components', + 'avatar', + 'avatar.recipe.dart', + ), + ).readAsStringSync(); + expect(recipe, contains('WindSlotRecipe')); + expect(recipe, contains("slots: {")); + + final component = File( + p.join( + projectRoot.path, + 'lib', + 'ui', + 'components', + 'avatar', + 'avatar.dart', + ), + ).readAsStringSync(); + expect(component, contains("slots['root']")); + }); + + test('triggers previews:refresh after scaffolding', () async { + // Seed an existing preview so the chained refresh has something to emit. + final existing = Directory(p.join(projectRoot.path, 'lib')) + ..createSync(recursive: true); + File(p.join(existing.path, 'seed.preview.dart')).writeAsStringSync(''' +import 'package:flutter/widgets.dart'; + +class SeedPreview extends StatelessWidget { + const SeedPreview({super.key}); + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} +'''); + + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + _ctx(cmd, ['Avatar', '--stubs-dir=$stubsDir']), + ); + + expect(code, 0); + // The chained previews:refresh regenerated the index for lib/. + final generated = File( + p.join(projectRoot.path, 'lib', '_previews.g.dart'), + ); + expect(generated.existsSync(), isTrue); + expect(generated.readAsStringSync(), contains('AvatarPreview')); + }); + }); +} diff --git a/test/cli/commands/previews_refresh_command_test.dart b/test/cli/commands/previews_refresh_command_test.dart new file mode 100644 index 0000000..c863db1 --- /dev/null +++ b/test/cli/commands/previews_refresh_command_test.dart @@ -0,0 +1,314 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/cli/commands/previews_refresh_command.dart'; +import 'package:magic/src/cli/helpers/previews_index_writer.dart'; +import 'package:path/path.dart' as p; + +/// Seeds a `*.preview.dart` source file under [dir] declaring [className]. +/// +/// The body is irrelevant to discovery; only the public class declaration is +/// matched, so a minimal stateless shell is enough. +void _seedPreview(Directory dir, String fileName, String className) { + final file = File(p.join(dir.path, fileName)); + file.writeAsStringSync(''' +import 'package:flutter/widgets.dart'; + +class $className extends StatelessWidget { + const $className({super.key}); + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} +'''); +} + +/// A bare [ArtisanContext] with a buffered output for assertions. +ArtisanContext _ctx(List args) { + final parser = ArgParser()..addOption('path'); + final input = ArgvInput.parse(parser, args); + return ArtisanContext.bare(input, BufferedOutput()); +} + +void main() { + group('PreviewsRefreshCommand metadata', () { + final cmd = PreviewsRefreshCommand(); + + test('declares name previews:refresh', () { + expect(cmd.name, 'previews:refresh'); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + + test('description mentions _previews.g.dart', () { + expect(cmd.description, contains('_previews.g.dart')); + }); + + test('extends ArtisanCommand', () { + expect(cmd, isA()); + }); + }); + + group('discoverPreviews', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('previews_discover_'); + }); + + tearDown(() { + if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); + }); + + test('discovers public *Preview classes in *.preview.dart files', () { + _seedPreview(tempDir, 'button.preview.dart', 'ButtonPreview'); + _seedPreview(tempDir, 'card.preview.dart', 'CardPreview'); + + final discovered = discoverPreviews(tempDir); + + expect( + discovered.map((e) => e.className), + containsAll(['ButtonPreview', 'CardPreview']), + ); + }); + + test('ignores files that are not *.preview.dart', () { + _seedPreview(tempDir, 'button.preview.dart', 'ButtonPreview'); + File( + p.join(tempDir.path, 'button.dart'), + ).writeAsStringSync('class Button {}'); + + final discovered = discoverPreviews(tempDir); + + expect(discovered, hasLength(1)); + expect(discovered.single.className, 'ButtonPreview'); + }); + + test('ignores private _State classes inside a stateful preview', () { + File(p.join(tempDir.path, 'toggle.preview.dart')).writeAsStringSync(''' +import 'package:flutter/widgets.dart'; + +class TogglePreview extends StatefulWidget { + const TogglePreview({super.key}); + @override + State createState() => _TogglePreviewState(); +} + +class _TogglePreviewState extends State { + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} +'''); + + final discovered = discoverPreviews(tempDir); + + expect(discovered, hasLength(1)); + expect(discovered.single.className, 'TogglePreview'); + }); + + test('derives a snake_case slug from the class name minus Preview', () { + _seedPreview( + tempDir, + 'segmented_control.preview.dart', + 'SegmentedControlPreview', + ); + + final discovered = discoverPreviews(tempDir); + + expect(discovered.single.slug, 'segmented_control'); + expect(discovered.single.label, 'SegmentedControl'); + }); + + test('returns entries sorted deterministically by slug', () { + _seedPreview(tempDir, 'zebra.preview.dart', 'ZebraPreview'); + _seedPreview(tempDir, 'alpha.preview.dart', 'AlphaPreview'); + _seedPreview(tempDir, 'mid.preview.dart', 'MidPreview'); + + final discovered = discoverPreviews(tempDir); + + expect(discovered.map((e) => e.slug).toList(), [ + 'alpha', + 'mid', + 'zebra', + ]); + }); + + test('throws on a file declaring more than one public preview class', () { + File(p.join(tempDir.path, 'multi.preview.dart')).writeAsStringSync(''' +import 'package:flutter/widgets.dart'; + +class OnePreview extends StatelessWidget { + const OnePreview({super.key}); + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +class TwoPreview extends StatelessWidget { + const TwoPreview({super.key}); + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} +'''); + + expect(() => discoverPreviews(tempDir), throwsA(isA())); + }); + + test('throws on two files claiming the same slug', () { + final nested = Directory(p.join(tempDir.path, 'nested'))..createSync(); + _seedPreview(tempDir, 'button.preview.dart', 'ButtonPreview'); + _seedPreview(nested, 'button.preview.dart', 'ButtonPreview'); + + expect(() => discoverPreviews(tempDir), throwsA(isA())); + }); + }); + + group('renderPreviewsIndex', () { + test( + 'emits a function returning List, never a const list', + () { + final entries = [ + const DiscoveredPreview( + className: 'ButtonPreview', + slug: 'button', + label: 'Button', + importPath: 'ui/components/button/button.preview.dart', + ), + ]; + + final source = renderPreviewsIndex(entries); + + expect(source, contains('List previewEntries()')); + expect(source, isNot(contains('const List'))); + expect( + source, + contains("import 'package:magic_devtools/preview.dart';"), + ); + expect(source, contains('builder: (_) => const ButtonPreview()')); + expect(source, contains("slug: 'button'")); + expect(source, contains("label: 'Button'")); + expect( + source, + contains("import 'ui/components/button/button.preview.dart';"), + ); + }, + ); + + test('emits an empty function body for no previews', () { + final source = renderPreviewsIndex(const []); + + expect(source, contains('List previewEntries()')); + expect(source, contains('return [];')); + }); + }); + + group('PreviewsRefreshCommand.handle()', () { + late Directory projectRoot; + late Directory previewsDir; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('previews_refresh_'); + previewsDir = Directory(p.join(projectRoot.path, 'lib')) + ..createSync(recursive: true); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test('generates lib/_previews.g.dart from discovered previews', () async { + _seedPreview(previewsDir, 'button.preview.dart', 'ButtonPreview'); + + final cmd = PreviewsRefreshCommand(projectRoot: projectRoot.path); + final code = await cmd.handle(_ctx(const [])); + + expect(code, 0); + final generated = File( + p.join(previewsDir.path, '_previews.g.dart'), + ).readAsStringSync(); + expect(generated, contains('previewEntries()')); + expect(generated, contains('ButtonPreview')); + }); + + test('is idempotent: byte-identical _previews.g.dart on re-run', () async { + _seedPreview(previewsDir, 'button.preview.dart', 'ButtonPreview'); + _seedPreview(previewsDir, 'card.preview.dart', 'CardPreview'); + + final outputPath = p.join(previewsDir.path, '_previews.g.dart'); + + await PreviewsRefreshCommand( + projectRoot: projectRoot.path, + ).handle(_ctx(const [])); + final first = File(outputPath).readAsStringSync(); + + await PreviewsRefreshCommand( + projectRoot: projectRoot.path, + ).handle(_ctx(const [])); + final second = File(outputPath).readAsStringSync(); + + expect(second, first); + }); + + test('writes atomically with no .tmp leftover', () async { + _seedPreview(previewsDir, 'button.preview.dart', 'ButtonPreview'); + + await PreviewsRefreshCommand( + projectRoot: projectRoot.path, + ).handle(_ctx(const [])); + + expect( + File(p.join(previewsDir.path, '_previews.g.dart.tmp')).existsSync(), + isFalse, + ); + }); + + test('honours the --path option for the scan target', () async { + final components = Directory(p.join(projectRoot.path, 'lib', 'ui')) + ..createSync(recursive: true); + _seedPreview(components, 'badge.preview.dart', 'BadgePreview'); + + final cmd = PreviewsRefreshCommand(projectRoot: projectRoot.path); + final code = await cmd.handle(_ctx(const ['--path=lib/ui'])); + + expect(code, 0); + final generated = File( + p.join(components.path, '_previews.g.dart'), + ).readAsStringSync(); + expect(generated, contains('BadgePreview')); + }); + + test('computes the import path relative to the generated file', () async { + final nested = Directory(p.join(previewsDir.path, 'components', 'button')) + ..createSync(recursive: true); + _seedPreview(nested, 'button.preview.dart', 'ButtonPreview'); + + await PreviewsRefreshCommand( + projectRoot: projectRoot.path, + ).handle(_ctx(const [])); + + final generated = File( + p.join(previewsDir.path, '_previews.g.dart'), + ).readAsStringSync(); + expect( + generated, + contains("import 'components/button/button.preview.dart';"), + ); + }); + + test('fails fast on a slug collision', () async { + final nested = Directory(p.join(previewsDir.path, 'nested')) + ..createSync(recursive: true); + _seedPreview(previewsDir, 'button.preview.dart', 'ButtonPreview'); + _seedPreview(nested, 'button.preview.dart', 'ButtonPreview'); + + final cmd = PreviewsRefreshCommand(projectRoot: projectRoot.path); + + expect( + () => cmd.handle(_ctx(const [])), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/cli/helpers/design_md_parser_test.dart b/test/cli/helpers/design_md_parser_test.dart new file mode 100644 index 0000000..01706e4 --- /dev/null +++ b/test/cli/helpers/design_md_parser_test.dart @@ -0,0 +1,206 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/cli/helpers/design_md_parser.dart'; + +/// A minimal but complete DESIGN.md fixture used across parser tests. +/// +/// Exercises the single-file `dark:` overlay (`surface`), light-only roles +/// (`accent`), `{colors.x}` references inside `components`, and the canonical +/// section order in the markdown body. +const String _fixture = ''' +--- +name: Test System +colors: + surface: + light: "#f9f9ff" + dark: "#0f1419" + on-surface: + light: "#151c27" + dark: "#e6e9f0" + primary: + light: "#7c3aed" + dark: "#a78bfa" + on-primary: "#ffffff" + accent: "#4f46e5" +typography: + body-md: + fontFamily: Plus Jakarta Sans + fontSize: 16px + fontWeight: "400" + lineHeight: 24px +rounded: + sm: 4px + md: 8px +spacing: + sm: 8px + md: 16px +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.on-primary}" + rounded: "{rounded.md}" + padding: "{spacing.md}" +--- + +# Test System + +## Overview + +A test design system. + +## Colors + +Color guidance. + +## Typography + +Type guidance. + +## Components + +Component guidance. +'''; + +void main() { + group('DesignMdParser front matter', () { + test('parses the document name', () { + final spec = DesignMdParser.parse(_fixture); + + expect(spec.name, 'Test System'); + }); + + test('parses a single-file light+dark overlay color role', () { + final spec = DesignMdParser.parse(_fixture); + + final surface = spec.colors['surface']; + expect(surface, isNotNull); + expect(surface!.light, '#f9f9ff'); + expect(surface.dark, '#0f1419'); + }); + + test('parses a light-only color role with dark falling back to light', () { + final spec = DesignMdParser.parse(_fixture); + + final accent = spec.colors['accent']; + expect(accent, isNotNull); + expect(accent!.light, '#4f46e5'); + expect(accent.dark, '#4f46e5'); + }); + + test('exposes typography, rounded and spacing groups', () { + final spec = DesignMdParser.parse(_fixture); + + expect(spec.typography.containsKey('body-md'), isTrue); + expect(spec.rounded['md'], '8px'); + expect(spec.spacing['md'], '16px'); + }); + + test('extracts the markdown section headings in document order', () { + final spec = DesignMdParser.parse(_fixture); + + expect(spec.sections, [ + 'Overview', + 'Colors', + 'Typography', + 'Components', + ]); + }); + }); + + group('DesignMdParser reference resolution', () { + test('resolves {colors.x} / {rounded.x} / {spacing.x} references', () { + final spec = DesignMdParser.parse(_fixture); + + final button = spec.components['button-primary']!; + expect(button.resolved['backgroundColor'], '#7c3aed'); + expect(button.resolved['textColor'], '#ffffff'); + expect(button.resolved['rounded'], '8px'); + expect(button.resolved['padding'], '16px'); + expect(button.unresolvedRefs, isEmpty); + }); + + test('records an unresolved reference rather than throwing', () { + const broken = ''' +--- +name: Broken +colors: + primary: "#7c3aed" +components: + card: + backgroundColor: "{colors.does-not-exist}" +--- + +## Colors +'''; + final spec = DesignMdParser.parse(broken); + + final card = spec.components['card']!; + expect(card.unresolvedRefs, contains('{colors.does-not-exist}')); + }); + + test('treats a circular reference as unresolved (cycle guard)', () { + const circular = ''' +--- +name: Circular +colors: + a: "{colors.b}" + b: "{colors.a}" +components: + card: + backgroundColor: "{colors.a}" +--- + +## Colors +'''; + final spec = DesignMdParser.parse(circular); + + expect(spec.components['card']!.unresolvedRefs, isNotEmpty); + }); + }); + + group('DesignMdParser missing front matter', () { + test('returns an empty spec with a recoverable parse warning', () { + final spec = DesignMdParser.parse('# No front matter\n\n## Colors\n'); + + expect(spec.colors, isEmpty); + expect(spec.hasFrontMatter, isFalse); + }); + }); + + group('WCAG contrast helper (greenfield)', () { + test('computes black-on-white at the canonical 21:1 ratio', () { + final ratio = DesignContrast.ratio('#000000', '#ffffff'); + + expect(ratio, closeTo(21.0, 0.01)); + }); + + test('white-on-white is 1:1', () { + expect(DesignContrast.ratio('#ffffff', '#ffffff'), closeTo(1.0, 0.001)); + }); + + test('is symmetric regardless of argument order', () { + final a = DesignContrast.ratio('#7c3aed', '#ffffff'); + final b = DesignContrast.ratio('#ffffff', '#7c3aed'); + + expect(a, closeTo(b, 0.0001)); + }); + + test('linearizes sRGB channels: mid-gray on white below AA', () { + // #808080 on white is ~3.95:1, under the 4.5:1 AA threshold. + final ratio = DesignContrast.ratio('#808080', '#ffffff'); + + expect(ratio, lessThan(4.5)); + expect(ratio, greaterThan(3.5)); + }); + + test('expands 3-digit shorthand hex', () { + final long = DesignContrast.ratio('#000000', '#ffffff'); + final short = DesignContrast.ratio('#000', '#fff'); + + expect(short, closeTo(long, 0.0001)); + }); + + test('returns null on an invalid color', () { + expect(DesignContrast.relativeLuminance('not-a-color'), isNull); + }); + }); +} diff --git a/test/foundation/magic_app_title_test.dart b/test/foundation/magic_app_title_test.dart new file mode 100644 index 0000000..065683e --- /dev/null +++ b/test/foundation/magic_app_title_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + setUp(() { + MagicApp.reset(); + Magic.flush(); + // Resets the built GoRouter (and TitleManager) so each test can register + // its own routes into a fresh router before build. + MagicRouter.reset(); + Translator.reset(); + }); + + /// Pump a [MagicApplication] and let its async initializer settle. + /// + /// Registers a single `/` route by default so the underlying + /// `MaterialApp.router` can build a valid GoRouter configuration. + Future pumpApp( + WidgetTester tester, { + String title = 'App', + String? titleSuffix, + String? titleSeparator, + void Function()? onInit, + }) async { + await tester.pumpWidget( + MagicApplication( + title: title, + titleSuffix: titleSuffix, + titleSeparator: titleSeparator, + onInit: onInit ?? () => MagicRoute.page('/', () => const SizedBox()), + ), + ); + + // The initializer is async (loads theme preference, wires the title) and + // the route guard schedules a zero-duration timer before revealing the + // page; settle so no timers stay pending past the test body. + await tester.pumpAndSettle(); + } + + group('MagicApplication โ€” titleSeparator wiring', () { + testWidgets('titleSeparator flows through to TitleManager', (tester) async { + final titles = []; + TitleManager.configure(onTitleChanged: (title, _) => titles.add(title)); + + await pumpApp( + tester, + title: 'App', + titleSuffix: 'Site', + titleSeparator: ' | ', + ); + + TitleManager.instance.setRouteTitle('Home'); + + expect(TitleManager.instance.effectiveTitle, 'Home | Site'); + }); + + testWidgets('separator falls back to " - " when param and config absent', ( + tester, + ) async { + final titles = []; + TitleManager.configure(onTitleChanged: (title, _) => titles.add(title)); + + await pumpApp(tester, title: 'App', titleSuffix: 'Site'); + + TitleManager.instance.setRouteTitle('Home'); + + expect(TitleManager.instance.effectiveTitle, 'Home - Site'); + }); + + testWidgets('separator falls back to config app.title_separator', ( + tester, + ) async { + Config.set('app.title_separator', ' :: '); + final titles = []; + TitleManager.configure(onTitleChanged: (title, _) => titles.add(title)); + + await pumpApp(tester, title: 'App', titleSuffix: 'Site'); + + TitleManager.instance.setRouteTitle('Home'); + + expect(TitleManager.instance.effectiveTitle, 'Home :: Site'); + }); + }); + + group('MagicApplication โ€” suffix config fallback', () { + testWidgets('suffix falls back to config app.name when param is null', ( + tester, + ) async { + Config.set('app.name', 'ConfigApp'); + final titles = []; + TitleManager.configure(onTitleChanged: (title, _) => titles.add(title)); + + await pumpApp(tester, title: 'App'); + + TitleManager.instance.setRouteTitle('Home'); + + expect(TitleManager.instance.effectiveTitle, 'Home - ConfigApp'); + }); + + testWidgets('explicit titleSuffix wins over config app.name', ( + tester, + ) async { + Config.set('app.name', 'ConfigApp'); + final titles = []; + TitleManager.configure(onTitleChanged: (title, _) => titles.add(title)); + + await pumpApp(tester, title: 'App', titleSuffix: 'ExplicitSuffix'); + + TitleManager.instance.setRouteTitle('Home'); + + expect(TitleManager.instance.effectiveTitle, 'Home - ExplicitSuffix'); + }); + }); + + group('MagicApplication โ€” locale change re-apply', () { + testWidgets('a fired locale change re-applies the title', (tester) async { + // The translator must be bound for the listener to attach. + MagicApp.instance.singleton('translator', () => Translator.instance); + Translator.instance.setLoader(const _FakeTranslationLoader({})); + + final titles = []; + TitleManager.configure(onTitleChanged: (title, _) => titles.add(title)); + + await pumpApp(tester, title: 'App', titleSuffix: 'Site'); + TitleManager.instance.setRouteTitle('Home'); + titles.clear(); + + await Lang.setLocale(const Locale('tr'), reload: false); + + expect(titles, isNotEmpty); + expect(titles.last, 'Home - Site'); + }); + + testWidgets('listener is removed on dispose (no leak)', (tester) async { + MagicApp.instance.singleton('translator', () => Translator.instance); + Translator.instance.setLoader(const _FakeTranslationLoader({})); + + final titles = []; + TitleManager.configure(onTitleChanged: (title, _) => titles.add(title)); + + await pumpApp(tester, title: 'App', titleSuffix: 'Site'); + TitleManager.instance.setRouteTitle('Home'); + + // Tear down MagicApplication โ€” triggers _MagicApplicationState.dispose. + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + titles.clear(); + + // Firing a locale change now must NOT re-apply the title. + await Lang.setLocale(const Locale('de'), reload: false); + + expect(titles, isEmpty); + }); + + testWidgets('listener does not attach when translator is unbound', ( + tester, + ) async { + // No translator binding registered. + Translator.instance.setLoader(const _FakeTranslationLoader({})); + + final titles = []; + TitleManager.configure(onTitleChanged: (title, _) => titles.add(title)); + + await pumpApp(tester, title: 'App', titleSuffix: 'Site'); + TitleManager.instance.setRouteTitle('Home'); + titles.clear(); + + await Lang.setLocale(const Locale('tr'), reload: false); + + expect(titles, isEmpty); + }); + }); +} + +/// Test double feeding a fixed sentence map to the [Translator]. +/// +/// Keeps [Translator.load] from hitting the asset bundle so locale switches +/// can be exercised in isolation. +class _FakeTranslationLoader implements TranslationLoader { + const _FakeTranslationLoader(this._sentences); + + final Map _sentences; + + @override + Future> load(Locale locale) async => _sentences; +} diff --git a/test/routing/title_manager_test.dart b/test/routing/title_manager_test.dart index f572830..c1d7d7d 100644 --- a/test/routing/title_manager_test.dart +++ b/test/routing/title_manager_test.dart @@ -335,4 +335,117 @@ void main() { expect(route.routeTitle, 'Second'); }); }); + + group('TitleManager โ€” separator configuration', () { + test('default separator is " - "', () { + TitleManager.configure(); + TitleManager.instance + ..setRouteTitle('Home') + ..setSuffix('Site'); + + expect(TitleManager.instance.effectiveTitle, 'Home - Site'); + }); + + test('setSeparator changes the glue between title and suffix', () { + TitleManager.configure(); + TitleManager.instance + ..setRouteTitle('Home') + ..setSuffix('Site') + ..setSeparator(' | '); + + expect(TitleManager.instance.effectiveTitle, 'Home | Site'); + }); + + test('setSeparator returns the instance for chaining', () { + TitleManager.configure(); + + final result = TitleManager.instance.setSeparator(' :: '); + + expect(identical(result, TitleManager.instance), isTrue); + }); + + test('setSeparator triggers onTitleChanged with the recomposed title', () { + final titles = []; + TitleManager.configure(onTitleChanged: (title, _) => titles.add(title)); + TitleManager.instance + ..setRouteTitle('Home') + ..setSuffix('Site'); + titles.clear(); + + TitleManager.instance.setSeparator(' | '); + + expect(titles, hasLength(1)); + expect(titles.first, 'Home | Site'); + }); + }); + + group('TitleManager โ€” lazy translation resolution', () { + tearDown(Translator.reset); + + test('a loaded key resolves through trans in effectiveTitle', () async { + Translator.instance.setLoader( + const _FakeTranslationLoader({'titles.home': 'Anasayfa'}), + ); + await Translator.instance.load(const Locale('en')); + + TitleManager.configure(); + TitleManager.instance.setRouteTitle('titles.home'); + + expect(TitleManager.instance.effectiveTitle, 'Anasayfa'); + expect(TitleManager.instance.currentTitle, 'Anasayfa'); + }); + + test('an unmatched string passes through unchanged', () { + TitleManager.configure(); + TitleManager.instance.setRouteTitle('Dashboard'); + + expect(TitleManager.instance.effectiveTitle, 'Dashboard'); + expect(TitleManager.instance.currentTitle, 'Dashboard'); + }); + + test('the suffix is composed literally, never run through trans', () async { + Translator.instance.setLoader( + const _FakeTranslationLoader({ + 'titles.home': 'Anasayfa', + 'Site': 'TRANSLATED_SITE', + }), + ); + await Translator.instance.load(const Locale('en')); + + TitleManager.configure(); + TitleManager.instance + ..setRouteTitle('titles.home') + ..setSuffix('Site'); + + // The title resolves via trans; the suffix stays the literal 'Site'. + expect(TitleManager.instance.effectiveTitle, 'Anasayfa - Site'); + }); + }); + + group('TitleManager โ€” reapply', () { + test('reapply re-invokes onTitleChanged with the effective title', () { + final titles = []; + TitleManager.configure(onTitleChanged: (title, _) => titles.add(title)); + TitleManager.instance.setAppTitle('App'); + titles.clear(); + + TitleManager.instance.reapply(); + + expect(titles, hasLength(1)); + expect(titles.first, 'App'); + }); + }); +} + +/// Test double that feeds a fixed sentence map to the [Translator]. +/// +/// Used to prove that a loaded key resolves through `trans()` at read time +/// while the suffix remains a literal. +class _FakeTranslationLoader implements TranslationLoader { + const _FakeTranslationLoader(this._sentences); + + final Map _sentences; + + @override + Future> load(Locale locale) async => _sentences; }