Components
Visibility card
The Astr zone as a pill, a sky label, the sky brightness in magnitudes per square arcsecond and five rating bars. Zone 1 lights five bars and zone 9 lights one.
Preview
VISIBILITYZone 1
Dark Sky22.00 MPSAS
VISIBILITYZone 2
Dark Sky21.85 MPSAS
VISIBILITYZone 3
Rural Sky21.60 MPSAS
VISIBILITYZone 4
Rural Sky21.30 MPSAS
VISIBILITYZone 5
Suburban Sky20.90 MPSAS
VISIBILITYZone 6
Suburban Sky20.40 MPSAS
VISIBILITYZone 7
Urban Sky19.80 MPSAS
VISIBILITYZone 8
Urban Sky19.00 MPSAS
VISIBILITYZone 9
Urban Sky18.20 MPSAS
Variants
Pick a variant with the controls. The preview and both code blocks follow.
VISIBILITYZone 5
Suburban Sky20.90 MPSAS
Variant.tsx
<AppVisibilityCard zone={5} mpsas={20.9} />Implementation
The React tab is this site's own component, the same source that draws the preview, styled by the CSS below it. The Flutter tab is the Dart a screen builds it with, from the app's tokens.
Usage
<AppVisibilityCard zone={5} mpsas={20.9} />app-widgets.tsx · skyLabel
/** Sky type label as the visibility card words it. */export const skyLabel = (zone: number): string => zone <= 2 ? "Dark Sky" : zone <= 4 ? "Rural Sky" : zone <= 6 ? "Suburban Sky" : "Urban Sky";app-widgets.tsx · activeBars
/** Lit bars on the rating row: zone 1 lights five, zone 9 lights one (visibility_mini_card.dart). */export const activeBars = (zone: number): number => Math.min(5, Math.max(0, 5 - Math.floor((zone - 1) / 2)));app-widgets.tsx · AppVisibilityCard
/** VisibilityMiniCard: a 164 by 154 card with the zone pill, sky label, SQM figure and rating bars. */export function AppVisibilityCard({ zone, mpsas }: { zone: number; mpsas: number }) { const bars = activeBars(zone); return ( <div className="app-ui app-card is-mini"> <div className="app-card-head"> <span className="app-label-sm">VISIBILITY</span> <span className="app-zone-pill">Zone {zone}</span> </div> <div className="app-vis-body"> <span className="app-title app-vis-label">{skyLabel(zone)}</span> <span className="app-label-sm">{mpsas.toFixed(2)} MPSAS</span> </div> <div className="app-rating"> {Array.from({ length: 5 }, (_, i) => ( <i key={i} className={i < bars ? "on" : undefined} /> ))} </div> </div> );}app-ui.css
.app-card { position: relative; box-sizing: border-box; display: flex; flex-direction: column; padding: var(--spacing-f13); border-radius: var(--radius-f21); border: var(--spacing-f1) solid var(--hair-strong); background: var(--sheen), var(--fill); box-shadow: var(--highlight); backdrop-filter: blur(var(--spacing-f13)); overflow: hidden;}.app-card.is-mini { width: var(--spacing-f144); height: calc(var(--spacing-f144) + var(--spacing-f21)); flex: none; }.app-card-head { display: flex; justify-content: space-between; align-items: center; gap: var(--spacing-f8); }.app-vis-body { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--spacing-f2); }.app-vis-label { white-space: nowrap; }.app-zone-pill { padding: var(--spacing-f3) var(--spacing-f8); border-radius: var(--radius-full); background: color-mix(in srgb, var(--c-accent) var(--mag-4), transparent); border: var(--spacing-f1) solid var(--c-accent); font-size: var(--text-s-2); line-height: 1.3; font-weight: 700; color: var(--c-accent-ink); white-space: nowrap;}.app-rating { display: flex; gap: var(--spacing-f5); }.app-rating i { display: block; flex: 1; height: var(--spacing-f5); border-radius: var(--radius-full); background: var(--track); }.app-rating i.on { background: var(--c-accent); box-shadow: var(--glow); }Usage
VisibilityMiniCard(lightPollution: LightPollution(zone: 5, mpsas: 20.9),)astr_glass.dart · AstrGlassCard
/// The glass surface for cards: a sheen over the surface and a hairline edge./// Cards do not glow; glow belongs to buttons and tiles. Mini cards are/// [AstrSpace.f144] wide and 144 + 21 tall.class AstrGlassCard extends StatelessWidget { const AstrGlassCard({ super.key, required this.child, this.width, this.height, }); final Widget child; final double? width; final double? height; @override Widget build(BuildContext context) { final Color text = AstrColors.spaceGrey[50]!; final BorderRadius radius = BorderRadius.circular(AstrRadius.f21); return RepaintBoundary( child: ClipRRect( borderRadius: radius, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: AstrSpace.f13, sigmaY: AstrSpace.f13), child: Container( width: width, height: height, padding: const EdgeInsets.all(AstrSpace.f13), decoration: BoxDecoration( borderRadius: radius, border: Border.all(color: text.withValues(alpha: Mag.m5)), color: _ink.withValues(alpha: Mag.m1), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: <Color>[ text.withValues(alpha: Mag.m5), text.withValues(alpha: Mag.m8), ], ), ), child: child, ), ), ), ); }}visibility_mini_card.dart · VisibilityMiniCard
class VisibilityMiniCard extends StatelessWidget { const VisibilityMiniCard({ super.key, required this.lightPollution, }); final LightPollution lightPollution; @override Widget build(BuildContext context) { final int astrZone = lightPollution.visibilityIndex; // Inverted: Zone 1 (dark sky, best) → 5 bars; Zone 9 (city, worst) → 1 bar. // Before: ((astrZone - 1) / 2).floor() gave 0 bars for Zone 1 (wrong). final int activeBars = (5 - ((astrZone - 1) / 2).floor()).clamp(0, 5); return RepaintBoundary( child: ClipRRect( borderRadius: BorderRadius.circular(AppSpacing.kCardRadius), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), child: Container( width: AppSpacing.kMiniCardWidth, height: AppSpacing.kMiniCardHeight, decoration: BoxDecoration( color: AppColors.surfaceOverlay, border: Border.all(color: AppColors.borderPrimary), borderRadius: BorderRadius.circular(AppSpacing.kCardRadius), ), child: Padding( padding: const EdgeInsets.all(15), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ // Label and zone pill share one row, centred on each other. Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: <Widget>[ Flexible( child: Text( 'VISIBILITY', maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTypography.labelSm.copyWith(color: AppColors.textMuted), ), ), const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 4), decoration: BoxDecoration( color: AppColors.zonePillFill, borderRadius: BorderRadius.circular(AppSpacing.kBadgeRadius), border: Border.all(color: AppColors.accent), ), child: Text( 'Zone $astrZone', style: AppTypography.micro.copyWith( color: AppColors.zonePillText, fontWeight: FontWeight.w700, ), ), ), ], ), const SizedBox(height: 8), // Sky label (e.g. "Rural Sky") with the MPSAS value directly below it, centred // in the space above the bars. 20 px so the longest label, "Suburban Sky", fits on // one line; it scales down further if a font is wider. Expanded( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ FittedBox( fit: BoxFit.scaleDown, child: Text( _getSkyLabel(astrZone), maxLines: 1, style: AppTypography.title.copyWith( fontSize: 20, letterSpacing: -0.3, ), ), ), const SizedBox(height: 2), Text( '${lightPollution.mpsas.toStringAsFixed(2)} MPSAS', style: AppTypography.labelSm.copyWith(color: AppColors.textMuted), ), ], ), ), ), const SizedBox(height: 6), // Rating bars Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: List<Widget>.generate(5, (int index) { final bool isActive = index < activeBars; return Container( width: (AppSpacing.kRatingBarsWidth - 4 * AppSpacing.kRatingBarGap) / 5, height: AppSpacing.kRatingBarHeight, decoration: BoxDecoration( color: isActive ? AppColors.ratingBarActive : AppColors.ratingBarInactive, borderRadius: BorderRadius.circular(12), boxShadow: isActive ? AppColors.glowShadow : null, ), ); }), ), ], ), ), ), ), ), ); } static String _getSkyLabel(int zone) { if (zone <= 2) return 'Dark Sky'; if (zone <= 4) return 'Rural Sky'; if (zone <= 6) return 'Suburban Sky'; return 'Urban Sky'; }}API reference
| React prop | Flutter parameter | Type | Default | Meaning |
|---|---|---|---|---|
| zone | lightPollution.visibilityIndex | 1 to 9 | required | The Astr zone. |
| mpsas | lightPollution.mpsas | number / double | required | Sky brightness, mag/arcsec². |
Design tokens
Every value on this component is a system token. The last column is what the app widget uses today, so the gap to close is visible.
| Role | CSS token | Dart token | App widget today |
|---|---|---|---|
| Size | --spacing-f144 by 144 + --spacing-f21 | AstrSpace.f144 | 164 by 154 |
| Radius, padding | --radius-f21, --spacing-f13 | AstrRadius.f21, AstrSpace.f13 | 16, 15 |
| Surface | glass: --sheen over space-grey-950 at --mag-1 | AstrGlassCard | surfaceOverlay + borderPrimary |
| Zone pill | --radius-full, deep-space-200 at --mag-4, text deep-space-50 | AstrColors.deepSpace[200], [50] | zonePillFill, zonePillText |
| Sky label | --text-s1 (20) | AstrType.s1 | title 28 scaled to 20 |
| SQM line | --text-s-2 | AstrType.sNeg2 | labelSm 10 |
| Bars | --spacing-f5 high, gap f5, lit deep-space-200 | AstrSpace.f5 | 5 high, gap 5, ratingBarActive |