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): * 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. * 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. * 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 AmberHighlight = Color(0xFFFFEEB0)
val AmberDeep = Color(0xFFE0961E) 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 LimeLock = Color(0xFFCBEF5C)
val LimeLockText = Color(0xFFEAFFC2) 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings 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.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView 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.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer import com.onthelevel.AppContainer
@@ -163,7 +171,10 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
) { ) {
val placementOk = reading?.placementOk ?: true val placementOk = reading?.placementOk ?: true
val surfacePresentation = reading?.surfacePresentation 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) { if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) {
val pitch = reading?.stableSurfacePitchDegrees val pitch = reading?.stableSurfacePitchDegrees
val roll = reading?.stableSurfaceRollDegrees val roll = reading?.stableSurfaceRollDegrees
@@ -172,7 +183,8 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
} }
} }
Text( Text(
text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "", text = reading?.let { primaryReadout(formatDegrees(it.displayPrimaryDegrees)) }
?: AnnotatedString(""),
style = MaterialTheme.typography.displayLarge, style = MaterialTheme.typography.displayLarge,
color = LevelColors.TextPrimary, color = LevelColors.TextPrimary,
) )
@@ -210,7 +222,14 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
.height(56.dp), .height(56.dp),
contentAlignment = Alignment.TopCenter, 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. * strong instruction while the phone is still being placed.
*/ */
@Composable @Composable
private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnits) { private fun SurfaceAdjustmentInfo(
Column(horizontalAlignment = Alignment.CenterHorizontally) { reading: LevelReading?,
units: MeasurementUnits,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
if (reading == null) return@Column 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) { if (reading.isSettling) {
Text( Text(
text = "Settling", text = "Settling",
@@ -259,9 +286,11 @@ private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnit
val roll = reading.secondaryBDegrees ?: return@Column val roll = reading.secondaryBDegrees ?: return@Column
val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: 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(
text = highLabel, text = highLabel,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.titleLarge,
color = LevelColors.Amber, color = LevelColors.Amber,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
@@ -277,15 +306,26 @@ private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnit
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) { private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
Surface( Surface(
modifier = modifier, modifier = modifier,
color = LevelColors.Panel, color = LevelColors.ReadoutPanel,
contentColor = LevelColors.TextPrimary, contentColor = LevelColors.TextPrimary,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(
Text(label, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim) 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(
text = value?.let { formatDegrees(it) } ?: "", text = value?.let { formatDegrees(it) } ?: "",
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.headlineMedium, 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) 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) { internal fun formatRiseRun(valueDegrees: Double, units: MeasurementUnits): String = when (units) {
MeasurementUnits.METRIC -> MeasurementUnits.METRIC ->
String.format(Locale.US, "%.1f mm/m", RiseRun.millimetersPerMeter(valueDegrees)) 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.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.platform.LocalContext 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.dp
import androidx.compose.ui.unit.sp
import com.onthelevel.core.design.LevelColors import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.design.ReadoutFontFamily
import com.onthelevel.core.sensors.SurfaceGuidance 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 @Composable
fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean, reducedMotion: Boolean) { fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean, reducedMotion: Boolean) {
val guidance = SurfaceGuidance.from(pitchDegrees, rollDegrees) 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 Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
} }
val lockPulse = remember { Animatable(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) { if (reducedMotion || systemMotionDisabled) {
animatedX.snapTo(targetX) animatedX.snapTo(targetX)
animatedY.snapTo(targetY)
} else { } else {
animatedX.animateTo(targetX, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) 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)) 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 radius = size.minDimension / 2f
val center = Offset(size.width / 2f, size.height / 2f) val center = Offset(size.width / 2f, size.height / 2f)
val vialRadius = radius * .74f 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)) val bubbleCenter = center + Offset(animatedX.value * vialRadius, animatedY.value * vialRadius)
SurfaceGuidance.RING_DEGREES.forEach { mark -> // Nested inside the 1° target ring (0.148 × radius): the level-read is the
drawCircle(Color.White.copy(alpha = .11f), vialRadius * SurfaceGuidance.ringRadiusRatio(mark).toFloat(), center, style = Stroke(1f)) // 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) drawBubbleBody(bubbleCenter, bubbleRadius)
if (lockPulse.value > 0f) drawCircle(LevelColors.LimeLock.copy(alpha = .35f * lockPulse.value), vialRadius, center, style = Stroke(4f)) drawBubbleLight(bubbleCenter, bubbleRadius)
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))
} }
} }
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
+252
View File
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
body{margin:0;background:radial-gradient(1200px 800px at 50% 20%, #1a1c20 0%, #101114 60%, #0b0c0e 100%)}
a{color:#ffb454;text-decoration:none} a:hover{color:#ffc97e}
@keyframes ping{0%{transform:scale(0.08);opacity:0.55}75%{transform:scale(1);opacity:0}100%{transform:scale(1);opacity:0}}
@keyframes chipring{0%{transform:scale(0.6);opacity:0.9}100%{transform:scale(2.4);opacity:0}}
@keyframes breathe{0%,100%{opacity:1}50%{opacity:0.25}}
@keyframes bob{0%,100%{transform:translate(0px,0px)}30%{transform:translate(1.4px,-1.1px)}65%{transform:translate(-1.2px,0.9px)}}
</style>
</helmet>
<div style="min-height:100vh;display:grid;place-items:center;padding:36px 24px">
<x-import component-from-global-scope="AndroidDevice" from="./android-frame.jsx" dark="{{ true }}" height="{{ 920 }}" hint-size="428px,936px">
<div data-screen-label="Level" style="position:relative;height:100%;display:flex;flex-direction:column;gap:14px;padding:14px 20px 18px;box-sizing:border-box;overflow:hidden;background:radial-gradient(140% 90% at 50% 0%, #121419 0%, #0a0b0d 55%, #060708 100%);font-family:'Archivo',system-ui,sans-serif;color:#e8eaed">
<div style="position:absolute;left:-120px;bottom:-120px;width:340px;height:340px;border-radius:50%;background:radial-gradient(circle, rgba(255,164,60,0.14) 0%, rgba(255,164,60,0) 70%);pointer-events:none;opacity:{{ glowOpacity }};transition:opacity 0.6s"></div>
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;flex-direction:column;gap:3px">
<div style="font-size:14px;font-weight:700;letter-spacing:4px;color:#e8eaed">LEVEL</div>
<div style="display:flex;align-items:center;gap:6px">
<div style="width:5px;height:5px;border-radius:50%;background:#7ed9a0;animation:breathe 2.6s ease-in-out infinite"></div>
<div style="font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:2px;color:#6f767e">LIVE · FUSED</div>
</div>
</div>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#6f767e" stroke-width="1.6" style="cursor:pointer">
<circle cx="12" cy="12" r="3.2"></circle>
<path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1"></path>
</svg>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:9px 14px;border:1px solid rgba(255,255,255,0.07);border-radius:12px;background:linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0))">
<div style="position:relative;width:8px;height:8px;flex:none">
<div style="position:absolute;inset:0;border-radius:50%;background:{{ sonarDotColor }}"></div>
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
<div style="position:absolute;inset:-3px;border-radius:50%;border:1px solid #5fd4c4;animation:chipring 2.2s ease-out infinite"></div>
</sc-if>
</div>
<div style="font-size:10px;font-weight:600;letter-spacing:2.5px;color:{{ sonarLabelColor }}">SONAR</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:12px;color:{{ sonarValueColor }}">{{ sonarText }}</div>
<div style="flex:1"></div>
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;align-items:center;gap:3px">
<div style="width:2px;height:6px;background:#5fd4c4;opacity:0.35;border-radius:1px"></div>
<div style="width:2px;height:10px;background:#5fd4c4;opacity:0.6;border-radius:1px"></div>
<div style="width:2px;height:14px;background:#5fd4c4;opacity:0.9;border-radius:1px"></div>
<div style="width:2px;height:9px;background:#5fd4c4;opacity:0.5;border-radius:1px"></div>
<div style="width:2px;height:5px;background:#5fd4c4;opacity:0.3;border-radius:1px"></div>
</div>
</sc-if>
</div>
<div style="position:relative;display:flex;background:#15171b;border:1px solid rgba(255,255,255,0.06);border-radius:999px;padding:3px">
<div style="position:absolute;top:3px;bottom:3px;width:calc(50% - 3px);left:{{ thumbLeft }};border-radius:999px;background:linear-gradient(180deg,#2a2d33,#1f2126);border:1px solid rgba(255,255,255,0.1);box-shadow:0 2px 8px rgba(0,0,0,0.5);transition:left 0.25s cubic-bezier(0.3,0.9,0.3,1)"></div>
<button onClick="{{ setSurface }}" style="position:relative;flex:1;padding:9px 0;background:none;border:none;border-radius:999px;font-family:'Archivo',sans-serif;font-size:12px;font-weight:600;letter-spacing:1.5px;color:{{ surfTabColor }};cursor:pointer;transition:color 0.25s">SURFACE</button>
<button onClick="{{ setEdge }}" style="position:relative;flex:1;padding:9px 0;background:none;border:none;border-radius:999px;font-family:'Archivo',sans-serif;font-size:12px;font-weight:600;letter-spacing:1.5px;color:{{ edgeTabColor }};cursor:pointer;transition:color 0.25s">EDGE</button>
</div>
<sc-if value="{{ isSurface }}" hint-placeholder-val="{{ true }}">
<div data-screen-label="Surface mode" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:space-evenly;min-height:0">
<svg width="308" height="308" viewBox="0 0 360 360" style="flex:none">
<defs>
<radialGradient id="dialFace" cx="42%" cy="34%" r="80%">
<stop offset="0%" stop-color="#1b1e23"></stop>
<stop offset="60%" stop-color="#111318"></stop>
<stop offset="100%" stop-color="#0a0b0e"></stop>
</radialGradient>
<radialGradient id="bubbleG" cx="38%" cy="30%" r="75%">
<stop offset="0%" stop-color="#eaffb0"></stop>
<stop offset="35%" stop-color="#c3e85e"></stop>
<stop offset="75%" stop-color="#8ab825"></stop>
<stop offset="100%" stop-color="#5f8510"></stop>
</radialGradient>
<radialGradient id="bubbleHalo" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#b7e050" stop-opacity="0.35"></stop>
<stop offset="100%" stop-color="#b7e050" stop-opacity="0"></stop>
</radialGradient>
</defs>
<circle cx="180" cy="180" r="172" fill="url(#dialFace)" stroke="rgba(255,255,255,0.1)" stroke-width="1"></circle>
<circle cx="180" cy="180" r="164" fill="none" stroke="rgba(255,255,255,0.28)" stroke-width="7" stroke-dasharray="1.4 41.55"></circle>
<circle cx="180" cy="180" r="164" fill="none" stroke="rgba(255,255,255,0.12)" stroke-width="5" stroke-dasharray="1 13.35"></circle>
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
<g style="transform-origin:180px 180px;animation:ping 4.5s linear infinite">
<circle cx="180" cy="180" r="150" fill="none" stroke="#5fd4c4" stroke-width="1" opacity="0.5"></circle>
</g>
<g style="transform-origin:180px 180px;animation:ping 4.5s linear 2.25s infinite">
<circle cx="180" cy="180" r="150" fill="none" stroke="#5fd4c4" stroke-width="1" opacity="0.5"></circle>
</g>
</sc-if>
<circle cx="180" cy="180" r="150" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="1"></circle>
<circle cx="180" cy="180" r="100" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1"></circle>
<circle cx="180" cy="180" r="50" fill="none" stroke="rgba(255,255,255,0.14)" stroke-width="1"></circle>
<text x="186" y="44" font-family="JetBrains Mono" font-size="10" fill="#5a616a">1.5°</text>
<text x="186" y="94" font-family="JetBrains Mono" font-size="10" fill="#5a616a"></text>
<text x="186" y="144" font-family="JetBrains Mono" font-size="10" fill="#5a616a">0.5°</text>
<path d="M180 30 V150 M180 210 V330 M30 180 H150 M210 180 H330" stroke="rgba(255,255,255,0.16)" stroke-width="1"></path>
<path d="M78 249 A122 122 0 0 1 60.5 202" fill="none" stroke="#ffb454" stroke-width="2.5" stroke-linecap="round" opacity="{{ dirOpacity }}" style="transition:opacity 0.5s"></path>
<g opacity="{{ dirOpacity }}" style="transition:opacity 0.5s">
<path d="M96 262 l-9 5 3-10" fill="none" stroke="#ffb454" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</g>
<circle cx="180" cy="180" r="2.5" fill="#ffb454"></circle>
<g transform="translate({{ bubbleX }} {{ bubbleY }})" style="transition:transform 0.7s cubic-bezier(0.3,0.9,0.35,1)">
<g style="animation:bob 5.5s ease-in-out infinite">
<circle cx="180" cy="180" r="46" fill="url(#bubbleHalo)"></circle>
<circle cx="163" cy="196" r="3" fill="#b7e050" opacity="0.12"></circle>
<circle cx="171" cy="189" r="4.5" fill="#b7e050" opacity="0.22"></circle>
<circle cx="180" cy="180" r="21" fill="url(#bubbleG)"></circle>
<circle cx="180" cy="180" r="21" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="0.8"></circle>
<ellipse cx="173" cy="172" rx="7" ry="4.5" fill="#ffffff" opacity="0.55" transform="rotate(-32 173 172)"></ellipse>
</g>
</g>
</svg>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
<div style="font-family:'JetBrains Mono',monospace;font-weight:300;font-size:84px;line-height:1;letter-spacing:-3px;color:#f2f4f6">{{ mainVal }}<span style="font-size:40px;font-weight:400;color:#8a919b;vertical-align:26px;letter-spacing:0">{{ mainUnit }}</span></div>
<svg width="240" height="22" viewBox="0 0 240 22">
<line x1="10" y1="14" x2="230" y2="14" stroke="rgba(255,255,255,0.1)" stroke-width="4" stroke-dasharray="1 10"></line>
<line x1="120" y1="6" x2="120" y2="20" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"></line>
<rect x="{{ devX }}" y="4" width="3" height="16" rx="1.5" fill="{{ stateColor }}" style="transition:x 0.7s, fill 0.5s"></rect>
</svg>
<div style="display:flex;align-items:center;gap:8px">
<div style="width:5px;height:5px;border-radius:50%;background:{{ stateColor }};transition:background 0.5s"></div>
<div style="font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:2px;color:#6f767e">{{ subReadout }}</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isEdge }}" hint-placeholder-val="{{ false }}">
<div data-screen-label="Edge mode" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:space-evenly;min-height:0">
<svg width="340" height="150" viewBox="0 0 340 150" style="flex:none">
<defs>
<linearGradient id="tubeG" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#07080a"></stop>
<stop offset="45%" stop-color="#15181d"></stop>
<stop offset="100%" stop-color="#0b0c0f"></stop>
</linearGradient>
</defs>
<rect x="8" y="34" width="324" height="62" rx="31" fill="url(#tubeG)" stroke="rgba(255,255,255,0.1)" stroke-width="1"></rect>
<rect x="10" y="36" width="320" height="14" rx="7" fill="rgba(255,255,255,0.03)"></rect>
<line x1="146" y1="30" x2="146" y2="100" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"></line>
<line x1="194" y1="30" x2="194" y2="100" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"></line>
<g transform="translate({{ edgeBx }} 0)" style="transition:transform 0.7s cubic-bezier(0.3,0.9,0.35,1)">
<ellipse cx="170" cy="65" rx="24" ry="19" fill="url(#bubbleG2)"></ellipse>
<ellipse cx="170" cy="65" rx="24" ry="19" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="0.8"></ellipse>
<ellipse cx="162" cy="57" rx="8" ry="4" fill="#ffffff" opacity="0.5" transform="rotate(-18 162 57)"></ellipse>
</g>
<defs>
<radialGradient id="bubbleG2" cx="38%" cy="30%" r="75%">
<stop offset="0%" stop-color="#eaffb0"></stop>
<stop offset="35%" stop-color="#c3e85e"></stop>
<stop offset="75%" stop-color="#8ab825"></stop>
<stop offset="100%" stop-color="#5f8510"></stop>
</radialGradient>
</defs>
<line x1="20" y1="118" x2="320" y2="118" stroke="rgba(255,255,255,0.12)" stroke-width="6" stroke-dasharray="1 14"></line>
<line x1="170" y1="110" x2="170" y2="126" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"></line>
<text x="14" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">-2°</text>
<text x="166" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">0</text>
<text x="312" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">+2°</text>
</svg>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
<div style="font-family:'JetBrains Mono',monospace;font-weight:300;font-size:84px;line-height:1;letter-spacing:-3px;color:#f2f4f6">{{ edgeVal }}<span style="font-size:40px;font-weight:400;color:#8a919b;vertical-align:26px;letter-spacing:0">°</span></div>
<div style="display:flex;align-items:center;gap:8px">
<div style="width:5px;height:5px;border-radius:50%;background:{{ stateColor }};transition:background 0.5s"></div>
<div style="font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:2px;color:#6f767e">{{ edgeSubReadout }}</div>
</div>
</div>
</div>
</sc-if>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div style="background:linear-gradient(180deg,#14161b,#101216);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:13px 16px 11px;display:flex;flex-direction:column;gap:6px">
<div style="font-size:9.5px;font-weight:600;letter-spacing:2.5px;color:#6f767e">PITCH</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:26px;font-weight:400;color:#e8eaed">{{ pitchText }}</div>
<svg width="100%" height="20" viewBox="0 0 130 20" preserveAspectRatio="none">
<line x1="0" y1="10" x2="130" y2="10" stroke="rgba(255,255,255,0.08)" stroke-width="1"></line>
<g style="transform-origin:65px 10px;transform:rotate({{ pitchRot }}deg);transition:transform 0.7s">
<line x1="20" y1="10" x2="110" y2="10" stroke="{{ stateColor }}" stroke-width="1.5"></line>
</g>
<circle cx="65" cy="10" r="2" fill="{{ stateColor }}"></circle>
</svg>
</div>
<div style="background:linear-gradient(180deg,#14161b,#101216);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:13px 16px 11px;display:flex;flex-direction:column;gap:6px">
<div style="font-size:9.5px;font-weight:600;letter-spacing:2.5px;color:#6f767e">ROLL</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:26px;font-weight:400;color:#e8eaed">{{ rollText }}</div>
<svg width="100%" height="20" viewBox="0 0 130 20" preserveAspectRatio="none">
<line x1="0" y1="10" x2="130" y2="10" stroke="rgba(255,255,255,0.08)" stroke-width="1"></line>
<g style="transform-origin:65px 10px;transform:rotate({{ rollRot }}deg);transition:transform 0.7s">
<line x1="20" y1="10" x2="110" y2="10" stroke="{{ stateColor }}" stroke-width="1.5"></line>
</g>
<circle cx="65" cy="10" r="2" fill="{{ stateColor }}"></circle>
</svg>
</div>
</div>
</div>
</x-import>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:520,&quot;height&quot;:1000},&quot;leveled&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;State&quot;},&quot;sonarOn&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Sonar&quot;},&quot;units&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;degrees&quot;,&quot;mm/m&quot;,&quot;percent&quot;],&quot;default&quot;:&quot;degrees&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Readout&quot;}}">
class Component extends DCLogic {
state = { mode: 'surface' };
renderVals() {
const leveled = this.props.leveled ?? false;
const sonarOn = this.props.sonarOn ?? true;
const units = this.props.units ?? 'degrees';
const mode = this.state.mode;
const pitch = leveled ? 0 : -0.6, roll = leveled ? 0 : -0.3;
const stateColor = leveled ? '#7ed9a0' : '#ffb454';
const fmt = (v) => (v > 0 ? '+' : v < 0 ? '-' : '') + Math.abs(v).toFixed(1) + '\u00b0';
let mainVal, mainUnit;
if (units === 'mm/m') { mainVal = leveled ? '0.0' : '10.5'; mainUnit = ' mm/m'; }
else if (units === 'percent') { mainVal = leveled ? '0.0' : '1.2'; mainUnit = '%'; }
else { mainVal = leveled ? '0.0' : '0.7'; mainUnit = '\u00b0'; }
return {
isSurface: mode === 'surface', isEdge: mode === 'edge',
setSurface: () => this.setState({ mode: 'surface' }),
setEdge: () => this.setState({ mode: 'edge' }),
thumbLeft: mode === 'surface' ? '3px' : 'calc(50%)',
surfTabColor: mode === 'surface' ? '#e8eaed' : '#6f767e',
edgeTabColor: mode === 'edge' ? '#e8eaed' : '#6f767e',
sonarOn,
sonarDotColor: sonarOn ? '#5fd4c4' : '#3a3f45',
sonarLabelColor: sonarOn ? '#9ba3ac' : '#4d535a',
sonarValueColor: sonarOn ? '#5fd4c4' : '#4d535a',
sonarText: sonarOn ? '0.42 m' : 'OFF',
glowOpacity: leveled ? 0 : 1,
dirOpacity: leveled ? 0 : 0.9,
bubbleX: roll * 100, bubbleY: -pitch * 100,
devX: 118.5 + pitch * 60,
stateColor,
mainVal, mainUnit,
subReadout: leveled ? 'LEVEL \u00b7 HOLD' : '10.5 MM/M \u00b7 HIGH \u2199',
edgeVal: leveled ? '0.0' : '0.3',
edgeSubReadout: leveled ? 'LEVEL \u00b7 HOLD' : '5.2 MM/M \u00b7 HIGH LEFT',
edgeBx: roll * 120,
pitchText: fmt(pitch), rollText: fmt(roll),
pitchRot: pitch * 12, rollRot: roll * 12,
};
}
}
</script>
</body>
</html>