Surface polish: motion fix, instrument redesign, readout typography (Codex + Claude)

Driven by on-device iteration on a Galaxy S24+ (live ADB install/
screenshot loop), gated by a real-device motion pass at animator
scale 1x and frame stats (p95 8ms, 0.85% legacy jank).

Motion (Codex): X/Y axes animate in independent concurrent effects.
The prior sequential animateTo calls starved Y under ~50Hz
retargeting - diagonal movement traced an L and the second axis
caught up seconds late. Reduced-motion/system-disabled paths snap
both axes.

Instrument (Claude, on Codex's base):
- Fluorescent yellow-green fluid (matches real spirit-level dye);
  amber retained for guidance text.
- Bubble as a true void: borderless symmetric gradient orb,
  translucent interior (marks read through it), soft bright
  refraction band, contact shadow, fluorescent halo, window-style
  reflection + glint. Velocity-squash experiment tried and removed -
  shape fidelity beats the flourish.
- Bubble (0.12R) nests inside the 1-degree target ring; ring turns
  lime on VISUAL containment of the drawn bubble (lock label/pulse/
  haptic remain on LockDetector truth).
- Machined bezel, etched cardinal/45 ticks, per-ring degree labels
  via the same SurfaceGuidance mapping. dp-scaled strokes replace
  raw-pixel hairlines; contrast lifted for real screens.
- Red center target dot beneath the translucent bubble (Codex).

Text hierarchy: guidance suppressed while locked (no correction
advice under a 'Flat within' verdict), demoted to titleLarge, and
raised into the empty status line's slot. Primary readout optically
dead-centered via an invisible leading degree twin; degree symbol
.55em at TextDim with cap-height baseline shift (.38 - metrics pair,
resize together).

59 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-14 07:34:58 -04:00
parent 30417a5f98
commit 721fccd5ce
4 changed files with 587 additions and 23 deletions
@@ -13,7 +13,8 @@ import androidx.compose.ui.unit.sp
/**
* Palette from the concept board (resources/Bubble Level Concepts.dc.html):
* deep graphite glass, warm amber spirit fluid, lime for the locked state.
* deep graphite glass, warm amber guidance, fluorescent spirit fluid, and lime
* for the locked state.
* The design is dark-only in v1 — this is an instrument, not a document.
* Per BRIEF.md, color never carries state alone; labels and numbers always accompany it.
*/
@@ -27,6 +28,16 @@ object LevelColors {
val AmberHighlight = Color(0xFFFFEEB0)
val AmberDeep = Color(0xFFE0961E)
// Fluorescent yellow-green is both familiar from physical spirit levels and
// substantially more legible than amber against the graphite vial.
val VialLime = Color(0xFFD9FF38)
val VialHighlight = Color(0xFFF7FFD5)
val VialDeep = Color(0xFF86B800)
val VialTarget = Color(0xFFFF6868)
/** Stronger neutral surface reserved for live numeric instrument panels. */
val ReadoutPanel = Color(0xFF242931)
val LimeLock = Color(0xFFCBEF5C)
val LimeLockText = Color(0xFFEAFFC2)
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
@@ -33,8 +34,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
@@ -163,7 +171,10 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
) {
val placementOk = reading?.placementOk ?: true
val surfacePresentation = reading?.surfacePresentation
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Column(
modifier = Modifier.offset(y = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) {
val pitch = reading?.stableSurfacePitchDegrees
val roll = reading?.stableSurfaceRollDegrees
@@ -172,7 +183,8 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
}
}
Text(
text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "",
text = reading?.let { primaryReadout(formatDegrees(it.displayPrimaryDegrees)) }
?: AnnotatedString(""),
style = MaterialTheme.typography.displayLarge,
color = LevelColors.TextPrimary,
)
@@ -210,7 +222,14 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
.height(56.dp),
contentAlignment = Alignment.TopCenter,
) {
SurfaceAdjustmentInfo(reading, measurementUnits)
// Guidance and the status label are mutually exclusive
// (guidance renders only while the label line is empty),
// so it rises to visually take the label's place.
SurfaceAdjustmentInfo(
reading,
measurementUnits,
Modifier.offset(y = (-36).dp),
)
}
}
}
@@ -242,10 +261,18 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
* strong instruction while the phone is still being placed.
*/
@Composable
private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnits) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
private fun SurfaceAdjustmentInfo(
reading: LevelReading?,
units: MeasurementUnits,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
if (reading == null) return@Column
// A locked surface is flat within tolerance: issuing correction guidance
// under the lock line would contradict it and crowd the status stack.
if (reading.isLocked) return@Column
if (reading.isSettling) {
Text(
text = "Settling",
@@ -259,9 +286,11 @@ private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnit
val roll = reading.secondaryBDegrees ?: return@Column
val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return@Column
// One rank below the lock/status line (28sp mono): guidance advises, it
// doesn't compete.
Text(
text = highLabel,
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleLarge,
color = LevelColors.Amber,
textAlign = TextAlign.Center,
)
@@ -277,15 +306,26 @@ private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnit
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = LevelColors.Panel,
color = LevelColors.ReadoutPanel,
contentColor = LevelColors.TextPrimary,
shape = MaterialTheme.shapes.large,
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(label, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
label,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
textAlign = TextAlign.Center,
)
Text(
text = value?.let { formatDegrees(it) } ?: "",
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
}
}
@@ -315,6 +355,31 @@ private fun statusLine(isCalibrated: Boolean, kind: SensorSource.Kind): String {
internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value)
/**
* Primary readout typography: the degree symbol rendered small and faint keeps the
* number elegant without losing the instrument voice — and its invisible leading
* twin balances the trailing one, so the digits sit optically dead-center no
* matter the glyph widths.
*/
private fun primaryReadout(text: String): AnnotatedString = buildAnnotatedString {
// The shrunken symbol would otherwise sit on the digits' baseline at
// mid-height; the baseline shift floats it back up to the cap line where a
// degree mark belongs.
val degreeStyle = SpanStyle(
fontSize = DEGREE_SYMBOL_EM.em,
baselineShift = BaselineShift(DEGREE_SYMBOL_SHIFT),
)
withStyle(degreeStyle.copy(color = Color.Transparent)) { append("°") }
append(text.removeSuffix("°"))
withStyle(degreeStyle.copy(color = LevelColors.TextDim)) { append("°") }
}
private const val DEGREE_SYMBOL_EM = .55f
// No cap-height-alignment primitive exists in Compose; this value is derived from
// Roboto's metrics (digit cap height minus the degree glyph's top at the symbol
// size above — resize them together), verified against on-device captures.
private const val DEGREE_SYMBOL_SHIFT = .38f
internal fun formatRiseRun(valueDegrees: Double, units: MeasurementUnits): String = when (units) {
MeasurementUnits.METRIC ->
String.format(Locale.US, "%.1f mm/m", RiseRun.millimetersPerMeter(valueDegrees))
@@ -13,13 +13,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.design.ReadoutFontFamily
import com.onthelevel.core.sensors.SurfaceGuidance
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.sin
/** Glass bullseye whose target and rings are both driven by SurfaceGuidance. */
/**
* Glass bullseye whose target and rings are both driven by SurfaceGuidance.
*
* The bubble is a VOID in fluorescent fluid: a perfectly round, borderless
* translucent orb — one symmetric gradient whose edge fades out rather than
* outlining — grounded by a contact shadow and lit by a small fixed upper-left
* reflection. The calibration marks (and the red target dot) stay readable
* through it. Every layer serves depth or readability (SURFACE_LEVEL_PLAN.md:
* no decorative gloss); alphas are the tuning knobs if anything reads as chrome.
*/
@Composable
fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean, reducedMotion: Boolean) {
val guidance = SurfaceGuidance.from(pitchDegrees, rollDegrees)
@@ -32,12 +53,21 @@ fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
val lockPulse = remember { Animatable(0f) }
LaunchedEffect(targetX, targetY, reducedMotion, systemMotionDisabled) {
val textMeasurer = rememberTextMeasurer()
// These must run independently. animateTo suspends until a spring settles;
// sequencing axes here would starve Y under the sensor's ~50 Hz retargeting
// and make diagonal movement trace an unnatural L.
LaunchedEffect(targetX, reducedMotion, systemMotionDisabled) {
if (reducedMotion || systemMotionDisabled) {
animatedX.snapTo(targetX)
animatedY.snapTo(targetY)
} else {
animatedX.animateTo(targetX, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
}
}
LaunchedEffect(targetY, reducedMotion, systemMotionDisabled) {
if (reducedMotion || systemMotionDisabled) {
animatedY.snapTo(targetY)
} else {
animatedY.animateTo(targetY, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
}
}
@@ -51,16 +81,222 @@ fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean
val radius = size.minDimension / 2f
val center = Offset(size.width / 2f, size.height / 2f)
val vialRadius = radius * .74f
drawCircle(Brush.radialGradient(listOf(Color(0xFF22272E), Color(0xFF090A0C)), center, radius), radius, center)
drawCircle(Color.White.copy(alpha = .08f), radius, center, style = Stroke(1.5f))
SurfaceGuidance.RING_DEGREES.forEach { mark ->
drawCircle(Color.White.copy(alpha = .11f), vialRadius * SurfaceGuidance.ringRadiusRatio(mark).toFloat(), center, style = Stroke(1f))
val bubbleCenter = center + Offset(animatedX.value * vialRadius, animatedY.value * vialRadius)
// Nested inside the 1° target ring (0.148 × radius): the level-read is the
// classic "bubble inside the circle", never the bubble swallowing the mark.
val bubbleRadius = radius * BUBBLE_RADIUS_RATIO
// The target ring highlights on what the EYE sees: the drawn bubble sitting
// inside (or tangent to) the circle. Lock truth — label, pulse, haptic —
// stays with LockDetector; this accent must never outlive the visual.
val targetRingRadius = vialRadius * SurfaceGuidance.ringRadiusRatio(1.0).toFloat()
val bubbleInsideTarget =
hypot(bubbleCenter.x - center.x, bubbleCenter.y - center.y) + bubbleRadius <=
targetRingRadius + 1.dp.toPx()
drawVialGlass(center, radius, vialRadius)
drawCalibrationMarks(center, radius, vialRadius, bubbleInsideTarget, textMeasurer)
if (lockPulse.value > 0f) {
drawCircle(LevelColors.LimeLock.copy(alpha = .55f * lockPulse.value), vialRadius, center, style = Stroke(3.dp.toPx()))
}
drawLine(Color.White.copy(alpha = .10f), Offset(center.x - vialRadius, center.y), Offset(center.x + vialRadius, center.y), 1f)
drawLine(Color.White.copy(alpha = .10f), Offset(center.x, center.y - vialRadius), Offset(center.x, center.y + vialRadius), 1f)
if (lockPulse.value > 0f) drawCircle(LevelColors.LimeLock.copy(alpha = .35f * lockPulse.value), vialRadius, center, style = Stroke(4f))
val bubble = center + Offset(animatedX.value * vialRadius, animatedY.value * vialRadius)
drawCircle(Brush.radialGradient(listOf(LevelColors.AmberHighlight, LevelColors.Amber, LevelColors.AmberDeep), bubble, radius * .18f), radius * .17f, bubble)
drawCircle(Color.White.copy(alpha = .45f), radius * .045f, bubble + Offset(-radius * .05f, -radius * .05f))
drawBubbleBody(bubbleCenter, bubbleRadius)
drawBubbleLight(bubbleCenter, bubbleRadius)
}
}
private fun DrawScope.drawVialGlass(center: Offset, radius: Float, vialRadius: Float) {
drawCircle(Brush.radialGradient(listOf(Color(0xFF22272E), Color(0xFF090A0C)), center, radius), radius, center)
// Faint sheen from the fixed upper-left light — a depth cue, kept far below gloss.
drawCircle(
Brush.radialGradient(
listOf(Color.White.copy(alpha = .05f), Color.Transparent),
center + Offset(-radius * .38f, -radius * .42f),
radius * .9f,
),
radius,
center,
)
// Fluid vignette: the dye reads darker where it meets the vial wall.
drawCircle(
Brush.radialGradient(
colorStops = arrayOf(
0f to Color.Transparent,
.78f to Color.Transparent,
1f to Color.Black.copy(alpha = .32f),
),
center = center,
radius = vialRadius,
),
vialRadius,
center,
)
// Machined bezel: a shallow metallic annulus, lit from above, instead of a
// flat outline — housing depth without adding information noise.
val bezelWidth = 5.dp.toPx()
drawCircle(
Brush.linearGradient(
listOf(Color.White.copy(alpha = .20f), Color.White.copy(alpha = .05f)),
start = Offset(center.x, center.y - radius),
end = Offset(center.x, center.y + radius),
),
radius - bezelWidth / 2f,
center,
style = Stroke(bezelWidth),
)
drawCircle(Color.White.copy(alpha = .14f), radius - bezelWidth, center, style = Stroke(1.dp.toPx()))
// Etched bezel ticks: cardinals strong, 45° minors faint — orientation
// vocabulary borrowed from real instrument bezels.
for (degrees in 0 until 360 step 45) {
val isCardinal = degrees % 90 == 0
val angle = Math.toRadians(degrees.toDouble())
val direction = Offset(cos(angle).toFloat(), sin(angle).toFloat())
val outer = radius - 1.dp.toPx()
val inner = outer - if (isCardinal) 8.dp.toPx() else 5.dp.toPx()
drawLine(
Color.White.copy(alpha = if (isCardinal) .45f else .22f),
center + direction * inner,
center + direction * outer,
if (isCardinal) 1.5.dp.toPx() else 1.dp.toPx(),
)
}
}
private fun DrawScope.drawCalibrationMarks(
center: Offset,
radius: Float,
vialRadius: Float,
bubbleInsideTarget: Boolean,
textMeasurer: TextMeasurer,
) {
val labelStyle = TextStyle(
fontSize = 9.sp,
fontFamily = ReadoutFontFamily,
color = Color.White.copy(alpha = .38f),
)
SurfaceGuidance.RING_DEGREES.forEach { mark ->
val isTargetRing = mark == 1.0
val ringRadius = vialRadius * SurfaceGuidance.ringRadiusRatio(mark).toFloat()
val color = if (isTargetRing && bubbleInsideTarget) {
// Restrained lime accent, driven by visual containment of the drawn
// bubble. The label still carries the lock state in words.
LevelColors.LimeLock.copy(alpha = .9f)
} else {
val alpha = when (mark) {
1.0 -> .46f // The target zone needs to be the clearest calibration mark.
2.0 -> .36f
else -> .27f
}
Color.White.copy(alpha = alpha)
}
drawCircle(
color,
ringRadius,
center,
style = Stroke(if (isTargetRing && bubbleInsideTarget) 2.dp.toPx() else 1.5.dp.toPx()),
)
// Each ring says what it means: its degree value, etched small on the
// lower-right diagonal just outside the ring.
val layout = textMeasurer.measure(AnnotatedString("${mark.toInt()}°"), labelStyle)
val diagonal = (ringRadius + 5.dp.toPx()) * DIAGONAL_COMPONENT
val position = center + Offset(diagonal, diagonal)
drawText(
layout,
topLeft = position - Offset(layout.size.width / 2f, layout.size.height / 2f),
)
}
val crosshairStroke = 1.dp.toPx()
drawLine(Color.White.copy(alpha = .32f), Offset(center.x - vialRadius, center.y), Offset(center.x + vialRadius, center.y), crosshairStroke)
drawLine(Color.White.copy(alpha = .32f), Offset(center.x, center.y - vialRadius), Offset(center.x, center.y + vialRadius), crosshairStroke)
// The exact-center target mark stays beneath the translucent bubble, seen
// through the trapped air just as it would be in a real vial.
drawCircle(Color.White.copy(alpha = .85f), radius * .030f, center)
drawCircle(LevelColors.VialTarget, radius * .020f, center)
}
/** A borderless, perfectly round translucent orb: one symmetric gradient, edge fading to nothing. */
private fun DrawScope.drawBubbleBody(c: Offset, b: Float) {
// Fluorescent halo: the dye glows brightest where the lens bends light past the rim.
drawCircle(
Brush.radialGradient(
listOf(LevelColors.VialLime.copy(alpha = .16f), Color.Transparent),
c,
b * 1.9f,
),
b * 1.9f,
c,
)
// Contact shadow: a soft dark ring just outside the rim seats the bubble IN the
// fluid instead of floating over the graphics.
drawCircle(
Brush.radialGradient(
colorStops = arrayOf(
0f to Color.Transparent,
.68f to Color.Transparent,
.80f to Color.Black.copy(alpha = .30f),
1f to Color.Transparent,
),
center = c,
radius = b * 1.3f,
),
b * 1.3f,
c,
)
// The orb itself. Centered gradient = perfectly round; the soft bright band
// sits evenly inside the rim, and the thin dark contact edge fades out rather
// than drawing a border.
drawCircle(
Brush.radialGradient(
colorStops = arrayOf(
0f to LevelColors.VialHighlight.copy(alpha = .24f),
.52f to LevelColors.VialLime.copy(alpha = .18f),
.80f to LevelColors.VialLime.copy(alpha = .36f),
.91f to LevelColors.VialHighlight.copy(alpha = .66f),
.965f to LevelColors.VialDeep.copy(alpha = .32f),
1f to Color.Transparent,
),
center = c,
radius = b * 1.06f,
),
b * 1.06f,
c,
)
}
/**
* Reflection layer, world-oriented: a soft window-style reflection patch at the
* upper-left (how glass and soap bubbles actually mirror a light source), a sharp
* glint at its heart, and a faint pass-through shimmer on the far rim.
*/
private fun DrawScope.drawBubbleLight(c: Offset, b: Float) {
withTransform({
translate(c.x - b * .30f, c.y - b * .32f)
rotate(-38f, Offset.Zero)
scale(1f, .55f, Offset.Zero)
}) {
drawCircle(
Brush.radialGradient(
listOf(Color.White.copy(alpha = .50f), Color.Transparent),
Offset.Zero,
b * .46f,
),
b * .46f,
Offset.Zero,
)
}
drawCircle(Color.White.copy(alpha = .85f), b * .07f, c + Offset(-b * .30f, -b * .34f))
val shimmerCenter = c + Offset(b * .42f, b * .44f)
drawCircle(
Brush.radialGradient(
listOf(Color.White.copy(alpha = .12f), Color.Transparent),
shimmerCenter,
b * .30f,
),
b * .30f,
shimmerCenter,
)
}
private const val BUBBLE_RADIUS_RATIO = .12f
private const val DIAGONAL_COMPONENT = .7071f