9rGmS2P0hsQ2#BUwYI#6px^plO0Xy{C-*A8059iDC`h1?}^L(B!
z@Aq?Ve`1W+O23r=0C*AhMJEBkD-W^%ir1E7@AVaFdhFv?kQB2U5UbvTFku<_qlAwD
z;AXk!i%(z0lLf}>H{|2x86Jd
zj%*L-HRBp&DgTIw-
zT{~1&cyr+waw99Qasz>M-2>(yy}omvlwyAylhuc2hPl$RAA*YGoIp*}S$-Edraohf
zimQa3LtWsl6b>Pki@z*zN0pJ8zCpyc&CGUINgw(K08}S!qu6rk`%}tt4W8_PcHDui&eT(B@0KKLVKvi
z=w~SAQl<2#tTV;ynIDW@4>CrJDGl21>@lg2LRLTR&Alox+0SN@40S!on~;#2Tcmus
zUlo^;kRoWeMss?Q$%8q=#x7>Z{K9dvDsuiJp4+RZfdu9XEE6@6=uP;(lHIG<9W)R7
z4R^!yzxGz`d`A;Ej__+5?KW++*i!3Y=jvhr;KrBw24aGN7(Ux@E{#_q2wAT4?6e`G
z$Ph!vc-wqKvBbEAN!duwi?4wL0U-N)wi+F})5$*z55}vMm4d6*F=hvO+8Q)RNB2~3`V=1vZHyS3c!6WcMsQ&=;)Lv1`HrE(03=bIp
z2>`=XeG`vpDxND);cAWGq<|zJ7r&TMDkxt)ypVFr0-q0cN(w*5#nC{AWKF(9NhK`!
zu5bem`{|qHiO70i;#|X;{Ngk;z0RQUZ5-*6m8E1)qo-3EGelXAvKE8D7b&=Yi9sYH
zn-DAh6`-W?5~nC%D5t!ojR~H5Q3B6Pi#$?-Z|6rozm=)*IE1W(gTc)xqxBMlmWb$H
zBbqTU4{z7EowQvT?8u*g%(lm!8c-tn@t6kpwrZAy-I?L(TWRWI6UElr+WD#)C~f9(eV){0pz
zIy~*E)J-rUBb}{@qp0@1=`F}j
zm1)&XgKZ0CDqVzl2+%hwiikbEOiuu~&7Rc}Xw&N<<^=
z3}kN7I3g8!2~$P%U7e9kL5LghNo$RrgsYslD%s5(_IJx+5*XnsXNF?dP_A7y*LQBD
zH!`?=E(1F`ov*>ok#fszCI&gG#1@cn&9*fq>_
zvbELC3ji7Hk&|ibQub2bxtb14R!fBJj4i*uFyGJb
zp(VaX;BLcO-Wsd#(8bXREMnf=mJ_x{%TwtQzH^c)sa57l|ipn?B`O1Q1#o(px<
zWL9}tzNH7&!9Pkx+8Xg%~HdulCH50!wpGczcR4$`r{w
z-L#1DWn}2l+Ep*fEL7hZ5+8O=$1v@+*H2JKxm9xyze7FL;Z8QuZ;Br|!3X5uo!<
z)bU4dAx8MWOv>aG0k0+=UaRZ&xhsw;JKUORXeh_(?6D+fAU?y`SIgG$e&goqeQfk&
z)8|$tApYkOenvvDqcm0=vJY8Odo=kCxu&at9UGWIoJT&V0(6V{wq
zUkX2J=)_
z?GdJ23nWzX`1@9Tkgwf6P3vO1%aS9wp;1}t>}Yr_S32%t|Hk3=9x;R%vJ_>$lFY){Uo9Z_J-kRg
zF64=XtV}KA(J2vr<%oXnU_iwx96=|ql!34XFOa@|(i#{Tdzv1Uso#W$GPQ80J50=xh1
z*kJs!W1lYkf*d$yvhQ-m?*kf-D|I$9txMkP6x?Z#Bb#2qrfh()Cox*Q`;*gu0BDfs
Af&c&j
literal 0
HcmV?d00001
diff --git a/tests/app-loading-contract.ps1 b/tests/app-loading-contract.ps1
new file mode 100644
index 0000000..d192626
--- /dev/null
+++ b/tests/app-loading-contract.ps1
@@ -0,0 +1,38 @@
+$ErrorActionPreference = 'Stop'
+
+$root = Split-Path -Parent $PSScriptRoot
+$component = Get-Content -LiteralPath (Join-Path $root 'components/AppLoading.vue') -Raw -Encoding utf8
+
+function Assert-Match {
+ param([string]$Content, [string]$Pattern, [string]$Message)
+ if ($Content -notmatch $Pattern) { throw $Message }
+}
+
+Assert-Match -Content $component -Pattern ':class="`app-loading--\$\{variant\}`"' -Message 'AppLoading must expose page and section modifier classes'
+Assert-Match -Content $component -Pattern 'variant:\s*\{\s*type:\s*String,\s*default:\s*''page'',\s*validator:' -Message 'AppLoading must own a validated page/section variant prop'
+Assert-Match -Content $component -Pattern 'description:\s*\{\s*type:\s*String,\s*default:\s*''''\s*\}' -Message 'AppLoading must expose an optional description prop'
+Assert-Match -Content $component -Pattern 'v-if="description" class="app-loading__description"' -Message 'AppLoading must render its optional description'
+Assert-Match -Content $component -Pattern '(?s)\.app-loading\s*\{[^}]*position:\s*relative;[^}]*z-index:\s*1;' -Message 'AppLoading must stay above page panel skins without page-specific overrides'
+Assert-Match -Content $component -Pattern 'class="app-loading__seal"\s+src="/static/assets/foundation/transparent/brand-seal\.png"' -Message 'AppLoading must render the approved real red-gold seal asset'
+Assert-Match -Content $component -Pattern 'class="app-loading__knot"\s+src="/static/assets/foundation/transparent/auth-divider-knot\.png"' -Message 'AppLoading must render the approved real gold knot asset'
+Assert-Match -Content $component -Pattern '(?s)\.app-loading--page\s*\{[^}]*min-height:\s*320rpx;' -Message 'AppLoading page variant must use the approved minimum height'
+Assert-Match -Content $component -Pattern '(?s)\.app-loading--page\s+\.app-loading__seal\s*\{[^}]*width:\s*132rpx;[^}]*height:\s*136rpx;' -Message 'AppLoading page seal must use the approved size'
+Assert-Match -Content $component -Pattern '(?s)\.app-loading--page\s+\.app-loading__copy\s*\{[^}]*font-size:\s*30rpx;' -Message 'AppLoading page copy must use the approved size'
+Assert-Match -Content $component -Pattern '(?s)\.app-loading--page\s+\.app-loading__description\s*\{[^}]*font-size:\s*24rpx;' -Message 'AppLoading page description must use the approved size'
+Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s*\{[^}]*min-height:\s*180rpx;' -Message 'AppLoading section variant must use the approved minimum height'
+Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s+\.app-loading__seal\s*\{[^}]*width:\s*88rpx;[^}]*height:\s*90rpx;' -Message 'AppLoading section seal must use the approved size'
+Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s+\.app-loading__copy\s*\{[^}]*font-size:\s*24rpx;' -Message 'AppLoading section copy must use the approved size'
+Assert-Match -Content $component -Pattern '(?s)\.app-loading--section\s+\.app-loading__description\s*\{[^}]*font-size:\s*22rpx;' -Message 'AppLoading section description must use the approved size'
+Assert-Match -Content $component -Pattern 'app-loading-seal-breathe\s+1\.6s' -Message 'AppLoading seal must use the approved restrained breathing rhythm'
+Assert-Match -Content $component -Pattern '@media\s*\(prefers-reduced-motion:\s*reduce\)' -Message 'AppLoading must respect reduced-motion preferences'
+Assert-Match -Content $component -Pattern '@keyframes\s+app-loading-seal-essential-pulse' -Message 'Reduced-motion AppLoading must retain an essential seal opacity pulse'
+Assert-Match -Content $component -Pattern '@keyframes\s+app-loading-knot-essential-pulse' -Message 'Reduced-motion AppLoading must retain an essential knot opacity pulse'
+Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__seal\s*\{[^}]*animation:\s*app-loading-seal-essential-pulse\s+1\.2s\s+ease-in-out\s+infinite;[^}]*transform:\s*none;' -Message 'Reduced-motion seal must pulse opacity without transform motion'
+Assert-Match -Content $component -Pattern '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?\.app-loading__knot\s*\{[^}]*animation:\s*app-loading-knot-essential-pulse\s+1\.2s\s+ease-in-out\s+\.2s\s+infinite;[^}]*transform:\s*none;' -Message 'Reduced-motion knot must pulse opacity without transform motion'
+if ($component -match '(?s)@media\s*\(prefers-reduced-motion:\s*reduce\).*?animation:\s*none') {
+ throw 'Reduced-motion AppLoading must not become completely static'
+}
+
+if ($component -match 'app-loading__mark|border:\s*4rpx\s+double') { throw 'AppLoading must not retain the legacy CSS box mark' }
+
+Write-Output 'APP-LOADING-CONTRACT PASS'
diff --git a/tests/capture-chrome-page-contract.ps1 b/tests/capture-chrome-page-contract.ps1
index 30b8b4b..cb683d4 100644
--- a/tests/capture-chrome-page-contract.ps1
+++ b/tests/capture-chrome-page-contract.ps1
@@ -12,8 +12,8 @@ $captureScript = Get-Content -LiteralPath $captureScriptPath -Raw -Encoding utf8
if ($captureScript -notmatch "const renderSettleMilliseconds = 900") {
throw 'Chrome capture must define a 900ms render-settle delay after the target selector appears'
}
-if ($captureScript -notmatch 'await sleep\(renderSettleMilliseconds\)') {
- throw 'Chrome capture must wait for the render-settle delay before taking a screenshot'
+if ($captureScript -notmatch "await sleep\(action\?\.endsWith\('-submitting'\) \? 80 : renderSettleMilliseconds\)") {
+ throw 'Chrome capture must wait for the standard render-settle delay except for verified transient submitting states'
}
if ($captureScript -match 'await sleep\(300\)') {
throw 'Chrome capture must not retain the unstable 300ms post-selector delay'
@@ -27,10 +27,10 @@ if ($captureScript -notmatch 'Array\.from\(document\.images\)\.every') {
if ($captureScript -notmatch 'await waitForLoadedImages\(send\)') {
throw 'Chrome capture must wait for document images after the target selector renders'
}
-if ($captureScript -notmatch "await send\('Page.reload'\)") {
- throw 'Chrome capture must reload after navigation so route screenshots do not inherit a prior Vue state'
+if ($captureScript -notmatch "await waitForPageLoad\(socket, \(\) => send\('Page.reload'\)\)") {
+ throw 'Chrome capture must reload and await Page.loadEventFired so route screenshots do not inherit a prior Vue state'
}
-if ($captureScript.IndexOf("await send('Page.navigate'") -ge $captureScript.IndexOf("await send('Page.reload')")) {
+if ($captureScript.IndexOf("await send('Page.navigate'") -ge $captureScript.IndexOf("await waitForPageLoad(socket, () => send('Page.reload'))")) {
throw 'Chrome capture must reload only after it has navigated to the requested URL'
}
if ($captureScript -notmatch 'const waitForFreshDocument = async \(send, documentTimeOrigin\) =>') {
@@ -45,7 +45,7 @@ if ($captureScript -notmatch 'await waitForFreshDocument\(send, documentTimeOrig
if ($captureScript -notmatch "action !== 'fresh-navigation'") {
throw 'Chrome capture must support fresh-navigation for root pages whose tab lifecycle closes a reloaded CDP target'
}
-foreach ($g06Action in @('g06-results', 'g06-empty', 'g06-invite')) {
+foreach ($g06Action in @('g06-results', 'g06-empty', 'g06-error', 'g06-invite', 'g06-invite-invalid')) {
if ($captureScript -notmatch [regex]::Escape($g06Action)) {
throw "Chrome capture must support the $g06Action visual state"
}
diff --git a/tests/capture-f-series-states-contract.ps1 b/tests/capture-f-series-states-contract.ps1
new file mode 100644
index 0000000..2898c29
--- /dev/null
+++ b/tests/capture-f-series-states-contract.ps1
@@ -0,0 +1,11 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$capture = Get-Content -LiteralPath (Join-Path $root 'scripts/capture-chrome-page.js') -Raw -Encoding utf8
+
+foreach ($required in @('prepareFSeriesState', 'f02-validation', 'module-preview-toast', 'waitForPageLoad')) {
+ if ($capture -notmatch [regex]::Escape($required)) { throw "F series capture state missing: $required" }
+}
+if ($capture -notmatch 'await prepareFSeriesState\(send, action\)') { throw 'Capture runner does not prepare F series states' }
+if ($capture -notmatch "waitForPageLoad\(socket, \(\) => send\('Page\.reload'\)\)") { throw 'Capture runner must wait for the reload load event before DOM inspection' }
+
+Write-Output 'CAPTURE-F-SERIES-STATES-CONTRACT PASS'
diff --git a/tests/capture-g03-states-contract.ps1 b/tests/capture-g03-states-contract.ps1
new file mode 100644
index 0000000..7c98652
--- /dev/null
+++ b/tests/capture-g03-states-contract.ps1
@@ -0,0 +1,23 @@
+$ErrorActionPreference = 'Stop'
+
+$root = Split-Path -Parent $PSScriptRoot
+$capture = Get-Content -LiteralPath (Join-Path $root 'scripts/capture-chrome-page.js') -Raw -Encoding utf8
+
+foreach ($required in @(
+ 'prepareG03State',
+ 'g03-create-validation',
+ 'g03-duplicate-reminder',
+ 'g03-create-submitting',
+ 'g03-create-error',
+ 'g03-ancestor-validation',
+ 'g03-ancestor-submitting',
+ 'g03-ancestor-error',
+ 'g03-ancestor-success'
+)) {
+ if ($capture -notmatch [regex]::Escape($required)) { throw "G03 capture state missing: $required" }
+}
+
+if ($capture -notmatch 'await prepareG03State\(send, action\)') { throw 'Capture runner does not prepare G03 states' }
+if ($capture -notmatch 'action\?\.endsWith\(''-submitting''\)') { throw 'Capture runner does not shorten settle time for transient submitting states' }
+
+Write-Output 'CAPTURE-G03-STATES-CONTRACT PASS'
diff --git a/tests/capture-g08-states-contract.ps1 b/tests/capture-g08-states-contract.ps1
new file mode 100644
index 0000000..3e00242
--- /dev/null
+++ b/tests/capture-g08-states-contract.ps1
@@ -0,0 +1,8 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$capture = Get-Content -LiteralPath (Join-Path $root 'scripts/capture-chrome-page.js') -Raw -Encoding utf8
+foreach ($required in @('prepareG08State', 'g08-validation', 'g08-submitting', 'g08-error', 'g08-success')) {
+ if ($capture -notmatch [regex]::Escape($required)) { throw "G08 capture state missing: $required" }
+}
+if ($capture -notmatch 'await prepareG08State\(send, action\)') { throw 'Capture runner does not prepare G08 states' }
+Write-Output 'CAPTURE-G08-STATES-CONTRACT PASS'
diff --git a/tests/capture-g10-states-contract.ps1 b/tests/capture-g10-states-contract.ps1
new file mode 100644
index 0000000..5435fed
--- /dev/null
+++ b/tests/capture-g10-states-contract.ps1
@@ -0,0 +1,17 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$capture = Get-Content -LiteralPath (Join-Path $root 'scripts/capture-chrome-page.js') -Raw -Encoding utf8
+
+foreach ($required in @(
+ 'prepareG10State',
+ 'g10-help',
+ 'g10-approve-dialog',
+ 'g10-approved',
+ 'g10-reject-dialog',
+ 'g10-rejected'
+)) {
+ if ($capture -notmatch [regex]::Escape($required)) { throw "G10 capture state missing: $required" }
+}
+if ($capture -notmatch 'await prepareG10State\(send, action\)') { throw 'Capture runner does not prepare G10 states' }
+
+Write-Output 'CAPTURE-G10-STATES-CONTRACT PASS'
diff --git a/tests/capture-g11-g12-states-contract.ps1 b/tests/capture-g11-g12-states-contract.ps1
new file mode 100644
index 0000000..82391af
--- /dev/null
+++ b/tests/capture-g11-g12-states-contract.ps1
@@ -0,0 +1,14 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$capture = Get-Content -LiteralPath (Join-Path $root 'scripts/capture-chrome-page.js') -Raw -Encoding utf8
+
+foreach ($required in @(
+ 'prepareG11State', 'g11-validation', 'g11-saved',
+ 'prepareG12State', 'g12-validation', 'g12-save-error', 'g12-saved'
+)) {
+ if ($capture -notmatch [regex]::Escape($required)) { throw "G11/G12 capture state missing: $required" }
+}
+if ($capture -notmatch 'await prepareG11State\(send, action\)') { throw 'Capture runner does not prepare G11 states' }
+if ($capture -notmatch 'await prepareG12State\(send, action\)') { throw 'Capture runner does not prepare G12 states' }
+
+Write-Output 'CAPTURE-G11-G12-STATES-CONTRACT PASS'
diff --git a/tests/capture-t03-t06-states-contract.ps1 b/tests/capture-t03-t06-states-contract.ps1
new file mode 100644
index 0000000..183eb97
--- /dev/null
+++ b/tests/capture-t03-t06-states-contract.ps1
@@ -0,0 +1,10 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$capture = Get-Content -LiteralPath (Join-Path $root 'scripts/capture-chrome-page.js') -Raw -Encoding utf8
+
+foreach ($required in @('prepareT03T06State', 't06-conflict-help')) {
+ if ($capture -notmatch [regex]::Escape($required)) { throw "T03-T06 capture state missing: $required" }
+}
+if ($capture -notmatch 'await prepareT03T06State\(send, action\)') { throw 'Capture runner does not prepare T03-T06 states' }
+
+Write-Output 'CAPTURE-T03-T06-STATES-CONTRACT PASS'
diff --git a/tests/core-flow-contract.ps1 b/tests/core-flow-contract.ps1
index 023db84..03db6bc 100644
--- a/tests/core-flow-contract.ps1
+++ b/tests/core-flow-contract.ps1
@@ -53,11 +53,12 @@ if ($pages -match 'pages/genealogy/g04-first-ancestor') {
throw 'G04 first-person route must be merged into G03'
}
$createFlow = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue')
-foreach ($token in @('step=ancestor', 'createPerson', 'genealogyContext')) {
+foreach ($token in @('step=ancestor', 'submitAncestor', 'genealogyId')) {
if ($createFlow -notmatch [regex]::Escape($token)) {
throw "Missing G03 first-person flow: $token"
}
}
+if ($createFlow -match 'createPerson') { throw 'G03 must not retain the removed createPerson entrypoint' }
foreach ($route in @('pages/family/f04-article-list', 'pages/family/f02-publish-feed')) {
if ($pages -notmatch [regex]::Escape($route)) {
throw "Missing content page route: $route"
@@ -69,7 +70,11 @@ foreach ($pageFile in @('pages/genealogy/g05-genealogy-overview.vue', 'pages/tre
if ($page -match "id=1001|id=3") {
throw "Hard-coded route ID remains in $pageFile"
}
- if ($page -notmatch 'genealogyContext') {
+ if ($pageFile -eq 'pages/genealogy/g05-genealogy-overview.vue') {
+ if ($page -notmatch "const genealogyId = ref\(''\)" -or $page -notmatch 'query\.genealogyId') {
+ throw 'G05 must own its explicit genealogyId route context'
+ }
+ } elseif ($page -notmatch 'genealogyContext') {
throw "Missing genealogy context in $pageFile"
}
}
diff --git a/tests/f-series-all-states-visual-contract.ps1 b/tests/f-series-all-states-visual-contract.ps1
new file mode 100644
index 0000000..f8c277d
--- /dev/null
+++ b/tests/f-series-all-states-visual-contract.ps1
@@ -0,0 +1,27 @@
+$ErrorActionPreference = 'Stop'
+
+function Read-Utf8([string]$Path) { [System.IO.File]::ReadAllText((Join-Path (Join-Path $PSScriptRoot '..') $Path), [System.Text.Encoding]::UTF8) }
+function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) { if (-not $Content.Contains($Expected)) { throw $Message } }
+
+$f01 = Read-Utf8 'pages/family/f01-family-feed.vue'
+$f02 = Read-Utf8 'pages/family/f02-publish-feed.vue'
+$module = Read-Utf8 'components/ModulePage.vue'
+
+foreach ($expected in @('font-size:24rpx','font-size:23rpx','font-size:22rpx')) {
+ Assert-Contains $f01 $expected "F01 readability token missing: $expected"
+}
+foreach ($expected in @('font-size:24rpx','font-size:23rpx')) {
+ Assert-Contains $f02 $expected "F02 readability token missing: $expected"
+}
+foreach ($expected in @(
+ '.module-lead { position: relative; height: 56rpx; margin: 0 12rpx 18rpx; color: $ink-muted; font-size: 23rpx;',
+ '.form-row > text { position: absolute; top: 28rpx; left: 26rpx; z-index: 1; color: $ink; font-size: 24rpx;',
+ '.form-row input { position: absolute; top: 0; right: 22rpx; bottom: 0; left: 178rpx; z-index: 1; height: 84rpx; color: $ink; font-size: 23rpx;',
+ '.list-card__copy text:nth-child(2) { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx;',
+ '.detail-card text:last-child { margin-top: 10rpx; color: $ink; font-size: 24rpx;',
+ '.status-card__note { display: block; margin-top: 17rpx; color: $ink-muted; font-size: 24rpx;'
+)) {
+ Assert-Contains $module $expected "ModulePage readability contract missing: $expected"
+}
+
+Write-Output 'F series all-states visual contract passed.'
diff --git a/tests/f01-module-baseline-contract.ps1 b/tests/f01-module-baseline-contract.ps1
new file mode 100644
index 0000000..7cd7808
--- /dev/null
+++ b/tests/f01-module-baseline-contract.ps1
@@ -0,0 +1,19 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$page = Get-Content -LiteralPath (Join-Path $root 'pages/family/f01-family-feed.vue') -Raw -Encoding utf8
+
+function Assert-Match([string]$Pattern, [string]$Message) {
+ if ($page -notmatch $Pattern) { throw $Message }
+}
+
+Assert-Match 'modules/family/transparent/f01-family-letter-card\.png' 'F01 must use its dedicated transparent family-letter card.'
+if ($page -match 'application-status-card\.png') { throw 'F01 must not reuse the application status card.' }
+Assert-Match 'class="feed-card__title"' 'F01 must expose an explicit feed title hierarchy.'
+Assert-Match 'class="feed-card__meta"' 'F01 must expose category and time as secondary metadata.'
+Assert-Match 'class="feed-card__summary"' 'F01 must expose feed summary copy.'
+Assert-Match 'class="feed-card__author"' 'F01 must expose the author as tertiary information.'
+Assert-Match '(?s)\.feed-shortcut\s*\{[^}]*min-height:\s*44px;' 'F01 shortcuts must preserve a 44 CSS px touch height.'
+Assert-Match 'class="feed-shortcuts__skin"' 'F01 shortcuts must share one visible navigation skin.'
+Assert-Match '(?s)\.feed-shortcuts\s*\{[^}]*grid-template-columns:\s*repeat\(4,minmax\(0,1fr\)\);' 'F01 shortcuts must use one four-column equal-width navigation strip.'
+
+Write-Output 'F01-MODULE-BASELINE-CONTRACT PASS'
diff --git a/tests/g-series-app-loading-contract.ps1 b/tests/g-series-app-loading-contract.ps1
new file mode 100644
index 0000000..607e360
--- /dev/null
+++ b/tests/g-series-app-loading-contract.ps1
@@ -0,0 +1,40 @@
+$ErrorActionPreference = 'Stop'
+
+$root = Split-Path -Parent $PSScriptRoot
+
+function Read-Page([string]$Path) {
+ Get-Content -LiteralPath (Join-Path $root $Path) -Raw -Encoding utf8
+}
+
+function Assert-Match {
+ param([string]$Content, [string]$Pattern, [string]$Message)
+ if ($Content -notmatch $Pattern) { throw $Message }
+}
+
+$pages = @{
+ G05 = Read-Page 'pages/genealogy/g05-genealogy-overview.vue'
+ G06 = Read-Page 'pages/genealogy/g06-search-genealogies.vue'
+ G09 = Read-Page 'pages/genealogy/g09-my-applications.vue'
+ G10 = Read-Page 'pages/genealogy/g10-application-review.vue'
+ G11 = Read-Page 'pages/genealogy/g11-genealogy-settings.vue'
+ G12 = Read-Page 'pages/genealogy/g12-generation-poems.vue'
+}
+
+foreach ($entry in $pages.GetEnumerator()) {
+ Assert-Match -Content $entry.Value -Pattern 'import AppLoading from [''"]@/components/AppLoading\.vue[''"]' -Message "$($entry.Key) must import AppLoading"
+}
+
+Assert-Match -Content $pages.G05 -Pattern '(?s).*?]+description="[^"]+"\s*/>.*?' -Message 'G05 must render AppLoading inside its transparent framed loading state'
+Assert-Match -Content $pages.G06 -Pattern ']+description="[^"]+"\s*/>' -Message 'G06 search results must render the section AppLoading variant'
+Assert-Match -Content $pages.G06 -Pattern 'requestedState === ''loading''' -Message 'G06 must expose its section loading state for visual review'
+Assert-Match -Content $pages.G09 -Pattern ']+description="[^"]+"\s*/>' -Message 'G09 must render a page AppLoading branch'
+Assert-Match -Content $pages.G10 -Pattern ']+description="[^"]+"\s*/>' -Message 'G10 must render a page AppLoading branch'
+Assert-Match -Content $pages.G11 -Pattern ']+description="[^"]+"\s*/>' -Message 'G11 must render a page AppLoading branch'
+Assert-Match -Content $pages.G12 -Pattern ']+description="[^"]+"\s*/>' -Message 'G12 must render a page AppLoading branch'
+Assert-Match -Content $pages.G10 -Pattern 'if \(query\.state === ''loading''\) \{ reviewState\.value = ''loading''; return \}' -Message 'G10 must preserve its loading presentation state'
+Assert-Match -Content $pages.G11 -Pattern 'query\.state === ''loading'' \? ''loading''' -Message 'G11 must preserve its loading presentation state'
+Assert-Match -Content $pages.G12 -Pattern 'query\.state === ''loading'' \? ''loading''' -Message 'G12 must preserve its loading presentation state'
+
+if ($pages.G06 -match 'class="search-status search-loading"') { throw 'G06 must not retain its duplicated text-only search loader' }
+
+Write-Output 'G-SERIES-APP-LOADING-CONTRACT PASS'
diff --git a/tests/g01-empty-state-contract.ps1 b/tests/g01-empty-state-contract.ps1
index 1689019..fc71a97 100644
--- a/tests/g01-empty-state-contract.ps1
+++ b/tests/g01-empty-state-contract.ps1
@@ -21,6 +21,9 @@ $paths = @($pages.pages.path)
$g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8
$runtimeSmoke = Get-Content -LiteralPath (Join-Path $root 'tests/g01-empty-state-runtime-smoke.js') -Raw -Encoding utf8
$panelPath = Join-Path $root 'static/assets/modules/genealogy/opaque/g01-empty-panel.png'
+$framePath = Join-Path $root 'static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png'
+$pipelinePackage = Get-Content -LiteralPath (Join-Path $root 'design-pipeline/package.json') -Raw -Encoding utf8
+$frameBuilderPath = Join-Path $root 'design-pipeline/scripts/build-g01-empty-frame.mjs'
if (-not ($paths -contains 'pages/genealogy/g01-my-genealogies')) { throw 'G01 route missing' }
if ($paths -contains 'pages/genealogy/g02-empty-genealogies') { throw 'G02 route must be removed; empty state belongs to G01' }
@@ -28,11 +31,12 @@ if ($paths -contains 'pages/genealogy/g02-empty-genealogies') { throw 'G02 route
foreach ($required in @(
'forceEmptyState',
'query.get("state") || "default"',
- 'g01-empty-panel.png',
'empty-create-action',
'empty-search-action',
'empty-invite-action',
'empty-create-note',
+ 'empty-panel__frame',
+ 'g01-empty-panel-frame.png',
'const createGenealogy = () =>',
'/pages/genealogy/g03-create-genealogy',
'/pages/genealogy/g06-search-genealogies',
@@ -56,12 +60,32 @@ foreach ($required in @(
Assert-Match -Content $g01 -Pattern '(?s).*class="current-slip".*class="shortcut-grid".*class="section-divider".*\s*]*class="genealogy-list-scroll"[^>]*>.*class="genealogy-lower".*' -Message 'G01 fixed and scrolling regions are not separated correctly'
Assert-Match -Content $g01 -Pattern '(?s)\.genealogy-index--split\s*\{[^}]*height:\s*100vh;[^}]*padding-bottom:\s*calc\(112rpx\s*\+\s*env\(safe-area-inset-bottom\)\)' -Message 'G01 split layout must reserve the fixed tabbar and safe area'
Assert-Match -Content $g01 -Pattern '(?s)\.genealogy-list-scroll\s*\{[^}]*height:\s*0;[^}]*flex:\s*1;' -Message 'G01 list scroll region must consume the remaining computed height'
+Assert-Match -Content $g01 -Pattern '(?s).*?\s*' -Message 'G01 empty state must render the exact extracted transparent frame without an opaque panel skin'
+Assert-Match -Content $g01 -Pattern '(?s)\.empty-panel__frame\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*width:\s*100%;[^}]*height:\s*100%;[^}]*pointer-events:\s*none;' -Message 'G01 empty state transparent frame does not cover the approved panel slot'
+Assert-Match -Content $g01 -Pattern '(?s)\.genealogy-empty-state\s*\{[^}]*min-height:\s*1120rpx;' -Message 'G01 empty state does not reserve the approved visibly fuller height'
+Assert-Match -Content $g01 -Pattern '(?s)\.empty-panel\s*\{[^}]*height:\s*1120rpx;' -Message 'G01 empty panel does not use the approved visibly fuller height'
+Assert-Match -Content $g01 -Pattern '(?s)\.empty-panel__content\s*\{[^}]*justify-content:\s*center;[^}]*padding:\s*50rpx 28rpx 46rpx;' -Message 'G01 empty panel content is not centered with the approved responsive padding'
+Assert-Match -Content $g01 -Pattern '(?s)\.empty-seal\s*\{[^}]*width:\s*120rpx;[^}]*height:\s*184rpx;' -Message 'G01 empty seal does not use the approved prominent size'
+Assert-Match -Content $g01 -Pattern '(?s)\.empty-title\s*\{[^}]*margin-top:\s*30rpx;[^}]*font-size:\s*48rpx;' -Message 'G01 empty title does not use the approved prominent size and spacing'
+Assert-Match -Content $g01 -Pattern '(?s)\.empty-copy\s*\{[^}]*min-height:\s*84rpx;[^}]*margin-top:\s*18rpx;[^}]*font-size:\s*28rpx;' -Message 'G01 empty guidance does not use the approved prominent size and spacing'
+Assert-Match -Content $g01 -Pattern '(?s)\.empty-search-action,\s*\.empty-invite-action\s*\{[^}]*width:\s*560rpx;[^}]*height:\s*124rpx;' -Message 'G01 empty actions do not use the approved prominent size'
+Assert-Match -Content $g01 -Pattern '(?s)\.empty-search-action__copy,\s*\.empty-invite-action__copy\s*\{[^}]*font-size:\s*34rpx;' -Message 'G01 empty action labels do not use the approved prominent size'
+Assert-Match -Content $g01 -Pattern '(?s)\.empty-create-action\s*\{[^}]*min-height:\s*76rpx;[^}]*margin-top:\s*14rpx;' -Message 'G01 empty create action does not use the approved prominent hit area and spacing'
+Assert-Match -Content $g01 -Pattern '(?s)\.empty-create-action__copy\s*\{[^}]*font-size:\s*31rpx;' -Message 'G01 empty create action does not use the approved prominent size'
+if ($g01 -match '(?s)\.empty-create-action__copy\s*\{[^}]*text-decoration:\s*underline') {
+ throw 'G01 empty create action must not render a text underline'
+}
+Assert-Match -Content $g01 -Pattern '(?s)\.empty-create-note\s*\{[^}]*font-size:\s*26rpx;[^}]*line-height:\s*38rpx;' -Message 'G01 empty-state create note does not use the approved readable size'
+if ($g01 -match '(?s).*?]+g01-empty-panel\.png') { throw 'G01 empty state must not retain the opaque panel background' }
foreach ($className in @('empty-panel', 'empty-create-action', 'empty-search-action')) {
Assert-NoCssSurface -Content $g01 -ClassName $className
}
if (-not (Test-Path -LiteralPath $panelPath)) { throw 'G01 requires its final raster empty-panel asset' }
+if (-not (Test-Path -LiteralPath $framePath)) { throw 'G01 requires the extracted transparent empty-panel frame asset' }
+if (-not (Test-Path -LiteralPath $frameBuilderPath)) { throw 'G01 transparent empty-panel frame must remain reproducible from its builder' }
+Assert-Match -Content $pipelinePackage -Pattern '"build:g01-empty-frame"\s*:\s*"node scripts/build-g01-empty-frame\.mjs"' -Message 'G01 transparent frame builder is not exposed by the design pipeline'
Assert-Match -Content $runtimeSmoke -Pattern '\{\s*width:\s*412,\s*height:\s*1000\s*\}' -Message 'G01 runtime smoke must include the approved 412x1000 tall-screen stress viewport'
Add-Type -AssemblyName System.Drawing
$panel = [System.Drawing.Bitmap]::FromFile($panelPath)
@@ -78,4 +102,21 @@ try {
$panel.Dispose()
}
+$frame = [System.Drawing.Bitmap]::FromFile($framePath)
+try {
+ if ($frame.Width -ne 1122 -or $frame.Height -ne 1402) { throw 'G01 transparent empty-panel frame must remain 1122 x 1402px' }
+ foreach ($point in @(@(0, 0), @(1121, 0), @(0, 1401), @(1121, 1401), @(561, 701))) {
+ if ($frame.GetPixel($point[0], $point[1]).A -ne 0) { throw 'G01 transparent empty-panel frame retains an opaque background pixel' }
+ }
+ $visible = 0
+ for ($y = 0; $y -lt $frame.Height; $y += 4) {
+ for ($x = 0; $x -lt $frame.Width; $x += 4) {
+ if ($frame.GetPixel($x, $y).A -gt 0) { $visible++ }
+ }
+ }
+ if ($visible -lt 1200 -or $visible -gt 12000) { throw "G01 transparent frame alpha coverage is implausible: $visible sampled pixels" }
+} finally {
+ $frame.Dispose()
+}
+
Write-Output 'G01-EMPTY-STATE-CONTRACT PASS'
diff --git a/tests/g01-error-state-contract.ps1 b/tests/g01-error-state-contract.ps1
new file mode 100644
index 0000000..c651f72
--- /dev/null
+++ b/tests/g01-error-state-contract.ps1
@@ -0,0 +1,24 @@
+$ErrorActionPreference = 'Stop'
+
+$root = Split-Path -Parent $PSScriptRoot
+$g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8
+
+function Assert-Match {
+ param([string]$Content, [string]$Pattern, [string]$Message)
+ if ($Content -notmatch $Pattern) { throw $Message }
+}
+
+$errorBlock = [regex]::Match($g01, '(?s).*?').Value
+if (-not $errorBlock) { throw 'G01 error state block missing' }
+foreach ($required in @('error-panel__frame', 'g01-empty-panel-frame.png', 'error-panel__seal', 'brand-seal.png', 'error-panel__divider', 'section-divider.png', 'state-retry', 'a01-scroll-primary-v3.png', '@click="retryLoad"')) {
+ if ($errorBlock -notmatch [regex]::Escape($required)) { throw "G01 error state missing approved contract: $required" }
+}
+Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s*\{[^}]*height:\s*1120rpx;' -Message 'G01 error state must use the approved full-height frame slot'
+Assert-Match -Content $g01 -Pattern '(?s)\.error-panel__seal\s*\{[^}]*width:\s*132rpx;[^}]*height:\s*136rpx;' -Message 'G01 error state seal must use the approved prominent size'
+Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s+\.state-title\s*\{[^}]*font-size:\s*42rpx;' -Message 'G01 error title must use the approved hierarchy'
+Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s+\.state-copy\s*\{[^}]*font-size:\s*27rpx;' -Message 'G01 error guidance must use the approved readable size'
+Assert-Match -Content $g01 -Pattern '(?s)\.state-panel--error\s+\.state-retry\s*\{[^}]*width:\s*560rpx;[^}]*height:\s*124rpx;' -Message 'G01 retry action must match the approved primary action size'
+
+if ($errorBlock -match 'modules/genealogy/opaque/g01-empty-panel\.png') { throw 'G01 error state must not retain the legacy opaque panel' }
+
+Write-Output 'G01-ERROR-STATE-CONTRACT PASS'
diff --git a/tests/g01-loading-state-contract.ps1 b/tests/g01-loading-state-contract.ps1
new file mode 100644
index 0000000..050d1bc
--- /dev/null
+++ b/tests/g01-loading-state-contract.ps1
@@ -0,0 +1,20 @@
+$ErrorActionPreference = 'Stop'
+
+$root = Split-Path -Parent $PSScriptRoot
+$g01 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-genealogies.vue') -Raw -Encoding utf8
+$appLoading = Get-Content -LiteralPath (Join-Path $root 'components/AppLoading.vue') -Raw -Encoding utf8
+
+function Assert-Match {
+ param([string]$Content, [string]$Pattern, [string]$Message)
+ if ($Content -notmatch $Pattern) { throw $Message }
+}
+
+Assert-Match -Content $g01 -Pattern 'import AppLoading from "@/components/AppLoading\.vue";' -Message 'G01 loading state must import the global AppLoading component'
+Assert-Match -Content $g01 -Pattern '(?s)\s*\s*' -Message 'G01 loading state must pass its approved title and supporting copy directly to AppLoading'
+Assert-Match -Content $appLoading -Pattern 'class="app-loading__seal"\s+src="/static/assets/foundation/transparent/brand-seal\.png"' -Message 'Global AppLoading must retain its approved red-gold seal asset'
+Assert-Match -Content $appLoading -Pattern 'app-loading-seal-breathe' -Message 'Global AppLoading must retain its restrained seal breathing motion'
+
+if ($g01 -match 'loading-seal') { throw 'G01 loading state must not retain the legacy hollow loading seal' }
+if ($g01 -match 'state-copy--loading') { throw 'G01 loading state must not duplicate AppLoading description layout' }
+
+Write-Output 'G01-LOADING-STATE-CONTRACT PASS'
diff --git a/tests/g03-create-flow-contract.ps1 b/tests/g03-create-flow-contract.ps1
index 9aabdb1..18bffd2 100644
--- a/tests/g03-create-flow-contract.ps1
+++ b/tests/g03-create-flow-contract.ps1
@@ -18,7 +18,7 @@ $pages = Get-Content -LiteralPath (Join-Path $root 'pages.json') -Raw -Encoding
$paths = @($pages.pages.path)
$g03 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
$g04 = Join-Path $root 'pages/genealogy/g04-first-ancestor.vue'
-$panelPath = Join-Path $root 'static/assets/modules/genealogy/opaque/g03-create-flow-panel.png'
+$panelPath = Join-Path $root 'static/assets/modules/genealogy/transparent/g01-empty-panel-frame.png'
if (-not ($paths -contains 'pages/genealogy/g03-create-genealogy')) { throw 'G03 route missing' }
if ($paths -contains 'pages/genealogy/g04-first-ancestor') { throw 'G04 route must be removed; first-ancestor is a G03 state' }
@@ -36,7 +36,7 @@ foreach ($required in @(
'class="duplicate-reminder-layer"',
'if (isSubmitting.value) return;',
'/pages/genealogy/g05-genealogy-overview?genealogyId=',
- 'g03-create-flow-panel.png',
+ 'g01-empty-panel-frame.png',
'root-header-cinnabar.jpg',
'a01-scroll-primary-v3.png',
'create-flow-panel',
@@ -53,18 +53,18 @@ foreach ($className in @('create-flow-panel', 'flow-primary-action')) {
Assert-NoCssSurface -Content $g03 -ClassName $className
}
-if (-not (Test-Path -LiteralPath $panelPath)) { throw 'G03 requires a final opaque flow-panel bitmap' }
+if (-not (Test-Path -LiteralPath $panelPath)) { throw 'G03 requires the accepted genealogy paper panel bitmap' }
Add-Type -AssemblyName System.Drawing
$panel = [System.Drawing.Bitmap]::FromFile($panelPath)
try {
- if ($panel.Width -ne 1122 -or $panel.Height -ne 1500) { throw 'G03 flow-panel asset must be 1122 x 1500px' }
+ if ($panel.Width -ne 1122 -or $panel.Height -ne 1402) { throw 'G03 paper panel asset must be 1122 x 1402px' }
$corners = @(
$panel.GetPixel(0, 0).A,
$panel.GetPixel(($panel.Width - 1), 0).A,
$panel.GetPixel(0, ($panel.Height - 1)).A,
$panel.GetPixel(($panel.Width - 1), ($panel.Height - 1)).A
)
- if (($corners | Where-Object { $_ -ne 255 }).Count -ne 0) { throw 'G03 flow-panel outer corners must be opaque' }
+ if (($corners | Where-Object { $_ -ne 0 }).Count -ne 0) { throw 'G03 paper panel outer corners must remain transparent' }
} finally {
$panel.Dispose()
}
diff --git a/tests/g03-visual-states-contract.ps1 b/tests/g03-visual-states-contract.ps1
new file mode 100644
index 0000000..5342acb
--- /dev/null
+++ b/tests/g03-visual-states-contract.ps1
@@ -0,0 +1,23 @@
+$ErrorActionPreference = 'Stop'
+
+$root = Split-Path -Parent $PSScriptRoot
+$g03 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g03-create-genealogy.vue') -Raw -Encoding utf8
+
+function Assert-Match {
+ param([string]$Content, [string]$Pattern, [string]$Message)
+ if ($Content -notmatch $Pattern) { throw $Message }
+}
+
+if ($g03 -match '返回') { throw 'G03 header must use only the approved image back arrow' }
+Assert-Match -Content $g03 -Pattern '(?s)\.flow-header__back,\s*\.flow-header__side\s*\{[^}]*width:\s*72rpx;' -Message 'G03 header side slots must preserve centered title after removing back text'
+Assert-Match -Content $g03 -Pattern '(?s)\.flow-header__back-icon\s*\{[^}]*width:\s*34rpx;[^}]*height:\s*34rpx;' -Message 'G03 back arrow must use the approved visible size'
+Assert-Match -Content $g03 -Pattern '(?s)\.flow-note,\s*\.flow-rule__note\s*\{[^}]*font-size:\s*25rpx;' -Message 'G03 guidance copy must use the approved readable size'
+Assert-Match -Content $g03 -Pattern '(?s)\.flow-rule__option\s*\{[^}]*font-size:\s*23rpx;' -Message 'G03 visibility options must use the approved readable size'
+Assert-Match -Content $g03 -Pattern '(?s)\.field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*34rpx;' -Message 'G03 field errors must use the approved readable size'
+Assert-Match -Content $g03 -Pattern '(?s)\.flow-error\s*\{[^}]*font-size:\s*25rpx;[^}]*line-height:\s*36rpx;' -Message 'G03 submit errors must use the approved readable size'
+
+foreach ($asset in @('a01-scroll-dialog-v3.png', 'a01-scroll-primary-v3.png', 'a01-scroll-secondary-v3.png')) {
+ if ($g03 -notmatch [regex]::Escape($asset)) { throw "G03 must retain approved shared asset: $asset" }
+}
+
+Write-Output 'G03-VISUAL-STATES-CONTRACT PASS'
diff --git a/tests/g05-all-states-visual-contract.ps1 b/tests/g05-all-states-visual-contract.ps1
new file mode 100644
index 0000000..6b91b07
--- /dev/null
+++ b/tests/g05-all-states-visual-contract.ps1
@@ -0,0 +1,19 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$g05 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue') -Raw -Encoding utf8
+
+function Assert-Match { param([string]$Pattern,[string]$Message); if ($g05 -notmatch $Pattern) { throw $Message } }
+
+Assert-Match 'overview-surface--state' 'G05 system states must own an independent surface'
+Assert-Match 'class="overview-state__frame".*g01-empty-panel-frame\.png' 'G05 system states must reuse the transparent gold frame'
+Assert-Match 'class="overview-state__seal".*brand-seal\.png' 'G05 non-loading states must use the approved red-gold seal'
+Assert-Match '(?s)\.overview-surface--state\s+\.overview-surface__skin\s*\{[^}]*display:\s*none;' 'G05 system states must hide the legacy function-grid skin'
+Assert-Match '(?s)\.overview-state__seal\s*\{[^}]*width:\s*132rpx;[^}]*height:\s*136rpx;' 'G05 state seal must use the approved size'
+Assert-Match 'overview-action-lock' 'G05 member mode must explain owner-only actions instead of leaving blank cells'
+Assert-Match '(?s)\.overview-actions--member\s*\{[^}]*grid-template-rows:\s*1fr 1fr;' 'G05 member actions must retain the full two-row surface'
+Assert-Match '(?s)\.overview-public__details > view:nth-child\(5\)\s*\{[^}]*grid-column:\s*1 / -1;' 'G05 public fifth identity detail must span the full row'
+Assert-Match '(?s)\.overview-public__details > view:nth-child\(5\)\s*\{[^}]*position:\s*absolute;[^}]*top:\s*250rpx;' 'G05 public parent genealogy must occupy the dedicated identity row'
+Assert-Match '(?s)\s*.*publicDescription.*\s* {
}
throw new Error(message)
}
-const open = async (send, query, selector) => {
+const waitForPageLoad = async (socket, trigger) => new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ socket.removeEventListener('message', onMessage)
+ reject(new Error('G05 reload did not emit Page.loadEventFired'))
+ }, 4000)
+ const onMessage = (event) => {
+ const message = JSON.parse(event.data)
+ if (message.method !== 'Page.loadEventFired') return
+ clearTimeout(timeout)
+ socket.removeEventListener('message', onMessage)
+ resolve()
+ }
+ socket.addEventListener('message', onMessage)
+ Promise.resolve().then(trigger).catch((error) => {
+ clearTimeout(timeout)
+ socket.removeEventListener('message', onMessage)
+ reject(error)
+ })
+})
+const open = async (socket, send, query, selector) => {
const url = `${origin}/#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, 'G05 navigation failed')
const documentTimeOrigin = await valueOf(send, 'performance.timeOrigin')
- await send('Page.reload')
+ await waitForPageLoad(socket, () => send('Page.reload'))
await waitFor(send, `performance.timeOrigin !== ${JSON.stringify(documentTimeOrigin)}`, `G05 ${query} did not reload into a fresh document`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `G05 ${query} did not render`)
}
@@ -57,27 +76,27 @@ const run = async () => {
try {
await send('Page.enable')
await send('Runtime.enable')
- await open(send, '?state=empty', '.overview-state--empty')
- await open(send, '?state=error&genealogyId=1001', '.overview-state--error')
- await open(send, '?state=no-permission&genealogyId=1001', '.overview-state--no-permission')
- await open(send, '?mode=public&genealogyId=2001', '.overview-public')
+ await open(socket, send, '?state=empty', '.overview-state--empty')
+ await open(socket, send, '?state=error&genealogyId=1001', '.overview-state--error')
+ await open(socket, send, '?state=no-permission&genealogyId=1001', '.overview-state--no-permission')
+ await open(socket, send, '?mode=public&genealogyId=2001', '.overview-public')
if ((await valueOf(send, "document.querySelectorAll('.overview-public__details > uni-view').length")) !== 5) throw new Error('G05 public preview is missing identity details')
if (await valueOf(send, "Boolean(document.querySelector('.overview-actions'))")) throw new Error('G05 public preview exposed member actions')
await valueOf(send, "document.querySelector('.overview-public__action').click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g08-join-application?source=search&genealogyId=2001')", 'G05 public apply action did not open G08 search source')
- await open(send, '?role=member&genealogyId=1001', '.overview-ready')
+ await open(socket, send, '?role=member&genealogyId=1001', '.overview-ready')
if ((await valueOf(send, "document.querySelectorAll('.overview-action').length")) !== 2) throw new Error('G05 member overview exposed owner actions')
if (await valueOf(send, "document.querySelector('.header-action')?.textContent.trim().length > 0")) throw new Error('G05 member overview exposed management action')
- await open(send, '?genealogyId=1001', '.overview-ready')
+ await open(socket, send, '?genealogyId=1001', '.overview-ready')
if ((await valueOf(send, "document.querySelectorAll('.overview-action').length")) !== 4) throw new Error('G05 owner overview is missing management actions')
await valueOf(send, "document.querySelector('.overview-action--ancestor')?.click()")
await waitFor(send, "location.href.includes('/pages/genealogy/g03-create-genealogy?step=ancestor&genealogyId=1001')", 'G05 ancestor action did not open the G03 ancestor step')
for (const size of [{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 412, height: 915 }]) {
await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true })
- await open(send, '?mode=public&genealogyId=2001', '.overview-public')
+ await open(socket, send, '?mode=public&genealogyId=2001', '.overview-public')
const width = await valueOf(send, 'document.documentElement.scrollWidth')
if (width > size.width + 1) throw new Error(`G05 has horizontal overflow at ${size.width}x${size.height}`)
}
diff --git a/tests/g06-search-flow-contract.ps1 b/tests/g06-search-flow-contract.ps1
index 241241d..dbb4a24 100644
--- a/tests/g06-search-flow-contract.ps1
+++ b/tests/g06-search-flow-contract.ps1
@@ -78,6 +78,11 @@ foreach ($className in @('search-title-strip', 'search-field', 'search-action'))
if ($g06 -notmatch '(?s)\.search-controls\s*\{[^}]*display:\s*flex[^}]*align-items:\s*center[^}]*gap:\s*20rpx') {
throw 'G06 search input and action must form one compact control row'
}
+Assert-Contains -Content $g06 -Expected 'class="status-retry"' -Message 'G06 error retry action is missing'
+Assert-Contains -Content $g06 -Expected 'src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"' -Message 'G06 error retry must use the approved A-series primary scroll asset'
+if ($g06 -notmatch '(?s)\.status-retry\s*\{[^}]*width:\s*300rpx;[^}]*height:\s*76rpx;') {
+ throw 'G06 error retry must use the approved compact action size'
+}
Add-Type -AssemblyName System.Drawing
foreach ($asset in @(
diff --git a/tests/g09-all-states-visual-contract.ps1 b/tests/g09-all-states-visual-contract.ps1
new file mode 100644
index 0000000..8e406db
--- /dev/null
+++ b/tests/g09-all-states-visual-contract.ps1
@@ -0,0 +1,18 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g09-my-applications.vue') -Raw -Encoding utf8
+
+$rules = @(
+ '(?s)\.application-intro text:last-child\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.application-card__time\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.application-card__relation\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.application-card__status\s*\{[^}]*font-size:\s*25rpx;',
+ '(?s)\.application-card__hint\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.application-card__action\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.application-state-card__copy text:last-child\s*\{[^}]*font-size:\s*24rpx;'
+)
+foreach ($rule in $rules) {
+ if ($page -notmatch $rule) { throw "G09 readable visual rule missing: $rule" }
+}
+
+Write-Output 'G09-ALL-STATES-VISUAL-CONTRACT PASS'
diff --git a/tests/g10-all-states-visual-contract.ps1 b/tests/g10-all-states-visual-contract.ps1
new file mode 100644
index 0000000..bb789e7
--- /dev/null
+++ b/tests/g10-all-states-visual-contract.ps1
@@ -0,0 +1,31 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g10-application-review.vue') -Raw -Encoding utf8
+
+foreach ($required in @(
+ 'class="review-result"',
+ 'review-result--approved',
+ 'review-result--rejected',
+ 'a01-secondary-button-v2.png',
+ 'a01-primary-button-v2.png',
+ 'a01-scroll-secondary-v3.png',
+ 'a01-scroll-primary-v3.png'
+)) {
+ if ($page -notmatch [regex]::Escape($required)) { throw "G10 result visual missing: $required" }
+}
+
+$rules = @(
+ '(?s)\.review-intro text:nth-child\(2\)\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.application-card__phone\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.application-card__time\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.application-card__relation\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.review-action\s*\{[^}]*width:\s*148rpx;[^}]*height:\s*68rpx;',
+ '(?s)\.review-action text\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.review-result\s*\{[^}]*width:\s*308rpx;[^}]*height:\s*68rpx;',
+ '(?s)\.review-state-card__copy text:last-child\s*\{[^}]*font-size:\s*24rpx;'
+)
+foreach ($rule in $rules) {
+ if ($page -notmatch $rule) { throw "G10 readable visual rule missing: $rule" }
+}
+
+Write-Output 'G10-ALL-STATES-VISUAL-CONTRACT PASS'
diff --git a/tests/g11-g12-all-states-visual-contract.ps1 b/tests/g11-g12-all-states-visual-contract.ps1
new file mode 100644
index 0000000..9065822
--- /dev/null
+++ b/tests/g11-g12-all-states-visual-contract.ps1
@@ -0,0 +1,26 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$g11 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g11-genealogy-settings.vue') -Raw -Encoding utf8
+$g12 = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g12-generation-poems.vue') -Raw -Encoding utf8
+
+foreach ($rule in @(
+ '(?s)\.settings-form__copy, \.settings-result__copy\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.settings-field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*34rpx;',
+ '(?s)\.visibility-block__hint\s*\{[^}]*font-size:\s*22rpx;',
+ '(?s)\.visibility-option text\s*\{[^}]*font-size:\s*24rpx;'
+)) {
+ if ($g11 -notmatch $rule) { throw "G11 readable visual rule missing: $rule" }
+}
+
+foreach ($rule in @(
+ '(?s)\.poem-list__copy\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.poem-row__number\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.poem-row__status\s*\{[^}]*font-size:\s*22rpx;',
+ '(?s)\.poem-field-error\s*\{[^}]*font-size:\s*24rpx;[^}]*line-height:\s*34rpx;',
+ '(?s)\.poem-policy > text\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.poem-preview\s*\{[^}]*font-size:\s*23rpx;'
+)) {
+ if ($g12 -notmatch $rule) { throw "G12 readable visual rule missing: $rule" }
+}
+
+Write-Output 'G11-G12-ALL-STATES-VISUAL-CONTRACT PASS'
diff --git a/tests/g11-g12-runtime-navigation-contract.ps1 b/tests/g11-g12-runtime-navigation-contract.ps1
new file mode 100644
index 0000000..bb1958f
--- /dev/null
+++ b/tests/g11-g12-runtime-navigation-contract.ps1
@@ -0,0 +1,13 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$runtime = Get-Content -LiteralPath (Join-Path $root 'tests/g11-g12-settings-poems-runtime-smoke.js') -Raw -Encoding utf8
+
+foreach ($required in @(
+ 'const waitForPageLoad = async (socket, trigger)',
+ "message.method === 'Page.loadEventFired'",
+ "await waitForPageLoad(socket, () => send('Page.reload'))"
+)) {
+ if ($runtime -notmatch [regex]::Escape($required)) { throw "G11/G12 runtime navigation synchronization missing: $required" }
+}
+
+Write-Output 'G11-G12-RUNTIME-NAVIGATION-CONTRACT PASS'
diff --git a/tests/g11-g12-settings-poems-contract.ps1 b/tests/g11-g12-settings-poems-contract.ps1
index 0a8183b..dbdbc48 100644
--- a/tests/g11-g12-settings-poems-contract.ps1
+++ b/tests/g11-g12-settings-poems-contract.ps1
@@ -22,7 +22,7 @@ $catalog = Get-Content -LiteralPath (Join-Path $root 'data/page-catalog.js') -Ra
foreach ($page in @($g11, $g12)) {
if ($page -match '' 'G11-G12 must use the accepted shared genealogy background'
- Assert-Contains $page 'g03-create-flow-panel.png' 'G11-G12 forms must reuse the complete genealogy paper panel'
+ Assert-Contains $page 'g01-empty-panel-frame.png' 'G11-G12 forms must reuse the complete genealogy paper panel'
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'G11-G12 design phase must not connect the API layer' }
}
@@ -34,7 +34,7 @@ foreach ($required in @(
'settings-state--no-permission',
'const nameError = ref',
'class="settings-feedback"',
- 'g06-search-input-wide.png',
+ 'g-form-field-frame.png',
'a01-scroll-primary-v3.png',
'const genealogyDraft',
'query.genealogyId',
@@ -51,7 +51,7 @@ foreach ($required in @(
'poem-state--no-permission',
'const poemError = ref',
'class="poem-feedback"',
- 'g06-search-input-wide.png',
+ 'g-form-field-frame.png',
'a01-scroll-primary-v3.png',
'a01-scroll-secondary-v3.png',
'const poemRows',
diff --git a/tests/g11-g12-settings-poems-runtime-smoke.js b/tests/g11-g12-settings-poems-runtime-smoke.js
index cd7b304..337c987 100644
--- a/tests/g11-g12-settings-poems-runtime-smoke.js
+++ b/tests/g11-g12-settings-poems-runtime-smoke.js
@@ -35,11 +35,31 @@ const waitFor = async (send, expression, message) => {
}
throw new Error(message)
}
-const open = async (send, route, query, selector) => {
+const waitForPageLoad = async (socket, trigger) => new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ socket.removeEventListener('message', onMessage)
+ reject(new Error('Page reload did not emit Page.loadEventFired'))
+ }, 4000)
+ const onMessage = (event) => {
+ const message = JSON.parse(event.data)
+ if (message.method === 'Page.loadEventFired') {
+ clearTimeout(timeout)
+ socket.removeEventListener('message', onMessage)
+ resolve()
+ }
+ }
+ socket.addEventListener('message', onMessage)
+ Promise.resolve().then(trigger).catch((error) => {
+ clearTimeout(timeout)
+ socket.removeEventListener('message', onMessage)
+ reject(error)
+ })
+})
+const open = async (socket, send, route, query, selector) => {
const url = `${origin}/#${route}${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `${route} navigation failed`)
- await send('Page.reload')
+ await waitForPageLoad(socket, () => send('Page.reload'))
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `${route}${query} did not render`)
}
@@ -48,11 +68,11 @@ const run = async () => {
try {
await send('Page.enable')
await send('Runtime.enable')
- await open(send, '/pages/genealogy/g11-genealogy-settings', '?genealogyId=1001', '.settings-state--form')
- await open(send, '/pages/genealogy/g11-genealogy-settings', '?state=success&genealogyId=1001', '.settings-state--success')
- await open(send, '/pages/genealogy/g12-generation-poems', '?genealogyId=1001', '.poem-state--list')
- await open(send, '/pages/genealogy/g12-generation-poems', '?state=empty&genealogyId=1001', '.poem-state--empty')
- await open(send, '/pages/genealogy/g12-generation-poems', '?state=edit&genealogyId=1001', '.poem-state--edit')
+ await open(socket, send, '/pages/genealogy/g11-genealogy-settings', '?genealogyId=1001', '.settings-state--form')
+ await open(socket, send, '/pages/genealogy/g11-genealogy-settings', '?state=success&genealogyId=1001', '.settings-state--success')
+ await open(socket, send, '/pages/genealogy/g12-generation-poems', '?genealogyId=1001', '.poem-state--list')
+ await open(socket, send, '/pages/genealogy/g12-generation-poems', '?state=empty&genealogyId=1001', '.poem-state--empty')
+ await open(socket, send, '/pages/genealogy/g12-generation-poems', '?state=edit&genealogyId=1001', '.poem-state--edit')
process.stdout.write('G11-G12-SETTINGS-POEMS-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
diff --git a/tests/genealogy-shared-background-contract.ps1 b/tests/genealogy-shared-background-contract.ps1
new file mode 100644
index 0000000..0e74332
--- /dev/null
+++ b/tests/genealogy-shared-background-contract.ps1
@@ -0,0 +1,66 @@
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.Drawing
+
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$pagesJsonPath = Join-Path $repoRoot 'pages.json'
+$componentPath = Join-Path $repoRoot 'components/GenealogyPageBackground.vue'
+$assetRelativePath = 'static/assets/modules/genealogy/opaque/genealogy-page-background-long.png'
+$assetPath = Join-Path $repoRoot $assetRelativePath
+$expectedHash = '9aa9ebdd231eddbc151e238ba340a00f8603f23143117ad8c93009947dcca2e2'
+
+function Assert-Contract {
+ param(
+ [bool]$Condition,
+ [string]$Message
+ )
+
+ if (-not $Condition) {
+ throw "G shared background contract failed: $Message"
+ }
+}
+
+$pagesConfig = Get-Content -Raw -Encoding UTF8 $pagesJsonPath | ConvertFrom-Json
+$genealogyRoutes = @(
+ $pagesConfig.pages |
+ Where-Object { $_.path -match '^pages/genealogy/g\d{2}-' } |
+ ForEach-Object { $_.path }
+)
+
+Assert-Contract ($genealogyRoutes.Count -eq 9) "expected 9 active G routes from pages.json, got $($genealogyRoutes.Count)"
+Assert-Contract (Test-Path -LiteralPath $componentPath) 'missing components/GenealogyPageBackground.vue'
+Assert-Contract (Test-Path -LiteralPath $assetPath) "missing $assetRelativePath"
+
+$componentSource = Get-Content -Raw -Encoding UTF8 $componentPath
+Assert-Contract ($componentSource -match [regex]::Escape('/static/assets/modules/genealogy/opaque/genealogy-page-background-long.png')) 'component must own the approved long runtime asset path'
+Assert-Contract ($componentSource -match 'mode="widthFix"') 'shared artwork must use widthFix'
+Assert-Contract ($componentSource -match '(?s)\.genealogy-page-background\s*\{.*position:\s*fixed;.*inset:\s*0;.*background:\s*#[0-9a-fA-F]{6};.*pointer-events:\s*none;') 'shared background layer must be fixed, full-screen, paper-colored, and non-interactive'
+Assert-Contract ($componentSource -match '(?s)\.genealogy-page-background__art\s*\{.*width:\s*100%;') 'shared artwork must render at full page width'
+Assert-Contract ($componentSource -match '(?s)\.genealogy-page-background__art\s*\{.*bottom:\s*0;') 'shared artwork must stay bottom-aligned on tall viewports'
+Assert-Contract ($componentSource -notmatch '(?s)\.genealogy-page-background__art\s*\{[^}]*top:\s*0;') 'shared artwork must not remain top-aligned'
+Assert-Contract ($componentSource -match '(?s)\.genealogy-page-background__art\s*\{[^}]*opacity:\s*0\.28;') 'shared artwork must use the approved 28% opacity'
+
+$image = [System.Drawing.Image]::FromFile($assetPath)
+try {
+ Assert-Contract ($image.Width -eq 1440 -and $image.Height -eq 3600) "expected 1440x3600 runtime background, got $($image.Width)x$($image.Height)"
+}
+finally {
+ $image.Dispose()
+}
+$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $assetPath).Hash.ToLowerInvariant()
+Assert-Contract ($actualHash -eq $expectedHash) "shared runtime asset SHA-256 mismatch: $actualHash"
+
+foreach ($route in $genealogyRoutes) {
+ $pagePath = Join-Path $repoRoot ($route + '.vue')
+ Assert-Contract (Test-Path -LiteralPath $pagePath) "missing route file $route.vue"
+
+ $pageSource = Get-Content -Raw -Encoding UTF8 $pagePath
+ Assert-Contract ($pageSource -match 'import\s+GenealogyPageBackground\s+from\s+["'']@/components/GenealogyPageBackground\.vue["''];?') "$route must explicitly import GenealogyPageBackground"
+ $componentCount = ([regex]::Matches($pageSource, '')).Count
+ Assert-Contract ($componentCount -eq 1) "$route must render exactly one GenealogyPageBackground, got $componentCount"
+ Assert-Contract ($pageSource -notmatch 'page-paper\.jpg') "$route still references page-paper.jpg"
+ Assert-Contract ($pageSource -notmatch 'footer-mountain-bamboo\.png') "$route still references footer-mountain-bamboo.png"
+ Assert-Contract ($pageSource -notmatch [regex]::Escape('/static/assets/modules/genealogy/opaque/genealogy-page-background-long.png')) "$route must consume the component instead of the asset directly"
+ Assert-Contract ($pageSource -notmatch '(?s)\.genealogy-page-background__art\s*\{[^}]*opacity:') "$route must not override the shared background opacity"
+}
+
+Write-Output 'PASS G genealogy shared background contract'
diff --git a/tests/global-heritage-visual-system-contract.ps1 b/tests/global-heritage-visual-system-contract.ps1
new file mode 100644
index 0000000..967729f
--- /dev/null
+++ b/tests/global-heritage-visual-system-contract.ps1
@@ -0,0 +1,86 @@
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.Drawing
+
+$root = Split-Path -Parent $PSScriptRoot
+$componentsRoot = Join-Path $root 'components'
+$pagesRoot = Join-Path $root 'pages'
+
+function Read-Utf8([string]$Path) {
+ return Get-Content -Raw -Encoding UTF8 $Path
+}
+
+foreach ($component in @('AppButton.vue', 'AppDialog.vue', 'AppToast.vue', 'AppLoading.vue', 'ModulePageBackground.vue')) {
+ $path = Join-Path $componentsRoot $component
+ if (-not (Test-Path -LiteralPath $path)) { throw "Missing global visual component: $component" }
+}
+
+$button = Read-Utf8 (Join-Path $componentsRoot 'AppButton.vue')
+if ($button -notmatch 'a01-scroll-primary-v3\.png') { throw 'AppButton does not own the approved A-series primary scroll asset.' }
+if ($button -notmatch 'a01-scroll-secondary-v3\.png') { throw 'AppButton does not own the approved A-series secondary scroll asset.' }
+
+$dialog = Read-Utf8 (Join-Path $componentsRoot 'AppDialog.vue')
+if ($dialog -notmatch 'a01-scroll-dialog-v3\.png') { throw 'AppDialog does not own the approved A-series dialog asset.' }
+
+$toast = Read-Utf8 (Join-Path $componentsRoot 'AppToast.vue')
+if ($toast -notmatch 'a01-scroll-toast-v3\.png') { throw 'AppToast does not own the approved A-series toast asset.' }
+
+$header = Read-Utf8 (Join-Path $componentsRoot 'PageHeader.vue')
+if ($header -notmatch 'chevron-right\.png') { throw 'PageHeader does not use the approved image arrow.' }
+if ($header -match '\{\{\s*backLabel\s*\}\}') { throw 'PageHeader still renders a text back label.' }
+if ($header -notmatch 'scaleX\(-1\)') { throw 'PageHeader back arrow is not oriented toward the previous page.' }
+
+$activeSources = @(
+ Get-ChildItem -LiteralPath $componentsRoot -Recurse -File -Filter '*.vue'
+ Get-ChildItem -LiteralPath $pagesRoot -Recurse -File -Filter '*.vue' | Where-Object { $_.Name -ne 'a06-auth-status.vue' }
+)
+foreach ($source in $activeSources) {
+ $content = Read-Utf8 $source.FullName
+ foreach ($forbidden in @(
+ 'uni.showToast',
+ 'uni.showModal',
+ 'uni.showLoading',
+ 'uni.showActionSheet',
+ '/static/assets/foundation/opaque/a01-primary-button.png',
+ '/static/assets/foundation/opaque/a01-secondary-button.png'
+ )) {
+ if ($content.Contains($forbidden)) { throw "$($source.FullName) still contains forbidden global UI path: $forbidden" }
+ }
+}
+
+$background = Read-Utf8 (Join-Path $componentsRoot 'ModulePageBackground.vue')
+$moduleAssets = @{
+ tree = 'tree-page-background-long.png'
+ family = 'family-page-background-long.png'
+ records = 'records-page-background-long.png'
+ notification = 'notification-page-background-long.png'
+ profile = 'profile-page-background-long.png'
+}
+foreach ($entry in $moduleAssets.GetEnumerator()) {
+ if ($background -notmatch [regex]::Escape($entry.Value)) { throw "ModulePageBackground is missing $($entry.Key) mapping." }
+ $asset = Get-ChildItem -LiteralPath (Join-Path $root 'static/assets/modules') -Recurse -File -Filter $entry.Value
+ if (-not $asset) { throw "Missing module background asset: $($entry.Value)" }
+ $image = [System.Drawing.Image]::FromFile($asset.FullName)
+ try {
+ if ($image.Width -ne 1440 -or $image.Height -ne 3600) {
+ throw "$($entry.Value) must be 1440x3600, got $($image.Width)x$($image.Height)."
+ }
+ }
+ finally {
+ $image.Dispose()
+ }
+}
+
+$modulePage = Read-Utf8 (Join-Path $componentsRoot 'ModulePage.vue')
+foreach ($token in @(']+description="[^"]+"\s*/>' -Message 'T01 must render AppLoading before its tree and state cards'
+Assert-Match -Content $pages.T03 -Pattern ']+description="[^"]+"\s*/>' -Message 'T03 must render AppLoading before detail and error states'
+Assert-Match -Content $pages.T07 -Pattern ']+description="[^"]+"\s*/>' -Message 'T07 must render AppLoading before list and state cards'
+Assert-Match -Content $pages.F01 -Pattern ']+description="[^"]+"\s*/>' -Message 'F01 must render AppLoading before feed content'
+Assert-Match -Content $pages.N01 -Pattern ']+description="[^"]+"\s*/>' -Message 'N01 must render AppLoading before message content'
+
+Assert-Match -Content $pages.T01 -Pattern 'query\.state === ''loading''' -Message 'T01 must preserve its loading presentation state'
+Assert-Match -Content $pages.T03 -Pattern 'query\.state\s*===\s*''loading''\s*\?\s*''loading''' -Message 'T03 must preserve its loading presentation state'
+Assert-Match -Content $pages.T07 -Pattern 'query\.state\s*===\s*''loading''\s*\?\s*''loading''' -Message 'T07 must preserve its loading presentation state'
+Assert-Match -Content $pages.F01 -Pattern 'query\.state\s*===\s*''loading''\s*\?\s*''loading''' -Message 'F01 must preserve its loading presentation state'
+Assert-Match -Content $pages.N01 -Pattern 'query\.state\s*===\s*''loading''\s*\?\s*''loading''' -Message 'N01 must preserve its loading presentation state'
+
+Write-Output 'MODULE-APP-LOADING-CONTRACT PASS'
diff --git a/tests/module-baseline-promotion-contract.ps1 b/tests/module-baseline-promotion-contract.ps1
new file mode 100644
index 0000000..af56005
--- /dev/null
+++ b/tests/module-baseline-promotion-contract.ps1
@@ -0,0 +1,33 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+
+function Read-Utf8([string]$Path) {
+ [System.IO.File]::ReadAllText((Join-Path $root $Path), [System.Text.Encoding]::UTF8)
+}
+
+$targets = @(
+ 'pages/family/f02-publish-feed.vue',
+ 'pages/tree/t03-member-profile.vue',
+ 'pages/tree/t08-member-states.vue',
+ 'pages/genealogy/g03-create-genealogy.vue',
+ 'pages/genealogy/g08-join-application.vue',
+ 'pages/genealogy/g09-my-applications.vue',
+ 'pages/genealogy/g10-application-review.vue',
+ 'pages/genealogy/g11-genealogy-settings.vue',
+ 'pages/genealogy/g12-generation-poems.vue'
+)
+
+foreach ($target in $targets) {
+ $content = Read-Utf8 $target
+ foreach ($forbidden in @('g03-create-flow-panel.png', 'g06-search-input-wide.png', 'application-status-card.png')) {
+ if ($content.Contains($forbidden)) { throw "$target still uses obsolete opaque cross-module surface: $forbidden" }
+ }
+}
+
+$modulePage = Read-Utf8 'components/ModulePage.vue'
+if ($modulePage.Contains('class="page-intro"')) { throw 'ModulePage must remove repeated numbered intro blocks' }
+foreach ($required in @('module-content-frame.png', 'module-field-frame.png', ' new Promise((resolve) => setTimeout(resolve, ms))
+const origin = process.argv[2] || 'http://localhost:5173'
+const sizes = [
+ { width: 320, height: 568 },
+ { width: 360, height: 640 },
+ { width: 360, height: 800 },
+ { width: 412, height: 915 }
+]
+const routes = [
+ ['/pages/family/f01-family-feed', '.family-page'],
+ ['/pages/family/f02-publish-feed', '.publish-page'],
+ ['/pages/family/f04-article-list', '.module-page--list'],
+ ['/pages/family/f06-article-editor', '.module-page--form'],
+ ['/pages/family/f03-feed-detail', '.module-page--detail'],
+ ['/pages/records/r08-growth-journal', '.module-page--timeline'],
+ ['/pages/profile/m03-security-settings', '.module-page--settings'],
+ ['/pages/family/f10-video-list', '.module-page--status'],
+ ['/pages/notification/n01-message-center', '.notice-page'],
+ ['/pages/profile/m01-profile-home', '.profile-page']
+]
+
+const connect = async () => {
+ const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
+ const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
+ if (!page) throw new Error(`Chrome debugging has no ${origin} page`)
+ const socket = new WebSocket(page.webSocketDebuggerUrl)
+ await new Promise((resolve, reject) => {
+ socket.addEventListener('open', resolve, { once: true })
+ socket.addEventListener('error', reject, { once: true })
+ })
+ let id = 0
+ const pending = new Map()
+ socket.addEventListener('message', (event) => {
+ const message = JSON.parse(event.data)
+ const request = pending.get(message.id)
+ if (!request) return
+ pending.delete(message.id)
+ message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
+ })
+ const send = (method, params = {}) => new Promise((resolve, reject) => {
+ id += 1
+ pending.set(id, { resolve, reject })
+ socket.send(JSON.stringify({ id, method, params }))
+ })
+ return { socket, send }
+}
+
+const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
+const waitFor = async (send, expression, message) => {
+ for (let attempt = 0; attempt < 50; attempt += 1) {
+ if (await valueOf(send, expression)) return
+ await sleep(80)
+ }
+ throw new Error(message)
+}
+
+const run = async () => {
+ const { socket, send } = await connect()
+ let auditId = 0
+ try {
+ await send('Page.enable')
+ await send('Runtime.enable')
+ for (const size of sizes) {
+ await send('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: true, screenWidth: size.width, screenHeight: size.height })
+ for (const [route, selector] of routes) {
+ auditId += 1
+ const url = `${origin}/?responsiveAudit=${auditId}#${route}`
+ await send('Page.navigate', { url })
+ await waitFor(send, `location.href === ${JSON.stringify(url)}`, `Navigation failed: ${route} at ${size.width}x${size.height}`)
+ await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `State missing: ${route} at ${size.width}x${size.height}`)
+ const metrics = await valueOf(send, `(() => {
+ const root = document.querySelector(${JSON.stringify(selector)})
+ const rect = root.getBoundingClientRect()
+ const visible = Array.from(document.querySelectorAll('.app-button,.list-card,.detail-card,.form-row,.settings-row,.notice-card,.profile-menu'))
+ .filter((node) => node.getClientRects().length)
+ .map((node) => node.getBoundingClientRect())
+ return {
+ documentWidth: document.documentElement.scrollWidth,
+ rootLeft: rect.left,
+ rootRight: rect.right,
+ childOverflow: visible.some((item) => item.left < -1 || item.right > innerWidth + 1)
+ }
+ })()`)
+ assert(metrics.documentWidth <= size.width + 1, `${route} has horizontal document overflow at ${size.width}x${size.height}`)
+ assert(metrics.rootLeft >= -1 && metrics.rootRight <= size.width + 1, `${route} root exceeds viewport at ${size.width}x${size.height}`)
+ assert.strictEqual(metrics.childOverflow, false, `${route} has a visible control outside the viewport at ${size.width}x${size.height}`)
+ }
+ }
+ process.stdout.write('MODULE-SERIES-RESPONSIVE-RUNTIME-SMOKE PASS\n')
+ } finally {
+ try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
+ socket.close()
+ }
+}
+
+run().catch((error) => {
+ process.stderr.write(`${error.stack || error.message}\n`)
+ process.exit(1)
+})
diff --git a/tests/n-series-all-states-visual-contract.ps1 b/tests/n-series-all-states-visual-contract.ps1
new file mode 100644
index 0000000..89cee8c
--- /dev/null
+++ b/tests/n-series-all-states-visual-contract.ps1
@@ -0,0 +1,14 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$n01 = Get-Content -LiteralPath (Join-Path $root 'pages/notification/n01-message-center.vue') -Raw -Encoding utf8
+$capture = Get-Content -LiteralPath (Join-Path $root 'scripts/capture-chrome-page.js') -Raw -Encoding utf8
+
+foreach ($required in @('font-size:\s*21rpx','font-size:\s*23rpx','class="notice-review-action" block',')") { throw 'R01 must own its page instead of delegating to ModulePage.' }
+if ($page -match 'application-status-card\.png') { throw 'R01 must not reuse the application status card.' }
+if ($page -match 'people-intro|brand-seal\.png') { throw 'R01 must not repeat a numbered intro block below the page header.' }
+Assert-Match 'modules/records/transparent/r01-person-name-card\.png' 'R01 must use its dedicated transparent person name card.'
+Assert-Match 'class="people-search"' 'R01 must expose its search region.'
+Assert-Match 'people-state--empty' 'R01 must expose an empty review state.'
+Assert-Match 'people-state--error' 'R01 must expose an error review state.'
+Assert-Match 'AppButton' 'R01 must use the project button component.'
+Assert-Match 'AppToast' 'R01 must use the project Toast component.'
+Assert-Match '/pages/records/r02-person-detail' 'R01 person cards must navigate to R02.'
+Assert-Match '(?s)\.people-search__action\s*\{[^}]*min-height:\s*44px;' 'R01 search action must preserve a 44 CSS px touch height.'
+
+Write-Output 'R01-MODULE-BASELINE-CONTRACT PASS'
diff --git a/tests/root-pages-visual-contract.ps1 b/tests/root-pages-visual-contract.ps1
index 1636544..f710488 100644
--- a/tests/root-pages-visual-contract.ps1
+++ b/tests/root-pages-visual-contract.ps1
@@ -4,8 +4,10 @@ $root=Split-Path -Parent $PSScriptRoot
$files=@('pages/family/f01-family-feed.vue','pages/family/f02-publish-feed.vue','pages/notification/n01-message-center.vue','pages/profile/m01-profile-home.vue')
$pages=@{}
foreach($file in $files){$content=Get-Content -LiteralPath(Join-Path $root $file)-Raw -Encoding utf8;if($content-match"@/utils/api\.js|\bappApi\b"){ throw "$file must not connect the API layer" };Assert-Contains $content 'ModulePageBackground' "$file must use its module background";$pages[$file]=$content}
-foreach($required in @('feed-state--list','feed-state--empty','feed-state--error','application-status-card.png','/pages/family/f02-publish-feed')){Assert-Contains $pages['pages/family/f01-family-feed.vue'] $required "Missing F01 contract: $required"}
-foreach($required in @('publish-state--form','publish-state--success','publish-state--error','g03-create-flow-panel.png','g06-search-input-wide.png','AppButton','AppToast')){Assert-Contains $pages['pages/family/f02-publish-feed.vue'] $required "Missing F02 contract: $required"}
-foreach($required in @('notice-state--list','notice-state--empty','notice-state--error','application-status-card.png','AppButton','AppToast','/pages/genealogy/g10-application-review')){Assert-Contains $pages['pages/notification/n01-message-center.vue'] $required "Missing N01 contract: $required"}
-foreach($required in @('profile-state--ready','profile-state--error','g03-create-flow-panel.png','g06-search-input-wide.png','application-status-card.png','/pages/profile/m02-edit-profile')){Assert-Contains $pages['pages/profile/m01-profile-home.vue'] $required "Missing M01 contract: $required"}
+foreach($required in @('feed-state--list','feed-state--empty','feed-state--error','f01-family-letter-card.png','/pages/family/f02-publish-feed')){Assert-Contains $pages['pages/family/f01-family-feed.vue'] $required "Missing F01 contract: $required"}
+foreach($required in @('publish-state--form','publish-state--success','publish-state--error','module-content-frame.png','module-field-frame.png','AppButton','AppToast')){Assert-Contains $pages['pages/family/f02-publish-feed.vue'] $required "Missing F02 contract: $required"}
+foreach($required in @('notice-state--loading','notice-state--list','notice-state--empty','notice-state--error','n01-notice-card.png','AppButton','AppLoading','AppToast','/pages/genealogy/g10-application-review')){Assert-Contains $pages['pages/notification/n01-message-center.vue'] $required "Missing N01 contract: $required"}
+if($pages['pages/notification/n01-message-center.vue']-match'application-status-card\.png'){throw 'N01 must not reuse the genealogy application card'}
+foreach($required in @('profile-state--ready','profile-state--error','m01-profile-summary-card.png','brand-seal.png','auth-divider-knot.png','chevron-right.png','AppButton','/pages/profile/m02-edit-profile')){Assert-Contains $pages['pages/profile/m01-profile-home.vue'] $required "Missing M01 contract: $required"}
+foreach($forbidden in @('m01-profile-notice-card.png','m01-profile-menu-frame.png','g03-create-flow-panel.png','g06-search-input-wide.png','application-status-card.png')){if($pages['pages/profile/m01-profile-home.vue']-match[regex]::Escape($forbidden)){throw "M01 must not reuse repeated or opaque business asset: $forbidden"}}
Write-Output 'ROOT-PAGES-VISUAL-CONTRACT PASS'
diff --git a/tests/t01-all-states-visual-contract.ps1 b/tests/t01-all-states-visual-contract.ps1
new file mode 100644
index 0000000..9941f9a
--- /dev/null
+++ b/tests/t01-all-states-visual-contract.ps1
@@ -0,0 +1,31 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8
+
+foreach ($required in @(
+ 'a01-primary-button-v2.png',
+ 'a01-secondary-button-v2.png',
+ 't01-member-node-standard.png',
+ 't01-member-node-selected.png',
+ 't01-state-panel.png',
+ 't01-member-drawer.png',
+ 'lineage-connector--root',
+ 'lineage-pan-cue'
+)) {
+ if ($page -notmatch [regex]::Escape($required)) { throw "T01 member action asset missing: $required" }
+}
+
+foreach ($rule in @(
+ '(?s)\.tree-toolbar__title text:last-child\s*\{[^}]*font-size:\s*22rpx;',
+ '(?s)\.tree-toolbar__actions\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.generation-band\s*\{[^}]*font-size:\s*22rpx;',
+ '(?s)\.node-relation\s*\{[^}]*font-size:\s*21rpx;',
+ '(?s)\.node-years\s*\{[^}]*font-size:\s*20rpx;',
+ '(?s)\.tree-state-card__copy\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.sheet-meta\s*\{[^}]*font-size:\s*22rpx;',
+ '(?s)\.sheet-action text\s*\{[^}]*font-family:\s*''Microsoft YaHei''[^}]*font-size:\s*22rpx;'
+)) {
+ if ($page -notmatch $rule) { throw "T01 readable visual rule missing: $rule" }
+}
+
+Write-Output 'T01-ALL-STATES-VISUAL-CONTRACT PASS'
diff --git a/tests/t01-tree-state-contract.ps1 b/tests/t01-tree-state-contract.ps1
index 7563294..dffb694 100644
--- a/tests/t01-tree-state-contract.ps1
+++ b/tests/t01-tree-state-contract.ps1
@@ -33,9 +33,14 @@ foreach ($required in @(
'tree-state--empty',
'tree-state--error',
'tree-canvas--state',
- 'g03-create-flow-panel.png',
- 'g06-search-input-wide.png',
- 'application-status-card.png',
+ 'lineage-connector',
+ 'generation-band--twelve',
+ 'generation-band--thirteen',
+ 'generation-band--fourteen',
+ 't01-member-node-standard.png',
+ 't01-member-node-selected.png',
+ 't01-state-panel.png',
+ 't01-member-drawer.png',
'/pages/tree/t03-member-profile',
'/pages/tree/t04-add-relative',
'/pages/tree/t06-edit-relationship',
@@ -43,6 +48,14 @@ foreach ($required in @(
'query.genealogyId'
)) { Assert-Contains $page $required "Missing T01 contract: $required" }
+foreach ($forbidden in @(
+ 'g03-create-flow-panel.png',
+ 'g06-search-input-wide.png',
+ 'application-status-card.png'
+)) {
+ if ($page -match [regex]::Escape($forbidden)) { throw "T01 must not keep opaque surface asset: $forbidden" }
+}
+
foreach ($className in @('tree-canvas', 'member-node', 'member-sheet', 'tree-state-card')) {
Assert-NoCssSurface $page $className
}
diff --git a/tests/t03-t06-all-states-visual-contract.ps1 b/tests/t03-t06-all-states-visual-contract.ps1
new file mode 100644
index 0000000..d698b46
--- /dev/null
+++ b/tests/t03-t06-all-states-visual-contract.ps1
@@ -0,0 +1,25 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$profile = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t03-member-profile.vue') -Raw -Encoding utf8
+$form = Get-Content -LiteralPath (Join-Path $root 'components/tree/TreeMemberForm.vue') -Raw -Encoding utf8
+
+foreach ($rule in @(
+ '(?s)\.member-heading > view:last-child text:last-child\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.member-section-title\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.member-info-row text\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.member-relatives\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.member-error > text:nth-child\(2\)\s*\{[^}]*font-size:\s*24rpx;'
+)) {
+ if ($profile -notmatch $rule) { throw "T03 readable visual rule missing: $rule" }
+}
+
+foreach ($rule in @(
+ '(?s)\.member-form__copy\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.member-field > text\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.member-field input\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.member-form__note\s*\{[^}]*font-size:\s*22rpx;'
+)) {
+ if ($form -notmatch $rule) { throw "T04-T06 readable visual rule missing: $rule" }
+}
+
+Write-Output 'T03-T06-ALL-STATES-VISUAL-CONTRACT PASS'
diff --git a/tests/t03-t08-member-flow-contract.ps1 b/tests/t03-t08-member-flow-contract.ps1
index 5f4e889..575c8b7 100644
--- a/tests/t03-t08-member-flow-contract.ps1
+++ b/tests/t03-t08-member-flow-contract.ps1
@@ -27,7 +27,7 @@ foreach ($path in @('pages/tree/t03-member-profile.vue', 'pages/tree/t07-member-
}
$t03 = $pages['pages/tree/t03-member-profile.vue']
-foreach ($required in @('member-state--detail', 'member-state--error', 'g03-create-flow-panel.png', 'g06-search-input-wide.png', '/pages/tree/t05-edit-member', 'query.personId')) {
+foreach ($required in @('member-state--detail', 'member-state--error', 't01-state-panel.png', 't07-search-input-frame.png', '/pages/tree/t05-edit-member', 'query.personId')) {
Assert-Contains $t03 $required "Missing T03 contract: $required"
}
@@ -38,7 +38,7 @@ if ($form -match "@/utils/api\.js|\bappApi\b") { throw 'TreeMemberForm must not
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
if ($form -match [regex]::Escape($nativeUi)) { throw "TreeMemberForm must use project-owned feedback instead of $nativeUi" }
}
-foreach ($required in @('form-state--form', 'form-state--success', 'form-state--conflict', 'form-state--error', 'g03-create-flow-panel.png', 'g06-search-input-wide.png', 'a01-scroll-primary-v3.png', 'a01-scroll-secondary-v3.png', 'AppDialog', 'ModulePageBackground')) {
+foreach ($required in @('form-state--form', 'form-state--success', 'form-state--conflict', 'form-state--error', 't01-state-panel.png', 't07-search-input-frame.png', 'a01-scroll-primary-v3.png', 'a01-scroll-secondary-v3.png', 'AppDialog', 'ModulePageBackground')) {
Assert-Contains $form $required "Missing shared form contract: $required"
}
foreach ($route in @('t04-add-relative.vue', 't05-edit-member.vue', 't06-edit-relationship.vue')) {
@@ -46,12 +46,12 @@ foreach ($route in @('t04-add-relative.vue', 't05-edit-member.vue', 't06-edit-re
}
$t07 = $pages['pages/tree/t07-member-directory.vue']
-foreach ($required in @('directory-state--list', 'directory-state--empty', 'directory-state--error', 'application-status-card.png', 'g06-search-input-wide.png', '/pages/tree/t03-member-profile')) {
+foreach ($required in @('directory-state--list', 'directory-state--empty', 'directory-state--error', 'list-slip-frame.png', 'directory-card__status', '/pages/tree/t03-member-profile')) {
Assert-Contains $t07 $required "Missing T07 contract: $required"
}
$t08 = $pages['pages/tree/t08-member-states.vue']
-foreach ($required in @('member-status--privacy', 'member-status--deceased', 'member-status--forbidden', 'application-status-card.png', 'query.state')) {
+foreach ($required in @('member-status--privacy', 'member-status--deceased', 'member-status--forbidden', 't01-state-panel.png', 'query.state')) {
Assert-Contains $t08 $required "Missing T08 contract: $required"
}
diff --git a/tests/t07-module-baseline-contract.ps1 b/tests/t07-module-baseline-contract.ps1
new file mode 100644
index 0000000..0d65a93
--- /dev/null
+++ b/tests/t07-module-baseline-contract.ps1
@@ -0,0 +1,33 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t07-member-directory.vue') -Raw -Encoding utf8
+
+function Assert-Match([string]$Pattern, [string]$Message) {
+ if ($page -notmatch $Pattern) { throw $Message }
+}
+
+Assert-Match 'class="directory-context"' 'T07 must show the current genealogy context.'
+Assert-Match '\u6c64\u6c0f\u5bb6\u8c31' 'T07 baseline must identify the current genealogy in the H5 mock state.'
+Assert-Match '''directory-state--loading'': directoryState === ''loading''' 'T07 loading state must have an explicit root class.'
+Assert-Match 'v-if="directoryState === ''list'' \|\| directoryState === ''empty''"' 'T07 search must remain available for list and empty states only.'
+Assert-Match 'class="directory-search__action"[^>]*role="button"[^>]*aria-label="\u67e5\u627e\u6210\u5458"' 'T07 search action must expose button semantics and an accessible label.'
+Assert-Match ']+type="secondary"[^>]+label="\u91cd\u65b0\u67e5\u770b"[^>]+@click="retryDirectory"' 'T07 error state must provide a custom recovery action.'
+Assert-Match 'const retryDirectory = \(\) => \{ directoryState\.value = ''list'' \}' 'T07 retry action must return to the list state.'
+Assert-Match '(?s)\.directory-page\s*\{[^}]*overflow-x:\s*hidden;[^}]*overflow-y:\s*auto;' 'T07 must allow natural vertical scrolling without horizontal overflow.'
+Assert-Match '(?s)\.directory-search\s*\{[^}]*height:\s*44px;' 'T07 search field must preserve a 44 CSS px touch height at every viewport.'
+Assert-Match '(?s)\.directory-search__action\s*\{[^}]*min-width:\s*88rpx;[^}]*min-height:\s*44px;' 'T07 search action must preserve a 44 CSS px touch target.'
+Assert-Match 'modules/tree/transparent/t07-search-input-frame\.png' 'T07 search must use its dedicated transparent input frame.'
+Assert-Match 'modules/genealogy/transparent/list-slip-frame\.png' 'T07 member cards must use the approved transparent list frame.'
+if ($page -match 'g06-search-input-wide\.png|application-status-card\.png') {
+ throw 'T07 must not reuse opaque search or application-status skins.'
+}
+if ($page -match 'row-seal-frame\.png|directory-card__seal') {
+ throw 'T07 member cards must not repeat generation information in decorative seals.'
+}
+Assert-Match 'class="directory-card__status"' 'T07 member identity or profile state must use a dedicated status label.'
+
+foreach ($state in @('loading', 'list', 'empty', 'error')) {
+ Assert-Match $state "T07 must preserve the $state state."
+}
+
+Write-Output 'T07-MODULE-BASELINE-CONTRACT PASS'
diff --git a/tests/t07-module-baseline-runtime-smoke.js b/tests/t07-module-baseline-runtime-smoke.js
new file mode 100644
index 0000000..18aab5c
--- /dev/null
+++ b/tests/t07-module-baseline-runtime-smoke.js
@@ -0,0 +1,181 @@
+const fs = require('fs')
+const path = require('path')
+
+const origin = 'http://localhost:5173'
+const outputDirectory = path.resolve('docs/design/screens/runtime/2026-07-19/t07-baseline')
+const sizes = [
+ { width: 320, height: 568 },
+ { width: 360, height: 640 },
+ { width: 360, height: 800 },
+ { width: 412, height: 915 }
+]
+const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
+
+const connect = async () => {
+ const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
+ const projectPages = pages.filter((page) => page.type === 'page' && page.url.startsWith(origin))
+ if (projectPages.length !== 1) throw new Error(`Expected one project page, found ${projectPages.length}`)
+ const socket = new WebSocket(projectPages[0].webSocketDebuggerUrl)
+ await new Promise((resolve, reject) => {
+ socket.addEventListener('open', resolve, { once: true })
+ socket.addEventListener('error', reject, { once: true })
+ })
+ let id = 0
+ const pending = new Map()
+ socket.addEventListener('message', (event) => {
+ const message = JSON.parse(event.data)
+ const request = pending.get(message.id)
+ if (!request) return
+ pending.delete(message.id)
+ message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
+ })
+ const send = (method, params = {}) => new Promise((resolve, reject) => {
+ id += 1
+ pending.set(id, { resolve, reject })
+ socket.send(JSON.stringify({ id, method, params }))
+ })
+ return { projectPageCount: projectPages.length, socket, send }
+}
+
+const valueOf = async (send, expression) => {
+ const result = await send('Runtime.evaluate', { expression, returnByValue: true })
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed')
+ return result.result?.value
+}
+
+const waitFor = async (send, expression, message) => {
+ for (let attempt = 0; attempt < 60; attempt += 1) {
+ try {
+ if (await valueOf(send, expression)) return
+ } catch (error) {
+ if (!String(error.message).includes('Inspected target navigated or closed')) throw error
+ }
+ await sleep(100)
+ }
+ throw new Error(message)
+}
+
+const setSize = (send, size) => send('Emulation.setDeviceMetricsOverride', {
+ ...size,
+ deviceScaleFactor: 1,
+ mobile: true,
+ screenWidth: size.width,
+ screenHeight: size.height
+})
+
+let navigationId = 0
+const openState = async (send, query = '') => {
+ navigationId += 1
+ const url = `${origin}/?t07Baseline=${navigationId}#/pages/tree/t07-member-directory${query}`
+ await send('Page.navigate', { url })
+ await waitFor(send, `location.href === ${JSON.stringify(url)} && Boolean(document.querySelector('.directory-page'))`, `T07 did not render ${query || 'list'}`)
+ await sleep(250)
+}
+
+const capture = async (send, filename) => {
+ const screenshot = await send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false })
+ fs.mkdirSync(outputDirectory, { recursive: true })
+ fs.writeFileSync(path.join(outputDirectory, filename), Buffer.from(screenshot.data, 'base64'))
+}
+
+const metrics = (send) => valueOf(send, `(() => {
+ const rect = (selector) => {
+ const node = document.querySelector(selector)
+ if (!node) return null
+ const box = node.getBoundingClientRect()
+ return { top: box.top, right: box.right, bottom: box.bottom, left: box.left, width: box.width, height: box.height }
+ }
+ return {
+ viewport: { width: innerWidth, height: innerHeight },
+ context: rect('.directory-context'),
+ search: rect('.directory-search'),
+ firstCard: rect('.directory-card'),
+ lastCard: rect('.directory-card:last-child'),
+ cardCount: document.querySelectorAll('.directory-card').length,
+ state: document.querySelector('.directory-state--loading') ? 'loading' : document.querySelector('.directory-state--empty') ? 'empty' : document.querySelector('.directory-state--error') ? 'error' : 'list',
+ horizontalOverflow: document.documentElement.scrollWidth > innerWidth || document.body.scrollWidth > innerWidth,
+ documentScrollHeight: document.documentElement.scrollHeight
+ }
+})()`)
+
+const assert = (condition, message) => { if (!condition) throw new Error(message) }
+
+const run = async () => {
+ const { projectPageCount, socket, send } = await connect()
+ const runtimeErrors = []
+ const resourceErrors = []
+ socket.addEventListener('message', (event) => {
+ const message = JSON.parse(event.data)
+ if (message.method === 'Runtime.exceptionThrown') runtimeErrors.push(message.params.exceptionDetails?.text || 'runtime exception')
+ if (message.method === 'Network.responseReceived' && message.params.response.status >= 400) resourceErrors.push(`${message.params.response.status} ${message.params.response.url}`)
+ })
+
+ try {
+ await send('Page.enable')
+ await send('Runtime.enable')
+ await send('Network.enable')
+ const responsive = []
+ for (const size of sizes) {
+ await setSize(send, size)
+ await openState(send, '?genealogyId=1001')
+ const current = await metrics(send)
+ assert(!current.horizontalOverflow, `${size.width}x${size.height}: horizontal overflow`)
+ assert(current.context && current.search && current.firstCard, `${size.width}x${size.height}: missing baseline content`)
+ assert(current.cardCount === 3, `${size.width}x${size.height}: expected three members`)
+ await valueOf(send, 'scrollTo(0, document.documentElement.scrollHeight)')
+ await sleep(50)
+ const lastBottom = await valueOf(send, "document.querySelector('.directory-card:last-child').getBoundingClientRect().bottom")
+ assert(lastBottom <= size.height + 1, `${size.width}x${size.height}: last member is not reachable`)
+ await valueOf(send, 'scrollTo(0, 0)')
+ responsive.push(current)
+ await capture(send, `responsive-${size.width}x${size.height}.png`)
+ }
+
+ await setSize(send, { width: 412, height: 915 })
+ await openState(send, '?state=loading&genealogyId=1001')
+ assert((await metrics(send)).state === 'loading', 'Loading state did not render')
+ await capture(send, '02-loading-412x915.png')
+
+ await openState(send, '?state=empty&genealogyId=1001')
+ assert((await metrics(send)).state === 'empty', 'Empty state did not render')
+ await capture(send, '03-empty-412x915.png')
+
+ await openState(send, '?state=error&genealogyId=1001')
+ assert((await metrics(send)).state === 'error', 'Error state did not render')
+ assert(!await valueOf(send, "Boolean(document.querySelector('.directory-search'))"), 'Error state must not expose search')
+ await capture(send, '04-error-412x915.png')
+ await valueOf(send, "document.querySelector('.directory-content .app-button').click()")
+ await waitFor(send, "document.querySelectorAll('.directory-card').length === 3 && Boolean(document.querySelector('.directory-search'))", 'Error retry did not restore the list')
+
+ const inputSearch = async (value) => {
+ await valueOf(send, `(() => {
+ const input = document.querySelector('.directory-search input')
+ input.value = ${JSON.stringify(value)}
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ return true
+ })()`)
+ await sleep(50)
+ await valueOf(send, "document.querySelector('.directory-search__action').click()")
+ }
+ await inputSearch('不存在')
+ await waitFor(send, "Boolean(document.querySelector('.directory-state--empty'))", 'No-result search did not render the empty state')
+ await inputSearch('')
+ await waitFor(send, "document.querySelectorAll('.directory-card').length === 3", 'Cleared search did not restore all members')
+
+ await openState(send, '?genealogyId=1001')
+ await capture(send, '01-list-412x915.png')
+ const final = await metrics(send)
+ assert(final.viewport.width === 412 && final.viewport.height === 915 && final.state === 'list', 'Final approval state is invalid')
+ assert(runtimeErrors.length === 0, `Runtime errors: ${runtimeErrors.join(' | ')}`)
+ assert(resourceErrors.length === 0, `Resource errors: ${resourceErrors.join(' | ')}`)
+ process.stdout.write(`${JSON.stringify({ projectPageCount, responsive, final, runtimeErrors, resourceErrors }, null, 2)}\n`)
+ process.stdout.write('PASS T07 module baseline runtime smoke\n')
+ } finally {
+ socket.close()
+ }
+}
+
+run().catch((error) => {
+ process.stderr.write(`${error.stack || error.message}\n`)
+ process.exit(1)
+})
diff --git a/tests/t07-t08-all-states-visual-contract.ps1 b/tests/t07-t08-all-states-visual-contract.ps1
new file mode 100644
index 0000000..7243b4f
--- /dev/null
+++ b/tests/t07-t08-all-states-visual-contract.ps1
@@ -0,0 +1,26 @@
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$t07 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t07-member-directory.vue') -Raw -Encoding utf8
+$t08 = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t08-member-states.vue') -Raw -Encoding utf8
+
+foreach ($rule in @(
+ '(?s)\.directory-search input\s*\{[^}]*font-size:\s*26rpx;',
+ '(?s)\.directory-summary text:last-child\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.directory-card__meta\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.directory-card__status\s*\{[^}]*font-size:\s*22rpx;',
+ '(?s)\.directory-state-card text:last-child\s*\{[^}]*font-size:\s*24rpx;'
+)) {
+ if ($t07 -notmatch $rule) { throw "T07 readable visual rule missing: $rule" }
+}
+
+foreach ($rule in @(
+ '(?s)\.status-tab text\s*\{[^}]*font-size:\s*23rpx;',
+ '(?s)\.status-card__copy text:first-child\s*\{[^}]*font-size:\s*22rpx;',
+ '(?s)\.status-card__copy text:nth-child\(2\)\s*\{[^}]*font-size:\s*32rpx;',
+ '(?s)\.status-card__copy text:last-child\s*\{[^}]*font-size:\s*24rpx;',
+ '(?s)\.status-guidance text\s*\{[^}]*font-size:\s*24rpx;'
+)) {
+ if ($t08 -notmatch $rule) { throw "T08 readable visual rule missing: $rule" }
+}
+
+Write-Output 'T07-T08-ALL-STATES-VISUAL-CONTRACT PASS'