diff --git a/mobile/lib/theme/sure_tokens.dart b/mobile/lib/theme/sure_tokens.dart index 4295f9967..4ec1173dd 100644 --- a/mobile/lib/theme/sure_tokens.dart +++ b/mobile/lib/theme/sure_tokens.dart @@ -2,7 +2,7 @@ // Source: design/tokens/sure.tokens.json // Build: node mobile/tool/generate_sure_tokens.mjs -import 'dart:ui'; +import 'package:flutter/painting.dart'; class SureTokens { const SureTokens._(); @@ -38,6 +38,8 @@ class SureTokens { info: Color(0xFF1570EF), link: Color(0xFF1570EF), shadow: Color(0x0F0B0B0B), + focusRing: Color(0x800B0B0B), + bgInverse: Color(0xFF242424), textPrimary: Color(0xFF171717), textInverse: Color(0xFFFFFFFF), textSecondary: Color(0xFF737373), @@ -49,6 +51,11 @@ class SureTokens { buttonPrimaryHover: Color(0xFF242424), buttonDestructive: Color(0xFFEC2222), buttonDestructiveHover: Color(0xFFC91313), + shadowXs: [BoxShadow(color: Color(0x0F0B0B0B), offset: Offset(0.0, 1.0), blurRadius: 2.0, spreadRadius: 0.0)], + shadowSm: [BoxShadow(color: Color(0x0F0B0B0B), offset: Offset(0.0, 1.0), blurRadius: 6.0, spreadRadius: 0.0)], + shadowMd: [BoxShadow(color: Color(0x0F0B0B0B), offset: Offset(0.0, 4.0), blurRadius: 8.0, spreadRadius: -2.0)], + shadowLg: [BoxShadow(color: Color(0x0F0B0B0B), offset: Offset(0.0, 12.0), blurRadius: 16.0, spreadRadius: -4.0)], + shadowXl: [BoxShadow(color: Color(0x0F0B0B0B), offset: Offset(0.0, 20.0), blurRadius: 24.0, spreadRadius: -4.0)], ); static const dark = SureTokenPalette( @@ -67,6 +74,8 @@ class SureTokens { info: Color(0xFF2E90FA), link: Color(0xFF2E90FA), shadow: Color(0x14FFFFFF), + focusRing: Color(0x80FFFFFF), + bgInverse: Color(0xFFFFFFFF), textPrimary: Color(0xFFFFFFFF), textInverse: Color(0xFF171717), textSecondary: Color(0xFFCFCFCF), @@ -78,6 +87,11 @@ class SureTokens { buttonPrimaryHover: Color(0xFFF7F7F7), buttonDestructive: Color(0xFFED4E4E), buttonDestructiveHover: Color(0xFFF13636), + shadowXs: [BoxShadow(color: Color(0x14FFFFFF), offset: Offset(0.0, 1.0), blurRadius: 2.0, spreadRadius: 0.0)], + shadowSm: [BoxShadow(color: Color(0x14FFFFFF), offset: Offset(0.0, 1.0), blurRadius: 6.0, spreadRadius: 0.0)], + shadowMd: [BoxShadow(color: Color(0x14FFFFFF), offset: Offset(0.0, 4.0), blurRadius: 8.0, spreadRadius: -2.0)], + shadowLg: [BoxShadow(color: Color(0x14FFFFFF), offset: Offset(0.0, 12.0), blurRadius: 16.0, spreadRadius: -4.0)], + shadowXl: [BoxShadow(color: Color(0x14FFFFFF), offset: Offset(0.0, 20.0), blurRadius: 24.0, spreadRadius: -4.0)], ); } @@ -98,6 +112,8 @@ class SureTokenPalette { required this.info, required this.link, required this.shadow, + required this.focusRing, + required this.bgInverse, required this.textPrimary, required this.textInverse, required this.textSecondary, @@ -109,6 +125,11 @@ class SureTokenPalette { required this.buttonPrimaryHover, required this.buttonDestructive, required this.buttonDestructiveHover, + required this.shadowXs, + required this.shadowSm, + required this.shadowMd, + required this.shadowLg, + required this.shadowXl, }); final Color surface; @@ -126,6 +147,8 @@ class SureTokenPalette { final Color info; final Color link; final Color shadow; + final Color focusRing; + final Color bgInverse; final Color textPrimary; final Color textInverse; final Color textSecondary; @@ -137,4 +160,9 @@ class SureTokenPalette { final Color buttonPrimaryHover; final Color buttonDestructive; final Color buttonDestructiveHover; + final List shadowXs; + final List shadowSm; + final List shadowMd; + final List shadowLg; + final List shadowXl; } diff --git a/mobile/test/theme/sure_tokens_test.dart b/mobile/test/theme/sure_tokens_test.dart index 7b700e6b5..b030127cb 100644 --- a/mobile/test/theme/sure_tokens_test.dart +++ b/mobile/test/theme/sure_tokens_test.dart @@ -52,6 +52,42 @@ void main() { expect(SureTokens.radiusMd, _resolveDimension(tokens, 'border.radius.md')); expect(SureTokens.radiusLg, _resolveDimension(tokens, 'border.radius.lg')); }); + + test('generated focus-ring and bg-inverse match canonical tokens', () { + expect( + SureTokens.light.focusRing.value, + _resolveColor(tokens, 'color.focus-ring', dark: false), + ); + expect( + SureTokens.dark.focusRing.value, + _resolveColor(tokens, 'color.focus-ring', dark: true), + ); + expect( + SureTokens.light.bgInverse.value, + _resolveColor(tokens, 'utility.bg-inverse', dark: false), + ); + expect( + SureTokens.dark.bgInverse.value, + _resolveColor(tokens, 'utility.bg-inverse', dark: true), + ); + }); + + test('generated shadow scale carries mode-aware color and geometry', () { + final lightSm = SureTokens.light.shadowSm.single; + expect(lightSm.offset, const Offset(0, 1)); + expect(lightSm.blurRadius, 6.0); + expect(lightSm.spreadRadius, 0.0); + expect( + lightSm.color.value, + _resolveColorValue(tokens, '{color.black|6%}', dark: false), + ); + expect( + SureTokens.dark.shadowSm.single.color.value, + _resolveColorValue(tokens, '{color.white|8%}', dark: true), + ); + // The xl step keeps the canonical negative spread (0px 20px 24px -4px). + expect(SureTokens.light.shadowXl.single.spreadRadius, -4.0); + }); } int _resolveColor( diff --git a/mobile/tool/generate_sure_tokens.mjs b/mobile/tool/generate_sure_tokens.mjs index f2c06e1a7..b92a1f9f1 100644 --- a/mobile/tool/generate_sure_tokens.mjs +++ b/mobile/tool/generate_sure_tokens.mjs @@ -23,6 +23,8 @@ const COLOR_TOKENS = [ ["info", "color.info"], ["link", "color.link"], ["shadow", "color.shadow"], + ["focusRing", "color.focus-ring"], + ["bgInverse", "utility.bg-inverse"], ["textPrimary", "utility.text-primary"], ["textInverse", "utility.text-inverse"], ["textSecondary", "utility.text-secondary"], @@ -41,6 +43,16 @@ const RADIUS_TOKENS = [ ["radiusLg", "border.radius.lg"], ]; +// Single-layer elevation scale (shadow/*). Mode-aware: each token carries a +// `sure.dark` extension, so light/dark emit different shadow colors. +const SHADOW_TOKENS = [ + ["shadowXs", "shadow.xs"], + ["shadowSm", "shadow.sm"], + ["shadowMd", "shadow.md"], + ["shadowLg", "shadow.lg"], + ["shadowXl", "shadow.xl"], +]; + function readTokens() { return JSON.parse(readFileSync(TOKENS_PATH, "utf8")); } @@ -105,6 +117,31 @@ function resolveDimension(tokens, path) { return Number(match[1]).toFixed(1); } +// Parse a single-layer CSS box-shadow ("px px px px {color}") +// into a Dart `BoxShadow(...)` literal. Offsets and spread may be negative +// (e.g. -4px); blur must be >= 0. A strict number pattern rejects malformed +// dimensions (e.g. "1.2.3px") instead of emitting NaN into the generated Dart. +function resolveShadow(tokens, path, mode) { + const node = nodeAt(tokens, path); + const value = valueForMode(node, mode); + const num = "-?[0-9]+(?:\\.[0-9]+)?"; + const nonNeg = "[0-9]+(?:\\.[0-9]+)?"; + const match = String(value).match( + new RegExp(`^(${num})px (${num})px (${nonNeg})px (${num})px (\\{[^}]+\\})$`), + ); + if (!match) { + throw new Error(`[mobile-tokens] ${path} must be a single-layer box shadow: ${value}`); + } + const [, offsetX, offsetY, blur, spread, colorRef] = match; + const color = resolveColorValue(tokens, colorRef, mode, path); + const px = (raw) => Number(raw).toFixed(1); + return ( + `BoxShadow(color: Color(${color}), ` + + `offset: Offset(${px(offsetX)}, ${px(offsetY)}), ` + + `blurRadius: ${px(blur)}, spreadRadius: ${px(spread)})` + ); +} + function firstFontFamily(stack) { for (const rawFamily of stack.split(",")) { const family = rawFamily.trim(); @@ -117,11 +154,14 @@ function firstFontFamily(stack) { } function emitPalette(tokens, mode) { - const lines = COLOR_TOKENS.map( + const colorLines = COLOR_TOKENS.map( ([name, path]) => ` ${name}: Color(${resolveColor(tokens, path, mode)}),`, ); + const shadowLines = SHADOW_TOKENS.map( + ([name, path]) => ` ${name}: [${resolveShadow(tokens, path, mode)}],`, + ); - return ` static const ${mode} = SureTokenPalette(\n${lines.join("\n")}\n );`; + return ` static const ${mode} = SureTokenPalette(\n${colorLines.join("\n")}\n${shadowLines.join("\n")}\n );`; } function buildDart(tokens) { @@ -135,7 +175,7 @@ function buildDart(tokens) { // Source: design/tokens/sure.tokens.json // Build: node mobile/tool/generate_sure_tokens.mjs -import 'dart:ui'; +import 'package:flutter/painting.dart'; class SureTokens { const SureTokens._(); @@ -162,9 +202,11 @@ ${emitPalette(tokens, "dark")} class SureTokenPalette { const SureTokenPalette({ ${COLOR_TOKENS.map(([name]) => ` required this.${name},`).join("\n")} +${SHADOW_TOKENS.map(([name]) => ` required this.${name},`).join("\n")} }); ${COLOR_TOKENS.map(([name]) => ` final Color ${name};`).join("\n")} +${SHADOW_TOKENS.map(([name]) => ` final List ${name};`).join("\n")} } `; }