$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot function Read-Utf8([string]$path) { Get-Content -LiteralPath (Join-Path $root $path) -Raw -Encoding UTF8 } $button = Read-Utf8 'components/AppButton.vue' foreach ($token in @(']*class="app-button"') { throw 'AppButton must use a native button root' } $header = Read-Utf8 'components/PageHeader.vue' foreach ($pattern in @( '(?s)]+class="header-back"[^>]+aria-label=', '(?s)]+class="header-icon-button header-notice"[^>]+aria-label=', '(?s)', '(?s)v-if="root && notice"[^>]+:aria-label="unreadCount > 0', '(?s)v-else-if="root && action"[^>]+class="header-action header-action--root"', '(?s)v-else-if="action"\s+class="header-action"', '(?s)' )) { if ($header -notmatch $pattern) { throw "PageHeader accessibility contract missing: $pattern" } } if ($header.Contains('fallbackUrl')) { throw 'PageHeader must not expose a duplicate deep-link fallback route' } foreach ($token in @( 'import { goBack } from "@/utils/navigation.js";', 'customBack: { type: Boolean, default: false }', 'const emit = defineEmits(["notice", "action", "back"]);', '@click="handleBack"', 'if (props.customBack) {', 'emit("back");', 'return goBack();' )) { if (-not $header.Contains($token)) { throw "PageHeader back contract missing: $token" } } if ($header -match 'aria-label="返回首页"|@click="\$emit\(''brand''\)"') { throw 'PageHeader root brand without an action must be decorative and non-focusable' } if ($header -match ':disabled="!action"') { throw 'PageHeader without an action must render a decorative placeholder instead of an unnamed disabled button' } if ($header -match 'getCurrentPages\s*\(|uni\.(navigateBack|reLaunch)\s*\(') { throw 'PageHeader must delegate all ordinary back semantics to the navigation gateway' } if ($header -notmatch '(?s)const handleBack = \(\) => \{\s*if \(props\.customBack\) \{\s*emit\("back"\);\s*return;\s*\}\s*return goBack\(\);\s*\};') { throw 'PageHeader custom back must stop after emitting; ordinary back must return the gateway Promise' } if ($header -notmatch '(?s)\.header-action\s*\{[^}]*display:\s*flex;[^}]*width:\s*100%;[^}]*min-height:\s*88rpx;[^}]*align-items:\s*center;[^}]*justify-content:\s*flex-end;') { throw 'PageHeader text actions must provide an 88rpx native touch target' } $dialog = Read-Utf8 'components/AppDialog.vue' foreach ($token in @('role="dialog"', 'aria-modal="true"', 'tabindex="-1"', '@keydown.esc.stop.prevent="cancel"', '@keydown.tab="trapFocus"', 'const trapFocus = (event) =>', 'querySelectorAll(', 'event.preventDefault()', 'max-height: calc(var(--app-viewport-height, 100vh) - 80rpx - env(safe-area-inset-top) - env(safe-area-inset-bottom))', 'overflow-y: auto')) { if (-not $dialog.Contains($token)) { throw "AppDialog accessibility contract missing: $token" } } $toast = Read-Utf8 'components/AppToast.vue' foreach ($token in @('v-show="visible"', 'role="status"', 'aria-live="polite"', 'aria-atomic="true"')) { if (-not $toast.Contains($token)) { throw "AppToast accessibility contract missing: $token" } } $loading = Read-Utf8 'components/AppLoading.vue' foreach ($token in @('role="status"', 'aria-live="polite"', 'aria-busy="true"', 'animation: none')) { if (-not $loading.Contains($token)) { throw "AppLoading accessibility contract missing: $token" } } $tabbar = Read-Utf8 'components/AppTabbar.vue' foreach ($token in @( ']+role="tab"') { throw 'AppTabbar interactive tabs must use native buttons for keyboard activation' } if ($tabbar -notmatch '(?s)\.tab-item\s*\{[^}]*margin:\s*0;[^}]*border:\s*0;[^}]*border-radius:\s*0;[^}]*background:\s*transparent;[^}]*line-height:\s*normal;' -or $tabbar -notmatch '(?s)\.tab-item::after\s*\{[^}]*border:\s*0;') { throw 'AppTabbar native buttons must reset platform default geometry and decoration' } foreach ($mapping in @( @{ Key = 'genealogy'; RouteKey = 'G01' }, @{ Key = 'family'; RouteKey = 'F01' }, @{ Key = 'profile'; RouteKey = 'M01' } )) { $pattern = '(?s)\{\s*key:\s*"' + $mapping.Key + '",\s*label:\s*"[^"]+",\s*routeKey:\s*"' + $mapping.RouteKey + '",' if ($tabbar -notmatch $pattern) { throw "AppTabbar mapping drift: $($mapping.Key) -> $($mapping.RouteKey)" } } $itemsBlock = [regex]::Match($tabbar, '(?s)const items = \[(?.*?)\];') if (-not $itemsBlock.Success) { throw 'AppTabbar must declare its owned root item list' } $itemCount = ([regex]::Matches($itemsBlock.Groups['Body'].Value, '\bkey:\s*"[^"]+"')).Count $approvedItemCount = ([regex]::Matches($itemsBlock.Groups['Body'].Value, '\bkey:\s*"(?:genealogy|family|profile)"')).Count if ($itemCount -ne 3 -or $approvedItemCount -ne 3) { throw "AppTabbar must own exactly the three approved root items, found $itemCount total / $approvedItemCount approved" } if ($tabbar -match '\bpath\s*:|/pages/|uni\.reLaunch\s*\(') { throw 'AppTabbar must consume root route keys instead of paths or direct Uni navigation' } if ($tabbar -notmatch '(?s)const switchRoot = \(item\) => \{\s*if \(item\.key === props\.active\) return;\s*return goRoot\(item\.routeKey\);\s*\};') { throw 'AppTabbar active items must be no-ops and inactive items must return the gateway Promise' } $card = Read-Utf8 'components/GenealogyCard.vue' foreach ($token in @('role="button"', ':aria-label="accessibleLabel"', 'hover-class="genealogy-card--pressed"', 'const accessibleLabel = computed(')) { if (-not $card.Contains($token)) { throw "GenealogyCard interaction contract missing: $token" } } Write-Output 'SHARED-INTERACTION-ACCESSIBILITY-CONTRACT PASS'