590 lines
22 KiB
Python
590 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import ast
|
|
import hashlib
|
|
import re
|
|
from pathlib import Path
|
|
|
|
|
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
|
ORIGINAL_ROOT = APP_ROOT.parent
|
|
FRONTEND_ROOT = APP_ROOT / "frontend"
|
|
ORIGINAL_STATIC = ORIGINAL_ROOT / "static"
|
|
|
|
SOURCE_RANGE = re.compile(
|
|
r"/\* PRESERVATION-SOURCE-BEGIN app\.js:(\d+)-(\d+) \*/\n"
|
|
r"(.*?)"
|
|
r"/\* PRESERVATION-SOURCE-END app\.js:\1-\2 \*/\n?",
|
|
re.DOTALL,
|
|
)
|
|
|
|
# These exact original app.js line ranges were retired in slice 11 after the
|
|
# definition-only symbols passed static, runtime, and compatibility review.
|
|
RETIRED_FRONTEND_SOURCE_RANGES = (
|
|
(3537, 3540),
|
|
(4139, 4145),
|
|
(4973, 4989),
|
|
(9022, 9027),
|
|
(9052, 9055),
|
|
)
|
|
AUDITED_FRONTEND_SOURCE_LINE_COUNT = 9283
|
|
|
|
# CR-12 through CR-14 remove only declarations repeated by a later rule under
|
|
# the same cascade context. Entries are either an exact retired fragment or an
|
|
# exact (source, replacement) pair. Optional trailing values declare the source
|
|
# count and how many leading occurrences to transform when an identical later
|
|
# copy must remain. Every other byte remains part of the accepted CSS baseline.
|
|
AUDITED_CSS_RETIREMENTS = {
|
|
"styles.css": (
|
|
".workspace-view.active-view {\n display: block;\n}\n\n",
|
|
"body.sidebar-collapsed {\n grid-template-columns: 64px minmax(0, 1fr);\n}\n\n",
|
|
".rotation-day:last-child { border-right: 0; }\n\n",
|
|
(
|
|
"#screenerView .probability-value strong,\n"
|
|
"#screenerView .probability-value small {\n"
|
|
" display: block;\n"
|
|
"}\n\n"
|
|
),
|
|
(
|
|
(
|
|
"@media (min-width: 721px) and (max-width: 1279px) {\n"
|
|
" .market-tape {\n"
|
|
" display: none;\n"
|
|
" }\n\n"
|
|
" .app-header {"
|
|
),
|
|
(
|
|
"@media (min-width: 721px) and (max-width: 1279px) {\n"
|
|
" .app-header {"
|
|
),
|
|
),
|
|
(
|
|
" .module-nav .nav-brand,\n"
|
|
" .module-nav .nav-group-label,\n"
|
|
" .module-nav .market-sub-tab,\n"
|
|
" .module-nav .sidebar-collapse-button {\n"
|
|
" display: none;\n"
|
|
" }\n\n"
|
|
),
|
|
(
|
|
" .module-nav .nav-group,\n"
|
|
" body.sidebar-collapsed .module-nav .nav-group {\n"
|
|
" display: contents;\n"
|
|
" margin: 0;\n"
|
|
" padding: 0;\n"
|
|
" border: 0;\n"
|
|
" }\n\n"
|
|
),
|
|
(
|
|
" .module-nav .module-tab.mobile-primary-tab,\n"
|
|
" body.sidebar-collapsed .module-nav .module-tab.mobile-primary-tab {\n"
|
|
" display: flex;\n"
|
|
" }\n\n"
|
|
),
|
|
" body.mentor-directory-open {\n overflow: hidden;\n }\n\n",
|
|
(
|
|
" body,\n"
|
|
" body.sidebar-collapsed {\n"
|
|
" display: block;\n"
|
|
" min-height: 100dvh;\n"
|
|
" padding-bottom: calc(68px + env(safe-area-inset-bottom));\n"
|
|
" }\n\n"
|
|
),
|
|
" .brand-block {\n height: 42px;\n }\n\n",
|
|
(
|
|
" .header-actions {\n"
|
|
" position: absolute;\n"
|
|
" inset: 56px 10px auto;\n"
|
|
" display: flex;\n"
|
|
" justify-content: space-between;\n"
|
|
" gap: 6px;\n"
|
|
" }\n\n"
|
|
),
|
|
(
|
|
" .header-date-group {\n"
|
|
" height: 42px;\n"
|
|
" min-width: 0;\n"
|
|
" flex: 1;\n"
|
|
" }\n\n"
|
|
),
|
|
(
|
|
" .header-actions > .icon-button {\n"
|
|
" width: 40px;\n"
|
|
" min-width: 40px;\n"
|
|
" min-height: 42px;\n"
|
|
" }\n\n"
|
|
),
|
|
" .overview-strip .metric:nth-of-type(1) { grid-column: 2; grid-row: 1; }\n",
|
|
" .overview-strip .metric:nth-of-type(3) { grid-column: 2; grid-row: 2; }\n",
|
|
" .curated-strategy-list {\n grid-template-columns: 1fr;\n }\n\n",
|
|
" .quant-universe-grid { grid-template-columns: 1fr 1fr; }\n",
|
|
(
|
|
" .auction-workspace-layout { grid-template-columns: 1fr; }\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
" .auction-unified-table-frame { min-height: 360px; max-height: none; }\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
" .theme-library-layout { display: block; min-height: 0; }\n",
|
|
(
|
|
".brand-block {\n gap: 10px;\n}\n\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
" html,\n"
|
|
" body {\n"
|
|
" min-width: 320px;\n"
|
|
" width: 100%;\n"
|
|
" }\n\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
" .module-nav .module-tab .lucide {\n"
|
|
" width: 20px;\n"
|
|
" height: 20px;\n"
|
|
" }\n\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
" .sentiment-trend-panel {\n"
|
|
" border-right: 0;\n"
|
|
" border-bottom: 1px solid var(--border);\n"
|
|
" }\n\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
" .auction-dataset-segments { min-width: 430px; }\n",
|
|
),
|
|
"renovation.css": (
|
|
".overview-strip .sentiment-block { padding-left: 0; }\n",
|
|
(
|
|
'.overview-strip[data-overview-expanded="true"] .metric-value '
|
|
"{ font-size: 18px; }\n\n"
|
|
),
|
|
(
|
|
".curated-strategy-list { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
".sentiment-detail-toolbar { margin-top: 0; }\n\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
".sentiment-detail-toolbar { margin-top: 0; }\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
".review-workspace .workspace-section + .workspace-section { border-top: 1px solid var(--border); }\n\n",
|
|
(
|
|
" body,\n"
|
|
" body.sidebar-collapsed {\n"
|
|
" display: block;\n"
|
|
" min-height: 100dvh;\n"
|
|
" padding-bottom: calc(68px + env(safe-area-inset-bottom));\n"
|
|
" }\n\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
" .app-header {\n"
|
|
" width: 100%;\n"
|
|
" height: 108px;\n"
|
|
" min-height: 108px;\n"
|
|
" position: relative;\n"
|
|
" display: flex;\n"
|
|
" align-items: flex-start;\n"
|
|
" padding: 8px 10px 0;\n"
|
|
" }\n\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(" .brand-block { height: 42px; }\n", "", 2, 1),
|
|
(" .brand-block h1 { font-size: 16px; }\n", "", 2, 1),
|
|
(
|
|
" .header-actions { position: absolute; inset: 56px 10px auto; display: flex; justify-content: space-between; gap: 6px; }\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
" .header-date-group { height: 42px; min-width: 0; flex: 1; }\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
" .header-date-group .date-input { width: 104px; flex: 1; }\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
" .header-actions > .icon-button { width: 40px; min-width: 40px; min-height: 42px; }\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
" .module-nav,\n"
|
|
" body.sidebar-collapsed .module-nav {\n"
|
|
" width: 100%;\n"
|
|
" height: calc(64px + env(safe-area-inset-bottom));\n"
|
|
" min-height: 64px;\n"
|
|
" position: fixed;\n"
|
|
" inset: auto 0 0;\n"
|
|
" z-index: 45;\n"
|
|
" display: grid;\n"
|
|
" grid-template-columns: repeat(5, minmax(0, 1fr));\n"
|
|
" align-items: stretch;\n"
|
|
" padding: 4px 4px max(4px, env(safe-area-inset-bottom));\n"
|
|
" overflow: hidden;\n"
|
|
" border-top: 1px solid var(--border);\n"
|
|
" border-right: 0;\n"
|
|
" background: rgba(255, 255, 255, .98);\n"
|
|
" box-shadow: 0 -5px 18px rgba(16, 24, 40, .08);\n"
|
|
" }\n\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
" .app-main { width: 100%; min-height: calc(100dvh - 176px); margin: 0; padding: 10px 8px 20px; }\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
" .workspace-view,\n"
|
|
' body[data-active-view="screenerView"] .workspace-view,\n'
|
|
' body[data-active-view="mentorView"] .workspace-view,\n'
|
|
' body[data-active-view="reviewWorkspaceView"] .workspace-view { padding: 0 0 76px; }\n',
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(".insight-rail { gap: 12px; }\n", "", 2, 1),
|
|
("#screenerView .screener-strategy-view { gap: 12px; }\n", "", 2, 1),
|
|
(".mentor-chat-form .button { min-height: 38px; }\n\n", "", 2, 1),
|
|
(
|
|
".review-workspace .notes-history-section { grid-area: notes; }\n",
|
|
"",
|
|
3,
|
|
2,
|
|
),
|
|
(
|
|
".sentiment-stage-guide-grid article.current strong,\n"
|
|
".sentiment-stage-guide-grid article.current small { color: var(--danger); }\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(
|
|
".review-workspace {\n"
|
|
" grid-template-columns: minmax(0, 1fr) 360px;\n"
|
|
' grid-template-areas: "watch journal" "trades journal" "notes notes";\n'
|
|
" align-items: start;\n"
|
|
" gap: 12px;\n"
|
|
"}\n",
|
|
"",
|
|
2,
|
|
1,
|
|
),
|
|
(".review-workspace .watchlist-section { grid-area: watch; }\n", "", 2, 1),
|
|
(".review-workspace .journal-section { grid-area: journal; }\n", "", 2, 1),
|
|
(".review-workspace .trade-journal-section { grid-area: trades; }\n", "", 2, 1),
|
|
),
|
|
"redesign-v2.css": (
|
|
"#dragonView .dragon-operation-table .dragon-col-reason { width: auto; }\n",
|
|
(
|
|
" .market-tape { display: none; }\n"
|
|
" .header-actions { width: 100%; }\n"
|
|
" .header-command-group { position: absolute; }\n"
|
|
),
|
|
(
|
|
" .sidebar-brand,\n"
|
|
" .module-nav .nav-group-label,\n"
|
|
" .sidebar-collapse-button,\n"
|
|
" .module-nav .market-sub-tab { display: none; }\n"
|
|
" .module-nav .nav-group,\n"
|
|
" body.sidebar-collapsed .module-nav .nav-group { display: contents; }\n"
|
|
" .module-nav .module-tab,\n"
|
|
" body.sidebar-collapsed .module-nav .module-tab { display: none; }\n"
|
|
),
|
|
" .module-nav .module-tab.mobile-primary-tab span { display: inline; }\n",
|
|
(
|
|
" .overview-strip .metric:nth-of-type(n + 4),\n"
|
|
" .overview-strip .metric-wide { display: none; }\n"
|
|
" .overview-toggle { display: none; }\n"
|
|
),
|
|
(
|
|
(
|
|
" #screenerView .quant-summary-pane .quant-universe-grid,\n"
|
|
" #screenerView .quant-formula-summary,\n"
|
|
" #screenerView .quant-execution-actions { grid-template-columns: 1fr; }\n"
|
|
" #screenerView .quant-score-row,\n"
|
|
" #screenerView .quant-filter-row { grid-template-columns: 1fr; }\n"
|
|
" #screenerTrackingView { padding: 10px; }\n"
|
|
),
|
|
(
|
|
" #screenerView .quant-summary-pane .quant-universe-grid,\n"
|
|
" #screenerView .quant-formula-summary,\n"
|
|
" #screenerView .quant-execution-actions { grid-template-columns: 1fr; }\n"
|
|
" #screenerTrackingView { padding: 10px; }\n"
|
|
),
|
|
),
|
|
),
|
|
}
|
|
|
|
# User-approved product behavior changes remain separate from code-retirement
|
|
# records. Each entry is an exact source/replacement pair. Optional trailing
|
|
# values declare the expected source count and how many leading occurrences to
|
|
# transform when one identical occurrence must remain.
|
|
AUDITED_CSS_REPLACEMENTS = {
|
|
"redesign-v2.css": (
|
|
(
|
|
(
|
|
'@media (min-width: 721px) {\n'
|
|
' body[data-active-view="dragonView"] .app-main { display: flex; flex-direction: column; overflow: hidden; }\n'
|
|
' body[data-active-view="dragonView"] .overview-strip { flex: 0 0 auto; }\n'
|
|
' body[data-active-view="dragonView"] #dragonView.active-view {\n'
|
|
' min-height: 0;\n'
|
|
' flex: 1 1 auto;\n'
|
|
' display: flex;\n'
|
|
' flex-direction: column;\n'
|
|
' }\n'
|
|
' body[data-active-view="dragonView"] .dragon-page-head-v2 { flex: 0 0 auto; }\n'
|
|
' body[data-active-view="dragonView"] .dragon-daily-content-v2,\n'
|
|
' body[data-active-view="dragonView"] .dragon-empty-state-v2 { flex: 1 1 auto; }\n'
|
|
'}\n'
|
|
),
|
|
(
|
|
'@media (min-width: 721px) {\n'
|
|
' body[data-active-view="dragonView"] .app-main { height: var(--workspace-height); min-height: 0; display: block; overflow: auto; }\n'
|
|
' body[data-active-view="dragonView"] #dragonView.active-view {\n'
|
|
' display: block;\n'
|
|
' overflow: visible;\n'
|
|
' }\n'
|
|
'}\n'
|
|
),
|
|
),
|
|
(
|
|
(
|
|
"/* The Dragon-Tiger page stays still; only the selected trader's operations scroll. */\n"
|
|
'@media (min-width: 721px) {\n'
|
|
' body[data-active-view="dragonView"] .dragon-daily-content-v2 {\n'
|
|
' min-height: 0;\n'
|
|
' display: grid;\n'
|
|
' grid-template-rows: auto auto auto minmax(150px, 1fr) auto;\n'
|
|
' overflow: hidden;\n'
|
|
' }\n\n'
|
|
' body[data-active-view="dragonView"] #dragonView .dragon-card-stage-v2 {\n'
|
|
' flex: 0 0 auto;\n'
|
|
' }\n\n'
|
|
' body[data-active-view="dragonView"] #dragonView .dragon-trader-detail-v2 {\n'
|
|
' min-height: 0;\n'
|
|
' display: flex;\n'
|
|
' flex-direction: column;\n'
|
|
' }\n\n'
|
|
' body[data-active-view="dragonView"] #dragonView .dragon-detail-header {\n'
|
|
' flex: 0 0 auto;\n'
|
|
' }\n\n'
|
|
' body[data-active-view="dragonView"] #dragonView .dragon-trader-detail .trader-operations {\n'
|
|
' min-height: 0;\n'
|
|
' flex: 1 1 auto;\n'
|
|
' overflow: auto;\n'
|
|
' overscroll-behavior: contain;\n'
|
|
' scrollbar-gutter: stable;\n'
|
|
' }\n\n'
|
|
' body[data-active-view="dragonView"] #dragonView .dragon-unclassified-v2 {\n'
|
|
' max-height: 180px;\n'
|
|
' overflow: auto;\n'
|
|
' }\n'
|
|
),
|
|
(
|
|
'/* The Dragon-Tiger page owns vertical scrolling; wide operation tables scroll horizontally. */\n'
|
|
'@media (min-width: 721px) {\n'
|
|
' body[data-active-view="dragonView"] .dragon-daily-content-v2 {\n'
|
|
' display: block;\n'
|
|
' overflow: visible;\n'
|
|
' }\n\n'
|
|
' body[data-active-view="dragonView"] #dragonView .dragon-trader-detail-v2 {\n'
|
|
' display: block;\n'
|
|
' }\n\n'
|
|
' body[data-active-view="dragonView"] #dragonView .dragon-trader-detail .trader-operations {\n'
|
|
' max-height: none;\n'
|
|
' overflow: auto;\n'
|
|
' }\n\n'
|
|
' body[data-active-view="dragonView"] #dragonView .dragon-unclassified-v2 {\n'
|
|
' max-height: none;\n'
|
|
' overflow: visible;\n'
|
|
' }\n'
|
|
),
|
|
),
|
|
),
|
|
"design-system.css": (
|
|
(' [data-active-view="dragonView"],\n', "", 4, 3),
|
|
(' #dragonView.active-view,\n', ""),
|
|
(' #dragonView .dragon-page-head-v2,\n', ""),
|
|
(' #dragonView .dragon-daily-content-v2,\n', ""),
|
|
(
|
|
' #dragonView .dragon-daily-content-v2{overflow:hidden}\n'
|
|
' #dragonView .dragon-trader-detail-v2{min-height:0}\n'
|
|
' #dragonView .dragon-trader-detail .trader-operations{min-height:0;overflow:auto}\n\n',
|
|
"",
|
|
),
|
|
),
|
|
}
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def function_contract(path: Path, name: str) -> tuple[str, str]:
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
function = next(
|
|
node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name
|
|
)
|
|
body = ast.Module(body=function.body, type_ignores=[])
|
|
return (
|
|
ast.dump(function.args, include_attributes=False),
|
|
ast.dump(body, include_attributes=False),
|
|
)
|
|
|
|
|
|
def module_contract(
|
|
path: Path,
|
|
*,
|
|
excluded_definitions: set[str] | None = None,
|
|
excluded_import_modules: set[str] | None = None,
|
|
exclude_imports: bool = False,
|
|
) -> str:
|
|
excluded_definitions = excluded_definitions or set()
|
|
excluded_import_modules = excluded_import_modules or set()
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
tree.body = [
|
|
node
|
|
for node in tree.body
|
|
if not (exclude_imports and isinstance(node, (ast.Import, ast.ImportFrom)))
|
|
and not (
|
|
isinstance(node, ast.ImportFrom)
|
|
and node.module in excluded_import_modules
|
|
)
|
|
and getattr(node, "name", None) not in excluded_definitions
|
|
]
|
|
return ast.dump(tree, include_attributes=False)
|
|
|
|
|
|
def reassembled_frontend_runtime() -> str:
|
|
chunks: dict[tuple[int, int], str] = {}
|
|
for path in FRONTEND_ROOT.rglob("*.js"):
|
|
for match in SOURCE_RANGE.finditer(path.read_text(encoding="utf-8")):
|
|
source_range = (int(match.group(1)), int(match.group(2)))
|
|
if source_range in chunks:
|
|
raise AssertionError(f"duplicate app.js source range: {source_range}")
|
|
chunks[source_range] = match.group(3)
|
|
|
|
assembled: list[str] = []
|
|
next_line = 1
|
|
for (start, end), content in sorted(chunks.items()):
|
|
if start != next_line:
|
|
raise AssertionError(
|
|
f"app.js source coverage gap: expected line {next_line}, got {start}"
|
|
)
|
|
assembled.append(content)
|
|
next_line = end + 1
|
|
|
|
if next_line != AUDITED_FRONTEND_SOURCE_LINE_COUNT + 1:
|
|
raise AssertionError(
|
|
"app.js source coverage ended at "
|
|
f"{next_line - 1}, expected {AUDITED_FRONTEND_SOURCE_LINE_COUNT}"
|
|
)
|
|
return "".join(assembled)
|
|
|
|
|
|
def original_runtime_after_audited_retirements() -> str:
|
|
lines = (ORIGINAL_STATIC / "app.js").read_text(encoding="utf-8").splitlines(
|
|
keepends=True
|
|
)
|
|
retired = {
|
|
line_number
|
|
for start, end in RETIRED_FRONTEND_SOURCE_RANGES
|
|
for line_number in range(start, end + 1)
|
|
}
|
|
return "".join(
|
|
line for line_number, line in enumerate(lines, start=1) if line_number not in retired
|
|
)
|
|
|
|
|
|
def assert_frontend_runtime_matches_audited_baseline(testcase) -> None:
|
|
testcase.assertEqual(
|
|
reassembled_frontend_runtime(),
|
|
original_runtime_after_audited_retirements(),
|
|
)
|
|
|
|
|
|
def assert_moved_asset_matches(
|
|
testcase,
|
|
original_relative: str,
|
|
frontend_relative: str | None = None,
|
|
) -> None:
|
|
target_relative = frontend_relative or original_relative
|
|
if (
|
|
original_relative in AUDITED_CSS_RETIREMENTS
|
|
or original_relative in AUDITED_CSS_REPLACEMENTS
|
|
):
|
|
original = (ORIGINAL_STATIC / original_relative).read_text(encoding="utf-8")
|
|
for retired in AUDITED_CSS_RETIREMENTS.get(original_relative, ()):
|
|
source, replacement, *count_override = (
|
|
retired if isinstance(retired, tuple) else (retired, "")
|
|
)
|
|
expected_count = count_override[0] if count_override else 1
|
|
replacement_count = count_override[1] if len(count_override) > 1 else 1
|
|
testcase.assertEqual(original.count(source), expected_count, source)
|
|
original = original.replace(source, replacement, replacement_count)
|
|
for replacement in AUDITED_CSS_REPLACEMENTS.get(original_relative, ()):
|
|
source, target, *count_override = replacement
|
|
expected_count = count_override[0] if count_override else 1
|
|
replacement_count = count_override[1] if len(count_override) > 1 else expected_count
|
|
testcase.assertEqual(original.count(source), expected_count, source)
|
|
original = original.replace(source, target, replacement_count)
|
|
testcase.assertEqual(
|
|
(FRONTEND_ROOT / target_relative).read_text(encoding="utf-8"),
|
|
original,
|
|
original_relative,
|
|
)
|
|
return
|
|
testcase.assertEqual(
|
|
sha256(FRONTEND_ROOT / target_relative),
|
|
sha256(ORIGINAL_STATIC / original_relative),
|
|
original_relative,
|
|
)
|
|
|
|
|
|
def assert_page_prefix_matches(testcase, page_relative: str) -> None:
|
|
original = (ORIGINAL_STATIC / page_relative).read_text(encoding="utf-8")
|
|
migrated = (FRONTEND_ROOT / page_relative).read_text(encoding="utf-8")
|
|
testcase.assertTrue(
|
|
migrated.startswith(original.rstrip("\n") + "\n\n"),
|
|
page_relative,
|
|
)
|