Files
doormile_milderapp/test/surface_system_test.dart
2026-08-28 11:13:15 +05:30

145 lines
6.2 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE LAYERS HAVE TO BE VISIBLE TO BE LAYERS
///
/// The app looked flat for a measurable reason, not an aesthetic one: white on
/// the page ground was a **1.124 : 1** step. A tonal difference does not begin
/// to read as a *different surface* until roughly 1.25, and on the budget LCD
/// this app ships to it needs the top of that band. So every screen that
/// wanted its content to separate grew a border or a shadow to do the ground's
/// job — which is how an app ends up looking like a stack of rounded
/// rectangles while its ground does nothing.
///
/// There were also five near-identical light tokens, three of them within
/// 1.03 of the canvas: indistinguishable, and therefore five names for two
/// decisions.
///
/// These pin the arithmetic. A contrast rule that lives only in a comment is a
/// rule that gets undone by the next person who nudges a hex.
/// ─────────────────────────────────────────────────────────────────────────
double _luminance(Color c) {
double channel(double v) =>
v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b);
}
double contrast(Color a, Color b) {
final la = _luminance(a);
final lb = _luminance(b);
final hi = math.max(la, lb);
final lo = math.min(la, lb);
return (hi + 0.05) / (lo + 0.05);
}
void main() {
group('the ladder has real rungs', () {
// ── This rung is now bought two ways, not one ──
//
// The old contract was: the tonal step alone has to carry the separation,
// because a card had no border and no shadow — so anything under ~1.25:1
// meant screens would start drawing their own outlines to compensate.
//
// Cards carry a hairline *and* a soft lift now (see [MilerPanel] and
// [Narrative]), which is the brief's recipe rather than this one, and the
// canvas went light to match. The step is deliberately smaller; what must
// not happen is that it goes to nothing, because then the border is doing
// the whole job and a card on a page reads as a box drawn on paper.
test('a working surface still separates from the canvas', () {
final step = contrast(MilerSurface.working, MilerSurface.canvas);
expect(
step,
greaterThan(1.0),
reason: 'white on the canvas is $step : 1 — the ground has vanished',
);
// And not so far that the page turns into a dark frame around content.
expect(step, lessThan(1.6));
});
test('a card carries the edge and the lift the ground no longer does', () {
// The other half of the trade above. If either of these is dropped, the
// canvas has to go back to carrying the step on its own.
expect(Narrative.cardShadow, isNotEmpty);
expect(Narrative.cardBorder, isNot(Narrative.card));
});
test('the canvas is never white', () {
expect(MilerSurface.canvas, isNot(MilerSurface.working));
expect(MilerSurface.canvas, isNot(const Color(0xFFFFFFFF)));
});
test('raised and floating carry lift, working does not', () {
// What separates an action surface from a reading surface is that it
// appears to sit above the page — the fill is the same on purpose.
expect(MilerSurface.raisedShadow, isNotEmpty);
expect(MilerSurface.floatingShadow, isNotEmpty);
expect(MilerSurface.raised, MilerSurface.working);
});
test('the scrim carries the modal separation', () {
// A sheet that needs a heavy shadow to be legible is a sheet whose scrim
// is too weak.
expect(MilerSurface.scrim.a, greaterThan(0.3));
});
});
group('text stays readable on every layer', () {
// AA body is 4.5:1. Every operational colour is asserted on the canvas —
// the darkest ground text sits on — so a screen cannot fail by moving
// content from a card onto the page.
final grounds = <String, Color>{
'canvas': MilerSurface.canvas,
'working': MilerSurface.working,
};
final inks = <String, Color>{
'slateText': ColorConstants.slateText,
'secondaryText': ColorConstants.secondaryText,
'onSurfaceVariant': ColorConstants.onSurfaceVariant,
'primary': ColorConstants.primary,
'acceptGreen': ColorConstants.acceptGreen,
'warning': ColorConstants.warning,
'errorRed': ColorConstants.errorRed,
};
for (final g in grounds.entries) {
for (final ink in inks.entries) {
test('${ink.key} on ${g.key} clears AA', () {
final r = contrast(ink.value, g.value);
expect(
r,
greaterThanOrEqualTo(4.5),
reason: '${ink.key} on ${g.key} is ${r.toStringAsFixed(2)} : 1',
);
});
}
}
});
group('the near-duplicate tokens are gone or justified', () {
test('the inset tone is distinguishable from the surface it sits on', () {
// The only nesting the system allows. If it cannot be seen against the
// working surface it is furniture, not a layer.
final step = contrast(MilerSurface.inset, MilerSurface.working);
expect(step, greaterThan(1.05));
});
test('brand red is identity, error red is not the same colour', () {
// Red must not mean two things. Navigation/primary and failure have to
// be tellable apart at a glance, outdoors.
expect(ColorConstants.primary, isNot(ColorConstants.errorRed));
expect(
contrast(ColorConstants.primary, ColorConstants.errorRed),
greaterThan(1.15),
reason: 'brand red and error red must not read as the same ink',
);
});
});
}