Repository navigation
Expand file tree
/
Copy pathshell.py
More file actions
585 lines (539 loc) · 28.1 KB
/
Copy pathshell.py
File metadata and controls
585 lines (539 loc) · 28.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
"""The page skeleton, written here so a generation cannot cover the 3D.
Asking the model to keep the scene visible did not hold. It embedded the
iframe correctly every time and then put near-opaque cards over the whole
viewport, so the scene painted, responded to scroll, and was seen only in the
gaps between sections.
So the model no longer writes the skeleton. It writes SECTIONS. This file owns
the scene layer, the scrim, the containers and the cascade, and its rules are
appended last with !important, which means a section cannot make itself opaque
even if it tries. Two further defences, because a determined model will inline
styles on its own divs:
translucify() rewrites solid background colours in the model's CSS to the
same colour at alpha, including inline style attributes
verify_visible() renders the finished page and measures how much of the
viewport is actually scene -- the only check that cannot be
fooled by reading the CSS
Class contract given to the model:
.panel a translucent card. Text goes here. Backdrop-blurred, readable.
.bleed full-width band with NO background: for a headline over the scene.
.stack vertical rhythm inside a panel.
.lede large intro type. .fine small print.
"""
import re
SCRIM_ALPHA = 0.62
SHELL_CSS = """
/* Structure only. Every look decision -- type, colour, radius, measure,
whether headings shout -- comes from the skin's tokens, so the same markup
is a different website under a different skin rather than a recolour. */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; background: transparent !important; color: var(--ink);
font-family: var(--font-body); font-size: var(--body-size); }
#scene { position: fixed !important; inset: 0 !important; width: 100vw !important;
height: 100vh !important; border: 0 !important; z-index: 0 !important;
pointer-events: none !important; }
#content { position: relative; z-index: 1; background: transparent !important; }
#content > section { background: transparent !important; }
/* Section geometry is NOT here any more. It was, and that was the reason forty
skins rendered one page: whatever the type and the colour did, every build
was a centred column of cards 7vh apart, capped at the measure. The skin now
names a layout and layouts.py supplies the rules, which is why two skins can
put the same block in genuinely different places. */
/* A bleed sits on the live backdrop, not on a panel, so it needs the skin's
OTHER ink. This is done by re-declaring the tokens on the subtree rather than
by recolouring elements, because specificity could not win the argument: a
skin's ".bleed *{color:...}" ties with the shell's ".lede" and with any block
rule using --ink, and both of those are emitted later in the document, so
the hero lede under every paper skin was dark type on a dark scene. Tokens
cascade by inheritance instead of by selector weight, so a block written
months from now gets this for free. skins.css() defaults the two bleed
values to the panel ones, so a dark skin needs to say nothing. */
.bleed { background: transparent !important;
--ink: var(--ink-bleed); --muted: var(--muted-bleed);
--accent-panel: var(--accent); color: var(--ink); }
.panel { background: var(--panel) !important; border: var(--border) solid var(--line);
border-radius: var(--radius); padding: var(--pad); }
.stack > * + * { margin-top: 18px; }
/* A stage is sticky. This restates the engine's own rule at a weight a skin
cannot beat, and it is not belt and braces -- it is a bug that shipped.
`tape` sets .panel{position:relative} so it can pin a strip of tape to the
card, and seven blocks put data-sc-stage on an element that is also a
.panel. Same specificity, skin CSS comes later, so `relative` won: the act
still reserved its two viewports of scroll, the stage scrolled away after
one screen, and the rest was a hole. Four consecutive dead frames in a
finished video, on twenty-one skin-and-block combinations.
The engine's own stylesheet warns about exactly this next to
[data-sc-spotlight]. It only guarded the case it owned. */
#content [data-sc-stage] { position: sticky; }
/* ---- how blocks sit relative to each other -----------------------------
Everything above styles one block. These two style the RELATIONSHIP between
blocks, which is the thing a flat list of sections could not say.
A hold is two layers: a stage that sticks for the height of what follows,
and the following sections pulled back up over it. The held visual is then
on screen for a third of the video rather than four seconds, and the page
reads as layered instead of stacked.
The stage is 100vh and the overlay is pulled up by exactly that, so the
arithmetic does not depend on how many sections are held or how tall they
are. Held sections keep their own backgrounds: a panel over a held
photograph is the effect, not a mistake. */
.sr-hold { position: relative; }
.sr-hold__bed { position: sticky; top: 0; height: 100vh; z-index: 0;
overflow: clip; }
/* The held block's own words are NOT in the sticky layer. They are one screen
tall, pulled back over the bed, and they scroll away like anything else --
which is the only place they make sense, since the bed is crossed by every
section that follows. */
.sr-hold__intro { position: relative; z-index: 1; margin-top: -100vh; }
.sr-hold__over { position: relative; z-index: 1; }
/* A bracket is a pair of bookends with other sections between them. The two
ends carry a shared accent edge and the sections between them are inset, so
the middle reads as inside something.
The first version drew a rule down the left of the wrapper. It could not
work: a wrapper rule is positioned against the viewport, and there are seven
layouts putting the content in seven different places, so the rule landed in
the margin under some of them and through the type under others. An inset on
the enclosed sections is stated relative to whatever the layout already did,
which is why it holds everywhere. */
.sr-bracket { position: relative; }
.sr-bracket__inner > section { position: relative; }
.sr-bracket__inner > section > .panel,
.sr-bracket__inner > section > .bleed { margin-left: 3%; margin-right: 1%; }
@media (max-width: 760px) {
.sr-bracket__inner > section > .panel,
.sr-bracket__inner > section > .bleed { margin-inline: 0; }
}
/* An overlapping section climbs over the one before it. Negative margin rather
than a transform, so the page height shrinks with it and the scroll does not
gain a dead stretch at the bottom. */
.sr-overlap { margin-top: -7vh; position: relative; z-index: 1; }
.sr-overlap > .panel { box-shadow: 0 -18px 50px rgba(0,0,0,.30); }
@media (max-width: 760px) { .sr-overlap { margin-top: 0; } }
h1, h2, h3 { font-family: var(--font-display); margin: 0; }
h1 { font-size: var(--h1); line-height: 1.02; letter-spacing: var(--track);
text-shadow: 0 2px 40px rgba(0,0,0,.45); }
h2 { font-size: var(--h2); line-height: 1.08; letter-spacing: var(--track); }
h3 { font-size: 26px; }
p, li { font-size: var(--body-size); line-height: 1.55; color: var(--muted); margin: 0; }
.lede { font-size: calc(var(--body-size) * 1.25); color: var(--ink); }
.fine { font-size: calc(var(--body-size) * .82); color: var(--muted); }
a { color: var(--accent); }
img { max-width: 100%; border-radius: calc(var(--radius) * .7); display: block; }
.grid { display: grid; gap: 22px; grid-template-columns: repeat(2, 1fr); }
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
/* Panels on a light skin need the accent that reads on paper. */
.panel a, .panel .b-price-v, .panel .b-stat-v, .panel .b-radius-num,
.panel .b-step-meta { color: var(--accent-panel); }
/* A link FILLED with the accent needs the colour that sits on it, not the one
that sits beside it. Without this the rule above painted accent-on-accent and
the contact button was a blank orange pill under every skin that does not
declare accent_on_panel, which is all the dark ones. Later than the rule it
overrides and the same weight, so the two do not have to fight. */
.panel .cta, .bleed .cta { color: var(--on-accent); }
"""
MOUNT_JS = """
<script id="scrollreel-mount">
// The engine does not auto-init: it exposes ScrollCraft.mount and waits. The
// first build with it loaded looked fine and was not -- kinetic type was never
// split, and a [data-sc-in] section sat at opacity 0 because the engine's CSS
// had hidden it ready for a reveal that never came. Invisible content, and no
// console error to find it by.
(function () {
if (!window.ScrollCraft) {
document.documentElement.classList.add("sc-noengine");
return;
}
ScrollCraft.mount(document.body);
// Scroll velocity, published as --sc-vel (0..1, smoothed).
//
// The engine publishes --sc-p, --sc-mx/--sc-my and --sc-seg, but nothing for
// speed, and a band that runs faster the faster you scroll needs it. This is
// the skill's rule for bespoke behaviour: write it in the page, never in the
// engine. Without it, velocity-band silently fell back to a fixed-speed
// marquee -- working, but not the thing it claims to be.
var last = scrollY, vel = 0, root = document.documentElement;
addEventListener("scroll", function () {
var d = Math.abs(scrollY - last);
last = scrollY;
vel = Math.min(1, vel * 0.72 + (d / innerHeight) * 2.2);
}, { passive: true });
(function tick() {
vel *= 0.92; // settles when the reader stops
root.style.setProperty("--sc-vel", vel.toFixed(3));
requestAnimationFrame(tick);
})();
})();
</script>
<style>
/* If the engine ever fails to load, show what it would have revealed rather
than shipping a page with holes in it. */
.sc-noengine [data-sc-in], .sc-noengine [data-sc-cue] { opacity: 1 !important;
transform: none !important; }
</style>
"""
# There is deliberately no second reveal pass here any more.
#
# The shell used to add a .rise class to every .panel and .bleed and fade it in
# on its own IntersectionObserver. That predates the engine, and once blocks
# started declaring data-sc-in the two fought: a block could be revealed by the
# engine and still held at opacity 0 by .rise, whose 0.7s transition is 21
# frames at 30fps. It cost a full second of dead video in the middle of a build
# -- a frame with a faint empty rectangle where a section should be.
#
# The engine owns reveals now. A block that wants an entry declares data-sc-in;
# a block that does not is simply visible, which is the right answer for a
# price list.
# Solid colours in the model's own CSS, which would sit on top of the scene.
_HEX = re.compile(r"background(-color)?\s*:\s*(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3})\b")
_RGB = re.compile(r"background(-color)?\s*:\s*rgb\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)")
def _hex_to_rgb(h):
h = h.lstrip("#")
if len(h) == 3:
h = "".join(c * 2 for c in h)
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def translucify(text, alpha=SCRIM_ALPHA, skin=None):
"""Any solid background the model wrote becomes the same colour at alpha.
Blunt on purpose. A section that sets #0a0d12 is not making a design
decision we need to respect -- it is hiding the one thing the video is
for."""
# On a paper skin the panels are meant to be opaque; softening them there
# turns the page to fog and the type stops being readable.
if skin in ("press",):
return text
def hexsub(m):
r, g, b = _hex_to_rgb(m.group(2))
return f"background{m.group(1) or ''}: rgba({r},{g},{b},{alpha})"
def rgbsub(m):
return (f"background{m.group(1) or ''}: "
f"rgba({m.group(2)},{m.group(3)},{m.group(4)},{alpha})")
return _RGB.sub(rgbsub, _HEX.sub(hexsub, text))
def wrap(sections_html, scene_file="scene.html", title="", skin="glass",
accent="#ff6a2b", engine_dir="engine", layout=None,
ground="scene", ground_photo=None):
"""Assemble the finished document: engine, skin tokens, then structure.
The engine is scroll-craft's, vendored and unmodified. Blocks declare what
they want with data-sc-* and it drives all of them from one scroll value on
one rAF loop -- instead of a dozen blocks each running their own listener
with its own slightly different easing.
`ground` is what the page sits on -- see grounds.py. It exists because
every build before it sat on a WebGL scene with translucent cards over it,
which is one template with the hue changed: a viewer who saw two builds in
a week could not tell they came from different generators. "scene" is the
old behaviour and stays available; the other three are ordinary opaque
pages, which none of the first eleven builds ever were.
"""
import grounds as grounds_mod
import layouts as layouts_mod
import skins as skins_mod
skin_tokens = skins_mod.SKINS[skin]["tokens"]
# translucify only makes sense when something behind the page needs to
# show through it. On every other ground the panels are opaque by design
# -- grounds.css() sets --panel-solid for exactly that -- and running it
# anyway would fade block-authored colours (a held photo's own scrim, a
# drift band's wash) that were never meant to be see-through.
body = translucify(sections_html, skin=skin) if ground == "scene" else sections_html
skin_css = skins_mod.css(skin, accent)
# The same fallback skins.css() itself uses: a skin that has already
# chosen its own accent-on-panel colour (press picks #8a3b12 because it
# knows its own panel) is trusted over the raw per-build accent, which
# grounds.css() would otherwise contrast-correct FROM -- correcting the
# generic accent instead of the skin's considered one.
accent_on_panel = skins_mod.SKINS[skin].get("accent_on_panel", accent)
ground_css = grounds_mod.css(ground, skin_tokens, accent_on_panel,
photo_url=ground_photo)
ground_chrome = grounds_mod.chrome(ground, photo=ground_photo)
layout = layout or skins_mod.SKINS.get(skin, {}).get("layout", layouts_mod.DEFAULT)
layout_css = layouts_mod.css(layout)
# Chrome is markup no block knows about: a spine, a running caption, a
# progress rail. It goes in with the content so it inherits the tokens.
layout_chrome = layouts_mod.chrome(layout, title=title)
# The engine themes off --sc-* tokens; map ours onto them so a skin drives
# the engine's own surfaces too rather than fighting them.
bridge = """
:root{
--sc-canvas:transparent; --sc-surface:var(--panel);
--sc-ink:var(--ink); --sc-ink-soft:var(--muted);
--sc-accent:var(--accent); --sc-accent-ink:#0b0d10;
--sc-font-display:var(--font-display); --sc-font-text:var(--font-body);
}
/* The engine paints a ground; ours is the live backdrop behind it. */
html,body,.sc-page{background:transparent !important}
"""
# The scene iframe is omitted, not hidden, on any ground that is not
# "scene": main.py never writes scene.html for those builds, and an
# <iframe> pointed at a file that does not exist is still a request, a
# console error, and a blank rectangle sitting under everything.
scene_tag = (f'<iframe src="{scene_file}" id="scene" title="" tabindex="-1" '
f'scrolling="no"></iframe>') if ground == "scene" else ""
return f"""<!DOCTYPE html>
<html lang="en" data-skin="{skin}" data-ground="{ground}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="{engine_dir}/scrollcraft.css">
<style id="scrollreel-skin">{skin_css}</style>
<style id="scrollreel-engine-bridge">{bridge}</style>
<style id="scrollreel-shell">{SHELL_CSS}</style>
<style id="scrollreel-layout">{layout_css}</style>
<style id="scrollreel-ground">{ground_css}</style>
</head>
<body>
{scene_tag}
{ground_chrome}
<main id="content">
{body}
</main>
{layout_chrome}
<script src="{engine_dir}/scrollcraft.js"></script>
{MOUNT_JS}
</body>
</html>
"""
def verify_visible(page_path, min_scene=0.18, viewport=(1024, 850)):
"""Render the finished page and measure how much of the first screen is
actually scene. Reading the CSS cannot answer this; only pixels can."""
from playwright.sync_api import sync_playwright
import os
w, h = viewport
with sync_playwright() as p:
b = p.chromium.launch(channel="chrome")
pg = b.new_page(viewport={"width": w, "height": h})
try:
pg.goto("file://" + os.path.abspath(page_path), wait_until="networkidle",
timeout=30000)
pg.wait_for_timeout(2500)
# Sample a grid of points; count those where the topmost element is
# the scene iframe or a transparent container over it.
share = pg.evaluate("""() => {
let scene = 0, n = 0;
for (let x = 0.06; x < 1; x += 0.12) {
for (let y = 0.06; y < 1; y += 0.12) {
const el = document.elementFromPoint(x * innerWidth, y * innerHeight);
n++;
if (!el) { scene++; continue; }
if (el.id === 'scene' || el.tagName === 'IFRAME') { scene++; continue; }
const bg = getComputedStyle(el).backgroundColor;
const m = bg && bg.match(/rgba?\\(([^)]+)\\)/);
if (m) {
const parts = m[1].split(',').map(Number);
if (parts.length < 4 || parts[3] < 0.5) scene++;
}
}
}
return scene / n;
}""")
except Exception:
share = 0.0
finally:
b.close()
return float(share or 0.0) >= min_scene, float(share or 0.0)
def dead_scroll(page_path, viewport=(1024, 850), samples=48, min_ink=0.012):
"""Scroll positions where the page has almost nothing to look at.
The one failure a still cannot show you. Every screenshot of the Kelvin
build looked fine; the video had a second of empty card in it, because the
last pinned act owned 1360px of scroll and did not start its copy until 36%
of the way through. Nobody reviews 675 frames, so this walks the page and
reports the positions where the visible text area falls through the floor.
Returns [(scroll_y, ink_fraction)], worst first. `ink` is the share of the
viewport covered by text that is actually painted -- opacity is read off the
element, so a cue still waiting to fire counts as nothing, which is exactly
what it looks like.
"""
from playwright.sync_api import sync_playwright
import os
w, h = viewport
with sync_playwright() as p:
b = p.chromium.launch(channel="chrome")
pg = b.new_page(viewport={"width": w, "height": h})
pg.goto("file://" + os.path.abspath(page_path), wait_until="load")
pg.wait_for_timeout(1200)
travel = pg.evaluate("() => document.documentElement.scrollHeight - innerHeight")
found = []
for i in range(samples):
y = round(travel * i / max(samples - 1, 1))
pg.evaluate(f"scrollTo(0, {y})")
pg.wait_for_timeout(220)
ink = pg.evaluate("""() => {
let area = 0;
const seen = new WeakSet();
const paints = el =>
el.matches('img, svg, video, canvas') ||
(!el.children.length && el.textContent.trim());
document.querySelectorAll('#content *').forEach(el => {
// Pictures count. before-after is two photographs and four words,
// and a text-only measure called its best moment dead.
if (!paints(el)) return;
let node = el, o = 1;
while (node && node !== document.body) {
o *= parseFloat(getComputedStyle(node).opacity);
node = node.parentElement;
}
if (o < 0.25) return;
const r = el.getBoundingClientRect();
const top = Math.max(r.top, 0), bot = Math.min(r.bottom, innerHeight);
if (bot <= top) return;
area += (bot - top) * Math.min(r.width, innerWidth);
});
return area / (innerWidth * innerHeight);
}""")
if ink < min_ink:
found.append((y, round(ink, 4)))
b.close()
return sorted(found, key=lambda r: r[1])
def unstuck_stages(page_path, viewport=(1024, 850)):
"""Stages the browser did not make sticky, as [(class, position)].
Cannot be answered by reading the CSS: it depends on which skin, which
block, and which rule came later in a document assembled from four sources.
Only the cascade knows, so ask the cascade.
An unstuck stage is the worst shape of bug this project produces -- the act
still reserves its scroll, so the page is the right length and every
screenshot of it looks fine, and the defect is only a hole in the middle of
the video.
"""
from playwright.sync_api import sync_playwright
import os
w, h = viewport
with sync_playwright() as p:
b = p.chromium.launch(channel="chrome")
pg = b.new_page(viewport={"width": w, "height": h})
try:
pg.goto("file://" + os.path.abspath(page_path), wait_until="load")
pg.wait_for_timeout(1200)
bad = pg.evaluate("""() => {
const out = [];
document.querySelectorAll('[data-sc-stage], .sc-stage').forEach(el => {
const pos = getComputedStyle(el).position;
if (pos !== 'sticky') out.push([el.className || '(no class)', pos]);
});
return out;
}""")
except Exception: # noqa: BLE001
bad = []
finally:
b.close()
return [tuple(x) for x in bad]
def wrap_bespoke(body_html, title="", scene_file=None):
"""The minimal document for a fully bespoke page: the engine, mounted,
and nothing else imposed. No skin tokens, no ground CSS, no translucify
-- the model wrote its own six-role palette (taste.md: canvas, surface,
ink, ink-soft, accent, accent-ink) and its own section backgrounds, and
forcing any of that through the block-library's cascade would just be
fighting a page that was never built to expect it.
scene_file is optional and only wired in when the grammar asked for one
(filmic-one-shot); most bespoke pages have no live backdrop at all.
"""
scene_tag = (f'<iframe src="{scene_file}" id="scene" title="" tabindex="-1" '
f'scrolling="no" style="position:fixed;inset:0;width:100vw;'
f'height:100vh;border:0;z-index:0;pointer-events:none"></iframe>'
) if scene_file else ""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="engine/scrollcraft.css">
<style>*,*::before,*::after{{box-sizing:border-box}} body{{margin:0}}</style>
</head>
<body>
{scene_tag}
<main id="content">
{body_html}
</main>
<script src="engine/scrollcraft.js"></script>
{MOUNT_JS}
</body>
</html>
"""
# #content is not the engine's convention -- grep confirms scrollcraft.js/css
# never reference it -- it is dead_scroll()'s. Without this wrapper the first
# real bespoke build reported "dead scroll" at all 48 sampled positions,
# including y=0 where the hero plainly was not blank: the checker was
# querying `#content *` against a document that had no #content element at
# all, so it always found nothing and always reported 0% ink, regardless of
# what was actually on screen. Keeping the id, rather than rewriting the
# checker to scope to <body>, is what lets dead_scroll() and the rest of the
# block-library pipeline's checks stay exactly as tested.
def live_contrast(page_path, viewport=(1024, 850), min_body=4.5, min_large=3.0):
"""Real WCAG contrast, read off the ACTUAL rendered page -- not a token
table, because a bespoke page has no fixed token table to check. Walks
every leaf text node, reads its computed colour and the first ancestor
with an opaque-enough background, and grades the pair.
This is the bespoke-pipeline equivalent of contrast.py, and it has to
work differently: contrast.py can compute colours from CSS text because
the whole skin x ground matrix is closed and finite. A model can write
ANY CSS, including gradients, color-mix(), currentColor tricks and
inherited alpha -- the only colour that is ever actually correct is the
one the browser resolved, so this asks the browser instead of a parser.
"""
from playwright.sync_api import sync_playwright
import os
w, h = viewport
problems = []
with sync_playwright() as p:
b = p.chromium.launch(channel="chrome")
pg = b.new_page(viewport={"width": w, "height": h})
try:
pg.goto("file://" + os.path.abspath(page_path), wait_until="load", timeout=30000)
pg.wait_for_timeout(1500)
total = pg.evaluate("document.body.scrollHeight - innerHeight")
positions = [round(total * f) for f in (0, .15, .3, .45, .6, .75, .9, 1)] if total > 0 else [0]
seen = set()
for y in positions:
pg.evaluate(f"scrollTo(0, {max(y, 0)})")
pg.wait_for_timeout(400)
found = pg.evaluate(r"""() => {
function relLum(r,g,b){
const f = c => { c/=255; return c<=0.04045? c/12.92 : Math.pow((c+0.055)/1.055,2.4); };
return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b);
}
function parseRGBA(s){
const m = s.match(/rgba?\(([^)]+)\)/); if(!m) return null;
const p = m[1].split(',').map(x=>parseFloat(x));
return {r:p[0],g:p[1],b:p[2],a:p.length>3?p[3]:1};
}
function bgBehind(el){
let node = el, stack = [];
while (node && node !== document.documentElement) {
const bg = parseRGBA(getComputedStyle(node).backgroundColor);
if (bg && bg.a > 0.5) return bg;
node = node.parentElement;
}
return {r:255,g:255,b:255,a:1};
}
const out = [];
document.querySelectorAll('body *').forEach(el => {
if (el.children.length) return;
const t = el.textContent.trim();
if (!t || t.length < 2) return;
const r = el.getBoundingClientRect();
if (r.bottom < 0 || r.top > innerHeight || r.width < 1) return;
const cs = getComputedStyle(el);
if (parseFloat(cs.opacity) < 0.4) return;
const fg = parseRGBA(cs.color);
if (!fg) return;
const bg = bgBehind(el);
const lf = relLum(fg.r, fg.g, fg.b), lb = relLum(bg.r, bg.g, bg.b);
const ratio = (Math.max(lf,lb)+0.05) / (Math.min(lf,lb)+0.05);
const big = parseFloat(cs.fontSize) >= 24 ||
(parseFloat(cs.fontSize) >= 19 && parseInt(cs.fontWeight) >= 700);
out.push({text: t.slice(0,40), ratio: Math.round(ratio*100)/100, big});
});
return out;
}""")
for item in found:
key = item["text"]
if key in seen:
continue
seen.add(key)
need = min_large if item["big"] else min_body
if item["ratio"] < need:
problems.append(f"\"{item['text']}\" at {item['ratio']}:1, needs {need}:1")
except Exception as e: # noqa: BLE001
problems.append(f"could not check ({e})")
finally:
b.close()
return problems