diff --git a/.claude/skills/update-design-tokens/SKILL.md b/.claude/skills/update-design-tokens/SKILL.md index 97803ecd..d2715f8b 100644 --- a/.claude/skills/update-design-tokens/SKILL.md +++ b/.claude/skills/update-design-tokens/SKILL.md @@ -34,28 +34,44 @@ covered below. | --- | --- | | source of truth | `tokens/{core,chat,video}/semantics/{light,dark}.json` upstream | | upstream Flutter build | `build/flutter/tokens/lib/src/{android,ios,web}/{light,dark}/stream_tokens.dart` | -| vendored here | `packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/{light,dark}/stream_tokens.dart` | +| colors vendored here | `lib/src/theme/primitives/internal/tokens/{light,dark}/stream_tokens.dart` | +| dimensions vendored here | `lib/src/theme/primitives/internal/tokens/stream_tokens_dimensions.dart` (one copy — mode-independent) | | root semantics | `lib/src/theme/semantics/stream_color_scheme.dart` | | derived values | each component's `build` / defaults, reading `colorScheme.*` | The vendored files are **maintained by hand** — there is no sync command, and the -upstream build output is not copied in verbatim. Only `stream_colors.dart` and -`stream_color_scheme.dart` import them; no component theme or widget ever -references `StreamTokens`. (`stream_color_swatch_helper.dart` generates shades in -HCT from a seed and never reads a token — it is measured *against* the vendored -values, not driven by them.) - -**Colors are byte-identical across the three upstream flavors; typography is not -— and for type the flavor is `web`.** Only web carries the `Geist` family this -package ships; android resolves to Roboto, iOS to SF Pro, and iOS also runs a -size up at every step (`typographyFontSizeMd` is 17 there against 16 on -android/web). So read colors from any flavor and type from `web`. - -Dimensions — spacing, radius, sizes, line heights, weights — are identical across -all three, so no flavor choice arises. They do come from upstream, but -`StreamSpacing`, `StreamRadius` and `StreamTokensTypography` hard-code the values -instead of reading the vendored constants, so a dimension change upstream has to -be applied to those classes by hand. +upstream build output is not copied in verbatim. Their readers are only the +primitive and semantic classes: `stream_colors.dart` and +`stream_color_scheme.dart` for the colors, and `stream_spacing.dart`, +`stream_radius.dart` and `stream_tokens_typography.dart` for the dimensions. No +component theme or widget ever references `StreamTokens` directly — a component +reads a `colorScheme` field or one of those classes. +(`stream_color_swatch_helper.dart` is *not* a reader: it generates shades in HCT +from a seed, and is measured against the vendored values rather than driven by +them.) + +**Colors are byte-identical across the three upstream flavors, and so is every +dimension except font size.** iOS runs a size up at almost every step +(`typographyFontSizeMd` is 17 there against 16 on android and web), which is why +that one group is vendored per flavor while the rest is not. Read colors from any +flavor; read a font size from the flavor whose scale you are editing. + +The font *family* does not arise: this package never sets one for text, and ships +no text font — only the generated `Stream Icons` face. Upstream's +`typographyFontFamilySans` is therefore not vendored. + +Spacing, radius, line heights and font weights are identical across all three +flavors, so no flavor choice arises for them. They live in +`internal/tokens/stream_tokens_dimensions.dart`, one copy rather than one per +mode, read by `StreamSpacing`, `StreamRadius`, `StreamLineHeight` and +`StreamFontWeight`. + +**Font sizes are the exception**: iOS runs a size up at almost every step, so +they mirror upstream's flavor split in +`internal/tokens/{android,ios}/stream_tokens_font_size.dart` and feed +`StreamFontSize.android` and `StreamFontSize.ios`. A size change has to be taken +from the matching flavor — `check:tokens` enforces that both declare the same +names, but it cannot tell you a value came from the wrong one. Only core and chat semantics are vendored into `internal/tokens/`; the video namespace is not. That is about *vendoring*, not about impact — a video token can @@ -347,21 +363,34 @@ component from a token, which is the one thing a component must not do — a constant bypasses the seedable color scheme, so a custom brand or chrome stops applying. -Dimensions and type are a separate matter. Their upstream values *are* mirrored -here, but in `StreamSpacing`, `StreamRadius` and `StreamTokensTypography`, which -hard-code them rather than reading a token constant. So a spacing or radius change -is applied to those classes by hand — and taking the type values from the wrong -flavor is a live mistake, since only `web` carries the `Geist` family. - -That hand-copying is the same hazard as baking a hex where a swatch belongs, one -layer up: nothing ties the class to the constant it mirrors, so the two drift -silently. The fix is to have those classes read the constants, not to add more -unread ones. +Spacing, radius and line heights live in `stream_tokens_dimensions.dart` instead +— one mode-independent file, read by `StreamSpacing`, `StreamRadius` and +`StreamLineHeight`. Edit the value there and the classes follow; do not +re-introduce a literal in a class, which is the same hazard as baking a hex where +a swatch belongs, one layer up. + +One upstream dimension is deliberately absent: **`radiusNone`**, since the +analyzer's `use_named_constants` prefers `Radius.zero` over `circular(0)`. The +font family is not carried either — this package never sets one for text, only +for the emoji and icon fonts. Weights are carried as `FontWeight` rather than the +raw 400/500/600/700, for the same reason the rest are `double`: it is the type +Flutter consumes, and `FontWeight` has no public constructor from a number. + +**`melos run check:tokens` enforces all of this.** It fails when a constant in +`internal/tokens/` is never referenced, and when `light/` and `dark/` disagree +about which constants exist — the second because a field resolving from a constant +in one mode but not the other falls back silently rather than failing. So there is +no judgment call about what belongs: add a constant when a field or class will +read it, and CI tells you if you got it wrong. + +Run it after any token edit, alongside `analyze`. Its allowlist is empty and worth +keeping that way; an entry there is a token the SDK carries without using. ## After editing ```bash melos run analyze +melos run check:tokens melos run test:flutter ``` diff --git a/.github/workflows/stream_core_flutter_workflow.yml b/.github/workflows/stream_core_flutter_workflow.yml index 3f5a0c99..cb82e96e 100644 --- a/.github/workflows/stream_core_flutter_workflow.yml +++ b/.github/workflows/stream_core_flutter_workflow.yml @@ -53,9 +53,10 @@ jobs: run: | melos run analyze - - name: Check Barrels + - name: Check barrels and tokens run: | melos run check:barrels + melos run check:tokens - name: Check formatting run: | diff --git a/CLAUDE.md b/CLAUDE.md index 9eb04c0f..847985e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,16 +114,23 @@ Generated files have `.g.theme.dart` extension. After modifying `.theme.dart` fi Colors originate in [design-system-tokens](https://github.com/GetStream/design-system-tokens), the same repo the icons come from. `theme/primitives/internal/tokens/{light,dark}/stream_tokens.dart` holds the vendored values; it is maintained by hand, is not part of the public API, -and only `stream_colors.dart` and `stream_color_scheme.dart` read it. +and only `stream_colors.dart` and `stream_color_scheme.dart` read it. Dimensions +live beside it in `internal/tokens/stream_tokens_dimensions.dart` — one +mode-independent copy, read by `StreamSpacing`, `StreamRadius`, +`StreamLineHeight` and `StreamFontWeight`. Font sizes are the one group that +differs per platform, so they mirror upstream's flavor split in +`internal/tokens/{android,ios}/stream_tokens_font_size.dart` and feed the two +`StreamFontSize` scales. Only the **root semantics** are mapped to a `StreamColorScheme` field. Upstream's derived tokens (`badge/*`, `button/*`, `avatar/*`) get no field — components -re-derive them from `colorScheme.*` in their own defaults. Typography, spacing and -radius do come from upstream, but `StreamTokensTypography`, `StreamSpacing` and -`StreamRadius` hard-code the values rather than reading a token constant, so a -dimension change is applied to those classes by hand. `StreamColorScheme` is +re-derive them from `colorScheme.*` in their own defaults. `StreamColorScheme` is exported from `core.dart`, so every field on it is public API. +`melos run check:tokens` fails on a vendored constant nothing reads, and on +`light/` and `dark/` disagreeing about which constants exist — so add a constant +only when a field or class will read it, and let CI catch the rest. + A field's dartdoc comes from the token's own `$description` in the upstream JSON — quote it rather than inventing prose, but resolve the aliases first, since a description tracks the token's own light/dark progression and not a comparison diff --git a/melos.yaml b/melos.yaml index d2dbc4d2..25104928 100644 --- a/melos.yaml +++ b/melos.yaml @@ -155,6 +155,14 @@ scripts: run: melos exec -c 1 --file-exists="check_barrels.yaml" -- "dart run \$MELOS_ROOT_PATH/scripts/check_barrels.dart" description: Validate the public-barrel contract for packages with a check_barrels.yaml config. + check:tokens: + # Deliberately not `melos exec`: any package filter that stops matching + # selects zero packages and reports SUCCESS, which is the one outcome this + # check must never produce silently. Run once from the root instead, and + # let the script fail if the package or its token directory is missing. + run: dart run scripts/check_tokens.dart + description: Fail if a vendored design token is unreferenced, or if light/ and dark/ disagree. + generate:dart: run: melos exec -c 1 --depends-on="build_runner" --no-flutter -- "dart run build_runner build --delete-conflicting-outputs" description: Build all generated files for Dart packages in this project. diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/android/stream_tokens_font_size.dart b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/android/stream_tokens_font_size.dart new file mode 100644 index 00000000..123d7f14 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/android/stream_tokens_font_size.dart @@ -0,0 +1,20 @@ +/// Font-size tokens from the design system, android flavor. +/// +/// The one dimension group that differs per platform: iOS runs a size up at +/// almost every step, so the token repo publishes a separate set. Read through +/// `StreamFontSize.android`, which is the public surface. +/// +/// Declared as `double` because that is what `TextStyle` takes; the token repo +/// emits them without a decimal point. +class StreamTokensFontSize { + StreamTokensFontSize._(); + + static const double typographyFontSizeMicro = 8; + static const double typographyFontSizeXxs = 10; + static const double typographyFontSizeXs = 12; + static const double typographyFontSizeSm = 14; + static const double typographyFontSizeMd = 16; + static const double typographyFontSizeLg = 18; + static const double typographyFontSizeXl = 20; + static const double typographyFontSize2xl = 24; +} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/dark/stream_tokens.dart b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/dark/stream_tokens.dart index 0a112977..a7764591 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/dark/stream_tokens.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/dark/stream_tokens.dart @@ -126,120 +126,6 @@ class StreamTokens { static const lime700 = Color(0xFF355315); static const lime800 = Color(0xFF203A00); static const lime900 = Color(0xFF112100); - static const size2 = 2; - static const size4 = 4; - static const size6 = 6; - static const size8 = 8; - static const size12 = 12; - static const size16 = 16; - static const size20 = 20; - static const size24 = 24; - static const size32 = 32; - static const size40 = 40; - static const size48 = 48; - static const size64 = 64; - static const size28 = 28; - static const size80 = 80; - static const size128 = 128; - static const size240 = 240; - static const size320 = 320; - static const size480 = 480; - static const size560 = 560; - static const size640 = 640; - static const size760 = 760; - static const size144 = 144; - static const size208 = 208; - static const size56 = 56; - static const radius0 = 0; - static const radius2 = 2; - static const radius4 = 4; - static const radius6 = 6; - static const radius8 = 8; - static const radius12 = 12; - static const radius16 = 16; - static const radius20 = 20; - static const radius24 = 24; - static const radius32 = 32; - static const radiusFull = 9999; - static const space0 = 0; - static const space2 = 2; - static const space4 = 4; - static const space8 = 8; - static const space12 = 12; - static const space16 = 16; - static const space20 = 20; - static const space24 = 24; - static const space32 = 32; - static const space40 = 40; - static const space48 = 48; - static const space64 = 64; - static const space80 = 80; - static const w100 = 1; - static const w150 = 1.5; - static const w200 = 2; - static const w300 = 3; - static const w400 = 4; - static const w120 = 1.2; - static const fontFamilyGeist = 'Geist'; - static const fontFamilyGeistMono = 'Geist Mono'; - static const fontFamilySfPro = 'SF Pro'; - static const fontFamilySfMono = 'SF Mono'; - static const fontFamilyRoboto = 'Roboto'; - static const fontFamilyRobotoMono = 'Roboto Mono'; - static const fontWeightW400 = 400; - static const fontWeightW500 = 500; - static const fontWeightW600 = 600; - static const fontWeightW700 = 700; - static const fontSizeSize8 = 8; - static const fontSizeSize10 = 10; - static const fontSizeSize11 = 11; - static const fontSizeSize12 = 12; - static const fontSizeSize13 = 13; - static const fontSizeSize14 = 14; - static const fontSizeSize16 = 16; - static const fontSizeSize15 = 15; - static const fontSizeSize17 = 17; - static const fontSizeSize18 = 18; - static const fontSizeSize20 = 20; - static const fontSizeSize22 = 22; - static const fontSizeSize24 = 24; - static const fontSizeSize28 = 28; - static const fontSizeSize32 = 32; - static const fontSizeSize40 = 40; - static const fontSizeSize48 = 48; - static const fontSizeSize64 = 64; - static const lineHeightLineHeight8 = 8; - static const lineHeightLineHeight10 = 10; - static const lineHeightLineHeight12 = 12; - static const lineHeightLineHeight13 = 13; - static const lineHeightLineHeight14 = 14; - static const lineHeightLineHeight15 = 15; - static const lineHeightLineHeight16 = 16; - static const lineHeightLineHeight17 = 17; - static const lineHeightLineHeight18 = 18; - static const lineHeightLineHeight20 = 20; - static const lineHeightLineHeight24 = 24; - static const lineHeightLineHeight28 = 28; - static const lineHeightLineHeight32 = 32; - static const lineHeightLineHeight40 = 40; - static const lineHeightLineHeight48 = 48; - static const typographyFontFamilySans = 'Geist'; - static const typographyFontFamilyMono = 'Geist Mono'; - static const typographyFontWeightRegular = 400; - static const typographyFontWeightMedium = 500; - static const typographyFontWeightSemiBold = 600; - static const typographyFontWeightBold = 700; - static const typographyFontSizeXxs = 10; - static const typographyFontSizeXs = 12; - static const typographyFontSizeSm = 14; - static const typographyFontSizeMd = 16; - static const typographyFontSizeLg = 18; - static const typographyFontSizeXl = 20; - static const typographyFontSize2xl = 24; - static const typographyFontSizeMicro = 8; - static const typographyLineHeightTight = 16; - static const typographyLineHeightNormal = 20; - static const typographyLineHeightRelaxed = 24; static final lightElevation1 = [ const BoxShadow( color: Color.fromRGBO(0, 0, 0, 0.05), @@ -360,72 +246,6 @@ class StreamTokens { offset: Offset(0, 20), ), ]; - static const radiusNone = 0; - static const radiusXxs = 2; - static const radiusXs = 4; - static const radiusSm = 6; - static const radiusMd = 8; - static const radiusLg = 12; - static const radiusXl = 16; - static const radius2xl = 20; - static const radiusMax = 9999; - static const radius3xl = 24; - static const radius4xl = 32; - static const spacingNone = 0; - static const spacingXxs = 4; - static const spacingXs = 8; - static const spacingSm = 12; - static const spacingMd = 16; - static const spacingXl = 24; - static const spacing2xl = 32; - static const spacing3xl = 40; - static const spacingLg = 20; - static const spacingXxxs = 2; - static const deviceRadius = 8; - static const deviceSafeAreaBottom = 0; - static const deviceSafeAreaTop = 0; - static const messageBubbleRadiusGroupTop = 20; - static const messageBubbleRadiusGroupMiddle = 20; - static const messageBubbleRadiusGroupBottom = 20; - static const messageBubbleRadiusTail = 0; - static const messageBubbleRadiusAttachment = 12; - static const messageBubbleRadiusAttachmentInline = 8; - static const composerRadiusFixed = 24; - static const composerRadiusFloating = 24; - static const buttonRadiusLg = 9999; - static const buttonRadiusMd = 9999; - static const buttonRadiusSm = 9999; - static const buttonRadiusFull = 9999; - static const buttonVisualHeightSm = 32; - static const buttonVisualHeightMd = 40; - static const buttonVisualHeightLg = 48; - static const buttonVisualHeightXs = 24; - static const buttonHitTargetMinHeight = 48; - static const buttonHitTargetMinWidth = 48; - static const buttonPaddingYLg = 14; - static const buttonPaddingYMd = 10; - static const buttonPaddingYSm = 6; - static const buttonPaddingYXs = 4; - static const buttonPaddingXIconOnlyLg = 14; - static const buttonPaddingXIconOnlyMd = 10; - static const buttonPaddingXIconOnlySm = 6; - static const buttonPaddingXIconOnlyXs = 4; - static const buttonPaddingXWithLabelLg = 16; - static const buttonPaddingXWithLabelMd = 16; - static const buttonPaddingXWithLabelSm = 16; - static const buttonPaddingXWithLabelXs = 12; - static const iconSizeXs = 12; - static const iconSizeSm = 16; - static const iconSizeMd = 20; - static const iconSizeLg = 32; - static const iconStrokeSubtle = 1.2; - static const iconStrokeDefault = 1.5; - static const iconStrokeEmphasis = 2; - static const emojiSm = 16; - static const emojiMd = 24; - static const emojiLg = 32; - static const emojiXl = 48; - static const emoji2xl = 64; static const backgroundCoreHighlight = Color(0xFF5F1A05); static const backgroundCoreOverlayLight = Color(0xBF000000); static const backgroundCoreOverlayDark = Color(0x80000000); diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/ios/stream_tokens_font_size.dart b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/ios/stream_tokens_font_size.dart new file mode 100644 index 00000000..5db97270 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/ios/stream_tokens_font_size.dart @@ -0,0 +1,20 @@ +/// Font-size tokens from the design system, ios flavor. +/// +/// The one dimension group that differs per platform: iOS runs a size up at +/// almost every step, so the token repo publishes a separate set. Read through +/// `StreamFontSize.ios`, which is the public surface. +/// +/// Declared as `double` because that is what `TextStyle` takes; the token repo +/// emits them without a decimal point. +class StreamTokensFontSize { + StreamTokensFontSize._(); + + static const double typographyFontSizeMicro = 8; + static const double typographyFontSizeXxs = 12; + static const double typographyFontSizeXs = 13; + static const double typographyFontSizeSm = 15; + static const double typographyFontSizeMd = 17; + static const double typographyFontSizeLg = 20; + static const double typographyFontSizeXl = 22; + static const double typographyFontSize2xl = 24; +} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/light/stream_tokens.dart b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/light/stream_tokens.dart index 32d4b2cf..01f1c370 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/light/stream_tokens.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/light/stream_tokens.dart @@ -126,120 +126,6 @@ class StreamTokens { static const lime700 = Color(0xFF355315); static const lime800 = Color(0xFF203A00); static const lime900 = Color(0xFF112100); - static const size2 = 2; - static const size4 = 4; - static const size6 = 6; - static const size8 = 8; - static const size12 = 12; - static const size16 = 16; - static const size20 = 20; - static const size24 = 24; - static const size32 = 32; - static const size40 = 40; - static const size48 = 48; - static const size64 = 64; - static const size28 = 28; - static const size80 = 80; - static const size128 = 128; - static const size240 = 240; - static const size320 = 320; - static const size480 = 480; - static const size560 = 560; - static const size640 = 640; - static const size760 = 760; - static const size144 = 144; - static const size208 = 208; - static const size56 = 56; - static const radius0 = 0; - static const radius2 = 2; - static const radius4 = 4; - static const radius6 = 6; - static const radius8 = 8; - static const radius12 = 12; - static const radius16 = 16; - static const radius20 = 20; - static const radius24 = 24; - static const radius32 = 32; - static const radiusFull = 9999; - static const space0 = 0; - static const space2 = 2; - static const space4 = 4; - static const space8 = 8; - static const space12 = 12; - static const space16 = 16; - static const space20 = 20; - static const space24 = 24; - static const space32 = 32; - static const space40 = 40; - static const space48 = 48; - static const space64 = 64; - static const space80 = 80; - static const w100 = 1; - static const w150 = 1.5; - static const w200 = 2; - static const w300 = 3; - static const w400 = 4; - static const w120 = 1.2; - static const fontFamilyGeist = 'Geist'; - static const fontFamilyGeistMono = 'Geist Mono'; - static const fontFamilySfPro = 'SF Pro'; - static const fontFamilySfMono = 'SF Mono'; - static const fontFamilyRoboto = 'Roboto'; - static const fontFamilyRobotoMono = 'Roboto Mono'; - static const fontWeightW400 = 400; - static const fontWeightW500 = 500; - static const fontWeightW600 = 600; - static const fontWeightW700 = 700; - static const fontSizeSize8 = 8; - static const fontSizeSize10 = 10; - static const fontSizeSize11 = 11; - static const fontSizeSize12 = 12; - static const fontSizeSize13 = 13; - static const fontSizeSize14 = 14; - static const fontSizeSize16 = 16; - static const fontSizeSize15 = 15; - static const fontSizeSize17 = 17; - static const fontSizeSize18 = 18; - static const fontSizeSize20 = 20; - static const fontSizeSize22 = 22; - static const fontSizeSize24 = 24; - static const fontSizeSize28 = 28; - static const fontSizeSize32 = 32; - static const fontSizeSize40 = 40; - static const fontSizeSize48 = 48; - static const fontSizeSize64 = 64; - static const lineHeightLineHeight8 = 8; - static const lineHeightLineHeight10 = 10; - static const lineHeightLineHeight12 = 12; - static const lineHeightLineHeight13 = 13; - static const lineHeightLineHeight14 = 14; - static const lineHeightLineHeight15 = 15; - static const lineHeightLineHeight16 = 16; - static const lineHeightLineHeight17 = 17; - static const lineHeightLineHeight18 = 18; - static const lineHeightLineHeight20 = 20; - static const lineHeightLineHeight24 = 24; - static const lineHeightLineHeight28 = 28; - static const lineHeightLineHeight32 = 32; - static const lineHeightLineHeight40 = 40; - static const lineHeightLineHeight48 = 48; - static const typographyFontFamilySans = 'Geist'; - static const typographyFontFamilyMono = 'Geist Mono'; - static const typographyFontWeightRegular = 400; - static const typographyFontWeightMedium = 500; - static const typographyFontWeightSemiBold = 600; - static const typographyFontWeightBold = 700; - static const typographyFontSizeXxs = 10; - static const typographyFontSizeXs = 12; - static const typographyFontSizeSm = 14; - static const typographyFontSizeMd = 16; - static const typographyFontSizeLg = 18; - static const typographyFontSizeXl = 20; - static const typographyFontSize2xl = 24; - static const typographyFontSizeMicro = 8; - static const typographyLineHeightTight = 16; - static const typographyLineHeightNormal = 20; - static const typographyLineHeightRelaxed = 24; static final lightElevation1 = [ const BoxShadow( color: Color.fromRGBO(0, 0, 0, 0.05), @@ -360,72 +246,6 @@ class StreamTokens { offset: Offset(0, 20), ), ]; - static const radiusNone = 0; - static const radiusXxs = 2; - static const radiusXs = 4; - static const radiusSm = 6; - static const radiusMd = 8; - static const radiusLg = 12; - static const radiusXl = 16; - static const radius2xl = 20; - static const radiusMax = 9999; - static const radius3xl = 24; - static const radius4xl = 32; - static const spacingNone = 0; - static const spacingXxs = 4; - static const spacingXs = 8; - static const spacingSm = 12; - static const spacingMd = 16; - static const spacingXl = 24; - static const spacing2xl = 32; - static const spacing3xl = 40; - static const spacingLg = 20; - static const spacingXxxs = 2; - static const deviceRadius = 8; - static const deviceSafeAreaBottom = 0; - static const deviceSafeAreaTop = 0; - static const messageBubbleRadiusGroupTop = 20; - static const messageBubbleRadiusGroupMiddle = 20; - static const messageBubbleRadiusGroupBottom = 20; - static const messageBubbleRadiusTail = 0; - static const messageBubbleRadiusAttachment = 12; - static const messageBubbleRadiusAttachmentInline = 8; - static const composerRadiusFixed = 24; - static const composerRadiusFloating = 24; - static const buttonRadiusLg = 9999; - static const buttonRadiusMd = 9999; - static const buttonRadiusSm = 9999; - static const buttonRadiusFull = 9999; - static const buttonVisualHeightSm = 32; - static const buttonVisualHeightMd = 40; - static const buttonVisualHeightLg = 48; - static const buttonVisualHeightXs = 24; - static const buttonHitTargetMinHeight = 48; - static const buttonHitTargetMinWidth = 48; - static const buttonPaddingYLg = 14; - static const buttonPaddingYMd = 10; - static const buttonPaddingYSm = 6; - static const buttonPaddingYXs = 4; - static const buttonPaddingXIconOnlyLg = 14; - static const buttonPaddingXIconOnlyMd = 10; - static const buttonPaddingXIconOnlySm = 6; - static const buttonPaddingXIconOnlyXs = 4; - static const buttonPaddingXWithLabelLg = 16; - static const buttonPaddingXWithLabelMd = 16; - static const buttonPaddingXWithLabelSm = 16; - static const buttonPaddingXWithLabelXs = 12; - static const iconSizeXs = 12; - static const iconSizeSm = 16; - static const iconSizeMd = 20; - static const iconSizeLg = 32; - static const iconStrokeSubtle = 1.2; - static const iconStrokeDefault = 1.5; - static const iconStrokeEmphasis = 2; - static const emojiSm = 16; - static const emojiMd = 24; - static const emojiLg = 32; - static const emojiXl = 48; - static const emoji2xl = 64; static const backgroundCoreHighlight = Color(0xFFFEF9DA); static const backgroundCoreOverlayLight = Color(0xBFFFFFFF); static const backgroundCoreOverlayDark = Color(0x401A1B25); diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/stream_tokens_dimensions.dart b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/stream_tokens_dimensions.dart new file mode 100644 index 00000000..328d95db --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/stream_tokens_dimensions.dart @@ -0,0 +1,66 @@ +import 'dart:ui'; + +/// Dimension tokens from the design system. +/// +/// Mode-independent, unlike the colors: the token repo publishes one set of +/// spacing, radius, line-height and weight values, so these live here rather +/// than being duplicated under `light/` and `dark/`. They are also identical +/// across the android, ios and web flavors of the token build, so no flavor +/// choice arises. Font sizes are the exception and live in `android/` and +/// `ios/` beside this file. +/// +/// Declared in the types Flutter consumes rather than the raw numbers upstream +/// emits — `double` for what `Radius`, `EdgeInsets` and `TextStyle` take, and +/// `FontWeight` for the weights, which have no public constructor from a number +/// and so could not otherwise be read in a const expression. +/// +/// Read these through `StreamSpacing`, `StreamRadius`, `StreamLineHeight` and +/// `StreamFontWeight`, which are the public surface. +/// +/// One upstream dimension is deliberately not carried: `radiusNone`, since the +/// analyzer's `use_named_constants` prefers `Radius.zero` over `circular(0)`. +/// The font family is not carried either — this package never sets one for +/// text, only for the emoji and icon fonts. +class StreamTokensDimensions { + StreamTokensDimensions._(); + + // Spacing + static const double spacingNone = 0; + static const double spacingXxxs = 2; + static const double spacingXxs = 4; + static const double spacingXs = 8; + static const double spacingSm = 12; + static const double spacingMd = 16; + static const double spacingLg = 20; + static const double spacingXl = 24; + static const double spacing2xl = 32; + static const double spacing3xl = 40; + + // Radius + static const double radiusXxs = 2; + static const double radiusXs = 4; + static const double radiusSm = 6; + static const double radiusMd = 8; + static const double radiusLg = 12; + static const double radiusXl = 16; + static const double radius2xl = 20; + static const double radius3xl = 24; + static const double radius4xl = 32; + static const double radiusMax = 9999; + + // Typography + static const double typographyLineHeightTight = 16; + static const double typographyLineHeightNormal = 20; + static const double typographyLineHeightRelaxed = 24; + + // Typography — weight + // + // Declared as `FontWeight` rather than the raw 400/500/600/700 upstream + // emits, for the same reason the values above are `double`: it is the type + // Flutter consumes, and `FontWeight` has no public constructor taking a + // number, so an int here could not be read in a const expression. + static const FontWeight typographyFontWeightRegular = FontWeight.w400; + static const FontWeight typographyFontWeightMedium = FontWeight.w500; + static const FontWeight typographyFontWeightSemiBold = FontWeight.w600; + static const FontWeight typographyFontWeightBold = FontWeight.w700; +} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/stream_tokens_typography.dart b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/stream_tokens_typography.dart deleted file mode 100644 index 39e9b913..00000000 --- a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/stream_tokens_typography.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class StreamTokensTypography { - StreamTokensTypography._(); - - static const headingLg = TextStyle( - fontFamily: 'Geist', - fontSize: 20, - fontWeight: FontWeight.w600, - height: 1.2, - ); - static const headingMd = TextStyle( - fontFamily: 'Geist', - fontSize: 18, - fontWeight: FontWeight.w600, - height: 1.1111111111111112, - ); - static const headingSm = TextStyle( - fontFamily: 'Geist', - fontSize: 16, - fontWeight: FontWeight.w600, - height: 1.25, - ); - static const headingXs = TextStyle( - fontFamily: 'Geist', - fontSize: 12, - fontWeight: FontWeight.w600, - height: 1.3333333333333333, - ); - static const bodyDefault = TextStyle( - fontFamily: 'Geist', - fontSize: 16, - fontWeight: FontWeight.w400, - height: 1.25, - ); - static const bodyEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 16, - fontWeight: FontWeight.w600, - height: 1.25, - ); - static const bodyLink = TextStyle( - fontFamily: 'Geist', - fontSize: 16, - fontWeight: FontWeight.w400, - height: 1.25, - ); - static const bodyLinkEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 16, - fontWeight: FontWeight.w600, - height: 1.25, - ); - static const captionDefault = TextStyle( - fontFamily: 'Geist', - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1.1428571428571428, - ); - static const captionEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 14, - fontWeight: FontWeight.w600, - height: 1.1428571428571428, - ); - static const captionLink = TextStyle( - fontFamily: 'Geist', - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1.1428571428571428, - ); - static const captionLinkEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 14, - fontWeight: FontWeight.w600, - height: 1.1428571428571428, - ); - static const metadataDefault = TextStyle( - fontFamily: 'Geist', - fontSize: 12, - fontWeight: FontWeight.w400, - height: 1.3333333333333333, - ); - static const metadataEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 12, - fontWeight: FontWeight.w600, - height: 1.3333333333333333, - ); - static const metadataLink = TextStyle( - fontFamily: 'Geist', - fontSize: 12, - fontWeight: FontWeight.w400, - height: 1.3333333333333333, - ); - static const metadataLinkEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 12, - fontWeight: FontWeight.w600, - height: 1.3333333333333333, - ); - static const numericXl = TextStyle( - fontFamily: 'Geist', - fontSize: 14, - fontWeight: FontWeight.w700, - ); - static const numericLg = TextStyle( - fontFamily: 'Geist', - fontSize: 12, - fontWeight: FontWeight.w700, - ); - static const numericMd = TextStyle( - fontFamily: 'Geist', - fontSize: 10, - fontWeight: FontWeight.w700, - ); - static const numericSm = TextStyle( - fontFamily: 'Geist', - fontSize: 8, - fontWeight: FontWeight.w700, - ); -} diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.dart index ab8097f7..7067e619 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_radius.dart @@ -3,6 +3,8 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import 'internal/tokens/stream_tokens_dimensions.dart' as tokens; + part 'stream_radius.g.theme.dart'; /// Border radius primitives for the Stream design system. @@ -28,17 +30,20 @@ part 'stream_radius.g.theme.dart'; class StreamRadius with _$StreamRadius { /// Creates a [StreamRadius] with the default values. const StreamRadius({ + // `Radius.zero` rather than `circular(radiusNone)`: the analyzer's + // use_named_constants prefers the named constant, so the token has no + // reader and is not carried. this.none = .zero, - this.xxs = const .circular(2), - this.xs = const .circular(4), - this.sm = const .circular(6), - this.md = const .circular(8), - this.lg = const .circular(12), - this.xl = const .circular(16), - this.xxl = const .circular(20), - this.xxxl = const .circular(24), - this.xxxxl = const .circular(32), - this.max = const .circular(9999), + this.xxs = const .circular(tokens.StreamTokensDimensions.radiusXxs), + this.xs = const .circular(tokens.StreamTokensDimensions.radiusXs), + this.sm = const .circular(tokens.StreamTokensDimensions.radiusSm), + this.md = const .circular(tokens.StreamTokensDimensions.radiusMd), + this.lg = const .circular(tokens.StreamTokensDimensions.radiusLg), + this.xl = const .circular(tokens.StreamTokensDimensions.radiusXl), + this.xxl = const .circular(tokens.StreamTokensDimensions.radius2xl), + this.xxxl = const .circular(tokens.StreamTokensDimensions.radius3xl), + this.xxxxl = const .circular(tokens.StreamTokensDimensions.radius4xl), + this.max = const .circular(tokens.StreamTokensDimensions.radiusMax), }); /// No border radius. diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart index b56d5a3c..82928722 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import 'internal/tokens/stream_tokens_dimensions.dart' as tokens; + part 'stream_spacing.g.theme.dart'; /// Spacing primitives for the Stream design system. @@ -28,16 +30,16 @@ part 'stream_spacing.g.theme.dart'; class StreamSpacing with _$StreamSpacing { /// Creates a [StreamSpacing] with the default values. const StreamSpacing({ - this.none = 0, - this.xxxs = 2, - this.xxs = 4, - this.xs = 8, - this.sm = 12, - this.md = 16, - this.lg = 20, - this.xl = 24, - this.xxl = 32, - this.xxxl = 40, + this.none = tokens.StreamTokensDimensions.spacingNone, + this.xxxs = tokens.StreamTokensDimensions.spacingXxxs, + this.xxs = tokens.StreamTokensDimensions.spacingXxs, + this.xs = tokens.StreamTokensDimensions.spacingXs, + this.sm = tokens.StreamTokensDimensions.spacingSm, + this.md = tokens.StreamTokensDimensions.spacingMd, + this.lg = tokens.StreamTokensDimensions.spacingLg, + this.xl = tokens.StreamTokensDimensions.spacingXl, + this.xxl = tokens.StreamTokensDimensions.spacing2xl, + this.xxxl = tokens.StreamTokensDimensions.spacing3xl, }); /// No spacing. diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.dart index 098424c2..1cc544c3 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.dart @@ -3,6 +3,10 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import 'internal/tokens/android/stream_tokens_font_size.dart' as android_tokens; +import 'internal/tokens/ios/stream_tokens_font_size.dart' as ios_tokens; +import 'internal/tokens/stream_tokens_dimensions.dart' as tokens; + part 'stream_typography.g.theme.dart'; /// Typography primitives for the Stream design system. @@ -79,9 +83,9 @@ class StreamTypography with _$StreamTypography { class StreamLineHeight with _$StreamLineHeight { /// Creates a [StreamLineHeight] with the given values. const StreamLineHeight({ - this.tight = 16, - this.normal = 20, - this.relaxed = 24, + this.tight = tokens.StreamTokensDimensions.typographyLineHeightTight, + this.normal = tokens.StreamTokensDimensions.typographyLineHeightNormal, + this.relaxed = tokens.StreamTokensDimensions.typographyLineHeightRelaxed, }); /// The tight line height. @@ -172,28 +176,28 @@ class StreamFontSize with _$StreamFontSize { /// /// Uses San Francisco font sizing conventions. static const StreamFontSize ios = .raw( - micro: 8, - xxs: 12, - xs: 13, - sm: 15, - md: 17, - lg: 20, - xl: 22, - xxl: 24, + micro: ios_tokens.StreamTokensFontSize.typographyFontSizeMicro, + xxs: ios_tokens.StreamTokensFontSize.typographyFontSizeXxs, + xs: ios_tokens.StreamTokensFontSize.typographyFontSizeXs, + sm: ios_tokens.StreamTokensFontSize.typographyFontSizeSm, + md: ios_tokens.StreamTokensFontSize.typographyFontSizeMd, + lg: ios_tokens.StreamTokensFontSize.typographyFontSizeLg, + xl: ios_tokens.StreamTokensFontSize.typographyFontSizeXl, + xxl: ios_tokens.StreamTokensFontSize.typographyFontSize2xl, ); /// The Android font size scale. /// /// Uses Roboto font sizing conventions. static const StreamFontSize android = .raw( - micro: 8, - xxs: 10, - xs: 12, - sm: 14, - md: 16, - lg: 18, - xl: 20, - xxl: 24, + micro: android_tokens.StreamTokensFontSize.typographyFontSizeMicro, + xxs: android_tokens.StreamTokensFontSize.typographyFontSizeXxs, + xs: android_tokens.StreamTokensFontSize.typographyFontSizeXs, + sm: android_tokens.StreamTokensFontSize.typographyFontSizeSm, + md: android_tokens.StreamTokensFontSize.typographyFontSizeMd, + lg: android_tokens.StreamTokensFontSize.typographyFontSizeLg, + xl: android_tokens.StreamTokensFontSize.typographyFontSizeXl, + xxl: android_tokens.StreamTokensFontSize.typographyFontSize2xl, ); /// The micro font size. @@ -236,10 +240,10 @@ class StreamFontSize with _$StreamFontSize { class StreamFontWeight with _$StreamFontWeight { /// Creates a [StreamFontWeight] with the given values. const StreamFontWeight({ - this.regular = .w400, - this.medium = .w500, - this.semibold = .w600, - this.bold = .w700, + this.regular = tokens.StreamTokensDimensions.typographyFontWeightRegular, + this.medium = tokens.StreamTokensDimensions.typographyFontWeightMedium, + this.semibold = tokens.StreamTokensDimensions.typographyFontWeightSemiBold, + this.bold = tokens.StreamTokensDimensions.typographyFontWeightBold, }); /// The regular font weight. diff --git a/scripts/check_tokens.dart b/scripts/check_tokens.dart new file mode 100644 index 00000000..5b453d09 --- /dev/null +++ b/scripts/check_tokens.dart @@ -0,0 +1,392 @@ +// Verifies the vendored design-token files carry nothing dead. +// +// `lib/src/theme/primitives/internal/tokens/{light,dark}/stream_tokens.dart` +// mirror a fraction of what the design-token repo publishes, by design: only +// the root semantics live there, and a component's values are re-derived from +// those in its own defaults. Nothing enforced that, so the files had grown to +// 502 constants of which 163 were read — two thirds dead weight, and every +// unread constant an invitation to reach for a token where a `colorScheme` +// field was meant. +// +// This check fails when a constant is declared and never referenced, and when +// the light and dark files disagree about which constants exist. It covers the +// mode-independent dimension tokens next to them on the same terms. +// +// Every "nothing to check" path is a failure rather than a pass. A guard that +// exits 0 because it found nothing to inspect is worse than no guard: it prints +// the same tick as a real pass. So the package, the token directory, each +// per-mode file and the dimensions file must all exist, each must yield a +// plausible number of declarations, and the reference scan must actually open +// files. +// +// Run with `melos run check:tokens`. +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Names that are allowed to be unreferenced. +/// +/// Empty, and worth keeping that way — an entry here is a token the SDK +/// carries without using. Prefer deleting the constant and re-adding it when a +/// component needs it, since the design-token repo remains the source of truth +/// either way. +/// +/// An entry matching no declaration is itself a failure, so this cannot rot +/// into a list of names nobody can account for. +const _allowedUnused = {}; + +const _packageName = 'stream_core_flutter'; +const _tokenDir = 'lib/src/theme/primitives/internal/tokens'; + +/// Lower bound on the color constants each mode file must declare. +/// +/// Guards against a parse that silently yields nothing — a reformat, a switch +/// to `static final`, a move to generated code. Deliberately far below the real +/// count (163 when this was written) so ordinary pruning never trips it. +const _minColorTokens = 50; + +/// Lower bound on the dimension constants, on the same reasoning. +const _minDimensionTokens = 10; + +/// Lower bound on the per-flavor font-size constants, on the same reasoning. +const _minFontSizeTokens = 5; + +void main(List args) { + // Resolved from the repo root rather than inherited from the caller's cwd, + // so the check cannot be pointed at a package that has no tokens and pass. + final packageRoot = args.isNotEmpty ? args.first : p.join(Directory.current.path, 'packages', _packageName); + if (!Directory(packageRoot).existsSync()) { + _fail( + 'Missing ${p.relative(packageRoot, from: Directory.current.path)}.\n' + ' Run this from the repo root with `melos run check:tokens`, or pass\n' + ' the package root as the first argument.', + ); + } + + final tokenDir = Directory(p.join(packageRoot, _tokenDir)); + if (!tokenDir.existsSync()) { + _fail( + 'Missing $_tokenDir.\n' + ' If the vendored tokens moved, update _tokenDir here — do not let the\n' + ' check quietly pass on a directory that is not there.', + ); + } + + final libDir = Directory(p.join(packageRoot, 'lib')); + if (!libDir.existsSync()) _fail('Missing lib/ in $_packageName.'); + + final failures = []; + + final declared = >{}; + for (final mode in const ['light', 'dark']) { + final file = File(p.join(tokenDir.path, mode, 'stream_tokens.dart')); + final label = p.relative(file.path, from: packageRoot); + if (!file.existsSync()) _fail('Missing $label'); + declared[mode] = _declarations(file.readAsStringSync(), label); + if (declared[mode]!.length < _minColorTokens) { + _fail( + 'Only ${declared[mode]!.length} constants parsed from $label, expected at least $_minColorTokens.\n' + ' Either the file shrank drastically or the declaration pattern no\n' + ' longer matches it. Both mean this check has stopped checking.', + ); + } + } + + // Both modes must declare the same names — a field that resolves from a + // constant in one mode and not the other silently falls back. + final onlyLight = declared['light']!.difference(declared['dark']!); + final onlyDark = declared['dark']!.difference(declared['light']!); + if (onlyLight.isNotEmpty || onlyDark.isNotEmpty) { + failures.add( + 'light/ and dark/ declare different constants.\n' + '${onlyLight.isEmpty ? '' : ' only in light/: ${_list(onlyLight)}\n'}' + '${onlyDark.isEmpty ? '' : ' only in dark/: ${_list(onlyDark)}'}', + ); + } + + final modeFiles = { + for (final mode in const ['light', 'dark']) p.normalize(p.join(tokenDir.path, mode, 'stream_tokens.dart')), + }; + final colorScan = _references(libDir, modeFiles); + if (colorScan.filesRead == 0) { + _fail('Scanned no Dart files under lib/ — the reference search found nothing to read.'); + } + + final allColors = {...declared['light']!, ...declared['dark']!}; + final unused = allColors.where((name) => !colorScan.names.contains(name) && !_allowedUnused.contains(name)).toList() + ..sort(); + if (unused.isNotEmpty) { + failures.add( + '${unused.length} vendored token${unused.length == 1 ? '' : 's'} ' + '${unused.length == 1 ? 'is' : 'are'} never referenced:\n' + ' ${_list(unused)}\n' + ' Only the root semantics belong here. A component derives its values\n' + ' from a StreamColorScheme field, so a token no field reads is dead —\n' + ' delete it, or wire up the field that should read it.', + ); + } + + // The dimension tokens sit beside the per-mode files rather than inside them, + // because the token repo publishes one set for every mode. Same rule: a + // constant no class reads is dead weight. + final dimensionsFile = File(p.join(tokenDir.path, 'stream_tokens_dimensions.dart')); + final dimensionsLabel = p.relative(dimensionsFile.path, from: packageRoot); + if (!dimensionsFile.existsSync()) _fail('Missing $dimensionsLabel'); + + final declaredDimensions = _declarations(dimensionsFile.readAsStringSync(), dimensionsLabel); + if (declaredDimensions.length < _minDimensionTokens) { + _fail( + 'Only ${declaredDimensions.length} constants parsed from $dimensionsLabel, ' + 'expected at least $_minDimensionTokens.', + ); + } + final dimensionScan = _references(libDir, {p.normalize(dimensionsFile.path)}, className: 'StreamTokensDimensions'); + final unusedDimensions = + declaredDimensions.where((name) => !dimensionScan.names.contains(name) && !_allowedUnused.contains(name)).toList() + ..sort(); + if (unusedDimensions.isNotEmpty) { + failures.add( + '${unusedDimensions.length} dimension token${unusedDimensions.length == 1 ? '' : 's'} ' + '${unusedDimensions.length == 1 ? 'is' : 'are'} never referenced:\n' + ' ${_list(unusedDimensions)}\n' + ' StreamSpacing, StreamRadius and StreamLineHeight are the only readers.\n' + ' A dimension no class exposes belongs upstream, not here.', + ); + } + + // Font sizes are the one dimension group that differs per platform, so they + // mirror upstream's flavor split rather than sitting in the shared file. Same + // rules as light/dark: both must declare the same names, and each file must + // actually be imported — identical constant names across the two would + // otherwise let an orphaned flavor look referenced. + final fontSizeFiles = { + for (final flavor in const ['android', 'ios']) + flavor: File(p.join(tokenDir.path, flavor, 'stream_tokens_font_size.dart')), + }; + final declaredFontSizes = >{}; + fontSizeFiles.forEach((flavor, file) { + final label = p.relative(file.path, from: packageRoot); + if (!file.existsSync()) _fail('Missing $label'); + declaredFontSizes[flavor] = _declarations(file.readAsStringSync(), label); + if (declaredFontSizes[flavor]!.length < _minFontSizeTokens) { + _fail( + 'Only ${declaredFontSizes[flavor]!.length} constants parsed from $label, ' + 'expected at least $_minFontSizeTokens.', + ); + } + if (!_isImportedUnder(libDir, file, packageRoot)) { + failures.add( + '$label is never imported.\n' + ' Both flavors declare the same constant names, so an unused one still\n' + ' looks referenced. Wire it to StreamFontSize.$flavor, or delete it.', + ); + } + }); + + final onlyAndroid = declaredFontSizes['android']!.difference(declaredFontSizes['ios']!); + final onlyIos = declaredFontSizes['ios']!.difference(declaredFontSizes['android']!); + if (onlyAndroid.isNotEmpty || onlyIos.isNotEmpty) { + failures.add( + 'android/ and ios/ declare different font-size constants.\n' + '${onlyAndroid.isEmpty ? '' : ' only in android/: ${_list(onlyAndroid)}\n'}' + '${onlyIos.isEmpty ? '' : ' only in ios/: ${_list(onlyIos)}'}', + ); + } + + final fontSizeScan = _references( + libDir, + fontSizeFiles.values.map((f) => p.normalize(f.path)).toSet(), + className: 'StreamTokensFontSize', + ); + final unusedFontSizes = { + ...declaredFontSizes['android']!, + ...declaredFontSizes['ios']!, + }.where((name) => !fontSizeScan.names.contains(name) && !_allowedUnused.contains(name)).toList()..sort(); + if (unusedFontSizes.isNotEmpty) { + failures.add( + '${unusedFontSizes.length} font-size token${unusedFontSizes.length == 1 ? '' : 's'} ' + '${unusedFontSizes.length == 1 ? 'is' : 'are'} never referenced:\n' + ' ${_list(unusedFontSizes)}\n' + ' StreamFontSize.android and StreamFontSize.ios are the only readers.', + ); + } + + // Any other Dart file in the token directory is vendored too. Rather than + // being invisible to this check, it has to be accounted for — that is how a + // whole file of dead constants stayed hidden once already. + final covered = { + ...modeFiles, + p.normalize(dimensionsFile.path), + ...fontSizeFiles.values.map((f) => p.normalize(f.path)), + }; + final uncovered = + tokenDir + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.dart') && !covered.contains(p.normalize(f.path))) + .map((f) => p.relative(f.path, from: packageRoot)) + .toList() + ..sort(); + if (uncovered.isNotEmpty) { + failures.add( + '${uncovered.length} vendored token file${uncovered.length == 1 ? '' : 's'} ' + '${uncovered.length == 1 ? 'is' : 'are'} not covered by this check:\n' + ' ${_list(uncovered)}\n' + ' Give it a branch here, or delete it. A vendored file this check cannot\n' + ' see is exactly the dead weight it exists to prevent.', + ); + } + + // A name nobody declares cannot be "allowed to be unreferenced" — it is a + // typo, or a leftover from a constant that has since been deleted. + final staleAllowed = _allowedUnused.difference({...allColors, ...declaredDimensions}).toList()..sort(); + if (staleAllowed.isNotEmpty) { + failures.add( + '${staleAllowed.length} entr${staleAllowed.length == 1 ? 'y' : 'ies'} in _allowedUnused ' + 'match${staleAllowed.length == 1 ? 'es' : ''} no declaration:\n' + ' ${_list(staleAllowed)}\n' + ' Remove them — an allowlist nobody can account for is not an allowlist.', + ); + } + + if (failures.isNotEmpty) _fail(failures.join('\n\n')); + + stdout.writeln( + '✓ ${declared['light']!.length} color tokens, ${declaredDimensions.length} dimension tokens and ' + '${declaredFontSizes['android']!.length} font sizes per flavor, all referenced; ' + 'light/ and dark/ and android/ and ios/ in agreement ' + '(${colorScan.filesRead} Dart files scanned).', + ); +} + +/// The `static const ` declarations in a vendored token file. +/// +/// [source] is stripped of comments and string literals first, so a name in +/// prose is neither counted as a declaration nor able to keep a dead constant +/// alive. [label] names the file in any failure message. +Set _declarations(String source, String label) { + final code = _stripCommentsAndStrings(source); + final names = RegExp( + // The type annotation is optional: the color files omit it, the dimension + // file needs `double` so its values satisfy Radius and TextStyle. + r'static\s+const\s+(?:[\w<>?,\s]+\s+)?(\w+)\s*=', + ).allMatches(code).map((m) => m.group(1)!).toSet(); + + // A declaration this pattern misses is invisible to every check below — it + // escapes both the dead-token scan and the light/dark parity check. Compare + // against a count that cannot drift out of step with it. + final occurrences = RegExp(r'static\s+const\s').allMatches(code).length; + if (occurrences != names.length) { + _fail( + 'Parsed ${names.length} declarations from $label but found $occurrences `static const` occurrences.\n' + ' Some declaration form is not understood, and whatever this pattern\n' + ' misses is silently exempt from every check below.', + ); + } + return names; +} + +/// Result of a reference scan: the names found, and how many files were read. +/// +/// The file count is what separates "nothing is unreferenced" from "nothing was +/// examined". +typedef _Scan = ({Set names, int filesRead}); + +/// Every `.` reference under [libDir], ignoring the vendored +/// files in [declaringFiles] so a declaration does not count as its own use. +/// +/// Comments and string literals are stripped before matching. Without that a +/// stale `[StreamTokens.foo]` in a doc comment keeps a dead constant alive — +/// and `comment_references` is disabled in `analysis_options.yaml`, so such a +/// reference is not even a lint. +_Scan _references(Directory libDir, Set declaringFiles, {String className = 'StreamTokens'}) { + final pattern = RegExp( + '$className' + r'\.(\w+)', + ); + final found = {}; + var filesRead = 0; + for (final entity in libDir.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + if (declaringFiles.contains(p.normalize(entity.path))) continue; + filesRead++; + found.addAll(pattern.allMatches(_stripCommentsAndStrings(entity.readAsStringSync())).map((m) => m.group(1)!)); + } + return (names: found, filesRead: filesRead); +} + +/// Drops comments and string literals, keeping everything else verbatim. +/// +/// Crude on purpose: it only has to be right for the vendored token files and +/// the primitives that read them, which are constant declarations and field +/// defaults rather than arbitrary Dart. +String _stripCommentsAndStrings(String source) { + final out = StringBuffer(); + var i = 0; + while (i < source.length) { + if (source.startsWith('//', i)) { + while (i < source.length && source[i] != '\n') { + i++; + } + continue; + } + if (source.startsWith('/*', i)) { + final end = source.indexOf('*/', i + 2); + i = end == -1 ? source.length : end + 2; + continue; + } + final ch = source[i]; + if (ch == "'" || ch == '"') { + final delim = source.startsWith(ch * 3, i) ? ch * 3 : ch; + i += delim.length; + while (i < source.length) { + if (source[i] == r'\') { + i += 2; + continue; + } + if (source.startsWith(delim, i)) { + i += delim.length; + break; + } + i++; + } + continue; + } + out.write(ch); + i++; + } + return out.toString(); +} + +/// Whether any Dart file under [libDir] imports [file]. +/// +/// Matched on the file's basename inside an import directive, which is enough +/// here: the vendored token file names are unique across the package. +/// +/// Deliberately matches the raw source rather than the comment-stripped form — +/// the path is itself a string literal, so stripping strings would remove the +/// very thing being looked for. Anchoring to the start of a line keeps a +/// commented-out import from counting. +bool _isImportedUnder(Directory libDir, File file, String packageRoot) { + final needle = RegExp( + "^\\s*import\\s+['\"][^'\"]*${RegExp.escape(p.basename(file.path))}['\"]", + multiLine: true, + ); + for (final entity in libDir.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + if (p.normalize(entity.path) == p.normalize(file.path)) continue; + if (needle.hasMatch(entity.readAsStringSync())) return true; + } + return false; +} + +String _list(Iterable names) { + final sorted = names.toList()..sort(); + if (sorted.length <= 12) return sorted.join(', '); + return '${sorted.take(12).join(', ')} … and ${sorted.length - 12} more'; +} + +Never _fail(String message) { + stderr.writeln('✗ $message'); + exit(1); +}