Build cross-runtime CultUI parity smoke

This commit is contained in:
Meta Vens 2026-06-05 22:07:43 +01:00
commit c12d78726a
29 changed files with 1435 additions and 156 deletions

3
.gitignore vendored
View file

@ -3,6 +3,9 @@ packages/
obj/
artifacts/
tools/deps/
android/app/src/main/assets/current-surface.json
flutter/eve_parity/assets/current-surface.json
flutter/eve_parity/assets/fonts/
*.deb
*.ipa
*.dSYM/

View file

@ -169,6 +169,7 @@ static NSData *EVEMediaObservation(NSString *observationId,
@property(nonatomic, copy) NSString *dashboardStatus;
@property(nonatomic, copy) NSString *dashboardProviderId;
@property(nonatomic, copy) NSString *selectedNodeId;
@property(nonatomic, strong) NSDictionary *paritySurfaceValues;
@property(nonatomic, assign) CGFloat activeGestureStartScale;
@property(nonatomic, assign) CGFloat activeGestureStartRotation;
@property(nonatomic, assign) CGFloat activeSurfaceFontScale;
@ -340,6 +341,7 @@ static NSData *EVEMediaObservation(NSString *observationId,
[fileManager createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:nil];
[fileManager removeItemAtPath:requestPath error:nil];
[self renderParityFixtureIfPresentInDirectory:directory];
CGSize size = self.view.bounds.size;
if (size.width <= 0.0 || size.height <= 0.0) {
@ -392,6 +394,31 @@ static NSData *EVEMediaObservation(NSString *observationId,
}
}
- (void)renderParityFixtureIfPresentInDirectory:(NSString *)directory {
NSString *fixturePath = [directory stringByAppendingPathComponent:@"current-surface.json"];
NSData *data = [NSData dataWithContentsOfFile:fixturePath];
if (!data) {
return;
}
NSError *error = nil;
NSDictionary *fixture = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
if (![fixture isKindOfClass:NSDictionary.class]) {
return;
}
self.paritySurfaceValues = [fixture[@"values"] isKindOfClass:NSDictionary.class] ? fixture[@"values"] : @{};
self.voidBotDashboardView.hidden = YES;
self.sceneCanvasView.hidden = YES;
self.hierarchyStackView.hidden = YES;
self.toolbarStackView.hidden = YES;
self.dashboardStatusLabel.hidden = YES;
self.surfaceDashboardView.hidden = NO;
[self renderFullscreenSurfaceState:fixture title:fixture[@"title"] version:fixture[@"version"]];
[self.view setNeedsLayout];
[self.view layoutIfNeeded];
}
- (void)updateOverlay {
if (self.dashboardView) {
self.overlayLabel.hidden = YES;
@ -1145,15 +1172,88 @@ static NSData *EVEMediaObservation(NSString *observationId,
NSDictionary *props = [element[@"props"] isKindOfClass:NSDictionary.class] ? element[@"props"] : @{};
NSArray *children = [element[@"children"] isKindOfClass:NSArray.class] ? element[@"children"] : @[];
if ([kind isEqualToString:@"text"] || [kind isEqualToString:@"metric"]) {
if ([kind isEqualToString:@"label"] || [kind isEqualToString:@"text"] || [kind isEqualToString:@"text.title"] || [kind isEqualToString:@"text.dialogue"] || [kind isEqualToString:@"metric"]) {
NSString *text = [self surfaceTextForElement:element props:props fallback:kind];
if ([kind isEqualToString:@"label"]) {
text = text.uppercaseString;
}
return [self surfaceLabelWithText:text
size:depth < 2 ? 12.0 : 10.5
size:[kind isEqualToString:@"text.title"] ? 18.0 : (depth < 2 ? 12.0 : 10.5)
weight:UIFontWeightMedium
color:[self fensalirPrimaryTextColor]];
color:[kind isEqualToString:@"label"] ? [self fensalirAccentSoftColor] : [self fensalirPrimaryTextColor]];
}
if ([kind isEqualToString:@"rail"]) {
if ([kind isEqualToString:@"control.slider"]) {
NSString *bind = [props[@"bind"] isKindOfClass:NSString.class] ? props[@"bind"] : @"";
NSNumber *value = [self.paritySurfaceValues[bind] isKindOfClass:NSNumber.class] ? self.paritySurfaceValues[bind] : props[@"value"];
CGFloat min = [props[@"min"] respondsToSelector:@selector(doubleValue)] ? [props[@"min"] doubleValue] : 0.0;
CGFloat max = [props[@"max"] respondsToSelector:@selector(doubleValue)] ? [props[@"max"] doubleValue] : 1.0;
CGFloat raw = [value respondsToSelector:@selector(doubleValue)] ? value.doubleValue : min;
CGFloat percent = max == min ? 0.0 : MIN(MAX((raw - min) / (max - min), 0.0), 1.0);
UIProgressView *slider = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
slider.progress = percent;
slider.progressTintColor = [self fensalirAccentSoftColor];
slider.trackTintColor = [UIColor colorWithWhite:0.0 alpha:0.36];
[slider.heightAnchor constraintEqualToConstant:18.0].active = YES;
return slider;
}
if ([kind isEqualToString:@"control.button"]) {
NSString *text = [self surfaceTextForElement:element props:props fallback:@"button"];
UILabel *button = [self surfaceLabelWithText:text size:11.0 weight:UIFontWeightSemibold color:[self fensalirPrimaryTextColor]];
button.textAlignment = NSTextAlignmentCenter;
button.layer.borderWidth = 1.0;
button.layer.borderColor = [self fensalirAccentSoftColor].CGColor;
button.layer.cornerRadius = 5.0;
button.backgroundColor = [UIColor colorWithRed:0.34 green:0.15 blue:0.04 alpha:0.32];
return button;
}
if ([kind isEqualToString:@"panel.dialogue"]) {
UIView *panel = [[UIView alloc] initWithFrame:CGRectZero];
panel.backgroundColor = [UIColor colorWithRed:0.014 green:0.050 blue:0.052 alpha:0.94];
panel.layer.borderWidth = 1.0;
panel.layer.borderColor = [self fensalirOutlineColor].CGColor;
panel.layer.cornerRadius = 7.0;
UIStackView *stack = [[UIStackView alloc] initWithFrame:CGRectZero];
stack.translatesAutoresizingMaskIntoConstraints = NO;
stack.axis = UILayoutConstraintAxisVertical;
stack.spacing = 5.0;
[panel addSubview:stack];
[stack addArrangedSubview:[self surfaceHeaderLabelWithText:[props[@"speaker"] isKindOfClass:NSString.class] ? props[@"speaker"] : @"Speaker" size:13.0]];
[stack addArrangedSubview:[self surfaceLabelWithText:[props[@"text"] isKindOfClass:NSString.class] ? props[@"text"] : @"" size:14.0 weight:UIFontWeightRegular color:[self fensalirPrimaryTextColor]]];
[NSLayoutConstraint activateConstraints:@[
[stack.leadingAnchor constraintEqualToAnchor:panel.leadingAnchor constant:10.0],
[stack.trailingAnchor constraintEqualToAnchor:panel.trailingAnchor constant:-10.0],
[stack.topAnchor constraintEqualToAnchor:panel.topAnchor constant:10.0],
[stack.bottomAnchor constraintEqualToAnchor:panel.bottomAnchor constant:-10.0],
]];
return panel;
}
if ([kind isEqualToString:@"embed.tex"]) {
NSString *label = [props[@"label"] isKindOfClass:NSString.class] ? props[@"label"] : @"TeX";
NSString *source = [props[@"source"] isKindOfClass:NSString.class] ? props[@"source"] : @"";
return [self surfaceLabelWithText:[NSString stringWithFormat:@"%@\n%@", label.uppercaseString, source] size:11.0 weight:UIFontWeightMedium color:[self fensalirPrimaryTextColor]];
}
if ([kind isEqualToString:@"embed.norn"]) {
NSDictionary *graph = [props[@"graph"] isKindOfClass:NSDictionary.class] ? props[@"graph"] : @{};
NSArray *nodes = [graph[@"nodes"] isKindOfClass:NSArray.class] ? graph[@"nodes"] : @[];
NSMutableArray *labels = [NSMutableArray array];
for (NSDictionary *node in nodes) {
if (![node isKindOfClass:NSDictionary.class]) continue;
NSString *label = [node[@"label"] isKindOfClass:NSString.class] ? node[@"label"] : node[@"id"];
if (label.length > 0) [labels addObject:label];
}
return [self surfaceLabelWithText:[NSString stringWithFormat:@"%@\n%@", [props[@"label"] isKindOfClass:NSString.class] ? props[@"label"] : @"Norn", [labels componentsJoinedByString:@" -> "]] size:11.0 weight:UIFontWeightMedium color:[self fensalirPrimaryTextColor]];
}
if ([kind isEqualToString:@"image.background"]) {
return [self surfaceLabelWithText:[props[@"label"] isKindOfClass:NSString.class] ? props[@"label"] : @"Scene" size:11.0 weight:UIFontWeightSemibold color:[UIColor colorWithWhite:0.72 alpha:1.0]];
}
if ([kind isEqualToString:@"rail"] || [kind isEqualToString:@"rail.actions"]) {
return [self renderSurfaceRail:element props:props children:children depth:depth];
}
@ -1172,7 +1272,8 @@ static NSData *EVEMediaObservation(NSString *observationId,
stack.translatesAutoresizingMaskIntoConstraints = NO;
NSDictionary *layout = [element[@"layout"] isKindOfClass:NSDictionary.class] ? element[@"layout"] : ([props[@"layout"] isKindOfClass:NSDictionary.class] ? props[@"layout"] : @{});
NSString *direction = [layout[@"direction"] isKindOfClass:NSString.class] ? layout[@"direction"] : @"";
BOOL wantsHorizontal = ![kind isEqualToString:@"row"] && [direction isEqualToString:@"horizontal"];
NSString *split = [props[@"split"] isKindOfClass:NSString.class] ? props[@"split"] : @"";
BOOL wantsHorizontal = ![kind isEqualToString:@"row"] && ([direction isEqualToString:@"horizontal"] || [split isEqualToString:@"x"]);
stack.axis = wantsHorizontal ? UILayoutConstraintAxisHorizontal : UILayoutConstraintAxisVertical;
stack.spacing = 6.0;
stack.alignment = UIStackViewAlignmentFill;
@ -1289,6 +1390,13 @@ static NSData *EVEMediaObservation(NSString *observationId,
- (NSString *)surfaceTextForElement:(NSDictionary *)element props:(NSDictionary *)props fallback:(NSString *)fallback {
NSString *text = [props[@"text"] isKindOfClass:NSString.class] ? props[@"text"] : nil;
NSString *bind = [props[@"bind"] isKindOfClass:NSString.class] ? props[@"bind"] : nil;
if (bind.length > 0) {
id value = self.paritySurfaceValues[bind];
if (value) {
text = [value description];
}
}
if (!text) {
text = [element[@"text"] isKindOfClass:NSString.class] ? element[@"text"] : nil;
}
@ -1313,7 +1421,7 @@ static NSData *EVEMediaObservation(NSString *observationId,
title = [props[@"label"] isKindOfClass:NSString.class] ? props[@"label"] : nil;
}
NSString *kind = [element[@"kind"] isKindOfClass:NSString.class] ? element[@"kind"] : @"";
BOOL structural = [kind isEqualToString:@"row"] || [kind isEqualToString:@"dashboard"] || [kind isEqualToString:@"cockpit"];
BOOL structural = [kind isEqualToString:@"row"] || [kind isEqualToString:@"dashboard"] || [kind isEqualToString:@"cockpit"] || [kind isEqualToString:@"surface"] || [kind isEqualToString:@"vn.stage"] || [kind hasPrefix:@"layer."];
if (!title && !structural) {
title = [element[@"id"] isKindOfClass:NSString.class] ? element[@"id"] : nil;
}

View file

@ -2,9 +2,13 @@ package org.gamecult.eve
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ImageFormat
import android.graphics.Paint
import android.graphics.RectF
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
@ -26,6 +30,7 @@ import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.ScrollView
@ -45,6 +50,7 @@ import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.Executors
import org.json.JSONObject
class MainActivity : Activity(), SensorEventListener {
private val mesh = CultMeshNode()
@ -79,6 +85,10 @@ class MainActivity : Activity(), SensorEventListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.getBooleanExtra("org.gamecult.eve.PARITY_FIXTURE", false)) {
setContentView(buildParityFixtureUi())
return
}
sensorManager = getSystemService(SENSOR_SERVICE) as? SensorManager
setContentView(buildUi())
startSensors()
@ -87,6 +97,229 @@ class MainActivity : Activity(), SensorEventListener {
requestMediaPermissionsAndStart()
}
private fun buildParityFixtureUi(): ScrollView {
val raw = assets.open("current-surface.json").bufferedReader().use { it.readText() }
val state = JSONObject(raw)
val values = state.optJSONObject("values") ?: JSONObject()
val surface = state.getJSONObject("surface")
val styles = surface.optJSONObject("styles") ?: JSONObject()
val tokens = styles.optJSONObject("tokens") ?: JSONObject()
val rootNode = surface.getJSONObject("root")
val scroll = ScrollView(this).apply {
isFillViewport = true
setBackgroundColor(tokenColor(tokens, "colorBackground", Color.rgb(2, 9, 9)))
}
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(16), dp(16), dp(16), dp(16))
}
scroll.addView(root, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
root.addView(renderCultUiNode(rootNode, values, tokens))
return scroll
}
private fun renderCultUiNode(node: JSONObject, values: JSONObject, tokens: JSONObject): View {
val kind = node.optString("kind", "panel")
val props = node.optJSONObject("props") ?: JSONObject()
val children = node.optJSONArray("children")
return when (kind) {
"vn.stage" -> FrameLayout(this).apply {
setBackgroundColor(tokenColor(tokens, "colorBackground", Color.rgb(5, 8, 13)))
val visibleAxis = minOf(resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels)
val height = visibleAxis - 48
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height.coerceAtLeast(520))
val dialogue = findChild(children, "panel.dialogue")
val actions = findChild(children, "rail.actions")
forEachChild(children) { child ->
val childKind = child.optString("kind", "")
if (childKind == "panel.dialogue" || childKind == "rail.actions") return@forEachChild
val view = renderCultUiNode(child, values, tokens)
val params = stageParams(child)
addView(view, params)
}
if (dialogue != null) {
val params = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM)
params.setMargins(24, 24, 24, if (actions == null) 24 else 92)
addView(renderCultUiNode(dialogue, values, tokens), params)
}
if (actions != null) {
val params = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM)
params.setMargins(24, 0, 24, 24)
addView(renderCultUiNode(actions, values, tokens), params)
}
}
"image.background" -> StageBackgroundView(this, props.optString("label", "Scene"), tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt())).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
"embed.norn" -> NornGraphView(this, props.optJSONObject("graph") ?: JSONObject(), tokens).apply {
background = android.graphics.drawable.GradientDrawable().apply {
setColor(Color.argb(210, 5, 18, 17))
setStroke(dp(1), tokenColor(tokens, "colorPanelBorder", Color.argb(110, 103, 240, 228)))
cornerRadius = dp(6).toFloat()
}
}
"embed.tex" -> LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(10), dp(10), dp(10), dp(10))
background = panelBackground(tokens, 6)
val labelText = props.optString("label", "")
if (labelText.isNotBlank()) addView(label(labelText.uppercase(Locale.US), 11f, tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt()), true))
addView(label(props.optString("source", ""), 17f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), false))
}
"panel.dialogue" -> LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(18), dp(18), dp(18), dp(18))
background = panelBackground(tokens, 8)
addView(label(props.optString("speaker", ""), 15f, tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt()), true))
addView(label(props.optString("text", ""), 20f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), false))
}
"rail.actions" -> LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
forEachChild(children) { child ->
addView(renderCultUiNode(child, values, tokens), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
}
}
"layer.embedded-surfaces", "layer.cards" -> FrameLayout(this).apply {
forEachChild(children) { child ->
addView(renderCultUiNode(child, values, tokens), stageParams(child))
}
}
"control.button" -> label(props.optString("label", ""), 14f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), true).apply {
gravity = Gravity.CENTER
setPadding(dp(14), dp(12), dp(14), dp(12))
background = android.graphics.drawable.GradientDrawable().apply {
setColor(Color.argb(44, 255, 138, 42))
setStroke(dp(1), tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt()))
cornerRadius = dp(6).toFloat()
}
}
"surface" -> LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
forEachChild(children) { addView(renderCultUiNode(it, values, tokens)) }
}
"partition" -> LinearLayout(this).apply {
if (props.optString("role", "") == "inspector.row") {
orientation = LinearLayout.HORIZONTAL
setPadding(dp(8), dp(4), dp(8), dp(4))
background = android.graphics.drawable.GradientDrawable(
android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf(Color.rgb(32, 31, 43), Color.rgb(19, 20, 29))).apply {
setStroke(dp(1), Color.argb(16, 255, 255, 255))
cornerRadius = dp(4).toFloat()
}
val labelNode = children?.optJSONObject(0)
val fieldNode = children?.optJSONObject(1)
if (labelNode != null) {
addView(renderCultUiNode(labelNode, values, tokens), LinearLayout.LayoutParams(dp(190), ViewGroup.LayoutParams.WRAP_CONTENT))
}
if (fieldNode != null) {
addView(renderCultUiNode(fieldNode, values, tokens), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
}
return@apply
}
orientation = if (props.optString("split", "y") == "x") LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
setPadding(dp(props.optInt("padding", 0)), dp(props.optInt("padding", 0)), dp(props.optInt("padding", 0)), dp(props.optInt("padding", 0)))
forEachChild(children) { child ->
val view = renderCultUiNode(child, values, tokens)
val params = LinearLayout.LayoutParams(
if (orientation == LinearLayout.HORIZONTAL) 0 else ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
if (orientation == LinearLayout.HORIZONTAL) 1f else 0f)
val gap = dp(props.optInt("gap", 0))
params.setMargins(0, 0, if (orientation == LinearLayout.HORIZONTAL) gap else 0, if (orientation == LinearLayout.VERTICAL) gap else 0)
addView(view, params)
}
}
"pane", "panel" -> LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(props.optInt("padding", 12)), dp(props.optInt("padding", 12)), dp(props.optInt("padding", 12)), dp(props.optInt("padding", 12)))
setBackgroundColor(tokenColor(tokens, "colorPanel", Color.rgb(7, 25, 24)))
background = android.graphics.drawable.GradientDrawable().apply {
setColor(tokenColor(tokens, "colorPanel", Color.rgb(7, 25, 24)))
setStroke(dp(1), Color.argb(110, 103, 240, 228))
cornerRadius = dp(6).toFloat()
}
val title = props.optString("title", "")
if (title.isNotBlank()) addView(label(title.uppercase(Locale.US), 12f, tokenColor(tokens, "colorMuted", 0xff8ba5a3.toInt()), true))
forEachChild(children) { addView(renderCultUiNode(it, values, tokens)) }
}
"card", "card.external" -> LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(10), dp(10), dp(10), dp(10))
background = android.graphics.drawable.GradientDrawable().apply {
setColor(tokenColor(tokens, "colorPanelAlt", Color.rgb(19, 20, 29)))
setStroke(dp(1), Color.argb(24, 255, 255, 255))
cornerRadius = dp(4).toFloat()
}
val title = props.optString("title", "")
if (title.isNotBlank()) addView(label(title, 14f, tokenColor(tokens, "colorText", 0xffe7f1f1.toInt()), true))
forEachChild(children) { addView(renderCultUiNode(it, values, tokens)) }
}
"label", "text", "text.title", "text.dialogue" -> {
val bind = props.optString("bind", "")
val text = if (bind.isNotBlank()) values.opt(bind)?.toString() ?: "" else props.optString("text", "")
label(if (kind == "label") text.uppercase(Locale.US) else text, if (kind == "text.title") 18f else 13f, if (kind == "label") tokenColor(tokens, "colorAccent", 0xffffb84f.toInt()) else tokenColor(tokens, "colorText", 0xffe7f1f1.toInt()), kind == "label")
}
"control.slider" -> CultSliderView(this, sliderValue(props, values), tokenColor(tokens, "colorAccent", 0xffffb84f.toInt())).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(24))
}
else -> LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
forEachChild(children) { addView(renderCultUiNode(it, values, tokens)) }
}
}
}
private fun stageParams(node: JSONObject): FrameLayout.LayoutParams {
val kind = node.optString("kind", "")
val props = node.optJSONObject("props") ?: JSONObject()
val placement = props.optJSONObject("placement")
val anchor = placement?.optString("anchor", "") ?: ""
return when {
kind == "image.background" -> FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
kind == "embed.norn" || anchor == "whiteboard" -> FrameLayout.LayoutParams(520, 310).apply { setMargins(54, 46, 0, 0) }
kind == "embed.tex" || anchor == "whiteboard-equation" -> FrameLayout.LayoutParams(500, 94).apply { setMargins(96, 350, 0, 0) }
kind.startsWith("layer.") -> FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
else -> FrameLayout.LayoutParams(300, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.RIGHT).apply { setMargins(0, 52, 32, 0) }
}
}
private fun findChild(children: org.json.JSONArray?, kind: String): JSONObject? {
if (children == null) return null
for (index in 0 until children.length()) {
val child = children.getJSONObject(index)
if (child.optString("kind", "") == kind) return child
}
return null
}
private fun panelBackground(tokens: JSONObject, radius: Int): android.graphics.drawable.GradientDrawable {
return android.graphics.drawable.GradientDrawable().apply {
setColor(tokenColor(tokens, "colorPanel", Color.rgb(7, 25, 24)))
setStroke(dp(1), Color.argb(110, 103, 240, 228))
cornerRadius = dp(radius).toFloat()
}
}
private fun forEachChild(children: org.json.JSONArray?, block: (JSONObject) -> Unit) {
if (children == null) return
for (index in 0 until children.length()) block(children.getJSONObject(index))
}
private fun sliderValue(props: JSONObject, values: JSONObject): Double {
val min = props.optDouble("min", 0.0)
val max = props.optDouble("max", 1.0)
val bind = props.optString("bind", "")
val value = if (bind.isNotBlank()) values.optDouble(bind, min) else props.optDouble("value", min)
return if (max == min) 0.0 else ((value - min) / (max - min)).coerceIn(0.0, 1.0)
}
private fun tokenColor(tokens: JSONObject, name: String, fallback: Int): Int {
val value = tokens.optString(name, "")
if (!value.startsWith("#") || value.length != 7) return fallback
return Color.rgb(value.substring(1, 3).toInt(16), value.substring(3, 5).toInt(16), value.substring(5, 7).toInt(16))
}
override fun onDestroy() {
super.onDestroy()
mediaRunning = false
@ -622,3 +855,130 @@ class MainActivity : Activity(), SensorEventListener {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date())
}
private class CultSliderView(
context: Context,
private val value: Double,
private val accent: Int,
) : View(context) {
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(87, 0, 0, 0) }
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = accent }
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(115, Color.red(accent), Color.green(accent), Color.blue(accent))
maskFilter = android.graphics.BlurMaskFilter(7f, android.graphics.BlurMaskFilter.Blur.NORMAL)
}
private val thumbPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = accent }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredHeight = (24f * resources.displayMetrics.density + 0.5f).toInt()
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = resolveSize(desiredHeight, heightMeasureSpec)
setMeasuredDimension(width, height)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val centerY = height / 2f
val left = paddingLeft.toFloat()
val right = (width - paddingRight).toFloat()
val track = RectF(left, centerY - 3f, right, centerY + 3f)
val fillRight = left + ((right - left) * value).toFloat()
val fill = RectF(left, centerY - 3f, fillRight, centerY + 3f)
canvas.drawRoundRect(track, 2f, 2f, trackPaint)
canvas.drawRoundRect(fill, 2f, 2f, fillPaint)
canvas.drawCircle(fillRight, centerY, 10f, glowPaint)
canvas.drawCircle(fillRight, centerY, 6f, thumbPaint)
}
}
private class StageBackgroundView(
context: Context,
private val sceneLabel: String,
private val accent: Int
) : View(context) {
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(5, 8, 13) }
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(28, Color.red(accent), Color.green(accent), Color.blue(accent))
strokeWidth = 1f
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(183, 199, 217)
textSize = 14f * resources.displayMetrics.scaledDensity
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
override fun onDraw(canvas: Canvas) {
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint)
val step = 32f * resources.displayMetrics.density
var x = 0f
while (x < width) {
canvas.drawLine(x, 0f, x, height.toFloat(), gridPaint)
x += step
}
var y = 0f
while (y < height) {
canvas.drawLine(0f, y, width.toFloat(), y, gridPaint)
y += step
}
canvas.drawText(sceneLabel, 28f * resources.displayMetrics.density, 36f * resources.displayMetrics.density, textPaint)
}
}
private class NornGraphView(
context: Context,
private val graph: JSONObject,
private val tokens: JSONObject
) : View(context) {
private val density = resources.displayMetrics.density
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = colorToken("colorText", Color.rgb(246, 241, 226))
textSize = 13f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
private val edgePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(120, 183, 199, 217)
strokeWidth = 2f * density
}
override fun onDraw(canvas: Canvas) {
val nodes = graph.optJSONArray("nodes") ?: return
val edges = graph.optJSONArray("edges")
val points = mutableMapOf<String, android.graphics.PointF>()
for (index in 0 until nodes.length()) {
val node = nodes.getJSONObject(index)
val id = node.optString("id", "")
points[id] = android.graphics.PointF((node.optDouble("x", 0.5) * width).toFloat(), (node.optDouble("y", 0.5) * height).toFloat())
}
if (edges != null) {
for (index in 0 until edges.length()) {
val edge = edges.getJSONObject(index)
val source = points[edge.optString("source", "")]
val target = points[edge.optString("target", "")]
if (source != null && target != null) canvas.drawLine(source.x, source.y, target.x, target.y, edgePaint)
}
}
val nodePaint = Paint(Paint.ANTI_ALIAS_FLAG)
val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 2f * density
}
for (index in 0 until nodes.length()) {
val node = nodes.getJSONObject(index)
val point = points[node.optString("id", "")] ?: continue
val current = node.optBoolean("current", false)
nodePaint.color = if (current) colorToken("colorAccent", Color.rgb(255, 138, 42)) else Color.rgb(20, 20, 29)
strokePaint.color = if (current) colorToken("colorAccent", Color.rgb(255, 138, 42)) else Color.argb(140, 103, 240, 228)
val radius = (if (current) 24f else 20f) * density
canvas.drawCircle(point.x, point.y, radius, nodePaint)
canvas.drawCircle(point.x, point.y, radius, strokePaint)
canvas.drawText(node.optString("label", node.optString("id", "")), point.x, point.y + radius + 20f * density, textPaint)
}
}
private fun colorToken(name: String, fallback: Int): Int {
val value = tokens.optString(name, "")
if (!value.startsWith("#") || value.length != 7) return fallback
return Color.rgb(value.substring(1, 3).toInt(16), value.substring(3, 5).toInt(16), value.substring(5, 7).toInt(16))
}
}

View file

@ -375,6 +375,33 @@ Runtime lowerers must report capability gaps. If Android cannot yet render
Repixelizer's pixel-art image preview or cleanup canvas, the surface should say
that plainly instead of faking a control that cannot carry the command.
### Parity Unit Boundary
CultUI layout values are portable design units. A runtime may map them through
device scale for normal application rendering, but parity capture must make
that mapping explicit. The screenshot harness treats responsive web sizes,
Flutter test surface sizes, Android `wm size` captures, and native panel
captures as different verification layers.
Renderer defaults are forbidden writers for portable geometry. A runtime can
choose the local primitive that draws a slider, graph, card, dialogue panel, or
button, but the portable surface owns:
- component kind;
- child order;
- partition split, size, padding, gap, and alignment;
- style tokens and variants;
- element anatomy such as slider track/fill/thumb/bleed;
- embedded surface identity such as `embed.norn` or `embed.tex`;
- command identity and payload shape.
The current parity harness exposes this boundary with five fixtures:
CultUI Inspector, Repixelizer, Sai VN, Reactive DSL, and Huginn `.cc`. The
current screenshot body emits web, Windows Flutter, Linux Flutter on Nightwing,
iOS/UIKit, and Android/Kotlin captures. Fensalir Direct2D remains a named
pending target until the Eve-to-`AquariumUiDocument` adapter and Direct2D frame
capture exist.
## Style Patches
Style controls emit `style.patch`. The provider accepts, rejects, or reconciles

View file

@ -109,11 +109,13 @@ Pending runtimes are allowed. Silent fake parity is not.
## Next Cuts
1. Move the Flutter parity renderer from hardcoded inspector anatomy to the
compiled `gamecult.eve.surface.v1` fixture.
2. Wire iOS and Android capture to select a manifest fixture before capture,
instead of only launching the current app body.
3. Replace Flutter golden screenshots with native window captures once the
1. Add screenshot comparison metrics that score structure, color tokens,
bounding boxes, and text presence without pretending byte-identical pixels
are the goal.
2. Normalize text scale and font loading across web, Flutter, Android, and iOS.
3. Give iOS a real `vn.stage` scene compositor instead of compact stacked
fixture lowering.
4. Replace Flutter golden screenshots with native window captures once the
Flutter app body can emit a desktop frame without test harness help.
4. Add Direct2D frame capture once the Eve-to-`AquariumUiDocument` adapter
5. Add Direct2D frame capture once the Eve-to-`AquariumUiDocument` adapter
exists.

View file

@ -23,10 +23,10 @@ current evidence surface for semantic parity and runtime capture gaps.
| Renderer | Status | Proof | Gap |
| --- | --- | --- | --- |
| Web reference | Active reference/debug renderer | `web/` renders live VoidBot and fixture surfaces through the provider picker; compiles indentation CultUI; lowers partitions, field rows, and slider anatomy; Chrome headless capture emits phone/tablet/desktop PNGs | Needs command round-trip tests |
| Windows / Flutter | Active shared graphical smoke target | `flutter/eve_parity` renders the CultUI Inspector surface and emits phone/tablet/desktop golden PNGs through `capture-flutter-parity.ps1` | Needs lowerer fed by compiled `gamecult.eve.surface.v1` instead of hardcoded inspector widget |
| Linux / Flutter | Runner-blocked shared graphical target | Flutter Linux platform scaffold exists | Needs a Linux host/runner to emit a real Linux PNG |
| iOS / UIKit | Native proof | Renders Odin's fullscreen interface wall from `surface.root`, generic provider trees, and a custom VoidBot cockpit with avatar images; SSH capture emits PNGs | Needs provider picker, Repixelizer style token lowering, and a decision on what UIKit still owns if Flutter covers the shared graphical path |
| Android / Kotlin | Device-edge proof | APK builds, Periwinkle attaches through adb, and smoke capture emits phone/tablet/desktop PNGs via `adb shell wm size` | Needs provider picker, full surface tree rendering, style token lowering, asset image lowering, command controls, and a decision on what Kotlin still owns if Flutter covers the shared graphical path |
| Windows / Flutter | Active shared graphical smoke target | `flutter/eve_parity` loads exported `gamecult.eve.surface.v1` fixture JSON and emits phone/tablet/desktop golden PNGs through `capture-flutter-parity.ps1` | Needs native window capture and tighter text/style normalization |
| Linux / Flutter | Active shared graphical smoke target | Nightwing runs the Flutter parity goldens over SSH and returns phone/tablet/desktop PNGs | Needs native window capture and runner health checks |
| iOS / UIKit | Active native screenshot target | SSH capture stages selected fixture JSON, EveCanvas renders it, and emits a PNG from `/var/mobile/Library/EveCanvas/latest-screenshot.png` | Needs real scene composition for `vn.stage`, Repixelizer token fidelity, and responsive/simulator capture |
| Android / Kotlin | Active device screenshot target | APK builds with selected fixture assets; Periwinkle captures phone/tablet/desktop plus native portrait/landscape PNGs through adb | Needs text scale/font normalization, image lowering, and command controls |
| Fensalir Direct2D | Specialized native/game-runtime landing zone | Existing `AquariumUiDocument` and `DirectWriteOverlay` path | Needs adapter from Eve surface document to `AquariumUiDocument`, DirectWrite/Direct2D token lowering, and a clear reason to stay separate from Flutter for desktop UI |
## Spawned Surfaces

View file

@ -1,131 +1,54 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const EveParityApp());
}
class EveParityApp extends StatelessWidget {
const EveParityApp({super.key});
class EveParityApp extends StatefulWidget {
const EveParityApp({super.key, this.assetPath = 'assets/current-surface.json'});
final String assetPath;
@override
State<EveParityApp> createState() => _EveParityAppState();
}
class _EveParityAppState extends State<EveParityApp> {
late Future<EveSurfaceState> surface;
@override
void initState() {
super.initState();
surface = EveSurfaceState.load(widget.assetPath);
}
@override
Widget build(BuildContext context) {
return const MaterialApp(
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
backgroundColor: Color(0xFF020909),
body: SafeArea(
child: Padding(
padding: EdgeInsets.all(16),
child: CultUiInspectorSurface(),
),
),
),
);
}
}
class CultUiInspectorSurface extends StatelessWidget {
const CultUiInspectorSurface({super.key});
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1180),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xEE071918),
border: Border.all(color: const Color(0x4767F0E4)),
borderRadius: BorderRadius.circular(6),
boxShadow: const [
BoxShadow(color: Color(0x59000000), blurRadius: 26, offset: Offset(0, 12)),
],
),
child: const Padding(
padding: EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AETHERIC FIELD TESTER',
style: TextStyle(
color: Color(0xFF8BA5A3),
fontFamily: 'monospace',
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 10),
InspectorSliderRow(label: 'FOV', value: 38 / 120),
SizedBox(height: 6),
InspectorSliderRow(label: 'THROTTLE DECAY', value: 5 / 20),
SizedBox(height: 6),
InspectorSliderRow(label: 'FLOW SPEED', value: 2 / 10),
],
home: FutureBuilder<EveSurfaceState>(
future: surface,
builder: (context, snapshot) {
final state = snapshot.data;
final tokens = state?.tokens ?? EveTokens.defaults;
return Scaffold(
backgroundColor: tokens.background,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: snapshot.hasError
? Text(
'Eve surface load failed\n${snapshot.error}',
style: const TextStyle(color: Color(0xFFFFB84F), fontFamily: 'monospace'),
)
: state == null
? const SizedBox.shrink()
: EveSurfaceView(state: state),
),
),
),
),
),
);
}
}
class InspectorSliderRow extends StatelessWidget {
const InspectorSliderRow({
required this.label,
required this.value,
super.key,
});
final String label;
final double value;
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(minHeight: 34),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xF5201F2B), Color(0xF513141D)],
),
border: Border.all(color: const Color(0x10FFFFFF)),
borderRadius: BorderRadius.circular(4),
boxShadow: const [
BoxShadow(color: Color(0x66000000), blurRadius: 8, offset: Offset(0, 2)),
],
),
child: LayoutBuilder(
builder: (context, constraints) {
final labelText = Text(
label,
style: const TextStyle(
color: Color(0xFFFFB84F),
fontFamily: 'monospace',
fontSize: 13,
fontWeight: FontWeight.w700,
),
);
if (constraints.maxWidth < 520) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
labelText,
const SizedBox(height: 4),
CultUiSlider(value: value),
],
);
}
return Row(
children: [
SizedBox(width: 190, child: labelText),
Expanded(child: CultUiSlider(value: value)),
],
);
},
),
@ -133,53 +56,744 @@ class InspectorSliderRow extends StatelessWidget {
}
}
class CultUiSlider extends StatelessWidget {
const CultUiSlider({required this.value, super.key});
class EveSurfaceState {
EveSurfaceState({
required this.providerId,
required this.title,
required this.root,
required this.tokens,
required this.controlSkins,
required this.values,
});
final double value;
final String providerId;
final String title;
final EveNode root;
final EveTokens tokens;
final Map<String, List<EveNode>> controlSkins;
final Map<String, dynamic> values;
static Future<EveSurfaceState> load(String assetPath) async {
final raw = await rootBundle.loadString(assetPath);
final json = jsonDecode(raw) as Map<String, dynamic>;
final surface = json['surface'] as Map<String, dynamic>;
final styles = (surface['styles'] as Map<String, dynamic>?) ?? const {};
final skins = <String, List<EveNode>>{};
final rawSkins = (styles['controlSkins'] as Map<String, dynamic>?) ?? const {};
for (final entry in rawSkins.entries) {
final skin = entry.value as Map<String, dynamic>;
skins[entry.key] = ((skin['children'] as List<dynamic>?) ?? const [])
.map((child) => EveNode.fromJson(child as Map<String, dynamic>))
.toList();
}
return EveSurfaceState(
providerId: (json['providerId'] ?? '').toString(),
title: (json['title'] ?? '').toString(),
root: EveNode.fromJson(surface['root'] as Map<String, dynamic>),
tokens: EveTokens.fromJson((styles['tokens'] as Map<String, dynamic>?) ?? const {}),
controlSkins: skins,
values: (json['values'] as Map<String, dynamic>?) ?? const {},
);
}
}
class EveNode {
EveNode({
required this.id,
required this.kind,
required this.props,
required this.layout,
required this.children,
});
final String id;
final String kind;
final Map<String, dynamic> props;
final Map<String, dynamic> layout;
final List<EveNode> children;
factory EveNode.fromJson(Map<String, dynamic> json) {
return EveNode(
id: (json['id'] ?? '').toString(),
kind: (json['kind'] ?? 'panel').toString(),
props: (json['props'] as Map<String, dynamic>?) ?? const {},
layout: (json['layout'] as Map<String, dynamic>?) ?? const {},
children: ((json['children'] as List<dynamic>?) ?? const [])
.map((child) => EveNode.fromJson(child as Map<String, dynamic>))
.toList(),
);
}
}
class EveTokens {
const EveTokens({
required this.background,
required this.panel,
required this.panelAlt,
required this.panelInset,
required this.border,
required this.text,
required this.muted,
required this.accent,
});
final Color background;
final Color panel;
final Color panelAlt;
final Color panelInset;
final Color border;
final Color text;
final Color muted;
final Color accent;
static const defaults = EveTokens(
background: Color(0xFF020909),
panel: Color(0xFF071918),
panelAlt: Color(0xFF15151D),
panelInset: Color(0x57000000),
border: Color(0x4767F0E4),
text: Color(0xFFE7F1F1),
muted: Color(0xFF8BA5A3),
accent: Color(0xFFFFB84F),
);
factory EveTokens.fromJson(Map<String, dynamic> json) {
return EveTokens(
background: _color(json['colorBackground'], defaults.background),
panel: _color(json['colorPanel'], defaults.panel),
panelAlt: _color(json['colorPanelAlt'], defaults.panelAlt),
panelInset: _color(json['colorPanelInset'], defaults.panelInset),
border: _color(json['colorPanelBorder'], defaults.border),
text: _color(json['colorText'], defaults.text),
muted: _color(json['colorMuted'], defaults.muted),
accent: _color(json['colorAccent'], defaults.accent),
);
}
static Color _color(Object? value, Color fallback) {
if (value is! String) return fallback;
final hex = value.trim();
if (!hex.startsWith('#')) return fallback;
final body = hex.substring(1);
if (body.length == 6) return Color(int.parse('ff$body', radix: 16));
if (body.length == 8) return Color(int.parse(body, radix: 16));
return fallback;
}
}
class EveSurfaceView extends StatelessWidget {
const EveSurfaceView({required this.state, super.key});
final EveSurfaceState state;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _SliderPainter(value.clamp(0, 1)),
child: const SizedBox(height: 22, width: double.infinity),
);
return EveNodeView(state: state, node: state.root);
}
}
class _SliderPainter extends CustomPainter {
_SliderPainter(this.value);
class EveNodeView extends StatelessWidget {
const EveNodeView({required this.state, required this.node, super.key});
final double value;
final EveSurfaceState state;
final EveNode node;
@override
Widget build(BuildContext context) {
switch (node.kind) {
case 'surface':
return _surface();
case 'partition':
return _partition();
case 'pane':
case 'panel':
return _pane();
case 'card':
case 'card.external':
return _card();
case 'label':
case 'text':
case 'text.title':
case 'text.dialogue':
return _text();
case 'control.slider':
return EveSlider(state: state, node: node);
case 'control.button':
return _button();
case 'vn.stage':
return _vnStage();
case 'image.background':
return _background();
case 'embed.norn':
return _norn();
case 'embed.tex':
return _tex();
case 'panel.dialogue':
return _dialogue();
case 'rail.actions':
return _actions();
default:
return _fallback();
}
}
Widget _vnStage() {
final background = node.children.where((child) => child.kind == 'image.background').firstOrNull;
final sceneChildren = node.children.where((child) {
return child.kind != 'image.background' && child.kind != 'panel.dialogue' && child.kind != 'rail.actions';
}).toList();
final dialogue = node.children.where((child) => child.kind == 'panel.dialogue').firstOrNull;
final actions = node.children.where((child) => child.kind == 'rail.actions').firstOrNull;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight.isFinite ? constraints.maxHeight : width * 0.56;
return SizedBox(
width: width,
height: height.clamp(520, 900),
child: Stack(
children: [
Positioned.fill(
child: background == null
? DecoratedBox(decoration: BoxDecoration(color: state.tokens.background))
: EveNodeView(state: state, node: background),
),
for (final child in sceneChildren) _stageOverlay(child),
if (dialogue != null)
Positioned(
left: 28,
right: 28,
bottom: actions == null ? 24 : 96,
child: EveNodeView(state: state, node: dialogue),
),
if (actions != null)
Positioned(left: 28, right: 28, bottom: 24, child: EveNodeView(state: state, node: actions)),
],
),
);
},
);
}
Positioned _stageOverlay(EveNode child) {
if (child.kind == 'layer.embedded-surfaces' || child.kind == 'layer.cards') {
return Positioned.fill(
child: IgnorePointer(
child: Stack(children: [for (final grandchild in child.children) _stageOverlay(grandchild)]),
),
);
}
final placement = _map(child.props['placement']);
final anchor = placement['anchor']?.toString();
if (child.kind == 'embed.norn' || anchor == 'whiteboard') {
return Positioned(left: 54, top: 46, width: 520, height: 310, child: EveNodeView(state: state, node: child));
}
if (child.kind == 'embed.tex' || anchor == 'whiteboard-equation') {
return Positioned(left: 96, top: 350, width: 500, height: 94, child: EveNodeView(state: state, node: child));
}
return Positioned(right: 32, top: 52, width: 300, child: EveNodeView(state: state, node: child));
}
Widget _background() {
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.background,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [state.tokens.panelAlt, state.tokens.background, const Color(0xFF10161F)],
),
),
child: Stack(
children: [
Positioned.fill(
child: CustomPaint(painter: EveGridPainter(color: state.tokens.accent.withValues(alpha: 0.10))),
),
Positioned(
left: 28,
top: 24,
child: Text(
node.props['label']?.toString() ?? 'Scene',
style: TextStyle(
color: state.tokens.muted,
fontFamily: 'EveParity',
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
Widget _norn() {
final graph = _map(node.props['graph']);
final nodes = (graph['nodes'] as List<dynamic>?) ?? const [];
final edges = (graph['edges'] as List<dynamic>?) ?? const [];
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panel.withValues(alpha: 0.82),
border: Border.all(color: state.tokens.border),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: CustomPaint(
painter: EveNornPainter(nodes: nodes, edges: edges, tokens: state.tokens),
child: const SizedBox.expand(),
),
),
);
}
Widget _tex() {
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panel.withValues(alpha: 0.88),
border: Border.all(color: state.tokens.border),
borderRadius: BorderRadius.circular(5),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (node.props['label'] != null)
Text(
node.props['label'].toString().toUpperCase(),
style: TextStyle(
color: state.tokens.accent,
fontFamily: 'EveParity',
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
Text(
node.props['source']?.toString() ?? '',
style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 18),
),
],
),
),
);
}
Widget _dialogue() {
final speaker = node.props['speaker']?.toString() ?? '';
final text = node.props['text']?.toString() ?? '';
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panel.withValues(alpha: 0.94),
border: Border.all(color: state.tokens.border),
borderRadius: BorderRadius.circular(8),
boxShadow: const [BoxShadow(color: Color(0x8A000000), blurRadius: 24, offset: Offset(0, 10))],
),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
speaker,
style: TextStyle(
color: state.tokens.accent,
fontFamily: 'EveParity',
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(text, style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 20)),
],
),
),
);
}
Widget _actions() {
return Row(
children: [
for (var index = 0; index < node.children.length; index += 1) ...[
Expanded(child: EveNodeView(state: state, node: node.children[index])),
if (index != node.children.length - 1) const SizedBox(width: 10),
],
],
);
}
Widget _button() {
final label = node.props['label']?.toString() ?? node.props['text']?.toString() ?? '';
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.accent.withValues(alpha: 0.17),
border: Border.all(color: state.tokens.accent.withValues(alpha: 0.64)),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
color: state.tokens.text,
fontFamily: 'EveParity',
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
);
}
Widget _surface() {
final padding = _number(node.layout['padding'] ?? node.props['padding'], 0);
return Padding(
padding: EdgeInsets.all(padding),
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 900 && node.children.length > 1) {
return _children(axis: Axis.horizontal, maxWidth: 1280);
}
return _children(axis: Axis.vertical, maxWidth: 1180);
},
),
);
}
Widget _partition() {
final split = (node.props['split'] ?? _layoutDirection()).toString();
final gap = _number(node.props['gap'] ?? node.layout['gap'], 0);
final padding = _number(node.props['padding'] ?? node.layout['padding'], 0);
final body = _children(axis: split == 'x' ? Axis.horizontal : Axis.vertical);
return Padding(
padding: EdgeInsets.all(padding),
child: split == 'x'
? LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 520 && node.props['role'] == 'inspector.row') {
return _column(gap);
}
return body;
},
)
: body,
);
}
Widget _pane() {
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panel,
border: Border.all(color: state.tokens.border),
borderRadius: BorderRadius.circular(6),
boxShadow: const [
BoxShadow(color: Color(0x59000000), blurRadius: 26, offset: Offset(0, 12)),
],
),
child: Padding(
padding: EdgeInsets.all(_number(node.props['padding'], 12)),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (node.props['title'] != null)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
node.props['title'].toString().toUpperCase(),
style: TextStyle(
color: state.tokens.muted,
fontFamily: 'EveParity',
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
..._spacedChildren(Axis.vertical, _number(node.props['gap'], 0)),
],
),
),
);
}
Widget _card() {
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panelAlt,
border: Border.all(color: const Color(0x10FFFFFF)),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (node.props['title'] != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
node.props['title'].toString(),
style: TextStyle(
color: state.tokens.muted,
fontFamily: 'EveParity',
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
_children(axis: Axis.vertical),
],
),
),
);
}
Widget _text() {
final bind = node.props['bind']?.toString();
final value = bind == null ? node.props['text'] : state.values[bind];
final text = '${node.props['prefix'] ?? ''}${value ?? ''}${node.props['suffix'] ?? ''}';
return Text(
node.kind == 'label' ? text.toUpperCase() : text,
style: TextStyle(
color: node.kind == 'label' ? state.tokens.accent : state.tokens.text,
fontFamily: 'EveParity',
fontSize: node.kind == 'text.title' ? 18 : 13,
fontWeight: node.kind == 'label' ? FontWeight.w700 : FontWeight.w500,
),
);
}
Widget _fallback() {
return node.children.isEmpty ? const SizedBox.shrink() : _children(axis: Axis.vertical);
}
Widget _children({required Axis axis, double? maxWidth}) {
final gap = _number(node.props['gap'], 0);
final childWidgets = _spacedChildren(axis, gap);
final content = axis == Axis.horizontal
? Row(crossAxisAlignment: CrossAxisAlignment.center, children: childWidgets)
: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: childWidgets);
final constrained = maxWidth == null
? content
: Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(constraints: BoxConstraints(maxWidth: maxWidth), child: content),
);
return constrained;
}
Widget _column(double gap) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _spacedChildren(Axis.vertical, gap),
);
}
List<Widget> _spacedChildren(Axis axis, double gap) {
final widgets = <Widget>[];
for (var index = 0; index < node.children.length; index += 1) {
final child = node.children[index];
Widget view = EveNodeView(state: state, node: child);
if (axis == Axis.horizontal && _isFlexible(child)) {
view = Expanded(child: view);
}
widgets.add(view);
if (gap > 0 && index != node.children.length - 1) {
widgets.add(axis == Axis.horizontal ? SizedBox(width: gap) : SizedBox(height: gap));
}
}
return widgets;
}
bool _isFlexible(EveNode child) {
final size = child.props['size']?.toString();
return size == null || size.endsWith('fr');
}
String _layoutDirection() {
final direction = node.layout['direction']?.toString();
if (direction == 'horizontal') {
return 'x';
}
return 'y';
}
Map<String, dynamic> _map(Object? value) => value is Map<String, dynamic> ? value : const {};
}
class EveGridPainter extends CustomPainter {
EveGridPainter({required this.color});
final Color color;
@override
void paint(Canvas canvas, Size size) {
const accent = Color(0xFFFFB84F);
final centerY = size.height / 2;
final track = RRect.fromRectAndRadius(
Rect.fromLTWH(0, centerY - 3, size.width, 6),
const Radius.circular(2),
);
final fill = RRect.fromRectAndRadius(
Rect.fromLTWH(0, centerY - 3, size.width * value, 6),
const Radius.circular(2),
);
final trackPaint = Paint()..color = const Color(0x57000000);
final fillPaint = Paint()..color = accent;
final thumbPaint = Paint()
..color = accent
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
final thumbCorePaint = Paint()..color = accent;
canvas.drawRRect(track, trackPaint);
canvas.drawRRect(fill, fillPaint);
final thumbCenter = Offset(size.width * value, centerY);
canvas.drawCircle(thumbCenter, 7, thumbPaint);
canvas.drawCircle(thumbCenter, 6, thumbCorePaint);
final paint = Paint()..color = color;
for (var x = 0.0; x < size.width; x += 32) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
for (var y = 0.0; y < size.height; y += 32) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
@override
bool shouldRepaint(covariant _SliderPainter oldDelegate) {
return oldDelegate.value != value;
bool shouldRepaint(EveGridPainter oldDelegate) => oldDelegate.color != color;
}
class EveNornPainter extends CustomPainter {
EveNornPainter({required this.nodes, required this.edges, required this.tokens});
final List<dynamic> nodes;
final List<dynamic> edges;
final EveTokens tokens;
@override
void paint(Canvas canvas, Size size) {
final nodeById = <String, Offset>{};
for (final raw in nodes) {
if (raw is! Map<String, dynamic>) continue;
final id = raw['id']?.toString();
if (id == null) continue;
nodeById[id] = Offset(_number(raw['x'], 0.5) * size.width, _number(raw['y'], 0.5) * size.height);
}
final edgePaint = Paint()
..color = tokens.muted.withValues(alpha: 0.5)
..strokeWidth = 2;
for (final raw in edges) {
if (raw is! Map<String, dynamic>) continue;
final source = nodeById[raw['source']?.toString()];
final target = nodeById[raw['target']?.toString()];
if (source != null && target != null) {
canvas.drawLine(source, target, edgePaint);
}
}
for (final raw in nodes) {
if (raw is! Map<String, dynamic>) continue;
final id = raw['id']?.toString();
final center = id == null ? null : nodeById[id];
if (center == null) continue;
final current = raw['current'] == true;
canvas.drawCircle(center, current ? 24 : 20, Paint()..color = current ? tokens.accent : tokens.panelAlt);
canvas.drawCircle(
center,
current ? 24 : 20,
Paint()
..color = current ? tokens.accent : tokens.border
..style = PaintingStyle.stroke
..strokeWidth = 2,
);
final label = raw['label']?.toString() ?? id;
final paragraph = TextPainter(
text: TextSpan(
text: label,
style: TextStyle(color: tokens.text, fontFamily: 'EveParity', fontSize: 13, fontWeight: FontWeight.w700),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: 100);
paragraph.paint(canvas, center + Offset(-paragraph.width / 2, 30));
}
}
@override
bool shouldRepaint(EveNornPainter oldDelegate) => oldDelegate.nodes != nodes || oldDelegate.edges != edges;
}
class EveSlider extends StatelessWidget {
const EveSlider({required this.state, required this.node, super.key});
final EveSurfaceState state;
final EveNode node;
@override
Widget build(BuildContext context) {
final bind = node.props['bind']?.toString();
final min = _number(node.props['min'], 0);
final max = _number(node.props['max'], 1);
final rawValue = bind == null ? node.props['value'] : state.values[bind];
final value = _number(rawValue, min).clamp(min, max);
final percent = max == min ? 0.0 : ((value - min) / (max - min)).clamp(0.0, 1.0);
final skin = state.controlSkins[node.props['skin']?.toString()] ?? const [];
final box = skin.where((part) => part.kind == 'control.box').firstOrNull?.props ?? const {};
return CustomPaint(
painter: EveSliderPainter(
value: percent,
tokens: state.tokens,
skin: skin,
),
child: SizedBox(
height: _number(box['height'], 22).clamp(18, 36),
width: double.infinity,
),
);
}
}
class EveSliderPainter extends CustomPainter {
EveSliderPainter({required this.value, required this.tokens, required this.skin});
final double value;
final EveTokens tokens;
final List<EveNode> skin;
@override
void paint(Canvas canvas, Size size) {
final centerY = size.height / 2;
final trackHeight = _partSize('track', 1, 6);
final thumbSize = _partSize('thumb', 0, 12);
final track = RRect.fromRectAndRadius(
Rect.fromLTWH(0, centerY - trackHeight / 2, size.width, trackHeight),
Radius.circular(_partRadius('track', 2)),
);
final fill = RRect.fromRectAndRadius(
Rect.fromLTWH(0, centerY - trackHeight / 2, size.width * value, trackHeight),
Radius.circular(_partRadius('fill', 2)),
);
canvas.drawRRect(track, Paint()..color = tokens.panelInset);
canvas.drawRRect(fill, Paint()..color = tokens.accent);
final thumbCenter = Offset(size.width * value, centerY);
canvas.drawCircle(
thumbCenter,
thumbSize / 2 + _partBleed('thumb', 0),
Paint()
..color = tokens.accent.withValues(alpha: 0.35)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 5),
);
canvas.drawCircle(thumbCenter, thumbSize / 2, Paint()..color = tokens.accent);
}
double _partSize(String name, int index, double fallback) {
final part = _part(name);
final size = part?['size'];
if (size is List && size.length > index) return _number(size[index], fallback);
return fallback;
}
double _partRadius(String name, double fallback) => _number(_part(name)?['radius'], fallback);
double _partBleed(String name, double fallback) => _number(_part(name)?['bleed'], fallback);
Map<String, dynamic>? _part(String name) {
for (final node in skin) {
if (node.kind == 'control.part' && node.props['name'] == name) return node.props;
}
return null;
}
@override
bool shouldRepaint(covariant EveSliderPainter oldDelegate) {
return oldDelegate.value != value || oldDelegate.tokens != tokens || oldDelegate.skin != skin;
}
}
double _number(Object? value, double fallback) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value) ?? fallback;
return fallback;
}

View file

@ -51,6 +51,16 @@ flutter:
# the material Icons class.
uses-material-design: true
assets:
- assets/current-surface.json
fonts:
- family: EveParity
fonts:
- asset: assets/fonts/Roboto-Regular.ttf
- asset: assets/fonts/Roboto-Bold.ttf
weight: 700
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

View file

@ -1,22 +1,44 @@
import 'package:eve_parity/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
final regular = FontLoader('EveParity')
..addFont(rootBundle.load('assets/fonts/Roboto-Regular.ttf'))
..addFont(rootBundle.load('assets/fonts/Roboto-Bold.ttf'));
await regular.load();
});
for (final viewport in _viewports) {
testWidgets('renders CultUI inspector parity fixture at ${viewport.id}', (tester) async {
testWidgets('renders selected Eve parity fixture at ${viewport.id}', (tester) async {
await tester.binding.setSurfaceSize(Size(viewport.width, viewport.height));
await tester.pumpWidget(const EveParityApp());
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(EveParityApp(key: ValueKey(viewport.id)));
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
await tester.pumpAndSettle();
expect(find.byType(EveSurfaceView), findsOneWidget);
if (fixtureId == 'cultui-inspector') {
expect(find.text('AETHERIC FIELD TESTER'), findsOneWidget);
expect(find.text('FOV'), findsOneWidget);
expect(find.text('THROTTLE DECAY'), findsOneWidget);
expect(find.text('FLOW SPEED'), findsOneWidget);
}
await expectLater(
find.byType(EveParityApp),
matchesGoldenFile('goldens/cultui-inspector-${viewport.id}.png'),
matchesGoldenFile('goldens/$fixtureId-${viewport.id}.png'),
);
});
}
}
const fixtureId = String.fromEnvironment('EVE_PARITY_FIXTURE', defaultValue: 'cultui-inspector');
const _viewports = [
_Viewport('phone', 390, 844),
_Viewport('tablet', 768, 1024),

View file

@ -94,6 +94,9 @@ if ($kotlinSources.Count -gt 0) {
}
Invoke-Checked $jar @("cf", $classesJar, "-C", $classes, ".")
Invoke-Checked $d8 @("--lib", $androidJar, "--output", $dex, $classesJar, $cultMeshJar, $kotlinStdlib)
if (Test-Path (Join-Path $androidRoot "src\main\assets")) {
Invoke-Checked $jar @("uf", $unsigned, "-C", (Join-Path $androidRoot "src\main"), "assets")
}
Invoke-Checked $jar @("uf", $unsigned, "-C", $dex, "classes.dex")
Invoke-Checked $zipalign @("-f", "4", $unsigned, $aligned)

View file

@ -8,6 +8,7 @@ param(
[int] $Height = 0,
[ValidateSet("portrait", "landscape", "natural")]
[string] $Orientation = "natural",
[switch] $UseFixture,
[switch] $ForceInstall
)
@ -61,7 +62,11 @@ try {
}
}
adb shell am start -n "$PackageName/$ActivityName" | Out-Host
$launchArgs = @("shell", "am", "start", "-n", "$PackageName/$ActivityName")
if ($UseFixture) {
$launchArgs += @("--ez", "org.gamecult.eve.PARITY_FIXTURE", "true")
}
adb @launchArgs | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "adb launch failed with exit code $LASTEXITCODE"
}

View file

@ -1,7 +1,8 @@
param(
[string] $Target = "eve",
[string] $OutputDirectory = "artifacts\screenshots",
[string] $BundleId = "org.gamecult.evecanvas"
[string] $BundleId = "org.gamecult.evecanvas",
[string] $FixturePath = ""
)
$ErrorActionPreference = "Stop"
@ -19,10 +20,17 @@ $localPath = Join-Path $outputRoot "eve-$stamp.png"
$remoteDir = "/var/mobile/Library/EveCanvas"
$remotePath = "$remoteDir/latest-screenshot.png"
$requestPath = "$remoteDir/capture-request"
$remoteFixturePath = "$remoteDir/current-surface.json"
ssh $Target "uiopen --bundleid '$BundleId' >/dev/null 2>&1 || true"
Start-Sleep -Seconds 1
ssh $Target "mkdir -p '$remoteDir' && chown mobile:mobile '$remoteDir' && rm -f '$remotePath' '$requestPath' && touch '$requestPath' && chown mobile:mobile '$requestPath' 2>/dev/null || true"
ssh $Target "mkdir -p '$remoteDir' && chown mobile:mobile '$remoteDir' && rm -f '$remotePath' '$requestPath' && chown mobile:mobile '$remoteDir' 2>/dev/null || true"
if ($FixturePath) {
scp $FixturePath "${Target}:$remoteFixturePath" | Out-Host
} else {
ssh $Target "rm -f '$remoteFixturePath'"
}
ssh $Target "touch '$requestPath' && chown mobile:mobile '$requestPath' 2>/dev/null || true"
$deadline = (Get-Date).AddSeconds(10)
do {

View file

@ -3,6 +3,7 @@ param(
[string] $Target = "windows",
[ValidateSet("phone", "tablet", "desktop")]
[string] $ViewportId = "desktop",
[string] $FixtureId = "cultui-inspector",
[string] $OutputPath = "artifacts\parity\flutter-windows-cultui-inspector.png"
)
@ -11,8 +12,12 @@ $ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $PSScriptRoot
$flutterRoot = Join-Path $projectRoot "flutter\eve_parity"
$flutter = Join-Path $projectRoot "tools\deps\flutter\bin\flutter.bat"
$assetPath = Join-Path $flutterRoot "assets\current-surface.json"
$fontDir = Join-Path $flutterRoot "assets\fonts"
$absoluteOutput = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path $projectRoot $OutputPath }
New-Item -ItemType Directory -Force (Split-Path -Parent $absoluteOutput) | Out-Null
New-Item -ItemType Directory -Force (Split-Path -Parent $assetPath) | Out-Null
New-Item -ItemType Directory -Force $fontDir | Out-Null
if (-not (Test-Path $flutter)) {
throw "Flutter is not installed at $flutter"
@ -22,9 +27,17 @@ if ($Target -eq "linux" -and $env:OS -like "Windows*") {
throw "Linux Flutter screenshot requires a Linux runner; this host is Windows."
}
Copy-Item -LiteralPath (Join-Path $projectRoot "tools\deps\flutter\bin\cache\artifacts\material_fonts\roboto-regular.ttf") -Destination (Join-Path $fontDir "Roboto-Regular.ttf") -Force
Copy-Item -LiteralPath (Join-Path $projectRoot "tools\deps\flutter\bin\cache\artifacts\material_fonts\roboto-bold.ttf") -Destination (Join-Path $fontDir "Roboto-Bold.ttf") -Force
node .\tools\parity\export-fixture.mjs $FixtureId $assetPath | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "Fixture export failed with exit code $LASTEXITCODE"
}
Push-Location $flutterRoot
try {
& $flutter test --update-goldens
& $flutter test --update-goldens "--dart-define=EVE_PARITY_FIXTURE=$FixtureId" --plain-name $ViewportId
if ($LASTEXITCODE -ne 0) {
throw "Flutter golden smoke failed with exit code $LASTEXITCODE"
}
@ -32,7 +45,7 @@ try {
Pop-Location
}
$golden = Join-Path $flutterRoot "test\goldens\cultui-inspector-$ViewportId.png"
$golden = Join-Path $flutterRoot "test\goldens\$FixtureId-$ViewportId.png"
if (-not (Test-Path $golden) -or (Get-Item $golden).Length -le 0) {
throw "Flutter golden did not produce a PNG: $golden"
}

View file

@ -2,6 +2,7 @@ param(
[string] $SshTarget = "nightwing",
[ValidateSet("phone", "tablet", "desktop")]
[string] $ViewportId = "desktop",
[string] $FixtureId = "cultui-inspector",
[string] $OutputPath = "artifacts\parity\linux-flutter-cultui-inspector.png",
[string] $RemoteRoot = "~/.local/share/gamecult/eve-parity-runner",
[string] $RemoteFlutter = "~/.local/share/gamecult/flutter/bin/flutter"
@ -11,14 +12,25 @@ $ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $PSScriptRoot
$sourceRoot = Join-Path $projectRoot "flutter\eve_parity"
$assetPath = Join-Path $sourceRoot "assets\current-surface.json"
$fontDir = Join-Path $sourceRoot "assets\fonts"
$absoluteOutput = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path $projectRoot $OutputPath }
$archivePath = Join-Path ([System.IO.Path]::GetTempPath()) "eve-parity-linux-$([guid]::NewGuid()).tar"
New-Item -ItemType Directory -Force (Split-Path -Parent $absoluteOutput) | Out-Null
New-Item -ItemType Directory -Force (Split-Path -Parent $assetPath) | Out-Null
New-Item -ItemType Directory -Force $fontDir | Out-Null
if (-not (Test-Path $sourceRoot)) {
throw "Flutter parity source not found: $sourceRoot"
}
node .\tools\parity\export-fixture.mjs $FixtureId $assetPath | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "Fixture export failed with exit code $LASTEXITCODE"
}
Copy-Item -LiteralPath (Join-Path $projectRoot "tools\deps\flutter\bin\cache\artifacts\material_fonts\roboto-regular.ttf") -Destination (Join-Path $fontDir "Roboto-Regular.ttf") -Force
Copy-Item -LiteralPath (Join-Path $projectRoot "tools\deps\flutter\bin\cache\artifacts\material_fonts\roboto-bold.ttf") -Destination (Join-Path $fontDir "Roboto-Bold.ttf") -Force
try {
Push-Location $sourceRoot
try {
@ -33,12 +45,12 @@ try {
ssh $SshTarget "rm -rf $RemoteRoot && mkdir -p $RemoteRoot"
scp $archivePath "${SshTarget}:/tmp/eve-parity-linux.tar" | Out-Host
ssh $SshTarget "tar -xf /tmp/eve-parity-linux.tar -C $RemoteRoot && rm /tmp/eve-parity-linux.tar"
ssh $SshTarget "cd $RemoteRoot && $RemoteFlutter test --update-goldens" | Out-Host
ssh $SshTarget "cd $RemoteRoot && $RemoteFlutter test --update-goldens --dart-define=EVE_PARITY_FIXTURE=$FixtureId --plain-name $ViewportId" | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "Nightwing Flutter golden smoke failed with exit code $LASTEXITCODE"
}
$remoteGolden = "$RemoteRoot/test/goldens/cultui-inspector-$ViewportId.png"
$remoteGolden = "$RemoteRoot/test/goldens/$FixtureId-$ViewportId.png"
scp "${SshTarget}:$remoteGolden" $absoluteOutput | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "scp from Nightwing failed with exit code $LASTEXITCODE"

View file

@ -74,9 +74,19 @@ try {
}
Invoke-Capture "ios" {
$iosFixture = Join-Path $runRoot "ios-current-surface.json"
node .\tools\parity\export-fixture.mjs $ProviderId $iosFixture | Out-Host
powershell -ExecutionPolicy Bypass -File .\scripts\capture-eve-screenshot.ps1 `
-Target $IosSshTarget `
-OutputDirectory $runRoot
-OutputDirectory $runRoot `
-FixturePath $iosFixture
}
New-Item -ItemType Directory -Force .\android\app\src\main\assets | Out-Null
node .\tools\parity\export-fixture.mjs $ProviderId .\android\app\src\main\assets\current-surface.json | Out-Host
powershell -ExecutionPolicy Bypass -File .\scripts\build-android.ps1 | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "Android fixture APK build failed with exit code $LASTEXITCODE"
}
foreach ($case in $responsiveCases) {
@ -85,7 +95,9 @@ try {
-OutputPath (Join-Path $runRoot "android-periwinkle-$($case.id).png") `
-Width $case.width `
-Height $case.height `
-Orientation $case.orientation
-Orientation $case.orientation `
-UseFixture `
-ForceInstall
}
}
@ -93,7 +105,8 @@ try {
Invoke-Capture "android-$($case.id)" {
powershell -ExecutionPolicy Bypass -File .\scripts\capture-android-screenshot.ps1 `
-OutputPath (Join-Path $runRoot "android-periwinkle-$($case.id).png") `
-Orientation $case.orientation
-Orientation $case.orientation `
-UseFixture
}
}
@ -101,8 +114,9 @@ try {
Invoke-Capture "windows-$($case.id)" {
powershell -ExecutionPolicy Bypass -File .\scripts\capture-flutter-parity.ps1 `
-Target windows `
-FixtureId $ProviderId `
-ViewportId $case.id `
-OutputPath (Join-Path $runRoot "windows-flutter-cultui-inspector-$($case.id).png")
-OutputPath (Join-Path $runRoot "windows-flutter-$ProviderId-$($case.id).png")
}
}
@ -110,8 +124,9 @@ try {
Invoke-Capture "linux-$($case.id)" {
powershell -ExecutionPolicy Bypass -File .\scripts\capture-linux-flutter-parity.ps1 `
-SshTarget $LinuxSshTarget `
-FixtureId $ProviderId `
-ViewportId $case.id `
-OutputPath (Join-Path $runRoot "linux-flutter-cultui-inspector-$($case.id).png")
-OutputPath (Join-Path $runRoot "linux-flutter-$ProviderId-$($case.id).png")
}
}

View file

@ -15,6 +15,13 @@ if (Test-Path $archive) {
tar `
--exclude=.git `
--exclude=.theos `
--exclude=android `
--exclude=artifacts `
--exclude=docs `
--exclude=flutter `
--exclude=tools `
--exclude=web `
--exclude=.voidbot `
--exclude=packages `
-cf $archive `
-C $projectRoot .

View file

@ -0,0 +1,36 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { compileEveDsl } from "../../web/eve-dsl.js";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const manifest = JSON.parse(await readFile(path.join(repoRoot, "tools/parity/parity-manifest.json"), "utf8"));
const fixtureId = process.argv[2] || "cultui-inspector";
const outputPath = process.argv[3]
? path.resolve(process.argv[3])
: path.join(repoRoot, "artifacts/parity/fixtures", `${fixtureId}.surface.json`);
const fixture = manifest.fixtures.find(candidate =>
candidate.id === fixtureId || candidate.expect?.providerId === fixtureId);
if (!fixture) {
throw new Error(`Unknown parity fixture: ${fixtureId}`);
}
const surface = await loadSurface(fixture.surface);
surface.fixtureId = fixture.id;
surface.values ||= surface.mesh?.snapshot?.() || {};
delete surface.mesh;
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(surface, null, 2)}\n`);
console.log(outputPath);
async function loadSurface(surface) {
const absolutePath = path.join(repoRoot, surface.path);
const source = await readFile(absolutePath, "utf8");
if (surface.transport === "local-eve-dsl") return compileEveDsl(source);
if (surface.transport === "local-json") return JSON.parse(source);
throw new Error(`Unsupported parity surface transport: ${surface.transport}`);
}

View file

@ -74,6 +74,36 @@
]
}
},
{
"id": "sai-vn",
"title": "Sai VN",
"surface": {
"transport": "local-json",
"path": "web/fixtures/sai-vn-surface.json"
},
"expect": {
"providerId": "gamecult.home.vn",
"componentKinds": [
"vn.stage",
"image.background",
"embed.norn",
"embed.tex",
"panel.dialogue",
"rail.actions",
"control.button"
],
"styleTokens": [
"colorBackground",
"colorPanel",
"colorPanelAlt",
"colorText",
"colorAccent",
"fontBody",
"fontTitle",
"pixelArt"
]
}
},
{
"id": "reactive-dsl",
"title": "Reactive DSL",
@ -125,7 +155,7 @@
{
"id": "linux-flutter",
"title": "Linux / Flutter",
"kind": "requires-linux-runner",
"kind": "active",
"expectedPaths": ["flutter/eve_parity/linux", "scripts/capture-flutter-parity.ps1"],
"capture": {
"status": "ssh-golden",
@ -135,7 +165,7 @@
{
"id": "ios-uikit",
"title": "iOS / UIKit",
"kind": "native-proof",
"kind": "active",
"expectedPaths": ["Sources/EVEViewController.mm", "scripts/capture-eve-screenshot.ps1"],
"capture": {
"status": "ssh-png",
@ -145,7 +175,7 @@
{
"id": "android-kotlin",
"title": "Android / Kotlin",
"kind": "device-proof",
"kind": "active",
"expectedPaths": ["android/app/src/main/java/org/gamecult/eve/MainActivity.kt", "scripts/build-android.ps1"],
"capture": {
"status": "adb-png",
@ -155,7 +185,7 @@
{
"id": "direct2d",
"title": "Fensalir Direct2D",
"kind": "specialized-native",
"kind": "pending",
"expectedPaths": ["web/fixtures/fensalir-client-surface.json"],
"capture": {
"status": "missing",

View file

@ -131,6 +131,9 @@ export function createCultMeshStore() {
},
};
},
snapshot() {
return Object.fromEntries([...records.entries()].map(([path, record]) => [path, record.value]));
},
derive(target, operator, sourcePath) {
const source = this.var(sourcePath);
const targetVar = this.var(target);

View file

@ -64,6 +64,7 @@ const localProviders = [
},
{
providerId: "sai.visual_novel",
aliases: ["gamecult.home.vn", "sai-vn"],
title: "Sai VN Surface",
kind: "content.runtime",
freshness: { state: "fixture" },
@ -108,7 +109,7 @@ async function bootProviders() {
return option;
}));
const requestedProviderId = new URLSearchParams(location.search).get("provider");
const firstProduct = providers.find(provider => provider.providerId === requestedProviderId)
const firstProduct = providers.find(provider => provider.providerId === requestedProviderId || provider.aliases?.includes(requestedProviderId))
|| providers.find(provider => provider.providerId === "repixelizer")
|| providers[0];
providerSelect.value = firstProduct.providerId;