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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a40b75762591bdeac34570da02abe59b95e228f96dc1c5452705a4bf3cbd29e9
4
- data.tar.gz: 3455162a8d38311c091c54acc2546075e83259bf6c9945977f91f25834d5f4d4
3
+ metadata.gz: 9f15a65d09189b3c4d7f02ea2afc30c0ae3041d3fbb1b40885943144d5bd393f
4
+ data.tar.gz: 15e42b4a696dea56e0abe802aab7b405b2d1646d6a14297609cb3402a8a126fb
5
5
  SHA512:
6
- metadata.gz: 9c5b2e3caaac639e87e1c6347991138ccbd6975a7a505f99dc0285056eeedd2216abc87d5a7b2934a6ba51f82c5fd44d2772875631c65a0ce7e5f87ded03dd1b
7
- data.tar.gz: aab5ae86c928198b31698dd99119dd6f1dba4efa36e4b8080460ce8f244bab5b09da56db5433d1c2bcd2c9afdc9a6947973b58ce3f0aadcda61764e77a356993
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).
@@ -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…102)
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
- (radial, left falloff, upper vertical). Linear in [0,1]; at 1.0 every
1867
- photo-area stop is capped ≤ --home-hero-strength-cap (~0.42) so the
1868
- figure stays recognizable.
1869
- pool → --home-hero-pool (0–1): absolute black alpha of the text band
1870
- (lower vertical stops / stacked copy band). Not a multiplier.
1871
- Every alpha is min(cap, …); caps < 1; shade is charcoal (not #000) so composites never hit pure-black RGB.
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
- --home-hero-strength-cap: 0.42;
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
- /* Deep charcoal (not #000): even over black clothing, composites stay RGB sum ≥ 3. */
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…102).
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; every stop min(cap, …); charcoal shade + caps < 1 → no pure-black pixels from the overlay. */
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 only (not scaled by strength).
1908
- Ramp starts high enough that title + summary sit fully in the opaque band. */
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.45))) 46%,
1914
- rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), calc(var(--home-hero-pool) * 0.78))) 56%,
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; masked off the text band so pool stays independent. */
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 32%, transparent 46%);
1923
- mask-image: linear-gradient(180deg, #000 0%, #000 32%, transparent 46%);
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 80% 50% at 55% 26%,
1927
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.05 * var(--home-hero-vignette)))) 0%,
1928
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.16 * var(--home-hero-vignette)))) 50%,
1929
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.28 * var(--home-hero-vignette)))) 78%,
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(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.30 * var(--home-hero-vignette)))) 0%,
1935
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.22 * var(--home-hero-vignette)))) 14%,
1936
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.12 * var(--home-hero-vignette)))) 34%,
1937
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.04 * var(--home-hero-vignette)))) 52%,
1938
- transparent 70%
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(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.16 * var(--home-hero-vignette)))) 0%,
1943
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.07 * var(--home-hero-vignette)))) 12%,
1944
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.08 * var(--home-hero-vignette)))) 24%,
1945
- transparent 48%
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…102: stack — ::after strength (upper); ::before pool (copy band) */
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.45))) 34%,
2040
- rgba(var(--home-hero-shade), min(var(--home-hero-pool-cap), calc(var(--home-hero-pool) * 0.78))) 46%,
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 28%, transparent 40%);
2049
- mask-image: linear-gradient(180deg, #000 0%, #000 28%, transparent 40%);
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 90% 42% at 50% 12%,
2053
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.06 * var(--home-hero-vignette)))) 0%,
2054
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.18 * var(--home-hero-vignette)))) 55%,
2055
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.28 * var(--home-hero-vignette)))) 80%,
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(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.14 * var(--home-hero-vignette)))) 0%,
2061
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.10 * var(--home-hero-vignette)))) 8%,
2062
- rgba(var(--home-hero-shade), min(var(--home-hero-strength-cap), calc(0.16 * var(--home-hero-vignette)))) 18%,
2063
- transparent 34%
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.16"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v2.3.17"
28
28
  gem "csv"
29
29
  gem "base64"
30
30
  RUBY
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "2.3.16"
4
+ VERSION = "2.3.17"
5
5
  end
@@ -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 shade, no /0.6 overscale.
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
- # Stack media also keeps the split (comment marker from home.css)
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
- # Shade is not pure black RGB(0,0,0)
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 css.include?("rgba(var(--home-hero-shade)")
87
- errors << "overlay stops must paint with rgba(var(--home-hero-shade), …) (PLT-AC-102)"
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.16
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