ltcai 10.6.3 → 10.8.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.
- package/README.md +56 -59
- package/docs/CHANGELOG.md +68 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/LAYOUT_REBUILD_SPEC.md +810 -0
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +29 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/retrieval_vector.py +69 -33
- package/lattice_brain/runtime/multi_agent.py +96 -15
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +81 -6
- package/latticeai/api/auth.py +37 -38
- package/latticeai/api/automation_intelligence.py +112 -0
- package/latticeai/api/browser.py +7 -6
- package/latticeai/api/knowledge_graph.py +133 -0
- package/latticeai/api/static_routes.py +35 -1
- package/latticeai/core/file_generation.py +141 -8
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +230 -0
- package/latticeai/core/workspace_computer_memory.py +84 -0
- package/latticeai/core/workspace_indexing.py +102 -0
- package/latticeai/core/workspace_onboarding.py +104 -0
- package/latticeai/core/workspace_os.py +27 -207
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_relationships.py +99 -0
- package/latticeai/core/workspace_runs.py +111 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +5 -3
- package/scripts/capture_release_evidence.mjs +335 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_release_evidence_bound.mjs +116 -0
- package/scripts/check_screenshot_pixel_delta.py +517 -0
- package/scripts/measure_brain_home_fill.mjs +141 -0
- package/scripts/release_screen_claims.json +37 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -39
- package/static/app/assets/{Act-05uZSgyw.js → Act-CbQNsFgc.js} +1 -1
- package/static/app/assets/AdminConsole-Bs8SR8fE.js +1 -0
- package/static/app/assets/{Brain-jjMYTdnU.js → Brain-BZIPsZlH.js} +2 -2
- package/static/app/assets/BrainHome-BaH5MDce.js +2 -0
- package/static/app/assets/{BrainSignals-BYxDqBH3.js → BrainSignals-aDt-nSsW.js} +1 -1
- package/static/app/assets/Capture-DDv940Bv.js +1 -0
- package/static/app/assets/{CommandPalette-Cj7z-qUE.js → CommandPalette-Dq_MmPBl.js} +1 -1
- package/static/app/assets/Library-BkShbqmN.js +1 -0
- package/static/app/assets/{LivingBrain-CTokkZn1.js → LivingBrain-CXb8094H.js} +1 -1
- package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
- package/static/app/assets/{ReviewCard-uAII_l9v.js → ReviewCard-Be7E7X18.js} +2 -2
- package/static/app/assets/System-DCstbTxC.js +1 -0
- package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
- package/static/app/assets/{bot-CkrHFdUl.js → bot-BR0eoeXY.js} +1 -1
- package/static/app/assets/brain-BmWa1wea.js +1 -0
- package/static/app/assets/{button-Ic6omIbs.js → button-U1nU7xX4.js} +1 -1
- package/static/app/assets/{circle-pause-DBaaUR08.js → circle-pause-DG5KHjDL.js} +1 -1
- package/static/app/assets/{circle-play-Dz0OLYN9.js → circle-play-Chhcuq90.js} +1 -1
- package/static/app/assets/{cpu-N8uE9ono.js → cpu-pkOSlT9h.js} +1 -1
- package/static/app/assets/{download-CCjhOjms.js → download-CSRwz923.js} +1 -1
- package/static/app/assets/{folder-open-Bld9TtHp.js → folder-open-DiMJ7W_7.js} +1 -1
- package/static/app/assets/{hard-drive-BMIqDSyY.js → hard-drive-iyLPAPdp.js} +1 -1
- package/static/app/assets/index-BZRvGOM2.css +2 -0
- package/static/app/assets/{index-yNtiRyw3.js → index-_ldE9TAX.js} +4 -4
- package/static/app/assets/{input-DW1tXtc0.js → input-e_r2hFAY.js} +1 -1
- package/static/app/assets/{permissionCopy-Djpwn54H.js → permissionCopy-9uOHktz3.js} +1 -1
- package/static/app/assets/{primitives-B6WtkLgB.js → primitives-CnEAOmg2.js} +1 -1
- package/static/app/assets/search-veep4n6f.js +1 -0
- package/static/app/assets/{share-2-DR5L1qRc.js → share-2-DLDJJKmI.js} +1 -1
- package/static/app/assets/{shield-alert-CVE5eXsl.js → shield-alert-BTtE-2EZ.js} +1 -1
- package/static/app/assets/{textarea-DdaVFgvk.js → textarea-Bbzkcgqe.js} +1 -1
- package/static/app/assets/{useFocusTrap-B5TyGaiB.js → useFocusTrap-DJzQd-Za.js} +1 -1
- package/static/app/assets/{useQuery-CiRdTQlD.js → useQuery-6R_tqHRe.js} +1 -1
- package/static/app/assets/utils-XDsjcrhS.js +4 -0
- package/static/app/assets/workspace-CwrG8LHq.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/AdminConsole-C8yz1Cs_.js +0 -1
- package/static/app/assets/BrainHome-D3t45_D4.js +0 -2
- package/static/app/assets/Capture-DKgu7fB0.js +0 -1
- package/static/app/assets/Library-CO76FWeO.js +0 -1
- package/static/app/assets/ProductFlow-Bmted2ys.js +0 -1
- package/static/app/assets/System-PUMzDmof.js +0 -1
- package/static/app/assets/activity-C_7182GG.js +0 -1
- package/static/app/assets/arrow-left-BQCpWR37.js +0 -1
- package/static/app/assets/brain-CdPX7svt.js +0 -1
- package/static/app/assets/index-DYUs0cWy.css +0 -2
- package/static/app/assets/search-CkHYuKpY.js +0 -1
- package/static/app/assets/users-pZqCRxdD.js +0 -1
- package/static/app/assets/utils-C3rXoa-U.js +0 -4
- package/static/app/assets/workspace-DfxKEttN.js +0 -1
|
@@ -0,0 +1,517 @@
|
|
|
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(
|
|
283
|
+
a: Path,
|
|
284
|
+
b: Path,
|
|
285
|
+
region: Optional[List[float]] = None,
|
|
286
|
+
) -> Tuple[float, int, int, int, int]:
|
|
287
|
+
"""Return (changed_pct, changed_px, total_px, height_a, height_b).
|
|
288
|
+
|
|
289
|
+
``region`` is ``[x0, y0, x1, y1]`` as fractions of the aligned canvas. It
|
|
290
|
+
narrows the comparison to the part of the screen a release says it
|
|
291
|
+
changed, so an animated element elsewhere cannot mask a real change — or
|
|
292
|
+
manufacture one. Heights are always reported for the full images.
|
|
293
|
+
"""
|
|
294
|
+
with Image.open(a) as im_a, Image.open(b) as im_b:
|
|
295
|
+
im_a = im_a.convert("RGB")
|
|
296
|
+
im_b = im_b.convert("RGB")
|
|
297
|
+
h_a, h_b = im_a.size[1], im_b.size[1]
|
|
298
|
+
# Align to common box so pure vertical growth still registers.
|
|
299
|
+
w = max(im_a.size[0], im_b.size[0])
|
|
300
|
+
h = max(h_a, h_b)
|
|
301
|
+
canvas_a = Image.new("RGB", (w, h), (0, 0, 0))
|
|
302
|
+
canvas_b = Image.new("RGB", (w, h), (0, 0, 0))
|
|
303
|
+
canvas_a.paste(im_a, (0, 0))
|
|
304
|
+
canvas_b.paste(im_b, (0, 0))
|
|
305
|
+
if region:
|
|
306
|
+
x0, y0, x1, y1 = region
|
|
307
|
+
box = (
|
|
308
|
+
max(0, min(w, int(round(w * x0)))),
|
|
309
|
+
max(0, min(h, int(round(h * y0)))),
|
|
310
|
+
max(0, min(w, int(round(w * x1)))),
|
|
311
|
+
max(0, min(h, int(round(h * y1)))),
|
|
312
|
+
)
|
|
313
|
+
if box[2] > box[0] and box[3] > box[1]:
|
|
314
|
+
canvas_a = canvas_a.crop(box)
|
|
315
|
+
canvas_b = canvas_b.crop(box)
|
|
316
|
+
w, h = canvas_a.size
|
|
317
|
+
diff = ImageChops.difference(canvas_a, canvas_b)
|
|
318
|
+
# Any channel delta > 8 counts as changed (ignore sub-pixel AA noise).
|
|
319
|
+
# Count on RGB getdata: a pixel is changed if any channel exceeds the
|
|
320
|
+
# threshold. (A point()+convert("L") mask would average channels and
|
|
321
|
+
# under-count single-channel diffs, so we do not use it.)
|
|
322
|
+
pixels = list(diff.getdata())
|
|
323
|
+
changed = sum(1 for r, g, b in pixels if r > 8 or g > 8 or b > 8)
|
|
324
|
+
total = w * h
|
|
325
|
+
pct = (100.0 * changed / total) if total else 0.0
|
|
326
|
+
return pct, changed, total, h_a, h_b
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
CLAIMS_PATH = "scripts/release_screen_claims.json"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _load_screen_claims(root: Path, version: str) -> Optional[Dict[str, Dict[str, object]]]:
|
|
333
|
+
"""What this release says it changed, or None for "all twelve".
|
|
334
|
+
|
|
335
|
+
A version with no entry is gated on every screen, so forgetting to write a
|
|
336
|
+
claim makes this check stricter rather than weaker.
|
|
337
|
+
"""
|
|
338
|
+
path = root / CLAIMS_PATH
|
|
339
|
+
if not path.is_file():
|
|
340
|
+
return None
|
|
341
|
+
try:
|
|
342
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
343
|
+
except ValueError as exc:
|
|
344
|
+
print(f"pixel delta: {CLAIMS_PATH} is not valid JSON ({exc})", file=sys.stderr)
|
|
345
|
+
return None
|
|
346
|
+
entry = data.get(_normalize_version(version))
|
|
347
|
+
if not isinstance(entry, dict):
|
|
348
|
+
return None
|
|
349
|
+
return {
|
|
350
|
+
name: (options if isinstance(options, dict) else {})
|
|
351
|
+
for name, options in entry.items()
|
|
352
|
+
if name in SHOTS
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
357
|
+
root = _repo_root()
|
|
358
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
359
|
+
parser.add_argument("--current", type=Path, default=_default_current(root))
|
|
360
|
+
parser.add_argument("--baseline", type=Path, default=None)
|
|
361
|
+
parser.add_argument(
|
|
362
|
+
"--expect-baseline-version",
|
|
363
|
+
type=str,
|
|
364
|
+
default=None,
|
|
365
|
+
help="Override expected baseline version pin (same as LTCAI_SCREENSHOT_BASELINE_VERSION)",
|
|
366
|
+
)
|
|
367
|
+
parser.add_argument(
|
|
368
|
+
"--min-pct",
|
|
369
|
+
type=float,
|
|
370
|
+
default=float(os.environ.get("LTCAI_PIXEL_MIN_PCT", "1.5")),
|
|
371
|
+
help="Minimum pixel change %% for non-LivingBrain screens",
|
|
372
|
+
)
|
|
373
|
+
parser.add_argument(
|
|
374
|
+
"--min-pct-live",
|
|
375
|
+
type=float,
|
|
376
|
+
default=float(os.environ.get("LTCAI_PIXEL_MIN_PCT_LIVE", "8.0")),
|
|
377
|
+
help="Minimum pixel change %% for LivingBrain screens (above ~7.9%% noise)",
|
|
378
|
+
)
|
|
379
|
+
parser.add_argument(
|
|
380
|
+
"--min-height-px",
|
|
381
|
+
type=int,
|
|
382
|
+
default=int(os.environ.get("LTCAI_HEIGHT_MIN_PX", "24")),
|
|
383
|
+
help="Height delta (px) that counts as a layout change even if pixel %% is low",
|
|
384
|
+
)
|
|
385
|
+
args = parser.parse_args(argv)
|
|
386
|
+
|
|
387
|
+
if args.expect_baseline_version:
|
|
388
|
+
os.environ["LTCAI_SCREENSHOT_BASELINE_VERSION"] = _normalize_version(
|
|
389
|
+
args.expect_baseline_version
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
current = args.current.resolve()
|
|
393
|
+
baseline, expected_version = resolve_baseline(
|
|
394
|
+
root, current, explicit=args.baseline
|
|
395
|
+
)
|
|
396
|
+
print(f"pixel delta: current = {current}")
|
|
397
|
+
print(f"pixel delta: baseline = {baseline}")
|
|
398
|
+
print(f"pixel delta: baseline version pin = v{expected_version}")
|
|
399
|
+
|
|
400
|
+
if not current.is_dir():
|
|
401
|
+
print(f"pixel delta: current dir missing: {current}", file=sys.stderr)
|
|
402
|
+
return 2
|
|
403
|
+
if baseline is None or not baseline.is_dir():
|
|
404
|
+
print(
|
|
405
|
+
f"pixel delta: FAIL — expected baseline v{expected_version} is missing "
|
|
406
|
+
f"(will not fall back to an older release). "
|
|
407
|
+
f"Restore output/release/v{expected_version}/screenshots or the git tag.",
|
|
408
|
+
file=sys.stderr,
|
|
409
|
+
)
|
|
410
|
+
return 1
|
|
411
|
+
|
|
412
|
+
# Final pin check: if baseline path embeds a version, it must match.
|
|
413
|
+
parent_name = baseline.parent.name if baseline.name == "screenshots" else baseline.name
|
|
414
|
+
if parent_name.startswith("v") and _normalize_version(parent_name) != expected_version:
|
|
415
|
+
print(
|
|
416
|
+
f"pixel delta: FAIL — baseline path version {parent_name} "
|
|
417
|
+
f"!= expected v{expected_version}",
|
|
418
|
+
file=sys.stderr,
|
|
419
|
+
)
|
|
420
|
+
return 1
|
|
421
|
+
|
|
422
|
+
print(
|
|
423
|
+
f"pixel delta: thresholds static>={args.min_pct:.1f}% "
|
|
424
|
+
f"live>={args.min_pct_live:.1f}% height>={args.min_height_px}px"
|
|
425
|
+
)
|
|
426
|
+
print(
|
|
427
|
+
f"{'file':<32} {'px%':>8} {'Δh':>6} {'min%':>6} {'pass':>5}"
|
|
428
|
+
)
|
|
429
|
+
print("-" * 64)
|
|
430
|
+
|
|
431
|
+
claims = _load_screen_claims(root, _package_version(root))
|
|
432
|
+
if claims is None:
|
|
433
|
+
print(f"pixel delta: no claim for this version in {CLAIMS_PATH} — gating all {len(SHOTS)} screens")
|
|
434
|
+
else:
|
|
435
|
+
print(
|
|
436
|
+
f"pixel delta: gating {len(claims)} claimed screen(s); "
|
|
437
|
+
f"{len(SHOTS) - len(claims)} unclaimed screen(s) measured but not gated"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
failures: List[str] = []
|
|
441
|
+
results: List[Dict[str, object]] = []
|
|
442
|
+
|
|
443
|
+
for name in SHOTS:
|
|
444
|
+
claimed = claims is None or name in SHOTS and name in claims
|
|
445
|
+
options = (claims or {}).get(name, {}) if claims is not None else {}
|
|
446
|
+
cur = current / name
|
|
447
|
+
base = baseline / name
|
|
448
|
+
if not cur.is_file():
|
|
449
|
+
failures.append(f"{name}: missing in current")
|
|
450
|
+
print(f"{name:<32} {'—':>8} {'—':>6} {'—':>6} FAIL")
|
|
451
|
+
continue
|
|
452
|
+
if not base.is_file():
|
|
453
|
+
failures.append(f"{name}: missing in baseline")
|
|
454
|
+
print(f"{name:<32} {'—':>8} {'—':>6} {'—':>6} FAIL")
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
region = options.get("region") if isinstance(options.get("region"), list) else None
|
|
458
|
+
pct, changed, total, h_cur, h_base = _pixel_delta_pct(cur, base, region)
|
|
459
|
+
height_delta = abs(h_cur - h_base)
|
|
460
|
+
live = name in LIVING_BRAIN
|
|
461
|
+
# A claim that names its region has already excluded the animated part
|
|
462
|
+
# of the screen, so the live threshold no longer buys anything there.
|
|
463
|
+
default_min = args.min_pct_live if (live and not region) else args.min_pct
|
|
464
|
+
override = options.get("min_pct")
|
|
465
|
+
min_pct = float(override) if isinstance(override, (int, float)) else default_min
|
|
466
|
+
# Height growth alone proves layout rebuild for full-page shots.
|
|
467
|
+
moved = pct >= min_pct or height_delta >= args.min_height_px
|
|
468
|
+
ok = moved or not claimed
|
|
469
|
+
tag = "ok" if moved else ("—" if not claimed else "FAIL")
|
|
470
|
+
if claimed and not moved:
|
|
471
|
+
failures.append(
|
|
472
|
+
f"{name}: pixel={pct:.2f}% (need>={min_pct:.1f}%) "
|
|
473
|
+
f"height_delta={height_delta}px (need>={args.min_height_px})"
|
|
474
|
+
+ (f" region={region}" if region else "")
|
|
475
|
+
)
|
|
476
|
+
marker = "" if claimed else " (unclaimed)"
|
|
477
|
+
print(f"{name:<32} {pct:7.2f}% {height_delta:5d}p {min_pct:5.1f}% {tag:>5}{marker}")
|
|
478
|
+
results.append(
|
|
479
|
+
{
|
|
480
|
+
"file": name,
|
|
481
|
+
"pixel_pct": round(pct, 3),
|
|
482
|
+
"height_current": h_cur,
|
|
483
|
+
"height_baseline": h_base,
|
|
484
|
+
"height_delta": height_delta,
|
|
485
|
+
"min_pct": min_pct,
|
|
486
|
+
"living_brain": live,
|
|
487
|
+
"region": region,
|
|
488
|
+
"claimed": claimed,
|
|
489
|
+
"moved": moved,
|
|
490
|
+
"pass": ok,
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
print("-" * 64)
|
|
495
|
+
gated = sum(1 for item in results if item["claimed"])
|
|
496
|
+
if failures:
|
|
497
|
+
print(f"pixel delta: FAIL — {len(failures)}/{gated} claimed screen(s) below threshold")
|
|
498
|
+
for item in failures:
|
|
499
|
+
print(f" - {item}", file=sys.stderr)
|
|
500
|
+
return 1
|
|
501
|
+
|
|
502
|
+
# An unclaimed screen that moved is not a failure, but it is worth saying:
|
|
503
|
+
# it is either an unintended side effect or a claim someone forgot to write.
|
|
504
|
+
surprises = [
|
|
505
|
+
f"{item['file']} ({item['pixel_pct']:.2f}%)"
|
|
506
|
+
for item in results
|
|
507
|
+
if not item["claimed"] and item["moved"]
|
|
508
|
+
]
|
|
509
|
+
if surprises:
|
|
510
|
+
print(f"pixel delta: note — unclaimed screens that changed anyway: {', '.join(surprises)}")
|
|
511
|
+
|
|
512
|
+
print(f"pixel delta: PASS — all {gated} claimed screen(s) moved vs baseline v{expected_version}")
|
|
513
|
+
return 0
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
if __name__ == "__main__":
|
|
517
|
+
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": [
|
|
3
|
+
"Which screens each release claims to have changed, and where.",
|
|
4
|
+
"",
|
|
5
|
+
"The pixel gate used to require all twelve screens to move on every",
|
|
6
|
+
"release. That was right for 10.6.x/10.7.0, which were explicitly",
|
|
7
|
+
"'rearrange every screen' releases, and wrong as a standing rule: a",
|
|
8
|
+
"release that deliberately touches four screens would have to churn the",
|
|
9
|
+
"other eight or fail — which is the dishonesty the gate exists to catch,",
|
|
10
|
+
"inverted.",
|
|
11
|
+
"",
|
|
12
|
+
"A release lists the screens it claims to have changed. Claimed screens",
|
|
13
|
+
"are gated exactly as before. Unclaimed screens are still measured and",
|
|
14
|
+
"printed, so an unintended change is visible, but do not fail the run.",
|
|
15
|
+
"A version with no entry here is gated on all twelve — forgetting to",
|
|
16
|
+
"write a claim makes the gate stricter, never weaker.",
|
|
17
|
+
"",
|
|
18
|
+
"'region' narrows the comparison to [x0, y0, x1, y1] as fractions of the",
|
|
19
|
+
"image. Use it when a screen carries an animated element that dominates a",
|
|
20
|
+
"whole-image delta: naming where the change is makes the claim more",
|
|
21
|
+
"specific, not less."
|
|
22
|
+
],
|
|
23
|
+
|
|
24
|
+
"10.8.0": {
|
|
25
|
+
"01-login.png": {},
|
|
26
|
+
"02-recommended-models.png": {},
|
|
27
|
+
"03-install-load-progress.png": {},
|
|
28
|
+
"05-memory-graph.png": {
|
|
29
|
+
"_why": "The provenance meter in this screen's header wrapped into a ragged three-line block that ran into the page title. It is a bordered column with a straight left edge now."
|
|
30
|
+
},
|
|
31
|
+
"04-brain-chat-home.png": {
|
|
32
|
+
"_why": "The Brain organism sits in this screen's greeting banner and moved 7.34% of that band between 10.6.3 and 10.7.0 with no layout intent, which is why 04 carries the 8% live threshold. 10.8.0 changed the suggestion deck and the shelf row below the composer; measuring that region directly is both stricter (the claim names where) and able to see a change the whole-image metric cannot.",
|
|
33
|
+
"region": [0.0, 0.45, 1.0, 0.85],
|
|
34
|
+
"min_pct": 1.5
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|