From 01c536b86154d8f434d95d94b1cac2533988f065 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 10 Sep 2026 14:07:50 +0200 Subject: [PATCH 1/5] refactor(ui): wire the dimension classes to their design tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamSpacing, StreamRadius and StreamTokensTypography hard-coded the same numbers the vendored token files already held, so the design system's dimensions were mirrored twice with nothing tying the copies together — the swatch-versus-baked-hex hazard one layer up, where an upstream change can be applied to one and not the other in silence. The three classes now read the tokens. The type ramp gains from it: `height: 1.1111111111111112` becomes `typographyLineHeightNormal / typographyFontSizeLg`, which says what the number is instead of leaving a magic constant. Every ratio resolves bit-identically, so no golden moves. Adds internal/tokens/stream_tokens_dimensions.dart, beside the mode-independent typography file rather than under light/ and dark/, because the token repo publishes one set of dimensions for every mode — all 180 constants were byte-identical between the two files. Values come from the web flavor, the only one carrying the Geist family this package ships. They are typed `double` so they satisfy Radius, EdgeInsets and TextStyle directly. Two tokens are deliberately not carried, since nothing can read them: font weights, because TextStyle.fontWeight takes a FontWeight that cannot be built from a number in a const expression, and radiusNone, because the analyzer's use_named_constants prefers Radius.zero over circular(0). Also extends check_tokens.dart to hold the dimension file to the same rule, which is what caught radiusNone and a stale skip that hid the typography file's own reads. Co-Authored-By: Claude Opus 5 --- .../stream_core_flutter_workflow.yml | 1 + melos.yaml | 4 + .../internal/tokens/dark/stream_tokens.dart | 180 ------------------ .../internal/tokens/light/stream_tokens.dart | 180 ------------------ .../tokens/stream_tokens_dimensions.dart | 58 ++++++ .../tokens/stream_tokens_typography.dart | 114 +++++------ .../src/theme/primitives/stream_radius.dart | 25 ++- .../src/theme/primitives/stream_spacing.dart | 22 ++- scripts/check_tokens.dart | 154 +++++++++++++++ 9 files changed, 302 insertions(+), 436 deletions(-) create mode 100644 packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/stream_tokens_dimensions.dart create mode 100644 scripts/check_tokens.dart diff --git a/.github/workflows/stream_core_flutter_workflow.yml b/.github/workflows/stream_core_flutter_workflow.yml index 3f5a0c99..13bb453a 100644 --- a/.github/workflows/stream_core_flutter_workflow.yml +++ b/.github/workflows/stream_core_flutter_workflow.yml @@ -56,6 +56,7 @@ jobs: - name: Check Barrels run: | melos run check:barrels + melos run check:tokens - name: Check formatting run: | diff --git a/melos.yaml b/melos.yaml index d2dbc4d2..5f85d779 100644 --- a/melos.yaml +++ b/melos.yaml @@ -155,6 +155,10 @@ 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: + run: melos exec -c 1 --dir-exists="lib/src/theme/primitives/internal/tokens" -- "dart run \$MELOS_ROOT_PATH/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/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/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..a52374ca --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/stream_tokens_dimensions.dart @@ -0,0 +1,58 @@ +/// Dimension tokens from the design system. +/// +/// Mode-independent, unlike the colors: the token repo publishes one set of +/// spacing, radius and type values, so these live here rather than being +/// duplicated under `light/` and `dark/`. +/// +/// Type comes from the **web** flavor of the token build — the only one +/// carrying the `Geist` family this package ships. Android resolves to Roboto, +/// iOS to SF Pro, and iOS runs a size up at every step. +/// +/// Declared as `double` because that is what `Radius`, `EdgeInsets` and +/// `TextStyle` take; the token repo emits them without a decimal point. +/// +/// Read these through `StreamSpacing`, `StreamRadius` and +/// `StreamTokensTypography`, which are the public surface. Font weights are +/// deliberately absent: `TextStyle.fontWeight` takes a `FontWeight`, which +/// cannot be built from a number in a const expression, so the weight tokens +/// would have no consumer. +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 typographyFontFamilySans = 'Geist'; + 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 typographyLineHeightTight = 16; + static const double typographyLineHeightNormal = 20; + static const double typographyLineHeightRelaxed = 24; +} 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 index 39e9b913..81b69fd2 100644 --- 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 @@ -1,122 +1,124 @@ import 'package:flutter/widgets.dart'; +import 'stream_tokens_dimensions.dart'; + class StreamTokensTypography { StreamTokensTypography._(); static const headingLg = TextStyle( - fontFamily: 'Geist', - fontSize: 20, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeXl, fontWeight: FontWeight.w600, - height: 1.2, + height: StreamTokensDimensions.typographyLineHeightRelaxed / StreamTokensDimensions.typographyFontSizeXl, ); static const headingMd = TextStyle( - fontFamily: 'Geist', - fontSize: 18, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeLg, fontWeight: FontWeight.w600, - height: 1.1111111111111112, + height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeLg, ); static const headingSm = TextStyle( - fontFamily: 'Geist', - fontSize: 16, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeMd, fontWeight: FontWeight.w600, - height: 1.25, + height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeMd, ); static const headingXs = TextStyle( - fontFamily: 'Geist', - fontSize: 12, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeXs, fontWeight: FontWeight.w600, - height: 1.3333333333333333, + height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeXs, ); static const bodyDefault = TextStyle( - fontFamily: 'Geist', - fontSize: 16, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeMd, fontWeight: FontWeight.w400, - height: 1.25, + height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeMd, ); static const bodyEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 16, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeMd, fontWeight: FontWeight.w600, - height: 1.25, + height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeMd, ); static const bodyLink = TextStyle( - fontFamily: 'Geist', - fontSize: 16, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeMd, fontWeight: FontWeight.w400, - height: 1.25, + height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeMd, ); static const bodyLinkEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 16, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeMd, fontWeight: FontWeight.w600, - height: 1.25, + height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeMd, ); static const captionDefault = TextStyle( - fontFamily: 'Geist', - fontSize: 14, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeSm, fontWeight: FontWeight.w400, - height: 1.1428571428571428, + height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeSm, ); static const captionEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 14, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeSm, fontWeight: FontWeight.w600, - height: 1.1428571428571428, + height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeSm, ); static const captionLink = TextStyle( - fontFamily: 'Geist', - fontSize: 14, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeSm, fontWeight: FontWeight.w400, - height: 1.1428571428571428, + height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeSm, ); static const captionLinkEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 14, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeSm, fontWeight: FontWeight.w600, - height: 1.1428571428571428, + height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeSm, ); static const metadataDefault = TextStyle( - fontFamily: 'Geist', - fontSize: 12, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeXs, fontWeight: FontWeight.w400, - height: 1.3333333333333333, + height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeXs, ); static const metadataEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 12, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeXs, fontWeight: FontWeight.w600, - height: 1.3333333333333333, + height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeXs, ); static const metadataLink = TextStyle( - fontFamily: 'Geist', - fontSize: 12, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeXs, fontWeight: FontWeight.w400, - height: 1.3333333333333333, + height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeXs, ); static const metadataLinkEmphasis = TextStyle( - fontFamily: 'Geist', - fontSize: 12, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeXs, fontWeight: FontWeight.w600, - height: 1.3333333333333333, + height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeXs, ); static const numericXl = TextStyle( - fontFamily: 'Geist', - fontSize: 14, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeSm, fontWeight: FontWeight.w700, ); static const numericLg = TextStyle( - fontFamily: 'Geist', - fontSize: 12, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeXs, fontWeight: FontWeight.w700, ); static const numericMd = TextStyle( - fontFamily: 'Geist', - fontSize: 10, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeXxs, fontWeight: FontWeight.w700, ); static const numericSm = TextStyle( - fontFamily: 'Geist', - fontSize: 8, + fontFamily: StreamTokensDimensions.typographyFontFamilySans, + fontSize: StreamTokensDimensions.typographyFontSizeMicro, 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/scripts/check_tokens.dart b/scripts/check_tokens.dart new file mode 100644 index 00000000..c428fa23 --- /dev/null +++ b/scripts/check_tokens.dart @@ -0,0 +1,154 @@ +// 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. +// +// 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. +const _allowedUnused = {}; + +const _tokenDir = 'lib/src/theme/primitives/internal/tokens'; + +void main(List args) { + final packageRoot = Directory.current.path; + final tokenDir = Directory(p.join(packageRoot, _tokenDir)); + if (!tokenDir.existsSync()) { + stdout.writeln('No vendored tokens in ${p.basename(packageRoot)}, skipping.'); + return; + } + + final declared = >{}; + for (final mode in const ['light', 'dark']) { + final file = File(p.join(tokenDir.path, mode, 'stream_tokens.dart')); + if (!file.existsSync()) _fail('Missing ${p.relative(file.path, from: packageRoot)}'); + declared[mode] = _declarations(file.readAsStringSync()); + } + + final failures = []; + + // 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 declaringFiles = { + for (final mode in const ['light', 'dark']) p.normalize(p.join(tokenDir.path, mode, 'stream_tokens.dart')), + }; + final referenced = _references(Directory(p.join(packageRoot, 'lib')), declaringFiles); + final unused = { + ...declared['light']!, + ...declared['dark']!, + }.where((name) => !referenced.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')); + var dimensions = 0; + if (dimensionsFile.existsSync()) { + final declaredDimensions = _declarations(dimensionsFile.readAsStringSync()); + dimensions = declaredDimensions.length; + final readDimensions = _references( + Directory(p.join(packageRoot, 'lib')), + {p.normalize(dimensionsFile.path)}, + className: 'StreamTokensDimensions', + ); + final unusedDimensions = + declaredDimensions.where((name) => !readDimensions.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 StreamTokensTypography are the only\n' + ' readers. A dimension no class exposes belongs upstream, not here.', + ); + } + } + + if (failures.isNotEmpty) _fail(failures.join('\n\n')); + + stdout.writeln( + '✓ ${declared['light']!.length} color tokens and $dimensions dimension tokens, ' + 'all referenced, light/ and dark/ in agreement.', + ); +} + +/// The `static const ` declarations in a vendored token file. +Set _declarations(String source) => 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 const (?:[\w<>?]+\s+)?(\w+)\s*=', +).allMatches(source).map((m) => m.group(1)!).toSet(); + +/// Every `.` reference under [libDir], ignoring the vendored +/// files themselves so a declaration does not count as its own use. +/// +/// The typography and dimension files do read each other, so they are excluded +/// as declarations but not as readers — hence the per-file skip rather than a +/// whole-directory one. +Set _references( + Directory libDir, + Set declaringFiles, { + String className = 'StreamTokens', +}) { + final pattern = RegExp( + '$className' + r'\.(\w+)', + ); + final found = {}; + 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; + found.addAll(pattern.allMatches(entity.readAsStringSync()).map((m) => m.group(1)!)); + } + return found; +} + +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); +} From 3fb1cb6f7f3301bb7e8184483033779ade795cd0 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 10 Sep 2026 14:16:37 +0200 Subject: [PATCH 2/5] docs(ui): describe the wired dimensions in the skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill still said the dimension classes hard-code their values and that a change has to be applied to them by hand, which this branch makes false. It now says a dimension is a value edit in stream_tokens_dimensions.dart and the classes follow, records why font weights and radiusNone are absent, and names melos run check:tokens as what enforces the rule — so there is no judgment call about what belongs in a token file. Also brings three now-stale details in line: the file table gains the dimensions row, the reader list gains the three dimension classes, and After editing runs check:tokens next to analyze. Co-Authored-By: Claude Opus 5 --- .claude/skills/update-design-tokens/SKILL.md | 57 +++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/.claude/skills/update-design-tokens/SKILL.md b/.claude/skills/update-design-tokens/SKILL.md index 97803ecd..b507d120 100644 --- a/.claude/skills/update-design-tokens/SKILL.md +++ b/.claude/skills/update-design-tokens/SKILL.md @@ -34,16 +34,21 @@ 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.) +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; typography is not — and for type the flavor is `web`.** Only web carries the `Geist` family this @@ -51,11 +56,11 @@ 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. +Dimensions — spacing, radius, sizes, line heights — are identical across all +three, so no flavor choice arises there. They live in +`internal/tokens/stream_tokens_dimensions.dart`, one copy rather than one per +mode, and `StreamSpacing`, `StreamRadius` and `StreamTokensTypography` read them. +So a dimension change is a value edit in that one file; the classes follow. 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 +352,33 @@ 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. +Dimensions and type live in `stream_tokens_dimensions.dart` instead — one +mode-independent file, read by `StreamSpacing`, `StreamRadius` and +`StreamTokensTypography`. 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. And take type values from the **web** flavor, the +only one carrying 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. +Two dimensions are deliberately absent because nothing can read them: font +weights, since `TextStyle.fontWeight` takes a `FontWeight` that cannot be built +from a number in a const expression, and `radiusNone`, since the analyzer's +`use_named_constants` prefers `Radius.zero` over `circular(0)`. + +**`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 ``` From a8e775736a8ff35a05ab21e2a43fb07d15e959cc Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 10 Sep 2026 16:39:29 +0200 Subject: [PATCH 3/5] docs(ui): describe the wired dimensions in CLAUDE.md too The skill gained this in the previous commit, but CLAUDE.md still described `StreamSpacing`, `StreamRadius` and `StreamTokensTypography` as hard-coding their values, which this PR is what changes. It now points at `stream_tokens_dimensions.dart` and at `check:tokens`, so the repo overview and the skill agree. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9eb04c0f..d10c2d7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,16 +114,20 @@ 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` and +`StreamTokensTypography`. 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 From c0a439708addd904287fb987b9007c0e2f773081 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 11:27:35 +0200 Subject: [PATCH 4/5] fix(ui): wire the real typography class, and close the guard's fail-open paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StreamTokensTypography` was wired to the dimension tokens, but nothing read it — not one Dart file in the repo, only prose. Meanwhile the public, exported typography surface still hard-coded every number. So the PR's premise held for `StreamSpacing` and `StreamRadius` and not for type: the 10 font-size and line-height constants were kept alive by a dead intermediary. `StreamLineHeight` now reads the three line-height tokens, which match exactly. The dead class is deleted, and with it the seven `typographyFontSize*` constants and the font family it was the only reader of. Font sizes stay unvendored on purpose: `StreamFontSize` ships an android and an ios scale, only android matches the web values, and the ios flavor is not vendored — wiring one and not the other would read as an oversight rather than a choice. That leaves the file mode- *and* flavor-independent, since spacing, radius and line heights are identical across android, ios and web. The guard would have caught this had it looked. It did not, and it had four other ways to pass without checking anything — each confirmed by running it: - Empty both color files and it reported `✓ 0 color tokens … all referenced`. - Delete the dimensions file and it reported `✓ … and 0 dimension tokens`. - A `--dir-exists` filter that stops matching selects zero packages, and melos prints SUCCESS. The one refactor most likely to need this guard — moving `internal/` — is the one that silently disabled it. - A dead token stayed "referenced" via a mention in a doc comment, and `comment_references` is off, so that is not even a lint. So: every "nothing to check" path is now a failure. Minimum declaration counts, a scanned-file count in the success line, comments and strings stripped before matching, a declaration-count cross-check so an unparsed form cannot hide, an error on any vendored file the check does not cover, and stale allowlist entries reported. The melos filter is gone — it runs once from the root and the script locates the package itself. Co-Authored-By: Claude Opus 5 --- .claude/skills/update-design-tokens/SKILL.md | 29 +- .../stream_core_flutter_workflow.yml | 2 +- CLAUDE.md | 4 +- melos.yaml | 6 +- .../tokens/stream_tokens_dimensions.dart | 35 ++- .../tokens/stream_tokens_typography.dart | 124 --------- .../theme/primitives/stream_typography.dart | 8 +- scripts/check_tokens.dart | 253 ++++++++++++++---- 8 files changed, 249 insertions(+), 212 deletions(-) delete mode 100644 packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/stream_tokens_typography.dart diff --git a/.claude/skills/update-design-tokens/SKILL.md b/.claude/skills/update-design-tokens/SKILL.md index b507d120..91545197 100644 --- a/.claude/skills/update-design-tokens/SKILL.md +++ b/.claude/skills/update-design-tokens/SKILL.md @@ -56,11 +56,15 @@ 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 — are identical across all -three, so no flavor choice arises there. They live in +Spacing, radius and line heights 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, and `StreamSpacing`, `StreamRadius` and `StreamTokensTypography` read them. -So a dimension change is a value edit in that one file; the classes follow. +mode, and `StreamSpacing`, `StreamRadius` and `StreamLineHeight` read them. So a +dimension change is a value edit in that one file; the classes follow. + +Font sizes are the exception, and are **not** vendored: `StreamFontSize` ships an +android and an ios scale, and only android matches the web values. Change one of +those by hand and you must change the other from the ios flavor. 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 @@ -352,16 +356,17 @@ 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 live in `stream_tokens_dimensions.dart` instead — one -mode-independent file, read by `StreamSpacing`, `StreamRadius` and -`StreamTokensTypography`. Edit the value there and the classes follow; do not +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. And take type values from the **web** flavor, the -only one carrying the `Geist` family. +a swatch belongs, one layer up. -Two dimensions are deliberately absent because nothing can read them: font -weights, since `TextStyle.fontWeight` takes a `FontWeight` that cannot be built -from a number in a const expression, and `radiusNone`, since the analyzer's +Three groups of upstream dimensions are deliberately absent, because nothing here +can read them: **font sizes**, since only `StreamFontSize.android` matches the web +values and the ios scale comes from a flavor this package does not vendor; **font +weights**, since `TextStyle.fontWeight` takes a `FontWeight` that cannot be built +from a number in a const expression; and **`radiusNone`**, since the analyzer's `use_named_constants` prefers `Radius.zero` over `circular(0)`. **`melos run check:tokens` enforces all of this.** It fails when a constant in diff --git a/.github/workflows/stream_core_flutter_workflow.yml b/.github/workflows/stream_core_flutter_workflow.yml index 13bb453a..cb82e96e 100644 --- a/.github/workflows/stream_core_flutter_workflow.yml +++ b/.github/workflows/stream_core_flutter_workflow.yml @@ -53,7 +53,7 @@ jobs: run: | melos run analyze - - name: Check Barrels + - name: Check barrels and tokens run: | melos run check:barrels melos run check:tokens diff --git a/CLAUDE.md b/CLAUDE.md index d10c2d7b..c985a295 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,9 @@ holds the vendored values; it is maintained by hand, is not part of the public A 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` and -`StreamTokensTypography`. +`StreamLineHeight`. Font sizes and weights are not vendored: `StreamFontSize` +ships an ios scale from a flavor this package does not carry, and a `FontWeight` +cannot be built from a number in a const expression. Only the **root semantics** are mapped to a `StreamColorScheme` field. Upstream's derived tokens (`badge/*`, `button/*`, `avatar/*`) get no field — components diff --git a/melos.yaml b/melos.yaml index 5f85d779..25104928 100644 --- a/melos.yaml +++ b/melos.yaml @@ -156,7 +156,11 @@ scripts: description: Validate the public-barrel contract for packages with a check_barrels.yaml config. check:tokens: - run: melos exec -c 1 --dir-exists="lib/src/theme/primitives/internal/tokens" -- "dart run \$MELOS_ROOT_PATH/scripts/check_tokens.dart" + # 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: 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 index a52374ca..bda52c16 100644 --- 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 @@ -1,21 +1,26 @@ /// Dimension tokens from the design system. /// /// Mode-independent, unlike the colors: the token repo publishes one set of -/// spacing, radius and type values, so these live here rather than being -/// duplicated under `light/` and `dark/`. -/// -/// Type comes from the **web** flavor of the token build — the only one -/// carrying the `Geist` family this package ships. Android resolves to Roboto, -/// iOS to SF Pro, and iOS runs a size up at every step. +/// spacing, radius and line-height 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 +/// here — unlike the font sizes, where iOS runs a size up at every step. /// /// Declared as `double` because that is what `Radius`, `EdgeInsets` and /// `TextStyle` take; the token repo emits them without a decimal point. /// -/// Read these through `StreamSpacing`, `StreamRadius` and -/// `StreamTokensTypography`, which are the public surface. Font weights are -/// deliberately absent: `TextStyle.fontWeight` takes a `FontWeight`, which -/// cannot be built from a number in a const expression, so the weight tokens -/// would have no consumer. +/// Read these through `StreamSpacing`, `StreamRadius` and `StreamLineHeight`, +/// which are the public surface. Three groups of upstream dimensions are +/// deliberately not carried, because nothing here can read them: +/// +/// - **Font sizes.** `StreamFontSize` ships two platform scales, and only the +/// android one matches these values; the ios scale comes from a flavor this +/// package does not vendor. Wiring one and not the other would read as an +/// oversight rather than a choice. +/// - **Font weights.** `TextStyle.fontWeight` takes a `FontWeight`, which +/// cannot be built from a number in a const expression. +/// - **`radiusNone`.** The analyzer's `use_named_constants` prefers +/// `Radius.zero` over `circular(0)`. class StreamTokensDimensions { StreamTokensDimensions._(); @@ -44,14 +49,6 @@ class StreamTokensDimensions { static const double radiusMax = 9999; // Typography - static const typographyFontFamilySans = 'Geist'; - 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 typographyLineHeightTight = 16; static const double typographyLineHeightNormal = 20; static const double typographyLineHeightRelaxed = 24; 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 81b69fd2..00000000 --- a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/stream_tokens_typography.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import 'stream_tokens_dimensions.dart'; - -class StreamTokensTypography { - StreamTokensTypography._(); - - static const headingLg = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeXl, - fontWeight: FontWeight.w600, - height: StreamTokensDimensions.typographyLineHeightRelaxed / StreamTokensDimensions.typographyFontSizeXl, - ); - static const headingMd = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeLg, - fontWeight: FontWeight.w600, - height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeLg, - ); - static const headingSm = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeMd, - fontWeight: FontWeight.w600, - height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeMd, - ); - static const headingXs = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeXs, - fontWeight: FontWeight.w600, - height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeXs, - ); - static const bodyDefault = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeMd, - fontWeight: FontWeight.w400, - height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeMd, - ); - static const bodyEmphasis = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeMd, - fontWeight: FontWeight.w600, - height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeMd, - ); - static const bodyLink = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeMd, - fontWeight: FontWeight.w400, - height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeMd, - ); - static const bodyLinkEmphasis = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeMd, - fontWeight: FontWeight.w600, - height: StreamTokensDimensions.typographyLineHeightNormal / StreamTokensDimensions.typographyFontSizeMd, - ); - static const captionDefault = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeSm, - fontWeight: FontWeight.w400, - height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeSm, - ); - static const captionEmphasis = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeSm, - fontWeight: FontWeight.w600, - height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeSm, - ); - static const captionLink = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeSm, - fontWeight: FontWeight.w400, - height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeSm, - ); - static const captionLinkEmphasis = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeSm, - fontWeight: FontWeight.w600, - height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeSm, - ); - static const metadataDefault = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeXs, - fontWeight: FontWeight.w400, - height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeXs, - ); - static const metadataEmphasis = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeXs, - fontWeight: FontWeight.w600, - height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeXs, - ); - static const metadataLink = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeXs, - fontWeight: FontWeight.w400, - height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeXs, - ); - static const metadataLinkEmphasis = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeXs, - fontWeight: FontWeight.w600, - height: StreamTokensDimensions.typographyLineHeightTight / StreamTokensDimensions.typographyFontSizeXs, - ); - static const numericXl = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeSm, - fontWeight: FontWeight.w700, - ); - static const numericLg = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeXs, - fontWeight: FontWeight.w700, - ); - static const numericMd = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeXxs, - fontWeight: FontWeight.w700, - ); - static const numericSm = TextStyle( - fontFamily: StreamTokensDimensions.typographyFontFamilySans, - fontSize: StreamTokensDimensions.typographyFontSizeMicro, - fontWeight: FontWeight.w700, - ); -} 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..2bea5aae 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,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_typography.g.theme.dart'; /// Typography primitives for the Stream design system. @@ -79,9 +81,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. diff --git a/scripts/check_tokens.dart b/scripts/check_tokens.dart index c428fa23..826dd7b2 100644 --- a/scripts/check_tokens.dart +++ b/scripts/check_tokens.dart @@ -12,6 +12,13 @@ // 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'; @@ -23,27 +30,65 @@ import 'package:path/path.dart' as p; /// 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; + void main(List args) { - final packageRoot = Directory.current.path; + // 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()) { - stdout.writeln('No vendored tokens in ${p.basename(packageRoot)}, skipping.'); - return; + _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')); - if (!file.existsSync()) _fail('Missing ${p.relative(file.path, from: packageRoot)}'); - declared[mode] = _declarations(file.readAsStringSync()); + 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.', + ); + } } - final failures = []; - // 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']!); @@ -56,15 +101,17 @@ void main(List args) { ); } - final declaringFiles = { + final modeFiles = { for (final mode in const ['light', 'dark']) p.normalize(p.join(tokenDir.path, mode, 'stream_tokens.dart')), }; - final referenced = _references(Directory(p.join(packageRoot, 'lib')), declaringFiles); - final unused = { - ...declared['light']!, - ...declared['dark']!, - }.where((name) => !referenced.contains(name) && !_allowedUnused.contains(name)).toList()..sort(); + 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'} ' @@ -80,66 +127,170 @@ void main(List args) { // 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')); - var dimensions = 0; - if (dimensionsFile.existsSync()) { - final declaredDimensions = _declarations(dimensionsFile.readAsStringSync()); - dimensions = declaredDimensions.length; - final readDimensions = _references( - Directory(p.join(packageRoot, 'lib')), - {p.normalize(dimensionsFile.path)}, - className: 'StreamTokensDimensions', + 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.', + ); + } + + // 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)}; + 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.', ); - final unusedDimensions = - declaredDimensions.where((name) => !readDimensions.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 StreamTokensTypography are the only\n' - ' readers. A dimension no class exposes belongs upstream, not here.', - ); - } } if (failures.isNotEmpty) _fail(failures.join('\n\n')); stdout.writeln( - '✓ ${declared['light']!.length} color tokens and $dimensions dimension tokens, ' - 'all referenced, light/ and dark/ in agreement.', + '✓ ${declared['light']!.length} color tokens and ${declaredDimensions.length} dimension tokens, ' + 'all referenced, light/ and dark/ in agreement ' + '(${colorScan.filesRead} Dart files scanned).', ); } /// The `static const ` declarations in a vendored token file. -Set _declarations(String source) => 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 const (?:[\w<>?]+\s+)?(\w+)\s*=', -).allMatches(source).map((m) => m.group(1)!).toSet(); +/// +/// [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 themselves so a declaration does not count as its own use. +/// files in [declaringFiles] so a declaration does not count as its own use. /// -/// The typography and dimension files do read each other, so they are excluded -/// as declarations but not as readers — hence the per-file skip rather than a -/// whole-directory one. -Set _references( - Directory libDir, - Set declaringFiles, { - String className = 'StreamTokens', -}) { +/// 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; - found.addAll(pattern.allMatches(entity.readAsStringSync()).map((m) => m.group(1)!)); + 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 found; + return out.toString(); } String _list(Iterable names) { From 98d37341c055b3de8a805a16e7a1d64cd295461b Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 11 Sep 2026 11:46:37 +0200 Subject: [PATCH 5/5] feat(ui): wire the font sizes and weights to their tokens too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typography was left half-wired: `StreamLineHeight` read its tokens but `StreamFontSize` and `StreamFontWeight` still hard-coded theirs, on two arguments that do not survive checking. The first was that font sizes could not be wired because only the android scale matches the values this package vendored. True, but the fix is to vendor both: the token repo publishes an ios flavor whose eight values match `StreamFontSize.ios` exactly. They now mirror upstream's split in `internal/tokens/{android,ios}/stream_tokens_font_size.dart`, keeping the bare upstream names, and feed the two scales. The second was that a `FontWeight` cannot be built from a number in a const expression. That is true of the raw 400/500/600/700 but does not mean the token has no home — the vendored constants are already declared in the types Flutter consumes rather than the types upstream emits, which is why the rest are `double`. The weights are `FontWeight` on the same reasoning, and `StreamFontWeight` reads them. Both rested on a third claim, that only the web flavor carries the `Geist` family this package ships. This package ships no text font at all — the one bundled face is the generated `Stream Icons` — and never sets `fontFamily` for text. So the flavor question was only ever about sizes, and the corrected note says so. All 23 wired values are identical to the literals they replace; `flutter test` is `+404 -56`, the 56 unchanged. `check:tokens` grew a matching branch, and the hardening earned itself out immediately: the uncovered-file check caught both new flavor files before they were wired in. It now also requires android/ and ios/ to declare the same names, and that each flavor file is actually imported — they declare identical names, so an orphaned one would otherwise still look referenced. Co-Authored-By: Claude Opus 5 --- .claude/skills/update-design-tokens/SKILL.md | 45 +++++---- CLAUDE.md | 9 +- .../android/stream_tokens_font_size.dart | 20 ++++ .../tokens/ios/stream_tokens_font_size.dart | 20 ++++ .../tokens/stream_tokens_dimensions.dart | 45 +++++---- .../theme/primitives/stream_typography.dart | 42 +++++---- scripts/check_tokens.dart | 93 ++++++++++++++++++- 7 files changed, 211 insertions(+), 63 deletions(-) create mode 100644 packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/android/stream_tokens_font_size.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/ios/stream_tokens_font_size.dart diff --git a/.claude/skills/update-design-tokens/SKILL.md b/.claude/skills/update-design-tokens/SKILL.md index 91545197..d2715f8b 100644 --- a/.claude/skills/update-design-tokens/SKILL.md +++ b/.claude/skills/update-design-tokens/SKILL.md @@ -50,21 +50,28 @@ reads a `colorScheme` field or one of those classes. from a seed, and is measured against the vendored values rather than 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`. - -Spacing, radius and line heights are identical across all three flavors, so no -flavor choice arises for them. They live in +**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, and `StreamSpacing`, `StreamRadius` and `StreamLineHeight` read them. So a -dimension change is a value edit in that one file; the classes follow. +mode, read by `StreamSpacing`, `StreamRadius`, `StreamLineHeight` and +`StreamFontWeight`. -Font sizes are the exception, and are **not** vendored: `StreamFontSize` ships an -android and an ios scale, and only android matches the web values. Change one of -those by hand and you must change the other from the ios flavor. +**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 @@ -362,12 +369,12 @@ Spacing, radius and line heights live in `stream_tokens_dimensions.dart` instead re-introduce a literal in a class, which is the same hazard as baking a hex where a swatch belongs, one layer up. -Three groups of upstream dimensions are deliberately absent, because nothing here -can read them: **font sizes**, since only `StreamFontSize.android` matches the web -values and the ios scale comes from a flavor this package does not vendor; **font -weights**, since `TextStyle.fontWeight` takes a `FontWeight` that cannot be built -from a number in a const expression; and **`radiusNone`**, since the analyzer's -`use_named_constants` prefers `Radius.zero` over `circular(0)`. +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 diff --git a/CLAUDE.md b/CLAUDE.md index c985a295..847985e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,10 +116,11 @@ the same repo the icons come from. `theme/primitives/internal/tokens/{light,dark 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. Dimensions live beside it in `internal/tokens/stream_tokens_dimensions.dart` — one -mode-independent copy, read by `StreamSpacing`, `StreamRadius` and -`StreamLineHeight`. Font sizes and weights are not vendored: `StreamFontSize` -ships an ios scale from a flavor this package does not carry, and a `FontWeight` -cannot be built from a number in a const expression. +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 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/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/stream_tokens_dimensions.dart b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/stream_tokens_dimensions.dart index bda52c16..328d95db 100644 --- 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 @@ -1,26 +1,26 @@ +import 'dart:ui'; + /// Dimension tokens from the design system. /// /// Mode-independent, unlike the colors: the token repo publishes one set of -/// spacing, radius and line-height 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 -/// here — unlike the font sizes, where iOS runs a size up at every step. +/// 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 as `double` because that is what `Radius`, `EdgeInsets` and -/// `TextStyle` take; the token repo emits them without a decimal point. +/// 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` and `StreamLineHeight`, -/// which are the public surface. Three groups of upstream dimensions are -/// deliberately not carried, because nothing here can read them: +/// Read these through `StreamSpacing`, `StreamRadius`, `StreamLineHeight` and +/// `StreamFontWeight`, which are the public surface. /// -/// - **Font sizes.** `StreamFontSize` ships two platform scales, and only the -/// android one matches these values; the ios scale comes from a flavor this -/// package does not vendor. Wiring one and not the other would read as an -/// oversight rather than a choice. -/// - **Font weights.** `TextStyle.fontWeight` takes a `FontWeight`, which -/// cannot be built from a number in a const expression. -/// - **`radiusNone`.** The analyzer's `use_named_constants` prefers -/// `Radius.zero` over `circular(0)`. +/// 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._(); @@ -52,4 +52,15 @@ class StreamTokensDimensions { 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/stream_typography.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_typography.dart index 2bea5aae..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,8 @@ 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'; @@ -174,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. @@ -238,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 index 826dd7b2..5b453d09 100644 --- a/scripts/check_tokens.dart +++ b/scripts/check_tokens.dart @@ -48,6 +48,9 @@ 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. @@ -151,10 +154,71 @@ void main(List args) { ); } + // 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)}; + final covered = { + ...modeFiles, + p.normalize(dimensionsFile.path), + ...fontSizeFiles.values.map((f) => p.normalize(f.path)), + }; final uncovered = tokenDir .listSync(recursive: true) @@ -188,8 +252,9 @@ void main(List args) { if (failures.isNotEmpty) _fail(failures.join('\n\n')); stdout.writeln( - '✓ ${declared['light']!.length} color tokens and ${declaredDimensions.length} dimension tokens, ' - 'all referenced, light/ and dark/ in agreement ' + '✓ ${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).', ); } @@ -293,6 +358,28 @@ String _stripCommentsAndStrings(String source) { 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(', ');