pandorga 1.13.0 → 1.14.1

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.
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // PLT-AC-47. Pure helper unit tests sliced from _includes/page/timeline-section.html
3
+ // Executes panTarget and centerPreservingScroll from source rather than a copy.
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
10
+ const source = fs.readFileSync(
11
+ path.join(root, '_includes/page/timeline-section.html'),
12
+ 'utf8'
13
+ );
14
+
15
+ const start = source.indexOf('function panTarget');
16
+ const end = source.indexOf('function parseDate');
17
+ if (start < 0 || end < start) {
18
+ console.error('FAIL: panTarget / centerPreservingScroll missing from timeline-section.html');
19
+ process.exit(1);
20
+ }
21
+
22
+ const slice = source.slice(start, end);
23
+ if (!slice.includes('function centerPreservingScroll')) {
24
+ console.error('FAIL: centerPreservingScroll missing from slice');
25
+ process.exit(1);
26
+ }
27
+
28
+ const api = new Function(
29
+ `${slice}\nreturn { panTarget, centerPreservingScroll };`
30
+ )();
31
+
32
+ const { panTarget, centerPreservingScroll } = api;
33
+
34
+ let failed = 0;
35
+
36
+ function check(condition, message) {
37
+ if (!condition) {
38
+ console.error(`FAIL: ${message}`);
39
+ failed += 1;
40
+ }
41
+ }
42
+
43
+ // ── panTarget tests (PLT-AC-47) ─────────────────────────────────────────────
44
+ // Check step sizes 10% (0.1) and 90% (0.9), and clamping at both bounds (0 and maxScroll).
45
+
46
+ const visibleW = 1000;
47
+ const maxScroll = 4000;
48
+
49
+ // Step 10% right (forward in time)
50
+ check(panTarget(500, visibleW, maxScroll, 0.1) === 600, 'panTarget 10% right from 500 should be 600');
51
+ // Step 10% left (backward in time)
52
+ check(panTarget(500, visibleW, maxScroll, -0.1) === 400, 'panTarget 10% left from 500 should be 400');
53
+
54
+ // Step 90% right
55
+ check(panTarget(500, visibleW, maxScroll, 0.9) === 1400, 'panTarget 90% right from 500 should be 1400');
56
+ // Step 90% left
57
+ check(panTarget(1500, visibleW, maxScroll, -0.9) === 600, 'panTarget 90% left from 1500 should be 600');
58
+
59
+ // Lower bound clamping (scrollLeft <= 0)
60
+ check(panTarget(50, visibleW, maxScroll, -0.1) === 0, 'panTarget clamps at lower bound (10% left)');
61
+ check(panTarget(500, visibleW, maxScroll, -0.9) === 0, 'panTarget clamps at lower bound (90% left)');
62
+ check(panTarget(0, visibleW, maxScroll, -0.1) === 0, 'panTarget at 0 stays at 0');
63
+
64
+ // Upper bound clamping (scrollLeft >= maxScroll)
65
+ check(panTarget(3950, visibleW, maxScroll, 0.1) === 4000, 'panTarget clamps at upper bound (10% right)');
66
+ check(panTarget(3500, visibleW, maxScroll, 0.9) === 4000, 'panTarget clamps at upper bound (90% right)');
67
+ check(panTarget(4000, visibleW, maxScroll, 0.1) === 4000, 'panTarget at maxScroll stays at maxScroll');
68
+
69
+ // ── centerPreservingScroll tests (PLT-AC-47) ────────────────────────────────
70
+ // Center time preserved (within 1 day) across zoom factors 0.75 and 1.33.
71
+
72
+ const MS_DAY = 86400000;
73
+ const MS_YEAR = 365.25 * MS_DAY;
74
+ const LABEL_W = 96;
75
+
76
+ // Simulated timeline: 10 years of range
77
+ const minT = new Date(2015, 0, 1).getTime();
78
+ const maxT = new Date(2025, 0, 1).getTime();
79
+ const rangeMs = maxT - minT;
80
+
81
+ const initialZoomYears = 2;
82
+ const visibleChartW = visibleW - LABEL_W;
83
+ const initialChartW = visibleChartW * (rangeMs / (initialZoomYears * MS_YEAR));
84
+
85
+ function makeXPosMs(chartW) {
86
+ return function (t) {
87
+ return LABEL_W + ((t - minT) / rangeMs) * chartW;
88
+ };
89
+ }
90
+
91
+ function timeFromX(x, chartW) {
92
+ const chartX = x - LABEL_W;
93
+ return minT + (chartX / chartW) * rangeMs;
94
+ }
95
+
96
+ // Suppose wrapper was scrolled to 1200px at 2 yr zoom
97
+ const initialScrollLeft = 1200;
98
+ const initialCenterPx = initialScrollLeft + visibleW / 2;
99
+ const centerMs = timeFromX(initialCenterPx, initialChartW);
100
+
101
+ // Zoom in: factor 0.75
102
+ const zoomInYears = initialZoomYears * 0.75;
103
+ const chartWZoomIn = visibleChartW * (rangeMs / (zoomInYears * MS_YEAR));
104
+ const maxScrollZoomIn = Math.max(0, LABEL_W + chartWZoomIn + 32 - visibleW);
105
+ const xPosMsZoomIn = makeXPosMs(chartWZoomIn);
106
+
107
+ const newScrollLeftZoomIn = centerPreservingScroll(centerMs, xPosMsZoomIn, visibleW, maxScrollZoomIn);
108
+ const newCenterPxZoomIn = newScrollLeftZoomIn + visibleW / 2;
109
+ const centerMsAfterZoomIn = timeFromX(newCenterPxZoomIn, chartWZoomIn);
110
+ const diffDaysZoomIn = Math.abs(centerMsAfterZoomIn - centerMs) / MS_DAY;
111
+
112
+ check(
113
+ diffDaysZoomIn <= 1.0,
114
+ `center time preserved within 1 day on zoom factor 0.75 (drift: ${diffDaysZoomIn.toFixed(3)} days)`
115
+ );
116
+
117
+ // Zoom out: factor 1.33
118
+ const zoomOutYears = initialZoomYears * 1.33;
119
+ const chartWZoomOut = visibleChartW * (rangeMs / (zoomOutYears * MS_YEAR));
120
+ const maxScrollZoomOut = Math.max(0, LABEL_W + chartWZoomOut + 32 - visibleW);
121
+ const xPosMsZoomOut = makeXPosMs(chartWZoomOut);
122
+
123
+ const newScrollLeftZoomOut = centerPreservingScroll(centerMs, xPosMsZoomOut, visibleW, maxScrollZoomOut);
124
+ const newCenterPxZoomOut = newScrollLeftZoomOut + visibleW / 2;
125
+ const centerMsAfterZoomOut = timeFromX(newCenterPxZoomOut, chartWZoomOut);
126
+ const diffDaysZoomOut = Math.abs(centerMsAfterZoomOut - centerMs) / MS_DAY;
127
+
128
+ check(
129
+ diffDaysZoomOut <= 1.0,
130
+ `center time preserved within 1 day on zoom factor 1.33 (drift: ${diffDaysZoomOut.toFixed(3)} days)`
131
+ );
132
+
133
+ // Clamping at lower bound
134
+ const startCenterMs = minT; // Start of timeline
135
+ const clampedStartScroll = centerPreservingScroll(startCenterMs, xPosMsZoomIn, visibleW, maxScrollZoomIn);
136
+ check(clampedStartScroll === 0, 'centerPreservingScroll clamps to 0 at timeline start');
137
+
138
+ // Clamping at upper bound
139
+ const endCenterMs = maxT; // End of timeline
140
+ const clampedEndScroll = centerPreservingScroll(endCenterMs, xPosMsZoomIn, visibleW, maxScrollZoomIn);
141
+ check(clampedEndScroll === Math.round(maxScrollZoomIn), 'centerPreservingScroll clamps to maxScroll at timeline end');
142
+
143
+ if (failed) {
144
+ console.error(`timeline pan: ${failed} failure(s)`);
145
+ process.exit(1);
146
+ }
147
+ console.log('PASS test-timeline-pan.mjs (PLT-AC-47)');
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # CV timeline temporal pan static gate (PLT-AC-47, docs/features/timeline-pan.md).
5
+ #
6
+ # Asserts:
7
+ # - wrapper has tabindex="0", role="region", and aria-label
8
+ # - pan prev/next and Today buttons exist with aria-labels
9
+ # - script registers pointerdown, pointermove, pointerup, and keydown handlers
10
+ # - shiftKey is checked on the selection path
11
+ # - changeZoom no longer calls scrollToToday()
12
+ # - prefers-reduced-motion check is present
13
+ # - hint text "Shift+drag to select a period" is present
14
+
15
+ require "pathname"
16
+
17
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
18
+ TIMELINE_HTML = ROOT.join("_includes/page/timeline-section.html")
19
+
20
+ def fail!(msg)
21
+ warn "FAIL test-timeline-pan: #{msg}"
22
+ exit 1
23
+ end
24
+
25
+ source = TIMELINE_HTML.read
26
+
27
+ # 1. Wrapper accessibility attributes
28
+ wrapper_match = source[/<div[^>]*id=["']timeline-chart-wrapper["'][^>]*>/]
29
+ fail!("missing #timeline-chart-wrapper") unless wrapper_match
30
+ fail!("wrapper missing tabindex=\"0\"") unless wrapper_match.include?('tabindex="0"')
31
+ fail!("wrapper missing role=\"region\"") unless wrapper_match.include?('role="region"')
32
+ fail!("wrapper missing aria-label") unless wrapper_match.include?('aria-label=')
33
+
34
+ # 2. Pan and Today buttons with aria-labels inside #timeline-zoom-controls
35
+ zoom_controls = source[/<div[^>]*id=["']timeline-zoom-controls["'][^>]*>.*?<\/div>/m]
36
+ fail!("missing #timeline-zoom-controls") unless zoom_controls
37
+
38
+ fail!("missing pan prev button (‹)") unless zoom_controls.include?("‹")
39
+ fail!("missing pan next button (›)") unless zoom_controls.include?("›")
40
+ fail!("missing Today button") unless zoom_controls.include?("Today")
41
+
42
+ prev_btn = zoom_controls[/<button[^>]*>[^<]*‹[^<]*<\/button>/m]
43
+ fail!("pan prev button missing aria-label") unless prev_btn && prev_btn.include?("aria-label=")
44
+
45
+ next_btn = zoom_controls[/<button[^>]*>[^<]*›[^<]*<\/button>/m]
46
+ fail!("pan next button missing aria-label") unless next_btn && next_btn.include?("aria-label=")
47
+
48
+ today_btn = zoom_controls[/<button[^>]*>[^<]*Today[^<]*<\/button>/m]
49
+ fail!("Today button missing aria-label") unless today_btn && today_btn.include?("aria-label=")
50
+
51
+ # 3. Pointer and keyboard event handlers registered in the script
52
+ script_match = source[/<script>(.*?)<\/script>/m, 1]
53
+ fail!("inline script missing") unless script_match
54
+
55
+ fail!("script missing pointerdown handler") unless script_match.include?("pointerdown")
56
+ fail!("script missing pointermove handler") unless script_match.include?("pointermove")
57
+ fail!("script missing pointerup handler") unless script_match.include?("pointerup")
58
+ fail!("script missing keydown handler") unless script_match.include?("keydown")
59
+
60
+ # 4. shiftKey checked on the selection path
61
+ fail!("script does not check shiftKey for selection") unless script_match.include?("shiftKey")
62
+
63
+ # 5. changeZoom no longer calls scrollToToday()
64
+ # Brace-matched body, so a nested block cannot hide a later call.
65
+ def function_body(script, name)
66
+ head = script.index(/function\s+#{name}\s*\([^)]*\)\s*\{/)
67
+ return nil unless head
68
+
69
+ open_at = script.index("{", head)
70
+ depth = 0
71
+ script[open_at..].each_char.with_index do |char, offset|
72
+ depth += 1 if char == "{"
73
+ depth -= 1 if char == "}"
74
+ return script[open_at + 1, offset - 1] if depth.zero?
75
+ end
76
+ nil
77
+ end
78
+
79
+ change_zoom_body = function_body(script_match, "changeZoom")
80
+ fail!("missing changeZoom function") unless change_zoom_body
81
+ if change_zoom_body.include?("scrollToToday")
82
+ fail!("changeZoom must not call scrollToToday(); zoom must preserve center date")
83
+ end
84
+
85
+ # 6. prefers-reduced-motion check present
86
+ fail!("missing prefers-reduced-motion check") unless script_match.include?("prefers-reduced-motion")
87
+
88
+ # 7. Selection hint text present
89
+ fail!("missing hint text 'Shift+drag to select a period'") unless source.include?("Shift+drag to select a period")
90
+
91
+ # 8. Pan buttons expose their bound state to assistive tech
92
+ fail!("pan buttons must set aria-disabled at bounds") unless script_match.include?("aria-disabled")
93
+
94
+ # 9. A second boot (inline stub, then runtime JSON) must not stack listeners
95
+ boot_body = function_body(script_match, "bootTimeline")
96
+ fail!("missing bootTimeline function") unless boot_body
97
+ fail!("bootTimeline must abort the previous boot's listeners") unless boot_body.include?("AbortController") && boot_body.include?(".abort()")
98
+ %w[pointerdown pointermove keydown resize].each do |type|
99
+ head = boot_body.index(/addEventListener\('#{type}',\s*function\s*\([^)]*\)\s*\{/)
100
+ fail!("missing #{type} listener in bootTimeline") unless head
101
+ open_at = boot_body.index("{", head)
102
+ depth = 0
103
+ close_at = nil
104
+ boot_body[open_at..].each_char.with_index do |char, offset|
105
+ depth += 1 if char == "{"
106
+ depth -= 1 if char == "}"
107
+ if depth.zero?
108
+ close_at = open_at + offset
109
+ break
110
+ end
111
+ end
112
+ tail = close_at && boot_body[(close_at + 1)..]
113
+ fail!("#{type} listener must take the boot's abort signal") unless tail&.match?(/\A,\s*bootScope\s*\)/)
114
+ end
115
+
116
+ puts "PASS test-timeline-pan (PLT-AC-47)"
data/scripts/validate.sh CHANGED
@@ -72,6 +72,8 @@ TESTS=(
72
72
  scripts/test/test-discovery.rb
73
73
  scripts/test/test-hero-portraits.rb
74
74
  scripts/test/test-thumbnail-variants.rb
75
+ scripts/test/test-highlighter-pills.rb
76
+ scripts/test/test-timeline-pan.rb
75
77
  )
76
78
 
77
79
  failed=0
@@ -107,6 +109,11 @@ if ! node scripts/test/test-studio-pipeline-focus.mjs; then
107
109
  failed=1
108
110
  fi
109
111
 
112
+ echo "-- scripts/test/test-timeline-pan.mjs"
113
+ if ! node scripts/test/test-timeline-pan.mjs; then
114
+ failed=1
115
+ fi
116
+
110
117
  if [[ "$failed" -ne 0 ]]; then
111
118
  echo "VALIDATE FAILED"
112
119
  exit 1
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: 1.13.0
4
+ version: 1.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Pandorga contributors
@@ -437,6 +437,7 @@ files:
437
437
  - scripts/test/test-hero-portraits.mjs
438
438
  - scripts/test/test-hero-portraits.rb
439
439
  - scripts/test/test-hero-summary.rb
440
+ - scripts/test/test-highlighter-pills.rb
440
441
  - scripts/test/test-home-articles.rb
441
442
  - scripts/test/test-home-band-art.rb
442
443
  - scripts/test/test-home-band-intro.rb
@@ -477,6 +478,8 @@ files:
477
478
  - scripts/test/test-text-excerpt.rb
478
479
  - scripts/test/test-theme-contrast.rb
479
480
  - scripts/test/test-thumbnail-variants.rb
481
+ - scripts/test/test-timeline-pan.mjs
482
+ - scripts/test/test-timeline-pan.rb
480
483
  - scripts/test/test-unwrap-media-src.mjs
481
484
  - scripts/test/test-unwrap-media-src.rb
482
485
  - scripts/test/test-validate-allowlist.rb