Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/guide/frame.js

11.6 KiB, 1 run

created by r2519314175:1219, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* The guide's frame: how a guide page learns how to look, which language to be
2 * in, and where a jump to a heading lands.
3 *
4 * One file, loaded by every page, because the alternative is the same forty
5 * lines pasted into every page of every language -- and the palette table
6 * inside it would then have to be right in all of them.
7 *
8 * The guide is framed inside Daimond's Web panel WITHOUT `allow-same-origin`,
9 * deliberately, so that a page an agent wrote cannot reach the user's keys. An
10 * opaque origin cannot read the app's document at all, so the look arrives over
11 * postMessage -- the one channel a sandboxed frame still has. Everything sent
12 * is cosmetic and is validated here on arrival, so accepting it from any framer
13 * costs nothing.
14 */
15(function () {
16 'use strict';
17
18 /* The palettes, as [tone, ink]. Mirrored from THEMES in js/daimond.js;
19 verify_theme asserts the tables agree. The guide loads the app's own
20 variables.css, so the colours themselves are never restated -- only the
21 two facts a stylesheet needs in order to ask a question about a palette
22 it has never heard of. */
23 var P = {
24 light: ['light', 'dark'], mist: ['light', 'dark'],
25 linen: ['light', 'dark'], lollypop: ['mid', 'dark'],
26 sage: ['mid', 'dark'], dusk: ['mid', 'light'],
27 dark: ['dark', 'light'], amber: ['dark', 'light'],
28 midnight: ['dark', 'light'],
29 forest: ['dark', 'light'], plum: ['dark', 'light'],
30 };
31
32 var root = document.documentElement;
33
34 /// Wear a palette, by name. Unknown names are ignored rather than guessed
35 /// at: a wrong palette is worse than the one already on screen.
36 function wear(theme) {
37 var spec = P[theme];
38 if (!spec) return false;
39 root.setAttribute('data-theme', theme);
40 root.setAttribute('data-tone', spec[0]);
41 root.setAttribute('data-ink', spec[1]);
42 return true;
43 }
44
45 /// The reader's chosen text size, which travels the same path as the palette.
46 function size(scale) {
47 var n = parseFloat(scale);
48 if (n >= 0.5 && n <= 2) root.style.setProperty('--fs-scale', String(n));
49 }
50
51 /// The language. A guide page exists once per locale under its own folder, so
52 /// changing language means going to the matching page -- there is no text
53 /// here to swap in place. Nothing happens when the reader is already in the
54 /// right language, or when this page has no translation.
55 function speak(locale) {
56 if (!locale || typeof locale !== 'string') return;
57 if (!/^[a-zA-Z-]{2,10}$/.test(locale)) return; // a locale, not a path
58 var here = (root.getAttribute('data-guide-locale') || 'en');
59 if (locale === here) return;
60 var have = (root.getAttribute('data-guide-locales') || '').split(' ');
61 if (have.indexOf(locale) < 0) return; // not translated: stay put
62 var page = location.pathname.split('/').pop() || 'index.html';
63 // Every locale but English lives one level down, in its own folder.
64 var to = (locale === 'en' ? (here === 'en' ? '' : '../') : (here === 'en' ? '' : '../') + locale + '/');
65 location.replace(to + page + location.hash);
66 }
67
68 function paint(d) {
69 if (d.theme) wear(d.theme);
70 size(d.scale);
71 speak(d.locale);
72 }
73
74 window.addEventListener('message', function (e) {
75 var d = e && e.data;
76 if (!d || d.daimondGuide !== 'style') return;
77 paint(d);
78 });
79
80 // Ask the framer to tell us, if there is one.
81 try {
82 if (window.parent && window.parent !== window) {
83 window.parent.postMessage({ daimondGuide: 'ready' }, '*');
84 }
85 } catch (e) { /* no framer, or one that will not listen. */ }
86
87 // The direct path, for the case where the guide is framed WITHOUT a sandbox
88 // and can simply read the app's root. Harmless wherever it is blocked.
89 function fromApp(appRoot) {
90 wear(appRoot.getAttribute('data-theme'));
91 var s = appRoot.style.getPropertyValue('--fs-scale');
92 if (s) root.style.setProperty('--fs-scale', s);
93 }
94 try {
95 if (window.parent && window.parent !== window) {
96 var appRoot = window.parent.document.documentElement;
97 fromApp(appRoot);
98 new MutationObserver(function () {
99 try { fromApp(window.parent.document.documentElement); } catch (e) {}
100 }).observe(appRoot, { attributes: true, attributeFilter: ['data-theme', 'style'] });
101 }
102 } catch (e) { /* cross-origin or opened directly. */ }
103
104 // Opened on its own, with nothing to mirror: follow the operating system.
105 // This runs last so an app that answered first is never overridden. The
106 // palettes live in the app's stylesheet now, whose default is the dark one,
107 // so without this a light-preferring reader would get a dark guide.
108 if (!root.hasAttribute('data-theme')) {
109 var light = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
110 wear(light ? 'light' : 'dark');
111 }
112
113 /* ── Where a jump lands ──────────────────────────────────────────────────
114 The header is sticky at the top of the page, so scrolling a heading to
115 the very top of the scrollport puts it UNDER the header: a reader who
116 picks a search result, or follows a deep link into the guide, arrives at
117 a heading they cannot see.
118
119 `scroll-margin-top` on the target fixes all of those at once, because in
120 every one of them it is the browser doing the scrolling -- a click on a
121 search result, the browser's own fragment navigation, and any later
122 `scrollIntoView`. Nothing has to know about the header except this.
123
124 The height is measured rather than written down. The header wraps: nine
125 nav links and a search box take one row on a wide screen and four on a
126 narrow one, the German labels wrap where the English ones do not, and the
127 reader can change the text size at any moment over the channel above. No
128 single number is right for all of that. The rule is installed from here
129 rather than kept in guide.css because the value in it can only come from
130 JavaScript, and a rule split across two files is one that gets half
131 changed. */
132
133 // Clear air between the header's lower edge and the heading it uncovers.
134 var GAP = 12;
135
136 /// The height last published, so a size that has not moved writes nothing.
137 var lastH = -1;
138
139 /// Publish the header's height, for the rule below to keep clear of.
140 ///
141 /// `h` is the height the caller ALREADY HAS -- the observer in `settle` is
142 /// handed one with every notification, and passes it. Measuring here instead,
143 /// from inside that callback, forces a layout while the notifications are
144 /// still being delivered: any size change that was pending lands mid-delivery,
145 /// the header therefore counts as having resized again after its own turn, and
146 /// Safari reports "ResizeObserver loop completed with undelivered
147 /// notifications" -- which Chromium swallows, so it only ever showed on
148 /// WebKit. It also meant measuring and laying the page out twice in one frame,
149 /// on a phone, for a number the observer was already carrying. Only the first
150 /// call, made before anything observes, measures.
151 function headroom(h) {
152 if (h === undefined) {
153 var head = document.querySelector('.site-head');
154 if (!head) return;
155 h = head.getBoundingClientRect().height;
156 }
157 h = Math.round(h);
158 if (h <= 0 || h === lastH) return;
159 lastH = h;
160 root.style.setProperty('--guide-head-h', (h + GAP) + 'px');
161 // THE HEADER CAN STILL GROW AFTER THE READER HAS LANDED. `search.js`
162 // builds the search box and appends it to the header, and on a narrow
163 // screen that is a whole extra row: measured at 360px, the header goes
164 // from 179px to 223px AFTER `reland` has already scrolled. A deep link
165 // into the guide therefore left its heading 44px under the header, which
166 // is the exact failure the measurement exists to prevent -- only later.
167 //
168 // So a landing is repeated whenever the height moves, for as long as the
169 // reader has not touched the page themselves. Comparing scroll positions
170 // instead of watching for input does NOT work: the browser's own scroll
171 // anchoring shifts the page by the header's growth to keep the text
172 // still, so the position always differs by exactly the amount that made
173 // the re-landing necessary.
174 settleLanding();
175 }
176
177 /// Put the reader back on their anchor, if they are still owed it.
178 ///
179 /// Called from every point where the header may have changed size since the
180 /// landing, and NOT only from the resize observer: whether that observer
181 /// fires at all depends on whether the search box was appended before or
182 /// after it started watching, which is a race that came out both ways
183 /// between runs of the same test. Scrolling to a place the page is already
184 /// at costs nothing, so this is called generously and gated only on the
185 /// reader not having moved.
186 function settleLanding() {
187 if (!landed || touched) return;
188 if (Date.now() - landed > SETTLE) return;
189 reland();
190 }
191
192 /// How long after a landing a growing header may still move the page. Long
193 /// enough for a stylesheet, a web font and the search box to arrive; short
194 /// enough that nothing jumps under a reader who has begun reading.
195 var SETTLE = 4000;
196
197 /// When `reland` last ran, or 0 when it never has.
198 var landed = 0;
199
200 /// Whether the reader has scrolled the page themselves. Once they have, the
201 /// page is theirs and nothing here moves it again.
202 var touched = false;
203 ['wheel', 'touchstart', 'keydown', 'pointerdown'].forEach(function (ev) {
204 window.addEventListener(ev, function () { touched = true; }, { passive: true, capture: true });
205 });
206
207 /// The browser jumps to a fragment while the page is still parsing, long
208 /// before the header exists to be measured, so a deep link lands using the
209 /// fallback in the rule. Once the real height is known, put the reader
210 /// where they asked to be. Only on arrival: a `hashchange` after this
211 /// scrolls with the measured value already in hand.
212 function reland() {
213 var id = (location.hash || '').slice(1);
214 if (!id) return;
215 var el = document.getElementById(id);
216 if (el && el.scrollIntoView) el.scrollIntoView();
217 if (!landed) landed = Date.now();
218 }
219
220 // Installed now, while the page is still in its head, so the rule is already
221 // in force for the browser's own jump to a fragment. 7rem is two wrapped
222 // header rows plus its padding: what a jump uses until the measurement
223 // lands, and what it keeps using on a page with no header at all.
224 var rule = document.createElement('style');
225 rule.textContent = 'main [id] { scroll-margin-top: var(--guide-head-h, 7rem); }';
226 document.head.appendChild(rule);
227
228 function settle() {
229 headroom();
230 reland();
231
232 var head = document.querySelector('.site-head');
233 if (head && window.ResizeObserver) {
234 new ResizeObserver(function (entries) {
235 var e = entries[entries.length - 1];
236 // The BORDER box, which is what the measurement above returns;
237 // `contentRect` is the content box and would lose the header's
238 // padding. An engine without `borderBoxSize` gets the measurement.
239 var b = e.borderBoxSize && e.borderBoxSize[0];
240 headroom(b ? b.blockSize : undefined);
241 }).observe(head);
242 // Everything that can still change the header's height after the
243 // first landing, each asked once: the last stylesheet and web font
244 // arrive with `load`, and the search box is built from a script that
245 // may run either side of the observer above.
246 window.addEventListener('load', function () { headroom(); settleLanding(); });
247 setTimeout(function () { headroom(); settleLanding(); }, 300);
248 setTimeout(function () { headroom(); settleLanding(); }, 1000);
249 } else {
250 // The listener is handed an Event, which is not a height.
251 window.addEventListener('resize', function () { headroom(); });
252 }
253 }
254
255 if (document.readyState === 'loading') {
256 document.addEventListener('DOMContentLoaded', settle);
257 } else {
258 settle();
259 }
260})();