feat(mobile): generate shadow scale + focus-ring/bg-inverse tokens (#2349)

Extend mobile/tool/generate_sure_tokens.mjs to emit, from the canonical design/tokens/sure.tokens.json: a mode-aware box-shadow scale (shadowXs..shadowXl, parsed from shadow.xs..xl into List<BoxShadow>), plus the focus-ring (color.focus-ring) and bg-inverse (utility.bg-inverse) colors on SureTokenPalette. Regenerates mobile/lib/theme/sure_tokens.dart (import dart:ui -> package:flutter/painting.dart for BoxShadow/Offset); keeps the --check parity gate; adds token tests for the new values incl. the negative xl spread.

Radii are already complete (the source only defines md/lg); a type-scale is not in the token source, so both are intentionally untouched. The shadow-border composites are deferred (derivable in-widget as a shadow + a 1px border from existing border tokens). This unblocks SureCard/elevated surfaces.

Part of #2235.
This commit is contained in:
ghost
2026-06-16 01:04:26 -07:00
committed by GitHub
parent 94d5ad2151
commit cfcd1e0d23
3 changed files with 110 additions and 4 deletions

View File

@@ -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<BoxShadow> shadowXs;
final List<BoxShadow> shadowSm;
final List<BoxShadow> shadowMd;
final List<BoxShadow> shadowLg;
final List<BoxShadow> shadowXl;
}

View File

@@ -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(

View File

@@ -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 ("<x>px <y>px <blur>px <spread>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<BoxShadow> ${name};`).join("\n")}
}
`;
}