pandorga 2.3.16 → 2.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/assets/css/ledger/home.css +46 -44
- data/lib/pandorga/commands/new.rb +1 -1
- data/lib/pandorga/version.rb +1 -1
- data/scripts/test/test-home-hero-overlay-monotonic.mjs +444 -0
- data/scripts/test/test-home-hero-overlay-monotonic.rb +21 -0
- data/scripts/test/test-home-hero-overlay-split.rb +33 -13
- data/scripts/validate.sh +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f15a65d09189b3c4d7f02ea2afc30c0ae3041d3fbb1b40885943144d5bd393f
|
|
4
|
+
data.tar.gz: 15e42b4a696dea56e0abe802aab7b405b2d1646d6a14297609cb3402a8a126fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e54b8886340a040ed93994272492147cf93f161900a70636ec88e80fd7597ab710a023a5869e9c28756747a5cac32e36e2991c39d9bed5c2f712d59a587553ae
|
|
7
|
+
data.tar.gz: 024aeeb0fd4aa20ae4a1e927347bbd2535bf813e623e467cd886559b304ec4859d46750ee722f5022b07c3343c96c0e25559fd1c1245bb45ae711ba32785620a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.3.17
|
|
4
|
+
|
|
5
|
+
- CR-24 overlay: strength (`::after`) now paints **true black** `rgba(0,0,0,…)` so it darkens photo areas monotonically (2.3.16 charcoal shade brightened dark regions and made strength effectively dead / slightly inverted) (PLT-AC-103).
|
|
6
|
+
- Stronger linear strength scale (peak ≈ 0.55 × strength, `--home-hero-strength-cap: 0.55`); pool mid-ramp slightly raised for perceptible 0.05–0.1 steps; pool stays charcoal (PLT-AC-102/103).
|
|
7
|
+
- Rendered monotonicity gate at 390/1440: face/upper brightness falls across strength 0.2/0.4/0.6/0.8; text band across pool; independence; 0% pure-black behind text (`test-home-hero-overlay-monotonic`).
|
|
8
|
+
|
|
3
9
|
## 2.3.16
|
|
4
10
|
|
|
5
11
|
- CR-24 overlay split: `strength` (`--home-hero-vignette`) darkens the photo area via `::after` (masked off the text band); `pool` (`--home-hero-pool`) is an **absolute** text-band alpha on `::before` (not scaled by strength) (PLT-AC-102).
|
data/assets/css/ledger/home.css
CHANGED
|
@@ -1859,24 +1859,25 @@ html[data-theme="light"] .home-featured .ledger-poster {
|
|
|
1859
1859
|
}
|
|
1860
1860
|
|
|
1861
1861
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|
1862
|
-
CR-22…22.4 — Home hero B3 + C4 (PLT-AC-81…94) + overlay strength/pool (PLT-AC-100…
|
|
1862
|
+
CR-22…22.4 — Home hero B3 + C4 (PLT-AC-81…94) + overlay strength/pool (PLT-AC-100…103)
|
|
1863
1863
|
Photo as hero background + 50/50 masthead with Projects from 1024px.
|
|
1864
1864
|
Config (pandorga.home.hero_overlay):
|
|
1865
1865
|
strength | vignette → --home-hero-vignette (0–1): photo-area darkness
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
pool → --home-hero-pool (0–1): absolute
|
|
1870
|
-
(
|
|
1871
|
-
|
|
1866
|
+
via ::after as true black rgba(0,0,0,…). Linear in [0,1]; peak stop
|
|
1867
|
+
≈ strength × 0.55, capped by --home-hero-strength-cap (~0.55). Charcoal
|
|
1868
|
+
must NOT be used here — it brightens dark photo regions (2.3.16 bug).
|
|
1869
|
+
pool → --home-hero-pool (0–1): absolute text-band alpha on ::before
|
|
1870
|
+
(charcoal --home-hero-shade). Not scaled by strength.
|
|
1871
|
+
Caps < 1; pool shade keeps text-band composites off pure-black RGB.
|
|
1872
1872
|
═══════════════════════════════════════════════════════════════════════════ */
|
|
1873
1873
|
|
|
1874
1874
|
:root {
|
|
1875
1875
|
--home-hero-vignette: 0.60;
|
|
1876
1876
|
--home-hero-pool: 0.70;
|
|
1877
|
-
|
|
1877
|
+
/* Strength peak alpha at vignette=1.0; <1 so overlay never paints opaque black. */
|
|
1878
|
+
--home-hero-strength-cap: 0.55;
|
|
1878
1879
|
--home-hero-pool-cap: 0.90;
|
|
1879
|
-
/*
|
|
1880
|
+
/* Pool charcoal (not #000): text-band composites stay RGB sum ≥ 3 over black clothing. */
|
|
1880
1881
|
--home-hero-shade: 18, 20, 26;
|
|
1881
1882
|
--home-masthead-gutter: clamp(1.25rem, 2.2vw, 2rem);
|
|
1882
1883
|
}
|
|
@@ -1891,9 +1892,9 @@ html[data-theme="light"] .home-featured .ledger-poster {
|
|
|
1891
1892
|
object-position: center;
|
|
1892
1893
|
}
|
|
1893
1894
|
|
|
1894
|
-
/* Soft edgeless photo overlay (C4 / PLT-AC-89 / PLT-AC-100…
|
|
1895
|
+
/* Soft edgeless photo overlay (C4 / PLT-AC-89 / PLT-AC-100…103).
|
|
1895
1896
|
::after = strength (photo area). ::before = pool (text-band absolute alpha).
|
|
1896
|
-
Independent;
|
|
1897
|
+
Independent; strength = true black; pool = charcoal; caps < 1 → no pure-black from the overlay behind text. */
|
|
1897
1898
|
.home-hero-portrait::before,
|
|
1898
1899
|
.home-hero-portrait::after {
|
|
1899
1900
|
content: "";
|
|
@@ -1904,45 +1905,46 @@ html[data-theme="light"] .home-featured .ledger-poster {
|
|
|
1904
1905
|
border-radius: 0;
|
|
1905
1906
|
}
|
|
1906
1907
|
|
|
1907
|
-
/* Text band: absolute pool alpha
|
|
1908
|
-
Ramp
|
|
1908
|
+
/* Text band: absolute pool alpha (charcoal); not scaled by strength.
|
|
1909
|
+
Ramp covers title + summary; alpha tracks pool ~linearly for perceptible steps. */
|
|
1909
1910
|
.home-hero-portrait::before {
|
|
1910
1911
|
background: linear-gradient(
|
|
1911
1912
|
180deg,
|
|
1912
1913
|
transparent 34%,
|
|
1913
|
-
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), calc(var(--home-hero-pool) * 0.
|
|
1914
|
-
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), calc(var(--home-hero-pool) * 0.
|
|
1914
|
+
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), calc(var(--home-hero-pool) * 0.50))) 46%,
|
|
1915
|
+
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), calc(var(--home-hero-pool) * 0.82))) 56%,
|
|
1915
1916
|
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), var(--home-hero-pool))) 66%,
|
|
1916
1917
|
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), var(--home-hero-pool))) 100%
|
|
1917
1918
|
);
|
|
1918
1919
|
}
|
|
1919
1920
|
|
|
1920
|
-
/* Photo area: strength only
|
|
1921
|
+
/* Photo area: strength only (true black — charcoal brightened dark photos in 2.3.16).
|
|
1922
|
+
Masked off the text band so pool stays independent. Peak ≈ 0.55 × strength. */
|
|
1921
1923
|
.home-hero-portrait::after {
|
|
1922
|
-
-webkit-mask-image: linear-gradient(180deg, #000 0%, #000
|
|
1923
|
-
mask-image: linear-gradient(180deg, #000 0%, #000
|
|
1924
|
+
-webkit-mask-image: linear-gradient(180deg, #000 0%, #000 36%, transparent 50%);
|
|
1925
|
+
mask-image: linear-gradient(180deg, #000 0%, #000 36%, transparent 50%);
|
|
1924
1926
|
background:
|
|
1925
1927
|
radial-gradient(
|
|
1926
|
-
ellipse
|
|
1927
|
-
rgba(
|
|
1928
|
-
rgba(
|
|
1929
|
-
rgba(
|
|
1928
|
+
ellipse 85% 55% at 52% 28%,
|
|
1929
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.22 * var(--home-hero-vignette)))) 0%,
|
|
1930
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.40 * var(--home-hero-vignette)))) 45%,
|
|
1931
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.55 * var(--home-hero-vignette)))) 78%,
|
|
1930
1932
|
transparent 100%
|
|
1931
1933
|
),
|
|
1932
1934
|
linear-gradient(
|
|
1933
1935
|
90deg,
|
|
1934
|
-
rgba(
|
|
1935
|
-
rgba(
|
|
1936
|
-
rgba(
|
|
1937
|
-
rgba(
|
|
1938
|
-
transparent
|
|
1936
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.50 * var(--home-hero-vignette)))) 0%,
|
|
1937
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.38 * var(--home-hero-vignette)))) 16%,
|
|
1938
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.22 * var(--home-hero-vignette)))) 36%,
|
|
1939
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.08 * var(--home-hero-vignette)))) 55%,
|
|
1940
|
+
transparent 72%
|
|
1939
1941
|
),
|
|
1940
1942
|
linear-gradient(
|
|
1941
1943
|
180deg,
|
|
1942
|
-
rgba(
|
|
1943
|
-
rgba(
|
|
1944
|
-
rgba(
|
|
1945
|
-
transparent
|
|
1944
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.32 * var(--home-hero-vignette)))) 0%,
|
|
1945
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.18 * var(--home-hero-vignette)))) 14%,
|
|
1946
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.20 * var(--home-hero-vignette)))) 28%,
|
|
1947
|
+
transparent 52%
|
|
1946
1948
|
);
|
|
1947
1949
|
}
|
|
1948
1950
|
|
|
@@ -2031,13 +2033,13 @@ html[data-theme="light"] .home-featured .ledger-poster {
|
|
|
2031
2033
|
filter: saturate(0.95) contrast(1.06) brightness(0.98);
|
|
2032
2034
|
}
|
|
2033
2035
|
|
|
2034
|
-
/* PLT-AC-100…
|
|
2036
|
+
/* PLT-AC-100…103: stack — ::after strength (upper); ::before pool (copy band) */
|
|
2035
2037
|
.home-hero-portrait::before {
|
|
2036
2038
|
background: linear-gradient(
|
|
2037
2039
|
180deg,
|
|
2038
2040
|
transparent 22%,
|
|
2039
|
-
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), calc(var(--home-hero-pool) * 0.
|
|
2040
|
-
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), calc(var(--home-hero-pool) * 0.
|
|
2041
|
+
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), calc(var(--home-hero-pool) * 0.50))) 34%,
|
|
2042
|
+
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), calc(var(--home-hero-pool) * 0.82))) 46%,
|
|
2041
2043
|
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), var(--home-hero-pool))) 56%,
|
|
2042
2044
|
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), var(--home-hero-pool))) 78%,
|
|
2043
2045
|
rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), calc(var(--home-hero-pool) * 0.92))) 100%
|
|
@@ -2045,22 +2047,22 @@ html[data-theme="light"] .home-featured .ledger-poster {
|
|
|
2045
2047
|
}
|
|
2046
2048
|
.home-hero-portrait::after {
|
|
2047
2049
|
border-radius: 0;
|
|
2048
|
-
-webkit-mask-image: linear-gradient(180deg, #000 0%, #000
|
|
2049
|
-
mask-image: linear-gradient(180deg, #000 0%, #000
|
|
2050
|
+
-webkit-mask-image: linear-gradient(180deg, #000 0%, #000 30%, transparent 44%);
|
|
2051
|
+
mask-image: linear-gradient(180deg, #000 0%, #000 30%, transparent 44%);
|
|
2050
2052
|
background:
|
|
2051
2053
|
radial-gradient(
|
|
2052
|
-
ellipse
|
|
2053
|
-
rgba(
|
|
2054
|
-
rgba(
|
|
2055
|
-
rgba(
|
|
2054
|
+
ellipse 92% 48% at 50% 14%,
|
|
2055
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.20 * var(--home-hero-vignette)))) 0%,
|
|
2056
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.38 * var(--home-hero-vignette)))) 50%,
|
|
2057
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.55 * var(--home-hero-vignette)))) 80%,
|
|
2056
2058
|
transparent 100%
|
|
2057
2059
|
),
|
|
2058
2060
|
linear-gradient(
|
|
2059
2061
|
180deg,
|
|
2060
|
-
rgba(
|
|
2061
|
-
rgba(
|
|
2062
|
-
rgba(
|
|
2063
|
-
transparent
|
|
2062
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.30 * var(--home-hero-vignette)))) 0%,
|
|
2063
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.22 * var(--home-hero-vignette)))) 10%,
|
|
2064
|
+
rgba(0, 0, 0, min(var(--home-hero-strength-cap), calc(0.28 * var(--home-hero-vignette)))) 22%,
|
|
2065
|
+
transparent 40%
|
|
2064
2066
|
);
|
|
2065
2067
|
}
|
|
2066
2068
|
|
|
@@ -24,7 +24,7 @@ module Pandorga
|
|
|
24
24
|
|
|
25
25
|
gem "pandorga", "~> 2.0"
|
|
26
26
|
# Local checkout: gem "pandorga", path: "../pandorga"
|
|
27
|
-
# GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v2.3.
|
|
27
|
+
# GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v2.3.17"
|
|
28
28
|
gem "csv"
|
|
29
29
|
gem "base64"
|
|
30
30
|
RUBY
|
data/lib/pandorga/version.rb
CHANGED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PLT-AC-103 — rendered monotonicity of strength (upper/face) and pool (text band)
|
|
3
|
+
// at 390 and 1440. Fixture uses a bright mid-tone "photo" so strength darkening
|
|
4
|
+
// is measurable (dark-wall regions hid the 2.3.16 charcoal inversion).
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { mkdtemp, writeFile, readFile, rm, mkdir } from 'node:fs/promises';
|
|
9
|
+
import { createServer as createNetServer } from 'node:net';
|
|
10
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
17
|
+
const ROOT = resolve(__dirname, '../..');
|
|
18
|
+
const HOME_CSS = join(ROOT, 'assets/css/ledger/home.css');
|
|
19
|
+
|
|
20
|
+
const STRENGTH_STEPS = [0.2, 0.4, 0.6, 0.8];
|
|
21
|
+
const POOL_STEPS = [0.2, 0.4, 0.6, 0.8];
|
|
22
|
+
// Minimum mean-RGB drop per 0.2 step on the bright face region / text band.
|
|
23
|
+
const MIN_DELTA_STRENGTH = 4.0;
|
|
24
|
+
const MIN_DELTA_POOL = 4.0;
|
|
25
|
+
const VIEWPORTS = [
|
|
26
|
+
{ name: '390', width: 390, height: 844, mobile: true },
|
|
27
|
+
{ name: '1440', width: 1440, height: 900, mobile: false }
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function freePort() {
|
|
31
|
+
return new Promise((resolveP, reject) => {
|
|
32
|
+
const server = createNetServer();
|
|
33
|
+
server.once('error', reject);
|
|
34
|
+
server.listen(0, '127.0.0.1', () => {
|
|
35
|
+
const { port } = server.address();
|
|
36
|
+
server.close(() => resolveP(port));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findChrome() {
|
|
42
|
+
const candidates = [
|
|
43
|
+
process.env.CHROME_PATH,
|
|
44
|
+
'/usr/bin/google-chrome-stable',
|
|
45
|
+
'/usr/bin/google-chrome',
|
|
46
|
+
'/usr/bin/chromium',
|
|
47
|
+
'/usr/bin/chromium-browser'
|
|
48
|
+
].filter(Boolean);
|
|
49
|
+
for (const c of candidates) {
|
|
50
|
+
if (c && existsSync(c)) return c;
|
|
51
|
+
}
|
|
52
|
+
return 'google-chrome';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class Cdp {
|
|
56
|
+
constructor(ws) {
|
|
57
|
+
this.ws = ws;
|
|
58
|
+
this.next = 1;
|
|
59
|
+
this.pending = new Map();
|
|
60
|
+
ws.addEventListener('message', (event) => {
|
|
61
|
+
const message = JSON.parse(event.data);
|
|
62
|
+
if (message.id && this.pending.has(message.id)) {
|
|
63
|
+
const { resolve: res, reject: rej } = this.pending.get(message.id);
|
|
64
|
+
this.pending.delete(message.id);
|
|
65
|
+
if (message.error) rej(new Error(message.error.message || JSON.stringify(message.error)));
|
|
66
|
+
else res(message.result || {});
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
send(method, params = {}, sessionId) {
|
|
71
|
+
const id = this.next++;
|
|
72
|
+
const payload = { id, method, params };
|
|
73
|
+
if (sessionId) payload.sessionId = sessionId;
|
|
74
|
+
this.ws.send(JSON.stringify(payload));
|
|
75
|
+
return new Promise((res, rej) => this.pending.set(id, { resolve: res, reject: rej }));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function openSocket(url) {
|
|
80
|
+
const ws = new WebSocket(url);
|
|
81
|
+
await new Promise((resolveP, reject) => {
|
|
82
|
+
ws.addEventListener('open', resolveP, { once: true });
|
|
83
|
+
ws.addEventListener('error', () => reject(new Error('DevTools socket failed')), { once: true });
|
|
84
|
+
});
|
|
85
|
+
return ws;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function waitForDevtools(port, chrome) {
|
|
89
|
+
let stderr = '';
|
|
90
|
+
if (chrome.stderr) chrome.stderr.on('data', (c) => { stderr += c; });
|
|
91
|
+
for (let i = 0; i < 100; i++) {
|
|
92
|
+
if (chrome.exitCode !== null) throw new Error(`Chrome exited ${chrome.exitCode}: ${stderr.slice(-500)}`);
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
|
|
95
|
+
if (response.ok) return response.json();
|
|
96
|
+
} catch (_e) { /* starting */ }
|
|
97
|
+
await delay(100);
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Chrome DevTools did not answer on ${port}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function extractOverlayCss(fullCss) {
|
|
103
|
+
const rootIdx = fullCss.indexOf('--home-hero-vignette:');
|
|
104
|
+
if (rootIdx < 0) throw new Error('missing --home-hero-vignette');
|
|
105
|
+
const rootStart = fullCss.lastIndexOf(':root {', rootIdx);
|
|
106
|
+
const rootEnd = fullCss.indexOf('}', rootIdx) + 1;
|
|
107
|
+
const rootBlock = fullCss.slice(rootStart, rootEnd);
|
|
108
|
+
|
|
109
|
+
const soft = fullCss.indexOf('/* Soft edgeless photo overlay');
|
|
110
|
+
if (soft < 0) throw new Error('missing Soft edgeless overlay block');
|
|
111
|
+
// Desk shared + ::before + ::after end at the blank line before stacked media
|
|
112
|
+
const stacked = fullCss.indexOf('/* ── Stacked / full-bleed', soft);
|
|
113
|
+
if (stacked < 0) throw new Error('missing stacked media marker');
|
|
114
|
+
const deskBlock = fullCss.slice(soft, stacked);
|
|
115
|
+
|
|
116
|
+
const stackMarker = fullCss.indexOf('PLT-AC-100…103: stack');
|
|
117
|
+
if (stackMarker < 0) throw new Error('missing stack overlay marker');
|
|
118
|
+
// Take from the stack comment through the end of the nested ::after rule
|
|
119
|
+
const afterStack = fullCss.indexOf('.home-hero-portrait::after {', stackMarker);
|
|
120
|
+
let depth = 0;
|
|
121
|
+
let i = fullCss.indexOf('{', afterStack);
|
|
122
|
+
for (; i < fullCss.length; i++) {
|
|
123
|
+
if (fullCss[i] === '{') depth++;
|
|
124
|
+
else if (fullCss[i] === '}') {
|
|
125
|
+
depth--;
|
|
126
|
+
if (depth === 0) { i++; break; }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const stackRules = fullCss.slice(stackMarker, i);
|
|
130
|
+
const stackMedia = `@media (max-width: 1023px) {
|
|
131
|
+
${stackRules}
|
|
132
|
+
}
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
return `${rootBlock}
|
|
136
|
+
${deskBlock}
|
|
137
|
+
${stackMedia}
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildFixtureHtml(overlayCss) {
|
|
142
|
+
// Bright face/shirt-like mid-tone (#d4b896) so black overlays darken measurably.
|
|
143
|
+
// Vertical gradient: brighter upper (face), slightly darker lower (so pool still measurable).
|
|
144
|
+
return `<!doctype html>
|
|
145
|
+
<html lang="en">
|
|
146
|
+
<head>
|
|
147
|
+
<meta charset="utf-8"/>
|
|
148
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
149
|
+
<style>
|
|
150
|
+
html, body { margin: 0; padding: 0; background: #111; }
|
|
151
|
+
.home-hero-portrait {
|
|
152
|
+
position: relative;
|
|
153
|
+
width: 100vw;
|
|
154
|
+
height: 100vh;
|
|
155
|
+
overflow: hidden;
|
|
156
|
+
background:
|
|
157
|
+
radial-gradient(ellipse 55% 45% at 48% 32%, #e8c9a8 0%, #d4b896 40%, #b8956e 70%, #6a5a48 100%),
|
|
158
|
+
linear-gradient(180deg, #c4a882 0%, #a88860 55%, #5a4a3a 100%);
|
|
159
|
+
}
|
|
160
|
+
${overlayCss}
|
|
161
|
+
</style>
|
|
162
|
+
</head>
|
|
163
|
+
<body>
|
|
164
|
+
<div class="home-hero-portrait" id="hero"></div>
|
|
165
|
+
</body>
|
|
166
|
+
</html>`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function readPngRgba(pngPath) {
|
|
170
|
+
// Minimal PNG decoder (8-bit RGB/RGBA, non-interlaced) using node:zlib.
|
|
171
|
+
const { inflateSync } = await import('node:zlib');
|
|
172
|
+
const buf = await readFile(pngPath);
|
|
173
|
+
if (buf.toString('ascii', 1, 4) !== 'PNG') throw new Error('not PNG');
|
|
174
|
+
let pos = 8;
|
|
175
|
+
let width = 0, height = 0, colorType = 2, bits = 8;
|
|
176
|
+
const idat = [];
|
|
177
|
+
while (pos < buf.length) {
|
|
178
|
+
const len = buf.readUInt32BE(pos); pos += 4;
|
|
179
|
+
const type = buf.toString('ascii', pos, pos + 4); pos += 4;
|
|
180
|
+
const data = buf.subarray(pos, pos + len); pos += len;
|
|
181
|
+
pos += 4; // crc
|
|
182
|
+
if (type === 'IHDR') {
|
|
183
|
+
width = data.readUInt32BE(0);
|
|
184
|
+
height = data.readUInt32BE(4);
|
|
185
|
+
bits = data[8];
|
|
186
|
+
colorType = data[9];
|
|
187
|
+
} else if (type === 'IDAT') {
|
|
188
|
+
idat.push(data);
|
|
189
|
+
} else if (type === 'IEND') break;
|
|
190
|
+
}
|
|
191
|
+
if (bits !== 8 || (colorType !== 2 && colorType !== 6)) {
|
|
192
|
+
throw new Error(`unsupported PNG ct=${colorType} bits=${bits}`);
|
|
193
|
+
}
|
|
194
|
+
const bpp = colorType === 6 ? 4 : 3;
|
|
195
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
196
|
+
const stride = width * bpp;
|
|
197
|
+
const rgba = Buffer.alloc(width * height * 4);
|
|
198
|
+
let src = 0;
|
|
199
|
+
for (let y = 0; y < height; y++) {
|
|
200
|
+
const filter = raw[src++];
|
|
201
|
+
const row = raw.subarray(src, src + stride);
|
|
202
|
+
src += stride;
|
|
203
|
+
const out = rgba.subarray(y * width * 4, (y + 1) * width * 4);
|
|
204
|
+
// Paeth/Sub/Up/Average/None — Chrome uses filter 0 or 1 typically; handle 0–4.
|
|
205
|
+
const prev = y > 0 ? rgba.subarray((y - 1) * width * 4, y * width * 4) : null;
|
|
206
|
+
for (let i = 0; i < width; i++) {
|
|
207
|
+
let r = row[i * bpp], g = row[i * bpp + 1], b = row[i * bpp + 2];
|
|
208
|
+
const a = bpp === 4 ? row[i * bpp + 3] : 255;
|
|
209
|
+
const left = i > 0 ? [out[(i - 1) * 4], out[(i - 1) * 4 + 1], out[(i - 1) * 4 + 2]] : [0, 0, 0];
|
|
210
|
+
const up = prev ? [prev[i * 4], prev[i * 4 + 1], prev[i * 4 + 2]] : [0, 0, 0];
|
|
211
|
+
const apply = (chan, li, ui) => {
|
|
212
|
+
if (filter === 0) return chan;
|
|
213
|
+
if (filter === 1) return (chan + li) & 255;
|
|
214
|
+
if (filter === 2) return (chan + ui) & 255;
|
|
215
|
+
if (filter === 3) return (chan + ((li + ui) >> 1)) & 255;
|
|
216
|
+
if (filter === 4) {
|
|
217
|
+
const p = li + ui - (prev && i > 0 ? prev[(i - 1) * 4 + (li === left[0] ? 0 : li === left[1] ? 1 : 2)] : 0);
|
|
218
|
+
// Proper Paeth for each channel below
|
|
219
|
+
return chan; // placeholder replaced
|
|
220
|
+
}
|
|
221
|
+
return chan;
|
|
222
|
+
};
|
|
223
|
+
if (filter === 0) {
|
|
224
|
+
// none
|
|
225
|
+
} else if (filter === 1) {
|
|
226
|
+
r = (r + left[0]) & 255; g = (g + left[1]) & 255; b = (b + left[2]) & 255;
|
|
227
|
+
} else if (filter === 2) {
|
|
228
|
+
r = (r + up[0]) & 255; g = (g + up[1]) & 255; b = (b + up[2]) & 255;
|
|
229
|
+
} else if (filter === 3) {
|
|
230
|
+
r = (r + ((left[0] + up[0]) >> 1)) & 255;
|
|
231
|
+
g = (g + ((left[1] + up[1]) >> 1)) & 255;
|
|
232
|
+
b = (b + ((left[2] + up[2]) >> 1)) & 255;
|
|
233
|
+
} else if (filter === 4) {
|
|
234
|
+
const paeth = (a, b, c) => {
|
|
235
|
+
const p = a + b - c;
|
|
236
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
237
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
238
|
+
};
|
|
239
|
+
const ul = prev && i > 0 ? [prev[(i - 1) * 4], prev[(i - 1) * 4 + 1], prev[(i - 1) * 4 + 2]] : [0, 0, 0];
|
|
240
|
+
r = (r + paeth(left[0], up[0], ul[0])) & 255;
|
|
241
|
+
g = (g + paeth(left[1], up[1], ul[1])) & 255;
|
|
242
|
+
b = (b + paeth(left[2], up[2], ul[2])) & 255;
|
|
243
|
+
} else {
|
|
244
|
+
throw new Error('unsupported PNG filter ' + filter);
|
|
245
|
+
}
|
|
246
|
+
out[i * 4] = r; out[i * 4 + 1] = g; out[i * 4 + 2] = b; out[i * 4 + 3] = a;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { width, height, rgba };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function meanBandsFromRgba({ width, height, rgba }, faceY0, faceY1, faceX0, faceX1, textY0, textY1) {
|
|
253
|
+
const band = (y0, y1, x0 = 0, x1 = 1) => {
|
|
254
|
+
const ya = Math.floor(height * y0);
|
|
255
|
+
const yb = Math.max(ya + 1, Math.floor(height * y1));
|
|
256
|
+
const xa = Math.floor(width * x0);
|
|
257
|
+
const xb = Math.max(xa + 1, Math.floor(width * x1));
|
|
258
|
+
let s = 0, n = 0, pb = 0;
|
|
259
|
+
for (let y = ya; y < yb; y++) {
|
|
260
|
+
for (let x = xa; x < xb; x++) {
|
|
261
|
+
const i = (y * width + x) * 4;
|
|
262
|
+
const r = rgba[i], g = rgba[i + 1], b = rgba[i + 2];
|
|
263
|
+
s += (r + g + b) / 3;
|
|
264
|
+
n++;
|
|
265
|
+
if (r + g + b < 3) pb++;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { mean: n ? s / n : 0, pb: n ? (100 * pb) / n : 0 };
|
|
269
|
+
};
|
|
270
|
+
const face = band(faceY0, faceY1, faceX0, faceX1);
|
|
271
|
+
const text = band(textY0, textY1);
|
|
272
|
+
return { face: face.mean, text: text.mean, pb: text.pb };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function main() {
|
|
276
|
+
const fullCss = await readFile(HOME_CSS, 'utf8');
|
|
277
|
+
const overlayCss = extractOverlayCss(fullCss);
|
|
278
|
+
const html = buildFixtureHtml(overlayCss);
|
|
279
|
+
|
|
280
|
+
const work = await mkdtemp(join(tmpdir(), 'pandorga-mono-'));
|
|
281
|
+
const htmlPath = join(work, 'index.html');
|
|
282
|
+
await writeFile(htmlPath, html);
|
|
283
|
+
|
|
284
|
+
const httpPort = await freePort();
|
|
285
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
286
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
287
|
+
res.end(html);
|
|
288
|
+
});
|
|
289
|
+
await new Promise((r) => httpServer.listen(httpPort, '127.0.0.1', r));
|
|
290
|
+
|
|
291
|
+
const chromePath = findChrome();
|
|
292
|
+
const profile = await mkdtemp(join(tmpdir(), 'pandorga-mono-chrome-'));
|
|
293
|
+
const dbgPort = await freePort();
|
|
294
|
+
const chrome = spawn(chromePath, [
|
|
295
|
+
'--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
|
|
296
|
+
'--no-first-run', '--remote-allow-origins=*',
|
|
297
|
+
'--remote-debugging-address=127.0.0.1',
|
|
298
|
+
`--remote-debugging-port=${dbgPort}`,
|
|
299
|
+
`--user-data-dir=${profile}`,
|
|
300
|
+
'about:blank'
|
|
301
|
+
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
302
|
+
|
|
303
|
+
const errors = [];
|
|
304
|
+
try {
|
|
305
|
+
const version = await waitForDevtools(dbgPort, chrome);
|
|
306
|
+
const ws = await openSocket(version.webSocketDebuggerUrl);
|
|
307
|
+
const browser = new Cdp(ws);
|
|
308
|
+
|
|
309
|
+
const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank' });
|
|
310
|
+
const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true });
|
|
311
|
+
await browser.send('Page.enable', {}, sessionId);
|
|
312
|
+
await browser.send('Runtime.enable', {}, sessionId);
|
|
313
|
+
|
|
314
|
+
for (const vp of VIEWPORTS) {
|
|
315
|
+
await browser.send('Emulation.setDeviceMetricsOverride', {
|
|
316
|
+
width: vp.width, height: vp.height,
|
|
317
|
+
deviceScaleFactor: 1, mobile: vp.mobile
|
|
318
|
+
}, sessionId);
|
|
319
|
+
|
|
320
|
+
await browser.send('Page.navigate', { url: `http://127.0.0.1:${httpPort}/` }, sessionId);
|
|
321
|
+
await delay(400);
|
|
322
|
+
|
|
323
|
+
// --- Strength monotonicity (pool fixed mid) ---
|
|
324
|
+
const strengthFace = [];
|
|
325
|
+
for (const s of STRENGTH_STEPS) {
|
|
326
|
+
await browser.send('Runtime.evaluate', {
|
|
327
|
+
expression: `document.documentElement.style.setProperty('--home-hero-vignette','${s}');document.documentElement.style.setProperty('--home-hero-pool','0.50');`
|
|
328
|
+
}, sessionId);
|
|
329
|
+
await delay(120);
|
|
330
|
+
const shot = await browser.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
331
|
+
const pngPath = join(work, `s-${vp.name}-${s}.png`);
|
|
332
|
+
await writeFile(pngPath, Buffer.from(shot.data, 'base64'));
|
|
333
|
+
// Face/shirt: central-upper bright region (not dark edges)
|
|
334
|
+
const m = meanBandsFromRgba(await readPngRgba(pngPath), 0.12, 0.38, 0.30, 0.70, 0.62, 0.92);
|
|
335
|
+
strengthFace.push(m.face);
|
|
336
|
+
console.log(`strength ${s} @${vp.name}: face=${m.face.toFixed(1)} text=${m.text.toFixed(1)}`);
|
|
337
|
+
}
|
|
338
|
+
for (let i = 1; i < strengthFace.length; i++) {
|
|
339
|
+
const delta = strengthFace[i - 1] - strengthFace[i];
|
|
340
|
+
if (delta < MIN_DELTA_STRENGTH) {
|
|
341
|
+
errors.push(`${vp.name}: strength ${STRENGTH_STEPS[i - 1]}→${STRENGTH_STEPS[i]} face brightness drop ${delta.toFixed(2)} < ${MIN_DELTA_STRENGTH} (PLT-AC-103)`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- Pool monotonicity (strength fixed mid) ---
|
|
346
|
+
const poolText = [];
|
|
347
|
+
let maxPb = 0;
|
|
348
|
+
for (const p of POOL_STEPS) {
|
|
349
|
+
await browser.send('Runtime.evaluate', {
|
|
350
|
+
expression: `document.documentElement.style.setProperty('--home-hero-vignette','0.50');document.documentElement.style.setProperty('--home-hero-pool','${p}');`
|
|
351
|
+
}, sessionId);
|
|
352
|
+
await delay(120);
|
|
353
|
+
const shot = await browser.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
354
|
+
const pngPath = join(work, `p-${vp.name}-${p}.png`);
|
|
355
|
+
await writeFile(pngPath, Buffer.from(shot.data, 'base64'));
|
|
356
|
+
const m = meanBandsFromRgba(await readPngRgba(pngPath), 0.12, 0.38, 0.30, 0.70, 0.62, 0.92);
|
|
357
|
+
poolText.push(m.text);
|
|
358
|
+
maxPb = Math.max(maxPb, m.pb);
|
|
359
|
+
console.log(`pool ${p} @${vp.name}: face=${m.face.toFixed(1)} text=${m.text.toFixed(1)} pb%=${m.pb.toFixed(2)}`);
|
|
360
|
+
}
|
|
361
|
+
for (let i = 1; i < poolText.length; i++) {
|
|
362
|
+
const delta = poolText[i - 1] - poolText[i];
|
|
363
|
+
if (delta < MIN_DELTA_POOL) {
|
|
364
|
+
errors.push(`${vp.name}: pool ${POOL_STEPS[i - 1]}→${POOL_STEPS[i]} text brightness drop ${delta.toFixed(2)} < ${MIN_DELTA_POOL} (PLT-AC-103)`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (maxPb > 0.01) {
|
|
368
|
+
errors.push(`${vp.name}: pure-black pixels in text band ${maxPb.toFixed(3)}% > 0 (PLT-AC-103)`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --- Independence: strength change must not move text band much; pool must not move face much ---
|
|
372
|
+
await browser.send('Runtime.evaluate', {
|
|
373
|
+
expression: `document.documentElement.style.setProperty('--home-hero-vignette','0.20');document.documentElement.style.setProperty('--home-hero-pool','0.80');`
|
|
374
|
+
}, sessionId);
|
|
375
|
+
await delay(120);
|
|
376
|
+
let shot = await browser.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
377
|
+
let pngPath = join(work, `ind-a-${vp.name}.png`);
|
|
378
|
+
await writeFile(pngPath, Buffer.from(shot.data, 'base64'));
|
|
379
|
+
const a = meanBandsFromRgba(await readPngRgba(pngPath), 0.12, 0.38, 0.30, 0.70, 0.62, 0.92);
|
|
380
|
+
|
|
381
|
+
await browser.send('Runtime.evaluate', {
|
|
382
|
+
expression: `document.documentElement.style.setProperty('--home-hero-vignette','0.80');document.documentElement.style.setProperty('--home-hero-pool','0.80');`
|
|
383
|
+
}, sessionId);
|
|
384
|
+
await delay(120);
|
|
385
|
+
shot = await browser.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
386
|
+
pngPath = join(work, `ind-b-${vp.name}.png`);
|
|
387
|
+
await writeFile(pngPath, Buffer.from(shot.data, 'base64'));
|
|
388
|
+
const b = meanBandsFromRgba(await readPngRgba(pngPath), 0.12, 0.38, 0.30, 0.70, 0.62, 0.92);
|
|
389
|
+
|
|
390
|
+
// Strength ↑ should darken face substantially; text band change should be small (mask)
|
|
391
|
+
if (a.face - b.face < MIN_DELTA_STRENGTH * 2) {
|
|
392
|
+
errors.push(`${vp.name}: independence strength 0.2→0.8 face drop ${(a.face - b.face).toFixed(2)} too small (PLT-AC-103)`);
|
|
393
|
+
}
|
|
394
|
+
if (Math.abs(a.text - b.text) > 6.0) {
|
|
395
|
+
errors.push(`${vp.name}: independence: strength change moved text band by ${Math.abs(a.text - b.text).toFixed(2)} (>6) (PLT-AC-103)`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
await browser.send('Runtime.evaluate', {
|
|
399
|
+
expression: `document.documentElement.style.setProperty('--home-hero-vignette','0.70');document.documentElement.style.setProperty('--home-hero-pool','0.20');`
|
|
400
|
+
}, sessionId);
|
|
401
|
+
await delay(120);
|
|
402
|
+
shot = await browser.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
403
|
+
pngPath = join(work, `ind-c-${vp.name}.png`);
|
|
404
|
+
await writeFile(pngPath, Buffer.from(shot.data, 'base64'));
|
|
405
|
+
const c = meanBandsFromRgba(await readPngRgba(pngPath), 0.12, 0.38, 0.30, 0.70, 0.62, 0.92);
|
|
406
|
+
|
|
407
|
+
await browser.send('Runtime.evaluate', {
|
|
408
|
+
expression: `document.documentElement.style.setProperty('--home-hero-vignette','0.70');document.documentElement.style.setProperty('--home-hero-pool','0.80');`
|
|
409
|
+
}, sessionId);
|
|
410
|
+
await delay(120);
|
|
411
|
+
shot = await browser.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
412
|
+
pngPath = join(work, `ind-d-${vp.name}.png`);
|
|
413
|
+
await writeFile(pngPath, Buffer.from(shot.data, 'base64'));
|
|
414
|
+
const d = meanBandsFromRgba(await readPngRgba(pngPath), 0.12, 0.38, 0.30, 0.70, 0.62, 0.92);
|
|
415
|
+
|
|
416
|
+
if (c.text - d.text < MIN_DELTA_POOL * 2) {
|
|
417
|
+
errors.push(`${vp.name}: independence pool 0.2→0.8 text drop ${(c.text - d.text).toFixed(2)} too small (PLT-AC-103)`);
|
|
418
|
+
}
|
|
419
|
+
if (Math.abs(c.face - d.face) > 6.0) {
|
|
420
|
+
errors.push(`${vp.name}: independence: pool change moved face by ${Math.abs(c.face - d.face).toFixed(2)} (>6) (PLT-AC-103)`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
ws.close();
|
|
425
|
+
} finally {
|
|
426
|
+
chrome.kill('SIGKILL');
|
|
427
|
+
httpServer.close();
|
|
428
|
+
await delay(100);
|
|
429
|
+
await rm(profile, { recursive: true, force: true });
|
|
430
|
+
await rm(work, { recursive: true, force: true });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (errors.length) {
|
|
434
|
+
console.error('FAIL test-home-hero-overlay-monotonic (PLT-AC-103):');
|
|
435
|
+
for (const e of errors) console.error(' - ' + e);
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
console.log('PASS test-home-hero-overlay-monotonic (PLT-AC-103)');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
main().catch((err) => {
|
|
442
|
+
console.error(`ERROR: ${err.stack || err}`);
|
|
443
|
+
process.exit(1);
|
|
444
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# PLT-AC-103 — rendered monotonicity gate (strength upper/face, pool text band).
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
ROOT = Pathname.new(__dir__).join("../..").expand_path
|
|
8
|
+
MJS = ROOT.join("scripts/test/test-home-hero-overlay-monotonic.mjs")
|
|
9
|
+
|
|
10
|
+
unless MJS.file?
|
|
11
|
+
warn "FAIL test-home-hero-overlay-monotonic: missing #{MJS}"
|
|
12
|
+
exit 1
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
stdout, stderr, status = Open3.capture3("node", MJS.to_s, chdir: ROOT.to_s)
|
|
16
|
+
if status.success?
|
|
17
|
+
puts stdout
|
|
18
|
+
else
|
|
19
|
+
warn "FAIL test-home-hero-overlay-monotonic:\n#{stdout}\n#{stderr}"
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# PLT-AC-102 — strength/pool independence, caps < 1, charcoal
|
|
3
|
+
# PLT-AC-102 — strength/pool independence, caps < 1, pool charcoal, strength true-black.
|
|
4
|
+
# PLT-AC-103 (structure half) — strength peak coeff ≥ 0.50; strength-cap ≥ 0.50; no shade on ::after.
|
|
4
5
|
|
|
5
6
|
require "pathname"
|
|
6
7
|
|
|
@@ -29,10 +30,14 @@ unless cap_p && cap_p.to_f > 0 && cap_p.to_f < 1
|
|
|
29
30
|
errors << "pool-cap must be in (0,1), got #{cap_p.inspect} (PLT-AC-102)"
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
# PLT-AC-103: strength-cap high enough for perceptible darkening
|
|
34
|
+
if cap_s && cap_s.to_f < 0.50
|
|
35
|
+
errors << "strength-cap must be ≥ 0.50 for perceptible strength range, got #{cap_s} (PLT-AC-103)"
|
|
36
|
+
end
|
|
37
|
+
|
|
32
38
|
# Independence: pool only on ::before backgrounds; vignette only on ::after; mask on ::after.
|
|
33
39
|
pool_before = css.scan(/\.home-hero-portrait::before \{.*?^\}/m)
|
|
34
40
|
vig_after = css.scan(/\.home-hero-portrait::after \{.*?^\}/m)
|
|
35
|
-
# Prefer the rule bodies that actually paint gradients (skip the shared content/inset rule).
|
|
36
41
|
before_paint = pool_before.find { |c| c.include?("linear-gradient") && c.include?("--home-hero-pool") }
|
|
37
42
|
after_paint = vig_after.find { |c| c.include?("linear-gradient") && c.include?("--home-hero-vignette") }
|
|
38
43
|
unless before_paint
|
|
@@ -50,11 +55,9 @@ end
|
|
|
50
55
|
unless css.match?(/\.home-hero-portrait::after[\s\S]*?mask-image:\s*linear-gradient/)
|
|
51
56
|
errors << "::after must mask off the text band (PLT-AC-102)"
|
|
52
57
|
end
|
|
53
|
-
|
|
54
|
-
unless css.include?("PLT-AC-100…102: stack") && css.include?("var(--home-hero-pool)") && css.include?("var(--home-hero-vignette)")
|
|
58
|
+
unless css.include?("PLT-AC-100…103: stack") && css.include?("var(--home-hero-pool)") && css.include?("var(--home-hero-vignette)")
|
|
55
59
|
errors << "stack overlay marker + pool/strength vars required (PLT-AC-102)"
|
|
56
60
|
end
|
|
57
|
-
# At least two pool ::before paints (desk + stack) and two strength ::after paints
|
|
58
61
|
pool_paints = css.scan(/--home-hero-pool\)/).length
|
|
59
62
|
vig_paints = css.scan(/--home-hero-vignette\)/).length
|
|
60
63
|
if pool_paints < 4
|
|
@@ -64,7 +67,6 @@ if vig_paints < 4
|
|
|
64
67
|
errors << "expected multiple strength stops (desk+stack), got #{vig_paints} (PLT-AC-102)"
|
|
65
68
|
end
|
|
66
69
|
|
|
67
|
-
# Every stop clamped
|
|
68
70
|
unless css.include?("min(var(--home-hero-strength-cap)")
|
|
69
71
|
errors << "strength stops must use min(strength-cap, …) (PLT-AC-102)"
|
|
70
72
|
end
|
|
@@ -72,32 +74,50 @@ unless css.include?("min(var(--home-hero-pool-cap)")
|
|
|
72
74
|
errors << "pool stops must use min(pool-cap, …) (PLT-AC-102)"
|
|
73
75
|
end
|
|
74
76
|
|
|
75
|
-
# No historic overscale that pushed alpha > 1
|
|
76
77
|
if css.match?(%r{var\(--home-hero-vignette\)\s*/\s*0\.6})
|
|
77
78
|
errors << "home.css must not use vignette/0.6 overscale (PLT-AC-102)"
|
|
78
79
|
end
|
|
79
80
|
|
|
80
|
-
#
|
|
81
|
+
# Pool shade is not pure black RGB(0,0,0)
|
|
81
82
|
shade = css[/--home-hero-shade:\s*([^;]+);/, 1].to_s.strip
|
|
82
83
|
parts = shade.split(",").map { |x| x.strip.to_i }
|
|
83
84
|
if parts.length != 3 || parts.all?(&:zero?)
|
|
84
85
|
errors << "--home-hero-shade must be non-zero charcoal RGB, got #{shade.inspect} (PLT-AC-102)"
|
|
85
86
|
end
|
|
86
|
-
unless
|
|
87
|
-
errors << "
|
|
87
|
+
unless before_paint&.include?("rgba(var(--home-hero-shade)")
|
|
88
|
+
errors << "pool ::before stops must paint with rgba(var(--home-hero-shade), …) (PLT-AC-102)"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# PLT-AC-103: strength MUST use true black rgba(0, 0, 0, …) — charcoal brightens dark photos.
|
|
92
|
+
strength_paints = vig_after.select { |c| c.include?("linear-gradient") && c.include?("--home-hero-vignette") }
|
|
93
|
+
strength_paints.each_with_index do |body, i|
|
|
94
|
+
if body.include?("rgba(var(--home-hero-shade)")
|
|
95
|
+
errors << "::after strength paint ##{i} must NOT use charcoal shade (brightens dark photos) (PLT-AC-103)"
|
|
96
|
+
end
|
|
97
|
+
unless body.match?(/rgba\(\s*0\s*,\s*0\s*,\s*0\s*,/)
|
|
98
|
+
errors << "::after strength paint ##{i} must use rgba(0, 0, 0, …) (PLT-AC-103)"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Peak strength coefficient (largest multiplier of vignette) must be ≥ 0.50
|
|
103
|
+
coeffs = css.scan(/calc\(\s*([\d.]+)\s*\*\s*var\(--home-hero-vignette\)\s*\)/).map { |m| m[0].to_f }
|
|
104
|
+
peak = coeffs.max || 0
|
|
105
|
+
if peak < 0.50
|
|
106
|
+
errors << "peak strength coefficient must be ≥ 0.50 for perceptible range, got #{peak} (PLT-AC-103)"
|
|
88
107
|
end
|
|
89
108
|
|
|
90
|
-
# Layout still emits both vars
|
|
91
109
|
home_layout = ROOT.join("_layouts/home.html").read
|
|
92
110
|
unless home_layout.include?("--home-hero-pool:")
|
|
93
111
|
errors << "home.html must emit --home-hero-pool (PLT-AC-102)"
|
|
94
112
|
end
|
|
95
113
|
|
|
96
|
-
# Docs + schema mention independence / caps
|
|
97
114
|
docs = ROOT.join("docs/features/home-hero-scale-overlay.md").read
|
|
98
115
|
unless docs.include?("PLT-AC-102")
|
|
99
116
|
errors << "docs/features/home-hero-scale-overlay.md must document PLT-AC-102"
|
|
100
117
|
end
|
|
118
|
+
unless docs.include?("PLT-AC-103")
|
|
119
|
+
errors << "docs/features/home-hero-scale-overlay.md must document PLT-AC-103"
|
|
120
|
+
end
|
|
101
121
|
|
|
102
122
|
schema = ROOT.join("lib/pandorga/registry/schema.yml").read
|
|
103
123
|
unless schema.include?("strength") && schema.include?("pool")
|
|
@@ -105,7 +125,7 @@ unless schema.include?("strength") && schema.include?("pool")
|
|
|
105
125
|
end
|
|
106
126
|
|
|
107
127
|
if errors.empty?
|
|
108
|
-
puts "OK home-hero-overlay-split (PLT-AC-102)"
|
|
128
|
+
puts "OK home-hero-overlay-split (PLT-AC-102/103 structure)"
|
|
109
129
|
else
|
|
110
130
|
warn "FAIL test-home-hero-overlay-split:\n#{errors.join("\n")}"
|
|
111
131
|
exit 1
|
data/scripts/validate.sh
CHANGED
|
@@ -83,6 +83,7 @@ TESTS=(
|
|
|
83
83
|
scripts/test/test-home-hero-scale.rb
|
|
84
84
|
scripts/test/test-home-hero-overlay-strength.rb
|
|
85
85
|
scripts/test/test-home-hero-overlay-split.rb
|
|
86
|
+
scripts/test/test-home-hero-overlay-monotonic.rb
|
|
86
87
|
scripts/test/test-detail-action-icons.rb
|
|
87
88
|
scripts/test/test-parallel-content-publish.rb
|
|
88
89
|
scripts/test/test-mobile-density-home-measure.rb
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pandorga
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.3.
|
|
4
|
+
version: 2.3.17
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- The Pandorga contributors
|
|
@@ -445,6 +445,8 @@ files:
|
|
|
445
445
|
- scripts/test/test-home-band-art.rb
|
|
446
446
|
- scripts/test/test-home-band-intro.rb
|
|
447
447
|
- scripts/test/test-home-hero-c4.rb
|
|
448
|
+
- scripts/test/test-home-hero-overlay-monotonic.mjs
|
|
449
|
+
- scripts/test/test-home-hero-overlay-monotonic.rb
|
|
448
450
|
- scripts/test/test-home-hero-overlay-split.rb
|
|
449
451
|
- scripts/test/test-home-hero-overlay-strength.rb
|
|
450
452
|
- scripts/test/test-home-hero-scale.rb
|