ltcai 10.6.2 → 10.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +44 -59
  2. package/docs/CHANGELOG.md +56 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/LAYOUT_REBUILD_SPEC.md +810 -0
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +29 -1
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/runtime/multi_agent.py +1 -1
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +72 -0
  15. package/latticeai/api/automation_intelligence.py +112 -0
  16. package/latticeai/api/knowledge_graph.py +133 -0
  17. package/latticeai/api/static_routes.py +35 -1
  18. package/latticeai/core/legacy_compatibility.py +1 -1
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/workspace_os.py +3 -0
  21. package/latticeai/core/workspace_os_constants.py +1 -1
  22. package/latticeai/core/workspace_runs.py +111 -0
  23. package/latticeai/services/architecture_readiness.py +1 -1
  24. package/latticeai/services/product_readiness.py +1 -1
  25. package/package.json +5 -3
  26. package/scripts/capture_release_evidence.mjs +335 -1
  27. package/scripts/check_current_release_docs.mjs +1 -1
  28. package/scripts/check_release_evidence_bound.mjs +116 -0
  29. package/scripts/check_screenshot_pixel_delta.py +435 -0
  30. package/scripts/measure_brain_home_fill.mjs +141 -0
  31. package/src-tauri/Cargo.lock +1 -1
  32. package/src-tauri/Cargo.toml +1 -1
  33. package/src-tauri/tauri.conf.json +1 -1
  34. package/static/app/asset-manifest.json +37 -39
  35. package/static/app/assets/{Act-aNud-lKL.js → Act-DIbkoJqs.js} +1 -1
  36. package/static/app/assets/AdminConsole-Cbi6Jre6.js +1 -0
  37. package/static/app/assets/{Brain-CMFh5q6k.js → Brain-BPfuXZ71.js} +2 -2
  38. package/static/app/assets/BrainHome-87XaAS4V.js +2 -0
  39. package/static/app/assets/{BrainSignals-RVMlmTGC.js → BrainSignals-ExpcdiUi.js} +1 -1
  40. package/static/app/assets/Capture-Bxe4WiVX.js +1 -0
  41. package/static/app/assets/{CommandPalette-bW5WxWWZ.js → CommandPalette-B1PV5Yd4.js} +1 -1
  42. package/static/app/assets/Library-BRlJJmwZ.js +1 -0
  43. package/static/app/assets/{LivingBrain-FlHFfu9i.js → LivingBrain-D61qG3WY.js} +1 -1
  44. package/static/app/assets/ProductFlow-BxjxI8Aa.js +1 -0
  45. package/static/app/assets/{ReviewCard-BHj86h2Z.js → ReviewCard-BoiWv0Y1.js} +2 -2
  46. package/static/app/assets/System-Ci0HEtTJ.js +1 -0
  47. package/static/app/assets/arrow-left-DArdBUxd.js +1 -0
  48. package/static/app/assets/{bot-D1gX4xks.js → bot-BaPt4PPn.js} +1 -1
  49. package/static/app/assets/brain-BSkWaj3i.js +1 -0
  50. package/static/app/assets/{button-DPFZ9lGw.js → button-CKwtO1O0.js} +1 -1
  51. package/static/app/assets/{circle-pause-B2P72YOO.js → circle-pause-CiUc5RRk.js} +1 -1
  52. package/static/app/assets/{circle-play-BiC8z2vg.js → circle-play-BaTDxzR9.js} +1 -1
  53. package/static/app/assets/{cpu-Cx-wRR_V.js → cpu-Dm1gHuP1.js} +1 -1
  54. package/static/app/assets/{download-CKxlzxsK.js → download-C32oGU-C.js} +1 -1
  55. package/static/app/assets/{folder-open-Bbjju0tt.js → folder-open-Ch2jLVdD.js} +1 -1
  56. package/static/app/assets/{hard-drive-Bon1VBvq.js → hard-drive-BBjA5st8.js} +1 -1
  57. package/static/app/assets/index-BV7Yf6Eq.css +2 -0
  58. package/static/app/assets/{index-mLP0-YNO.js → index-J6h01X78.js} +4 -4
  59. package/static/app/assets/{input-DrMc0Xns.js → input-y6ABdKWY.js} +1 -1
  60. package/static/app/assets/{permissionCopy-BGUsI7vw.js → permissionCopy-BEtc0Ihd.js} +1 -1
  61. package/static/app/assets/{primitives-CEMTjBz1.js → primitives-GSFXF2Pd.js} +1 -1
  62. package/static/app/assets/search-CaIZKyo_.js +1 -0
  63. package/static/app/assets/{share-2-D7THHq5K.js → share-2-BgGq2A63.js} +1 -1
  64. package/static/app/assets/{shield-alert-BoU4_8r9.js → shield-alert-BYA491cj.js} +1 -1
  65. package/static/app/assets/{textarea-rU1Lb7ia.js → textarea-BAcim3fs.js} +1 -1
  66. package/static/app/assets/{useFocusTrap-BgvK4Nkx.js → useFocusTrap-qLg24jiv.js} +1 -1
  67. package/static/app/assets/{useQuery-CT2ChyuU.js → useQuery-xkAu9eDX.js} +1 -1
  68. package/static/app/assets/utils-CogdofAA.js +4 -0
  69. package/static/app/assets/workspace-BMvfx9_R.js +1 -0
  70. package/static/app/index.html +4 -4
  71. package/static/sw.js +1 -1
  72. package/static/app/assets/AdminConsole-QiCTH68K.js +0 -1
  73. package/static/app/assets/BrainHome-B4Ar3atu.js +0 -2
  74. package/static/app/assets/Capture-DEBo-0vZ.js +0 -1
  75. package/static/app/assets/Library-5gFexm83.js +0 -1
  76. package/static/app/assets/ProductFlow-CFUNDOHu.js +0 -1
  77. package/static/app/assets/System-CeNHoZRu.js +0 -1
  78. package/static/app/assets/activity-7_ZmZqN0.js +0 -1
  79. package/static/app/assets/arrow-left-Dv2Tiwhe.js +0 -1
  80. package/static/app/assets/brain-Dn_bDfl4.js +0 -1
  81. package/static/app/assets/index-DYUs0cWy.css +0 -2
  82. package/static/app/assets/search-HsIji1wY.js +0 -1
  83. package/static/app/assets/users-DlbfHBQV.js +0 -1
  84. package/static/app/assets/utils-fEGWreKB.js +0 -7
  85. package/static/app/assets/workspace-ClDBz_0f.js +0 -1
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env python3
2
+ """Compare release screenshots against a prior release and fail on no-change.
3
+
4
+ Proves the layout rebuild actually moved pixels (or height), not just copy.
5
+
6
+ Usage:
7
+ python scripts/check_screenshot_pixel_delta.py \\
8
+ --current output/release/v10.6.3/screenshots \\
9
+ --baseline output/release/v10.6.2/screenshots
10
+
11
+ Environment:
12
+ LTCAI_SCREENSHOT_CURRENT default: output/release/v{package}/screenshots
13
+ LTCAI_SCREENSHOT_BASELINE explicit baseline dir (must match expected version)
14
+ LTCAI_SCREENSHOT_BASELINE_VERSION pin expected baseline, e.g. 10.6.2 (default: prior git tag)
15
+ LTCAI_PIXEL_MIN_PCT default: 1.5 (non-LivingBrain screens)
16
+ LTCAI_PIXEL_MIN_PCT_LIVE default: 8.0 (01/03/04 — animation alone ~7.9%)
17
+ LTCAI_HEIGHT_MIN_PX default: 24 (layout height shift counts as change)
18
+
19
+ Exit 0 all screens moved enough, 1 one or more below threshold / wrong baseline, 2 bad args.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import re
28
+ import subprocess
29
+ import sys
30
+ from pathlib import Path
31
+ from typing import Dict, List, Optional, Tuple
32
+
33
+ from PIL import Image, ImageChops
34
+
35
+ SHOTS = [
36
+ "01-login.png",
37
+ "02-recommended-models.png",
38
+ "03-install-load-progress.png",
39
+ "04-brain-chat-home.png",
40
+ "05-memory-graph.png",
41
+ "06-capture.png",
42
+ "07-model-library.png",
43
+ "08-system.png",
44
+ "09-automation-runs.png",
45
+ "10-admin-console.png",
46
+ "11-knowledge-journey.png",
47
+ "12-review-center.png",
48
+ ]
49
+
50
+ # LivingBrain animation noise can hit ~7.9% with zero layout change.
51
+ LIVING_BRAIN = {
52
+ "01-login.png",
53
+ "03-install-load-progress.png",
54
+ "04-brain-chat-home.png",
55
+ }
56
+
57
+
58
+ def _repo_root() -> Path:
59
+ return Path(__file__).resolve().parents[1]
60
+
61
+
62
+ def _package_version(root: Path) -> str:
63
+ package = json.loads((root / "package.json").read_text(encoding="utf-8"))
64
+ return str(package["version"])
65
+
66
+
67
+ def _default_current(root: Path) -> Path:
68
+ env = os.environ.get("LTCAI_SCREENSHOT_CURRENT")
69
+ if env:
70
+ return Path(env)
71
+ version = _package_version(root)
72
+ return root / "output" / "release" / f"v{version}" / "screenshots"
73
+
74
+
75
+ def _version_key(name: str) -> Tuple[int, ...]:
76
+ m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", name)
77
+ if not m:
78
+ return (0, 0, 0)
79
+ return tuple(int(p) for p in m.groups())
80
+
81
+
82
+ def _normalize_version(name: str) -> str:
83
+ return name[1:] if name.startswith("v") else name
84
+
85
+
86
+ def _git_version_tags(root: Path) -> List[str]:
87
+ """Return sorted semver tags like ``10.6.2`` (no leading v) from the repo."""
88
+ try:
89
+ result = subprocess.run(
90
+ ["git", "tag", "-l", "v*.*.*"],
91
+ cwd=root,
92
+ capture_output=True,
93
+ text=True,
94
+ check=False,
95
+ )
96
+ except OSError:
97
+ return []
98
+ if result.returncode != 0:
99
+ return []
100
+ tags: List[Tuple[Tuple[int, ...], str]] = []
101
+ for line in result.stdout.splitlines():
102
+ name = line.strip()
103
+ if not name:
104
+ continue
105
+ key = _version_key(name)
106
+ if key == (0, 0, 0):
107
+ continue
108
+ tags.append((key, _normalize_version(name)))
109
+ tags.sort(key=lambda item: item[0])
110
+ return [version for _, version in tags]
111
+
112
+
113
+ def expected_baseline_version(root: Path, current_version: Optional[str] = None) -> str:
114
+ """Pin the baseline to a release tag — never "any older dir".
115
+
116
+ Resolution order:
117
+ 1. ``LTCAI_SCREENSHOT_BASELINE_VERSION`` (explicit pin)
118
+ 2. Git tag whose name matches the package version (same-name tag)
119
+ — used when the version was not bumped after a release. Comparing
120
+ against the prior tag (e.g. 10.6.2 while package is still 10.6.3 and
121
+ ``v10.6.3`` is tagged) lets screens that are identical to the already
122
+ shipped release pass, because they still differ from two-releases-ago.
123
+ Same-name baseline forces a real delta vs the published screenshots
124
+ (materialized via ``git show`` when the on-disk dir is missing/stale).
125
+ 3. Highest git tag strictly older than the package version
126
+ 4. Patch-1 of the package version (last resort when tags are unavailable)
127
+
128
+ This also prevents a silent downgrade to v10.6.1 when v10.6.2 is deleted
129
+ from ``output/release/`` — which would inflate the pixel-change rate.
130
+ """
131
+ env = os.environ.get("LTCAI_SCREENSHOT_BASELINE_VERSION")
132
+ if env:
133
+ return _normalize_version(env.strip())
134
+
135
+ current = _normalize_version(current_version or _package_version(root))
136
+ tags = _git_version_tags(root)
137
+ # Same-name tag = this version already shipped. Baseline is that release,
138
+ # not the prior one (failure mode: unbumped recapture vs two-releases-ago).
139
+ if current in tags:
140
+ return current
141
+
142
+ cur_key = _version_key(current)
143
+ older = [v for v in tags if _version_key(v) < cur_key]
144
+ if older:
145
+ return older[-1]
146
+
147
+ major, minor, patch = cur_key
148
+ if patch > 0:
149
+ return f"{major}.{minor}.{patch - 1}"
150
+ if minor > 0:
151
+ return f"{major}.{minor - 1}.0"
152
+ if major > 0:
153
+ return f"{major - 1}.0.0"
154
+ return current
155
+
156
+
157
+ def _baseline_dir_for_version(root: Path, version: str) -> Path:
158
+ return root / "output" / "release" / f"v{_normalize_version(version)}" / "screenshots"
159
+
160
+
161
+ def _baseline_looks_complete(shots: Path) -> bool:
162
+ return shots.is_dir() and (shots / "01-login.png").is_file()
163
+
164
+
165
+ def _extract_baseline_from_git(root: Path, version: str) -> Optional[Path]:
166
+ """Materialize ``v{version}`` release screenshots from the matching git tag.
167
+
168
+ Used when the working tree no longer has ``output/release/vX.Y.Z`` but the
169
+ tag still carries the evidence (so deleting a local dir cannot silently
170
+ fall back to an older on-disk release).
171
+ """
172
+ version = _normalize_version(version)
173
+ tag = f"v{version}"
174
+ probe = subprocess.run(
175
+ ["git", "rev-parse", "--verify", f"{tag}^{{commit}}"],
176
+ cwd=root,
177
+ capture_output=True,
178
+ text=True,
179
+ check=False,
180
+ )
181
+ if probe.returncode != 0:
182
+ return None
183
+
184
+ cache = root / "output" / ".cache" / "screenshot-baseline" / f"v{version}" / "screenshots"
185
+ cache.mkdir(parents=True, exist_ok=True)
186
+ extracted = 0
187
+ for name in SHOTS:
188
+ dest = cache / name
189
+ if dest.is_file() and dest.stat().st_size > 0:
190
+ extracted += 1
191
+ continue
192
+ git_path = f"output/release/v{version}/screenshots/{name}"
193
+ result = subprocess.run(
194
+ ["git", "show", f"{tag}:{git_path}"],
195
+ cwd=root,
196
+ capture_output=True,
197
+ check=False,
198
+ )
199
+ if result.returncode != 0 or not result.stdout:
200
+ continue
201
+ dest.write_bytes(result.stdout)
202
+ extracted += 1
203
+ if extracted < len(SHOTS):
204
+ return None
205
+ return cache
206
+
207
+
208
+ def resolve_baseline(
209
+ root: Path,
210
+ current: Path,
211
+ *,
212
+ explicit: Optional[Path] = None,
213
+ ) -> Tuple[Optional[Path], str]:
214
+ """Return ``(baseline_dir_or_None, expected_version)``.
215
+
216
+ Never substitutes an older-than-expected on-disk release. Callers must fail
217
+ when the path is None.
218
+
219
+ When the baseline version equals the package version (same-name tag path),
220
+ prefer screenshots materialized from the git tag over
221
+ ``output/release/vX.Y.Z/`` — that on-disk dir is where capture writes, so
222
+ it may already hold WIP overwrites of the published release.
223
+ """
224
+ expected = expected_baseline_version(root)
225
+ print(f"pixel delta: expected baseline version = v{expected}")
226
+
227
+ if explicit is not None:
228
+ return explicit.resolve(), expected
229
+
230
+ env = os.environ.get("LTCAI_SCREENSHOT_BASELINE")
231
+ if env:
232
+ path = Path(env).resolve()
233
+ # If the path embeds a version segment, it must match the pin.
234
+ parent_name = path.parent.name if path.name == "screenshots" else path.name
235
+ if parent_name.startswith("v") and _normalize_version(parent_name) != expected:
236
+ print(
237
+ f"pixel delta: LTCAI_SCREENSHOT_BASELINE points at {parent_name}, "
238
+ f"expected v{expected}",
239
+ file=sys.stderr,
240
+ )
241
+ return None, expected
242
+ return path, expected
243
+
244
+ package = _normalize_version(_package_version(root))
245
+ same_name_as_package = _normalize_version(expected) == package
246
+
247
+ # Same-name baseline: always take the tagged release via git show.
248
+ # Local output/release/v{package}/ is the capture target and may already
249
+ # contain unreleased UI (exactly the failure mode this pin prevents).
250
+ if same_name_as_package:
251
+ extracted = _extract_baseline_from_git(root, expected)
252
+ if extracted is not None and _baseline_looks_complete(extracted):
253
+ print(
254
+ f"pixel delta: same-name tag v{expected} — "
255
+ f"materialized baseline from git (not on-disk output/release/)"
256
+ )
257
+ return extracted.resolve(), expected
258
+ print(
259
+ f"pixel delta: same-name tag v{expected} but git materialization failed",
260
+ file=sys.stderr,
261
+ )
262
+ return None, expected
263
+
264
+ local = _baseline_dir_for_version(root, expected)
265
+ if _baseline_looks_complete(local):
266
+ return local.resolve(), expected
267
+
268
+ extracted = _extract_baseline_from_git(root, expected)
269
+ if extracted is not None and _baseline_looks_complete(extracted):
270
+ print(f"pixel delta: materialized baseline from git tag v{expected}")
271
+ return extracted.resolve(), expected
272
+
273
+ return None, expected
274
+
275
+
276
+ def _default_baseline(root: Path, current: Path) -> Optional[Path]:
277
+ """Backward-compatible helper: path only (no version). Prefer resolve_baseline."""
278
+ path, _version = resolve_baseline(root, current)
279
+ return path
280
+
281
+
282
+ def _pixel_delta_pct(a: Path, b: Path) -> Tuple[float, int, int, int, int]:
283
+ """Return (changed_pct, changed_px, total_px, height_a, height_b)."""
284
+ with Image.open(a) as im_a, Image.open(b) as im_b:
285
+ im_a = im_a.convert("RGB")
286
+ im_b = im_b.convert("RGB")
287
+ h_a, h_b = im_a.size[1], im_b.size[1]
288
+ # Align to common box so pure vertical growth still registers.
289
+ w = max(im_a.size[0], im_b.size[0])
290
+ h = max(h_a, h_b)
291
+ canvas_a = Image.new("RGB", (w, h), (0, 0, 0))
292
+ canvas_b = Image.new("RGB", (w, h), (0, 0, 0))
293
+ canvas_a.paste(im_a, (0, 0))
294
+ canvas_b.paste(im_b, (0, 0))
295
+ diff = ImageChops.difference(canvas_a, canvas_b)
296
+ # Any channel delta > 8 counts as changed (ignore sub-pixel AA noise).
297
+ # Count on RGB getdata: a pixel is changed if any channel exceeds the
298
+ # threshold. (A point()+convert("L") mask would average channels and
299
+ # under-count single-channel diffs, so we do not use it.)
300
+ pixels = list(diff.getdata())
301
+ changed = sum(1 for r, g, b in pixels if r > 8 or g > 8 or b > 8)
302
+ total = w * h
303
+ pct = (100.0 * changed / total) if total else 0.0
304
+ return pct, changed, total, h_a, h_b
305
+
306
+
307
+ def main(argv: Optional[List[str]] = None) -> int:
308
+ root = _repo_root()
309
+ parser = argparse.ArgumentParser(description=__doc__)
310
+ parser.add_argument("--current", type=Path, default=_default_current(root))
311
+ parser.add_argument("--baseline", type=Path, default=None)
312
+ parser.add_argument(
313
+ "--expect-baseline-version",
314
+ type=str,
315
+ default=None,
316
+ help="Override expected baseline version pin (same as LTCAI_SCREENSHOT_BASELINE_VERSION)",
317
+ )
318
+ parser.add_argument(
319
+ "--min-pct",
320
+ type=float,
321
+ default=float(os.environ.get("LTCAI_PIXEL_MIN_PCT", "1.5")),
322
+ help="Minimum pixel change %% for non-LivingBrain screens",
323
+ )
324
+ parser.add_argument(
325
+ "--min-pct-live",
326
+ type=float,
327
+ default=float(os.environ.get("LTCAI_PIXEL_MIN_PCT_LIVE", "8.0")),
328
+ help="Minimum pixel change %% for LivingBrain screens (above ~7.9%% noise)",
329
+ )
330
+ parser.add_argument(
331
+ "--min-height-px",
332
+ type=int,
333
+ default=int(os.environ.get("LTCAI_HEIGHT_MIN_PX", "24")),
334
+ help="Height delta (px) that counts as a layout change even if pixel %% is low",
335
+ )
336
+ args = parser.parse_args(argv)
337
+
338
+ if args.expect_baseline_version:
339
+ os.environ["LTCAI_SCREENSHOT_BASELINE_VERSION"] = _normalize_version(
340
+ args.expect_baseline_version
341
+ )
342
+
343
+ current = args.current.resolve()
344
+ baseline, expected_version = resolve_baseline(
345
+ root, current, explicit=args.baseline
346
+ )
347
+ print(f"pixel delta: current = {current}")
348
+ print(f"pixel delta: baseline = {baseline}")
349
+ print(f"pixel delta: baseline version pin = v{expected_version}")
350
+
351
+ if not current.is_dir():
352
+ print(f"pixel delta: current dir missing: {current}", file=sys.stderr)
353
+ return 2
354
+ if baseline is None or not baseline.is_dir():
355
+ print(
356
+ f"pixel delta: FAIL — expected baseline v{expected_version} is missing "
357
+ f"(will not fall back to an older release). "
358
+ f"Restore output/release/v{expected_version}/screenshots or the git tag.",
359
+ file=sys.stderr,
360
+ )
361
+ return 1
362
+
363
+ # Final pin check: if baseline path embeds a version, it must match.
364
+ parent_name = baseline.parent.name if baseline.name == "screenshots" else baseline.name
365
+ if parent_name.startswith("v") and _normalize_version(parent_name) != expected_version:
366
+ print(
367
+ f"pixel delta: FAIL — baseline path version {parent_name} "
368
+ f"!= expected v{expected_version}",
369
+ file=sys.stderr,
370
+ )
371
+ return 1
372
+
373
+ print(
374
+ f"pixel delta: thresholds static>={args.min_pct:.1f}% "
375
+ f"live>={args.min_pct_live:.1f}% height>={args.min_height_px}px"
376
+ )
377
+ print(
378
+ f"{'file':<32} {'px%':>8} {'Δh':>6} {'min%':>6} {'pass':>5}"
379
+ )
380
+ print("-" * 64)
381
+
382
+ failures: List[str] = []
383
+ results: List[Dict[str, object]] = []
384
+
385
+ for name in SHOTS:
386
+ cur = current / name
387
+ base = baseline / name
388
+ if not cur.is_file():
389
+ failures.append(f"{name}: missing in current")
390
+ print(f"{name:<32} {'—':>8} {'—':>6} {'—':>6} FAIL")
391
+ continue
392
+ if not base.is_file():
393
+ failures.append(f"{name}: missing in baseline")
394
+ print(f"{name:<32} {'—':>8} {'—':>6} {'—':>6} FAIL")
395
+ continue
396
+
397
+ pct, changed, total, h_cur, h_base = _pixel_delta_pct(cur, base)
398
+ height_delta = abs(h_cur - h_base)
399
+ live = name in LIVING_BRAIN
400
+ min_pct = args.min_pct_live if live else args.min_pct
401
+ # Height growth alone proves layout rebuild for full-page shots.
402
+ ok = pct >= min_pct or height_delta >= args.min_height_px
403
+ tag = "ok" if ok else "FAIL"
404
+ if not ok:
405
+ failures.append(
406
+ f"{name}: pixel={pct:.2f}% (need>={min_pct:.1f}%) "
407
+ f"height_delta={height_delta}px (need>={args.min_height_px})"
408
+ )
409
+ print(f"{name:<32} {pct:7.2f}% {height_delta:5d}p {min_pct:5.1f}% {tag:>5}")
410
+ results.append(
411
+ {
412
+ "file": name,
413
+ "pixel_pct": round(pct, 3),
414
+ "height_current": h_cur,
415
+ "height_baseline": h_base,
416
+ "height_delta": height_delta,
417
+ "min_pct": min_pct,
418
+ "living_brain": live,
419
+ "pass": ok,
420
+ }
421
+ )
422
+
423
+ print("-" * 64)
424
+ if failures:
425
+ print(f"pixel delta: FAIL — {len(failures)}/{len(SHOTS)} screen(s) below threshold")
426
+ for item in failures:
427
+ print(f" - {item}", file=sys.stderr)
428
+ return 1
429
+
430
+ print(f"pixel delta: PASS — all {len(SHOTS)} screens moved enough vs baseline v{expected_version}")
431
+ return 0
432
+
433
+
434
+ if __name__ == "__main__":
435
+ raise SystemExit(main())
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Measure Brain chat home (screen 04) content fill against the viewport.
4
+ *
5
+ * The layout rebuild requires the empty home's content bottom to reach at
6
+ * least 90% of the 920px capture viewport — not "looks better" prose.
7
+ *
8
+ * Walks the same onboarding path as capture_release_evidence.mjs so #/brain
9
+ * is the empty first-run home against the visual mock server.
10
+ *
11
+ * Exit 0 when bottom/viewport >= --min-ratio (default 0.90), else 1.
12
+ */
13
+ import { chromium } from "playwright";
14
+ import { spawn } from "node:child_process";
15
+
16
+ const repoRoot = process.cwd();
17
+ const port = Number(process.env.LTCAI_RELEASE_EVIDENCE_PORT || 4937);
18
+ const baseURL = `http://127.0.0.1:${port}`;
19
+ const minRatio = Number(process.env.LTCAI_HOME_FILL_MIN || "0.90");
20
+
21
+ const server = spawn(process.execPath, ["tests/visual/mock_server.cjs"], {
22
+ cwd: repoRoot,
23
+ env: { ...process.env, LTCAI_VISUAL_PORT: String(port) },
24
+ stdio: ["ignore", "pipe", "pipe"],
25
+ });
26
+
27
+ async function waitForServer() {
28
+ const deadline = Date.now() + 15000;
29
+ while (Date.now() < deadline) {
30
+ try {
31
+ const res = await fetch(`${baseURL}/health`);
32
+ if (res.ok) return;
33
+ } catch {
34
+ /* retry */
35
+ }
36
+ await new Promise((r) => setTimeout(r, 150));
37
+ }
38
+ throw new Error(`visual server did not start on ${baseURL}`);
39
+ }
40
+
41
+ async function main() {
42
+ await waitForServer();
43
+ const browser = await chromium.launch({ headless: true });
44
+ const context = await browser.newContext({
45
+ viewport: { width: 1440, height: 920 },
46
+ });
47
+ const page = await context.newPage();
48
+ page.setDefaultTimeout(15000);
49
+
50
+ // Same first-run reset as release capture — without this the product flow
51
+ // may already be complete and the wake button never appears.
52
+ await page.addInitScript(() => {
53
+ localStorage.removeItem("lattice.productFlow.complete");
54
+ localStorage.removeItem("lattice.productFlow.user");
55
+ localStorage.setItem("lattice.language", "ko");
56
+ localStorage.setItem("lattice.mode", "basic");
57
+ });
58
+
59
+ await page.goto(`${baseURL}/app`, { waitUntil: "networkidle" });
60
+ const korean = page.getByRole("button", { name: "한국어" });
61
+ if (await korean.count()) {
62
+ await korean.click();
63
+ }
64
+ await page.getByRole("button", { name: "Brain 지금 깨우기" }).click();
65
+ await page.getByText("이 Brain의 주인을 정합니다.").waitFor();
66
+ await page.getByPlaceholder(/You|나/).fill("Codex");
67
+ await page.getByPlaceholder("you@local").fill("codex@local");
68
+ await page.getByPlaceholder("로컬 Brain 비밀번호").fill("Lattice123");
69
+ await page.getByRole("button", { name: "내 Brain 시작하기" }).click();
70
+ await page.getByText("추천대로 시작하세요.").waitFor();
71
+ await page.getByRole("button", { name: "추천으로 바로 시작" }).click();
72
+ await page.getByText("모델을 준비하고 시작합니다.").waitFor();
73
+ await page.getByRole("button", { name: "준비하고 시작하기" }).click();
74
+ await page.waitForTimeout(180);
75
+ await page
76
+ .getByRole("button", { name: /시작|들어가|Brain 열기|Open Brain/i })
77
+ .first()
78
+ .click({ timeout: 5000 })
79
+ .catch(() => {});
80
+ await page.locator("main[aria-label='Lattice Brain']").waitFor({ timeout: 15000 });
81
+ await page.waitForTimeout(250);
82
+
83
+ const metrics = await page.evaluate(() => {
84
+ const main = document.querySelector("main[aria-label='Lattice Brain']");
85
+ const home =
86
+ document.querySelector(".brain-centered-home") ||
87
+ document.querySelector(".brain-home-station") ||
88
+ main;
89
+ const quiet = document.querySelector(
90
+ "footer.brain-home-quiet, .brain-home-quiet, .brain-home-shelves",
91
+ );
92
+ const composer = document.querySelector(
93
+ ".brain-composer, [data-testid='brain-composer'], textarea",
94
+ );
95
+ const box = (el) => {
96
+ if (!el) return null;
97
+ const r = el.getBoundingClientRect();
98
+ return { top: r.top, bottom: r.bottom, height: r.height, width: r.width };
99
+ };
100
+ const candidates = [home, quiet, composer, main].filter(Boolean);
101
+ let bottom = 0;
102
+ let top = Infinity;
103
+ for (const el of candidates) {
104
+ const r = el.getBoundingClientRect();
105
+ bottom = Math.max(bottom, r.bottom);
106
+ top = Math.min(top, r.top);
107
+ }
108
+ return {
109
+ viewport: { width: window.innerWidth, height: window.innerHeight },
110
+ main: box(main),
111
+ home: box(home),
112
+ quiet: box(quiet),
113
+ composer: box(composer),
114
+ contentTop: top === Infinity ? 0 : top,
115
+ contentBottom: bottom,
116
+ };
117
+ });
118
+
119
+ await browser.close();
120
+
121
+ const ratio = metrics.contentBottom / metrics.viewport.height;
122
+ const pct = (100 * ratio).toFixed(1);
123
+ console.log(JSON.stringify({ ...metrics, fillRatio: ratio, fillPct: `${pct}%`, minRatio }, null, 2));
124
+ console.log(
125
+ `brain home fill: contentBottom=${metrics.contentBottom.toFixed(1)}px / viewport=${metrics.viewport.height}px = ${pct}% (need >= ${(100 * minRatio).toFixed(0)}%)`,
126
+ );
127
+
128
+ if (ratio + 1e-6 < minRatio) {
129
+ console.error(
130
+ `brain home fill FAIL: content only reaches ${pct}% of viewport (required ${(100 * minRatio).toFixed(0)}%).`,
131
+ );
132
+ process.exit(1);
133
+ }
134
+ console.log("brain home fill PASS");
135
+ }
136
+
137
+ try {
138
+ await main();
139
+ } finally {
140
+ server.kill();
141
+ }
@@ -1584,7 +1584,7 @@ dependencies = [
1584
1584
 
1585
1585
  [[package]]
1586
1586
  name = "lattice-ai-desktop"
1587
- version = "10.6.2"
1587
+ version = "10.7.0"
1588
1588
  dependencies = [
1589
1589
  "plist",
1590
1590
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "lattice-ai-desktop"
3
- version = "10.6.2"
3
+ version = "10.7.0"
4
4
  description = "Lattice AI Digital Brain desktop shell"
5
5
  authors = ["TaeSoo Park"]
6
6
  edition = "2021"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schema.tauri.app/config/2",
3
3
  "productName": "Lattice AI",
4
- "version": "10.6.2",
4
+ "version": "10.7.0",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",