diff --git a/.gitignore b/.gitignore index 57fecd3..4077d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ tools/deps/ android/app/src/main/assets/current-surface.json flutter/eve_parity/assets/current-surface.json flutter/eve_parity/assets/fonts/ +flutter/eve_parity/assets/repixelizer/ *.deb *.ipa *.dSYM/ diff --git a/android/app/src/main/java/org/gamecult/eve/MainActivity.kt b/android/app/src/main/java/org/gamecult/eve/MainActivity.kt index 73ddb19..56c08ea 100644 --- a/android/app/src/main/java/org/gamecult/eve/MainActivity.kt +++ b/android/app/src/main/java/org/gamecult/eve/MainActivity.kt @@ -193,6 +193,45 @@ class MainActivity : Activity(), SensorEventListener { cornerRadius = dp(6).toFloat() } } + "image.preview" -> previewBox(props.optString("label", "Image"), tokens, square = true) + "canvas.preview" -> previewBox(props.optString("label", "Canvas"), tokens, square = false) + "canvas.editor" -> previewBox(props.optString("label", "Editable canvas"), tokens, square = false, tall = true) + "status.stage" -> LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(12), dp(12), dp(12), dp(12)) + background = panelBackground(tokens, 4) + addView(label(props.optString("label", "Status"), 12f, tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt()), true)) + addView(label(props.optString("stage", ""), 15f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), true)) + addView(label(props.optString("detail", ""), 12f, tokenColor(tokens, "colorMuted", 0xffd3bb7f.toInt()), false)) + } + "input.file", "dropzone" -> label(props.optString("label", "Choose File"), 14f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), true).apply { + gravity = Gravity.CENTER + setPadding(dp(14), dp(12), dp(14), dp(12)) + background = panelBackground(tokens, 6) + } + "input.number", "input.select", "control.range" -> LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(label(props.optString("label", ""), 11f, tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt()), true)) + addView(label(props.optString("value", ""), 13f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), false).apply { + setPadding(dp(8), dp(6), dp(8), dp(6)) + background = panelBackground(tokens, 2) + }) + } + "control.toggle" -> label("${props.optString("label", "Toggle")}: ${if (props.optBoolean("value", false)) "on" else "off"}", 12f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), false).apply { + setPadding(dp(8), dp(6), dp(8), dp(6)) + background = panelBackground(tokens, 2) + } + "color.swatch" -> View(this).apply { + background = android.graphics.drawable.GradientDrawable().apply { + setColor(tokenColor(JSONObject().put("value", props.optString("value", "#ffffffff")), "value", Color.WHITE)) + setStroke(dp(2), tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt())) + } + layoutParams = ViewGroup.LayoutParams(dp(38), dp(38)) + } + "metric" -> LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(label("${props.optString("label", "Metric")}: ${props.opt("value") ?: ""}", 13f, tokenColor(tokens, "colorText", 0xfff6f1e2.toInt()), true)) + } "surface" -> LinearLayout(this).apply { orientation = LinearLayout.VERTICAL forEachChild(children) { addView(renderCultUiNode(it, values, tokens)) } @@ -301,6 +340,17 @@ class MainActivity : Activity(), SensorEventListener { } } + private fun previewBox(labelText: String, tokens: JSONObject, square: Boolean, tall: Boolean = false): View { + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(label(labelText, 12f, tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt()), true)) + addView(StageBackgroundView(this@MainActivity, "waiting for pixels", tokenColor(tokens, "colorAccent", 0xffff8a2a.toInt())), LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + if (square) dp(180) else if (tall) dp(280) else dp(160) + )) + } + } + 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)) diff --git a/flutter/eve_parity/lib/main.dart b/flutter/eve_parity/lib/main.dart index 0e82609..36c70b8 100644 --- a/flutter/eve_parity/lib/main.dart +++ b/flutter/eve_parity/lib/main.dart @@ -46,7 +46,7 @@ class _EveParityAppState extends State { ) : state == null ? const SizedBox.shrink() - : EveSurfaceView(state: state), + : SingleChildScrollView(child: EveSurfaceView(state: state)), ), ), ); @@ -220,6 +220,24 @@ class EveNodeView extends StatelessWidget { return EveSlider(state: state, node: node); case 'control.button': return _button(); + case 'image.preview': + return _imagePreview(); + case 'canvas.preview': + case 'canvas.editor': + return _canvasPlaceholder(editor: node.kind == 'canvas.editor'); + case 'status.stage': + return _statusStage(); + case 'input.file': + case 'dropzone': + return _button(); + case 'input.number': + case 'input.select': + case 'control.range': + return _field(); + case 'control.toggle': + return _toggle(); + case 'color.swatch': + return _swatch(); case 'vn.stage': return _vnStage(); case 'image.background': @@ -449,12 +467,159 @@ class EveNodeView extends StatelessWidget { ); } + Widget _imagePreview() { + final label = node.props['label']?.toString() ?? 'Image'; + final assetPath = _previewAssetPath(node.props['src']); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 1, + child: DecoratedBox( + decoration: BoxDecoration( + color: state.tokens.panelInset, + border: Border.all(color: state.tokens.border, width: 2), + ), + child: Stack( + children: [ + Positioned.fill(child: CustomPaint(painter: EveGridPainter(color: state.tokens.accent.withValues(alpha: 0.10)))), + if (assetPath != null) + Positioned.fill( + child: Image.asset(assetPath, fit: BoxFit.cover, filterQuality: FilterQuality.none), + ) + else + Center( + child: Padding( + padding: const EdgeInsets.all(10), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle(color: state.tokens.muted, fontFamily: 'EveParity', fontSize: 12), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Text(label, style: TextStyle(color: state.tokens.muted, fontFamily: 'EveParity', fontSize: 12)), + ], + ); + } + + Widget _canvasPlaceholder({required bool editor}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + node.props['label']?.toString() ?? 'Canvas', + style: TextStyle(color: state.tokens.accent, fontFamily: 'EveParity', fontSize: 12, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + SizedBox( + height: editor ? 280 : 170, + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + color: editor ? const Color(0xFF142033) : state.tokens.panelInset, + border: Border.all(color: state.tokens.border, width: 2), + ), + child: Stack( + children: [ + Positioned.fill(child: CustomPaint(painter: EveGridPainter(color: state.tokens.accent.withValues(alpha: editor ? 0.18 : 0.08)))), + Center( + child: Text( + node.props['state']?.toString() ?? '', + style: TextStyle(color: state.tokens.muted, fontFamily: 'EveParity', fontSize: 12), + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _statusStage() { + return DecoratedBox( + decoration: BoxDecoration( + color: state.tokens.panelAlt, + border: Border.all(color: state.tokens.border, width: 2), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(node.props['label']?.toString() ?? 'Status', style: TextStyle(color: state.tokens.accent, fontFamily: 'EveParity', fontSize: 12, fontWeight: FontWeight.w700)), + const SizedBox(height: 6), + Text(node.props['stage']?.toString() ?? '', style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 15, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text(node.props['detail']?.toString() ?? '', style: TextStyle(color: state.tokens.muted, fontFamily: 'EveParity', fontSize: 12)), + ], + ), + ), + ); + } + + Widget _field() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(node.props['label']?.toString() ?? '', style: TextStyle(color: state.tokens.accent, fontFamily: 'EveParity', fontSize: 11, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + DecoratedBox( + decoration: BoxDecoration(color: state.tokens.panelInset, border: Border.all(color: state.tokens.border)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Text(node.props['value']?.toString() ?? '', style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 13)), + ), + ), + ], + ); + } + + Widget _toggle() { + return DecoratedBox( + decoration: BoxDecoration(color: state.tokens.panelInset, border: Border.all(color: state.tokens.border)), + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + '${node.props['label'] ?? 'Toggle'}: ${node.props['value'] == true ? 'on' : 'off'}', + style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 12), + ), + ), + ); + } + + Widget _swatch() { + return Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: _parseColor(node.props['value']) ?? state.tokens.text, + border: Border.all(color: state.tokens.accent, width: 2), + ), + ); + } + Widget _surface() { final padding = _number(node.layout['padding'] ?? node.props['padding'], 0); + final direction = node.layout['direction']?.toString(); return Padding( padding: EdgeInsets.all(padding), child: LayoutBuilder( builder: (context, constraints) { + if (direction == 'vertical') { + return _children(axis: Axis.vertical, maxWidth: 1280); + } + if (direction == 'horizontal') { + return _children(axis: Axis.horizontal, maxWidth: 1280); + } if (constraints.maxWidth >= 900 && node.children.length > 1) { return _children(axis: Axis.horizontal, maxWidth: 1280); } @@ -625,6 +790,21 @@ class EveNodeView extends StatelessWidget { } Map _map(Object? value) => value is Map ? value : const {}; + + Color? _parseColor(Object? value) { + if (value is! String || !value.startsWith('#')) return null; + final body = value.substring(1); + if (body.length == 8) return Color(int.parse(body, radix: 16)); + if (body.length == 6) return Color(int.parse('ff$body', radix: 16)); + return null; + } + + String? _previewAssetPath(Object? source) { + final src = source?.toString() ?? ''; + if (src.endsWith('/character-input.png')) return 'assets/repixelizer/character-input.png'; + if (src.endsWith('/character-repixelized.png')) return 'assets/repixelizer/character-repixelized.png'; + return null; + } } class EveGridPainter extends CustomPainter { diff --git a/flutter/eve_parity/pubspec.yaml b/flutter/eve_parity/pubspec.yaml index a2fe3ea..01876b7 100644 --- a/flutter/eve_parity/pubspec.yaml +++ b/flutter/eve_parity/pubspec.yaml @@ -53,6 +53,8 @@ flutter: assets: - assets/current-surface.json + - assets/repixelizer/character-input.png + - assets/repixelizer/character-repixelized.png fonts: - family: EveParity diff --git a/flutter/eve_parity/test/goldens/repixelizer-desktop.png b/flutter/eve_parity/test/goldens/repixelizer-desktop.png index 93857e5..565a26c 100644 Binary files a/flutter/eve_parity/test/goldens/repixelizer-desktop.png and b/flutter/eve_parity/test/goldens/repixelizer-desktop.png differ diff --git a/flutter/eve_parity/test/goldens/repixelizer-phone.png b/flutter/eve_parity/test/goldens/repixelizer-phone.png index adba2eb..81b6eef 100644 Binary files a/flutter/eve_parity/test/goldens/repixelizer-phone.png and b/flutter/eve_parity/test/goldens/repixelizer-phone.png differ diff --git a/flutter/eve_parity/test/goldens/repixelizer-tablet.png b/flutter/eve_parity/test/goldens/repixelizer-tablet.png index 612d3ef..b1393f0 100644 Binary files a/flutter/eve_parity/test/goldens/repixelizer-tablet.png and b/flutter/eve_parity/test/goldens/repixelizer-tablet.png differ diff --git a/flutter/eve_parity/test/parity_smoke_test.dart b/flutter/eve_parity/test/parity_smoke_test.dart index 116b656..f996e59 100644 --- a/flutter/eve_parity/test/parity_smoke_test.dart +++ b/flutter/eve_parity/test/parity_smoke_test.dart @@ -21,6 +21,10 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); await tester.pumpAndSettle(); await tester.pumpAndSettle(); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 250)); + }); + await tester.pumpAndSettle(); expect(find.byType(EveSurfaceView), findsOneWidget); if (fixtureId == 'cultui-inspector') { expect(find.text('AETHERIC FIELD TESTER'), findsOneWidget); diff --git a/scripts/capture-flutter-parity.ps1 b/scripts/capture-flutter-parity.ps1 index 1a8b623..de938ef 100644 --- a/scripts/capture-flutter-parity.ps1 +++ b/scripts/capture-flutter-parity.ps1 @@ -14,10 +14,12 @@ $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" +$repixelizerAssetDir = Join-Path $flutterRoot "assets\repixelizer" $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 +New-Item -ItemType Directory -Force $repixelizerAssetDir | Out-Null if (-not (Test-Path $flutter)) { throw "Flutter is not installed at $flutter" @@ -29,6 +31,8 @@ if ($Target -eq "linux" -and $env:OS -like "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 +curl.exe -L "https://repixelizer.gamecult.org/app/landing-assets/character-input.png" -o (Join-Path $repixelizerAssetDir "character-input.png") | Out-Host +curl.exe -L "https://repixelizer.gamecult.org/app/landing-assets/character-repixelized.png" -o (Join-Path $repixelizerAssetDir "character-repixelized.png") | Out-Host node .\tools\parity\export-fixture.mjs $FixtureId $assetPath | Out-Host if ($LASTEXITCODE -ne 0) { diff --git a/scripts/capture-linux-flutter-parity.ps1 b/scripts/capture-linux-flutter-parity.ps1 index 4a3aa44..5f5384c 100644 --- a/scripts/capture-linux-flutter-parity.ps1 +++ b/scripts/capture-linux-flutter-parity.ps1 @@ -14,11 +14,13 @@ $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" +$repixelizerAssetDir = Join-Path $sourceRoot "assets\repixelizer" $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 +New-Item -ItemType Directory -Force $repixelizerAssetDir | Out-Null if (-not (Test-Path $sourceRoot)) { throw "Flutter parity source not found: $sourceRoot" @@ -30,6 +32,8 @@ if ($LASTEXITCODE -ne 0) { } 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 +curl.exe -L "https://repixelizer.gamecult.org/app/landing-assets/character-input.png" -o (Join-Path $repixelizerAssetDir "character-input.png") | Out-Host +curl.exe -L "https://repixelizer.gamecult.org/app/landing-assets/character-repixelized.png" -o (Join-Path $repixelizerAssetDir "character-repixelized.png") | Out-Host try { Push-Location $sourceRoot diff --git a/tools/parity/parity-manifest.json b/tools/parity/parity-manifest.json index 6001410..86c61b2 100644 --- a/tools/parity/parity-manifest.json +++ b/tools/parity/parity-manifest.json @@ -63,7 +63,20 @@ }, "expect": { "providerId": "repixelizer", - "componentKinds": ["surface", "pane", "card", "text"], + "componentKinds": [ + "surface", + "pane", + "card", + "text", + "image.preview", + "canvas.preview", + "canvas.editor", + "input.file", + "dropzone", + "input.number", + "control.button", + "status.stage" + ], "styleTokens": [ "colorBackgroundTop", "colorPanel", diff --git a/web/fixtures/repixelizer.eve-surface.json b/web/fixtures/repixelizer.eve-surface.json index ce73297..2326191 100644 --- a/web/fixtures/repixelizer.eve-surface.json +++ b/web/fixtures/repixelizer.eve-surface.json @@ -165,6 +165,697 @@ }, "text": "Repixelizer hosted demo" }, + { + "children": [ + { + "children": [], + "id": "repixelizer.product.pitch", + "kind": "text", + "props": { + "role": "strong", + "text": "You need pixel art for a project, but pixel art is a craft and your favorite AI is mostly vibes in a trench coat." + }, + "role": "strong", + "text": "You need pixel art for a project, but pixel art is a craft and your favorite AI is mostly vibes in a trench coat." + }, + { + "children": [ + { + "children": [ + { + "children": [], + "id": "repixelizer.product.input.image", + "kind": "image.preview", + "props": { + "aspectRatio": "1/1", + "imageRendering": "pixelated", + "label": "AI-generated hazmat character before repixelizing", + "role": "body", + "src": "https://repixelizer.gamecult.org/app/landing-assets/character-input.png" + }, + "role": "body", + "text": "AI-generated hazmat character before repixelizing" + }, + { + "children": [], + "id": "repixelizer.product.input.copy", + "kind": "text", + "props": { + "role": "body", + "text": "Looks charming from orbit. Suspiciously charming." + }, + "role": "body", + "text": "Looks charming from orbit. Suspiciously charming." + } + ], + "id": "repixelizer.product.input", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "The AI gives you this" + }, + "role": "group", + "style": { + "tone": "cool", + "variant": "repixelizer-retro-card" + }, + "text": "The AI gives you this" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.product.closeup.image", + "kind": "image.preview", + "props": { + "aspectRatio": "1/1", + "crop": "42% 63%", + "imageRendering": "pixelated", + "label": "Zoomed close-up showing fake pixel-art artifacts", + "role": "body", + "src": "https://repixelizer.gamecult.org/app/landing-assets/character-input.png", + "zoom": 3.45 + }, + "role": "body", + "text": "Zoomed close-up showing fake pixel-art artifacts" + }, + { + "children": [], + "id": "repixelizer.product.closeup.copy", + "kind": "text", + "props": { + "role": "body", + "text": "Smears, half-cells, anti-grid nonsense. The crime scene has opinions." + }, + "role": "body", + "text": "Smears, half-cells, anti-grid nonsense. The crime scene has opinions." + } + ], + "id": "repixelizer.product.closeup", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Then you zoom in" + }, + "role": "group", + "style": { + "tone": "cool", + "variant": "repixelizer-retro-card" + }, + "text": "Then you zoom in" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.product.output.image", + "kind": "image.preview", + "props": { + "aspectRatio": "1/1", + "imageRendering": "pixelated", + "label": "Repixelized hazmat character output", + "role": "body", + "src": "https://repixelizer.gamecult.org/app/landing-assets/character-repixelized.png" + }, + "role": "body", + "text": "Repixelized hazmat character output" + }, + { + "children": [], + "id": "repixelizer.product.output.copy", + "kind": "text", + "props": { + "role": "body", + "text": "Real cells on a real lattice, ready for cleanup or export." + }, + "role": "body", + "text": "Real cells on a real lattice, ready for cleanup or export." + } + ], + "id": "repixelizer.product.output", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Repixelizer spits out this" + }, + "role": "group", + "style": { + "tone": "cool", + "variant": "repixelizer-retro-card" + }, + "text": "Repixelizer spits out this" + } + ], + "id": "repixelizer.product.gallery", + "kind": "partition", + "layout": { + "direction": "horizontal", + "gap": 12 + }, + "props": { + "gap": 12, + "role": "product.gallery", + "split": "x" + }, + "role": "gallery" + } + ], + "id": "repixelizer.product.landing", + "kind": "pane", + "layout": { + "direction": "vertical", + "padding": 10 + }, + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Hosted demo" + }, + "role": "section", + "style": { + "tone": "warm", + "variant": "repixelizer-retro-pane" + }, + "text": "Hosted demo" + }, + { + "children": [ + { + "children": [ + { + "children": [], + "id": "repixelizer.workspace.status", + "kind": "status.stage", + "props": { + "detail": "Choose a file, then run the machine.", + "label": "Idle", + "role": "body", + "stage": "Waiting for input" + }, + "role": "body", + "text": "Idle" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.workspace.input.canvas", + "kind": "canvas.preview", + "props": { + "label": "Input", + "role": "body", + "state": "empty" + }, + "role": "body", + "text": "Input" + }, + { + "children": [], + "id": "repixelizer.workspace.output.canvas", + "kind": "canvas.preview", + "props": { + "label": "Output", + "role": "body", + "state": "empty" + }, + "role": "body", + "text": "Output" + } + ], + "id": "repixelizer.workspace.compare", + "kind": "partition", + "layout": { + "direction": "horizontal", + "gap": 10 + }, + "props": { + "gap": 10, + "role": "comparison", + "split": "x" + }, + "role": "comparison" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.workspace.inspect.copy", + "kind": "text", + "props": { + "role": "body", + "text": "Hold either preview to inspect both panels together." + }, + "role": "body", + "text": "Hold either preview to inspect both panels together." + }, + { + "children": [], + "id": "repixelizer.workspace.inspect.zoom", + "kind": "input.select", + "props": { + "label": "Inspect Zoom", + "role": "field", + "value": "8x" + }, + "role": "field", + "text": "Inspect Zoom" + } + ], + "id": "repixelizer.workspace.inspect", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Zoom" + }, + "role": "group", + "style": { + "tone": "panel", + "variant": "repixelizer-retro-card" + }, + "text": "Zoom" + } + ], + "id": "repixelizer.workspace.viewer", + "kind": "pane", + "layout": { + "direction": "vertical", + "padding": 10 + }, + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Pipeline Stages" + }, + "role": "section", + "style": { + "tone": "warm", + "variant": "repixelizer-retro-pane" + }, + "text": "Pipeline Stages" + }, + { + "children": [ + { + "children": [ + { + "children": [], + "id": "repixelizer.workspace.reset", + "kind": "control.button", + "props": { + "commandId": "repixelizer.editor.reset", + "label": "Reset Output", + "role": "command" + }, + "role": "command", + "text": "Reset Output" + }, + { + "children": [], + "id": "repixelizer.workspace.export", + "kind": "control.button", + "props": { + "commandId": "repixelizer.editor.export", + "label": "Export PNG", + "role": "command" + }, + "role": "command", + "text": "Export PNG" + } + ], + "id": "repixelizer.workspace.editor.actions", + "kind": "partition", + "layout": { + "direction": "horizontal", + "gap": 8 + }, + "props": { + "gap": 8, + "role": "toolbar", + "split": "x" + }, + "role": "toolbar" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.workspace.swatch", + "kind": "color.swatch", + "props": { + "label": "Paint", + "role": "body", + "value": "#ffffffff" + }, + "role": "body", + "text": "Paint" + }, + { + "children": [], + "id": "repixelizer.workspace.paint.r", + "kind": "input.number", + "props": { + "label": "R", + "role": "field", + "value": "255" + }, + "role": "field", + "text": "R" + }, + { + "children": [], + "id": "repixelizer.workspace.paint.g", + "kind": "input.number", + "props": { + "label": "G", + "role": "field", + "value": "255" + }, + "role": "field", + "text": "G" + }, + { + "children": [], + "id": "repixelizer.workspace.paint.b", + "kind": "input.number", + "props": { + "label": "B", + "role": "field", + "value": "255" + }, + "role": "field", + "text": "B" + }, + { + "children": [], + "id": "repixelizer.workspace.paint.a", + "kind": "input.number", + "props": { + "label": "A", + "role": "field", + "value": "255" + }, + "role": "field", + "text": "A" + }, + { + "children": [], + "id": "repixelizer.workspace.zoom", + "kind": "control.range", + "props": { + "label": "Zoom", + "max": 32, + "min": 4, + "role": "field", + "value": "12x" + }, + "role": "field", + "text": "Zoom" + }, + { + "children": [], + "id": "repixelizer.workspace.grid", + "kind": "control.toggle", + "props": { + "label": "Grid", + "role": "body", + "value": true + }, + "role": "body", + "text": "Grid" + } + ], + "id": "repixelizer.workspace.paint", + "kind": "partition", + "layout": { + "direction": "horizontal", + "gap": 6 + }, + "props": { + "gap": 6, + "role": "paint-controls", + "split": "x" + }, + "role": "paint-controls" + }, + { + "children": [], + "id": "repixelizer.workspace.editor.canvas", + "kind": "canvas.editor", + "props": { + "label": "Editable output canvas", + "role": "body", + "state": "empty" + }, + "role": "body", + "text": "Editable output canvas" + } + ], + "id": "repixelizer.workspace.editor", + "kind": "pane", + "layout": { + "direction": "vertical", + "padding": 10 + }, + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Pixel Fixes" + }, + "role": "section", + "style": { + "tone": "warm", + "variant": "repixelizer-retro-pane" + }, + "text": "Pixel Fixes" + }, + { + "children": [ + { + "children": [ + { + "children": [], + "id": "repixelizer.workspace.input.copy", + "kind": "text", + "props": { + "role": "body", + "text": "Start with the ugly input." + }, + "role": "body", + "text": "Start with the ugly input." + }, + { + "children": [], + "id": "repixelizer.workspace.file", + "kind": "input.file", + "props": { + "accept": "image/png,image/*", + "label": "Choose File", + "role": "body" + }, + "role": "body", + "text": "Choose File" + }, + { + "children": [], + "id": "repixelizer.workspace.dropzone", + "kind": "dropzone", + "props": { + "label": "PNG, sprite scrap, cursed logo. Whatever started this.", + "role": "body" + }, + "role": "body", + "text": "PNG, sprite scrap, cursed logo. Whatever started this." + } + ], + "id": "repixelizer.workspace.input", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Input" + }, + "role": "group", + "style": { + "tone": "panel", + "variant": "repixelizer-retro-card" + }, + "text": "Input" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.workspace.target.size", + "kind": "input.number", + "props": { + "label": "Target Size", + "role": "field", + "value": "auto" + }, + "role": "field", + "text": "Target Size" + }, + { + "children": [], + "id": "repixelizer.workspace.target.width", + "kind": "input.number", + "props": { + "label": "Target Width", + "role": "field", + "value": "auto" + }, + "role": "field", + "text": "Target Width" + }, + { + "children": [], + "id": "repixelizer.workspace.target.height", + "kind": "input.number", + "props": { + "label": "Target Height", + "role": "field", + "value": "auto" + }, + "role": "field", + "text": "Target Height" + }, + { + "children": [], + "id": "repixelizer.workspace.steps", + "kind": "input.number", + "props": { + "label": "Steps", + "role": "field", + "value": "48" + }, + "role": "field", + "text": "Steps" + }, + { + "children": [], + "id": "repixelizer.workspace.run", + "kind": "control.button", + "props": { + "commandId": "repixelizer.job.submit", + "label": "Run The Machine", + "method": "POST", + "role": "command", + "route": "/api/jobs" + }, + "role": "command", + "text": "Run The Machine" + } + ], + "id": "repixelizer.workspace.controls", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Controls" + }, + "role": "group", + "style": { + "tone": "panel", + "variant": "repixelizer-retro-card" + }, + "text": "Controls" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.workspace.queue.depth", + "kind": "metric", + "props": { + "label": "Queue", + "max": 32, + "role": "body", + "value": 0 + }, + "role": "body", + "text": "Queue" + }, + { + "children": [], + "id": "repixelizer.workspace.queue.worker", + "kind": "metric", + "props": { + "label": "Worker", + "max": 1, + "role": "body", + "value": 0 + }, + "role": "body", + "text": "Worker" + } + ], + "id": "repixelizer.workspace.queue", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Queue" + }, + "role": "group", + "style": { + "tone": "panel", + "variant": "repixelizer-retro-card" + }, + "text": "Queue" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.workspace.lattice.copy", + "kind": "text", + "props": { + "role": "body", + "text": "No lattice picked yet." + }, + "role": "body", + "text": "No lattice picked yet." + } + ], + "id": "repixelizer.workspace.lattice", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Lattice" + }, + "role": "group", + "style": { + "tone": "panel", + "variant": "repixelizer-retro-card" + }, + "text": "Lattice" + } + ], + "id": "repixelizer.workspace.sidebar", + "kind": "pane", + "layout": { + "direction": "vertical", + "padding": 10 + }, + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Run Controls" + }, + "role": "section", + "style": { + "tone": "warm", + "variant": "repixelizer-retro-pane" + }, + "text": "Run Controls" + } + ], + "id": "repixelizer.workspace", + "kind": "partition", + "layout": { + "direction": "horizontal", + "gap": 12 + }, + "props": { + "gap": 12, + "role": "repixelizer.workspace", + "split": "x" + }, + "role": "workspace" + }, { "children": [ { @@ -553,6 +1244,6 @@ }, "title": "Repixelizer", "type": "surface-state", - "updatedAt": "2026-06-04T00:00:00Z", + "updatedAt": "2026-06-05T00:00:00Z", "version": 1 } diff --git a/web/styles.css b/web/styles.css index 9e5d1f7..79f8b6c 100644 --- a/web/styles.css +++ b/web/styles.css @@ -284,6 +284,14 @@ p { align-content: start; } +.cultui-surface-root[data-direction="vertical"] { + grid-template-columns: minmax(0, 1fr); +} + +.cultui-surface-root[data-direction="horizontal"] { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + .cultui-pane { border-width: var(--border-width); border-bottom-width: var(--border-bottom-width); @@ -593,6 +601,83 @@ body[data-pixel-art="true"] .cultui-background { color: var(--accent); } +.cultui-image-preview { + display: grid; + gap: 8px; + margin: 0; +} + +.cultui-image-frame, +.cultui-canvas-grid { + display: grid; + place-items: center; + min-height: 160px; + border: 2px solid var(--panel-border); + background: + repeating-linear-gradient(45deg, rgba(247, 239, 216, 0.06) 0 8px, rgba(0, 0, 0, 0.18) 8px 16px), + linear-gradient(180deg, #07172c, #0a2039); + overflow: hidden; +} + +.cultui-image-frame { + aspect-ratio: 1 / 1; +} + +.cultui-image-frame img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cultui-image-preview figcaption, +.cultui-canvas-grid { + color: var(--muted); + font-size: 0.75rem; + line-height: 1.25; +} + +.cultui-canvas-placeholder { + display: grid; + gap: 8px; +} + +.cultui-canvas-placeholder.editor .cultui-canvas-grid { + min-height: 280px; + background: + linear-gradient(45deg, rgba(255,255,255,0.08) 25%, transparent 25% 75%, rgba(255,255,255,0.08) 75%), + linear-gradient(45deg, rgba(255,255,255,0.08) 25%, transparent 25% 75%, rgba(255,255,255,0.08) 75%), + #142033; + background-position: 0 0, 8px 8px; + background-size: 16px 16px; +} + +.cultui-status-stage { + display: grid; + gap: 8px; + padding: 12px; + border: 2px solid var(--panel-border); + background: rgba(6, 22, 39, 0.92); +} + +.cultui-field { + display: grid; + gap: 4px; +} + +.cultui-field-value { + display: block; + border: 2px solid var(--panel-border); + background: rgba(1, 4, 11, 0.58); + padding: 6px 8px; +} + +.cultui-swatch { + width: 36px; + min-height: 36px; + border: 2px solid var(--accent); + box-shadow: inset 0 0 0 2px rgba(0,0,0,0.45); +} + @media (max-width: 900px) { .cultui-graph { right: 12px; diff --git a/web/surface.js b/web/surface.js index 2911efb..c5ca751 100644 --- a/web/surface.js +++ b/web/surface.js @@ -252,6 +252,7 @@ function applySurfaceStyles(styles) { function renderCultComponent(node) { const kind = node.kind || "panel"; const props = node.props || {}; + const layout = node.layout || {}; const children = node.children || []; let rendered; @@ -322,6 +323,7 @@ function renderCultComponent(node) { if (kind === "surface") { const surface = el("section", "cultui-surface-root"); + if (layout.direction) surface.dataset.direction = layout.direction; for (const child of children) surface.append(renderCultComponent(child)); return surface; } @@ -378,6 +380,69 @@ function renderCultComponent(node) { return renderCultMetric(props); } + if (kind === "image.preview") { + const figure = el("figure", "cultui-image-preview"); + const frame = el("div", "cultui-image-frame"); + if (props.src) { + const image = el("img"); + image.src = props.src; + image.alt = props.label || ""; + image.style.imageRendering = props.imageRendering || "auto"; + if (props.zoom) { + image.style.transform = `scale(${props.zoom})`; + image.style.transformOrigin = props.crop || "50% 50%"; + } + frame.append(image); + } else { + frame.append(el("span", "", props.label || "image")); + } + figure.append(frame); + if (props.label) figure.append(el("figcaption", "", props.label)); + return figure; + } + + if (kind === "canvas.preview" || kind === "canvas.editor") { + const panel = el("div", `cultui-canvas-placeholder ${kind === "canvas.editor" ? "editor" : "preview"}`); + panel.append(el("div", "viz-label", props.label || "Canvas")); + panel.append(el("div", "cultui-canvas-grid", props.state === "empty" ? "waiting for pixels" : props.state || "")); + return panel; + } + + if (kind === "status.stage") { + const panel = el("section", "cultui-status-stage"); + panel.append(el("span", "status-badge", props.label || "Status")); + panel.append(el("p", "status-stage", props.stage || "")); + panel.append(el("p", "status-text", props.detail || "")); + return panel; + } + + if (kind === "input.file" || kind === "dropzone") { + const control = el("button", kind === "dropzone" ? "dropzone cultui-dropzone" : "cultui-button button-secondary", props.label || "Choose File"); + control.type = "button"; + return control; + } + + if (kind === "input.number" || kind === "input.select" || kind === "control.range") { + const field = el("label", "field cultui-field"); + field.append(el("span", "field-label", props.label || "")); + field.append(el("span", "field-control cultui-field-value", props.value === undefined ? "" : String(props.value))); + return field; + } + + if (kind === "control.toggle") { + const toggle = el("label", "toggle-row cultui-toggle"); + toggle.append(el("span", "field-control", props.value ? "on" : "off")); + toggle.append(el("span", "toggle-copy", props.label || "Toggle")); + return toggle; + } + + if (kind === "color.swatch") { + const swatch = el("div", "paint-swatch cultui-swatch"); + swatch.style.background = props.value || props.color || "currentColor"; + swatch.title = props.label || "Color"; + return swatch; + } + if (kind === "list") { return renderCultList(props); }