|
| 1 | +/** |
| 2 | + * Stoatworks Labs — support footer. |
| 3 | + * |
| 4 | + * One in-flow footer, appended to the end of <body>, saying the same thing on |
| 5 | + * every hosted web app: the tool is free, it comes from Stoatworks Labs, and |
| 6 | + * there are four ways to fund the work. |
| 7 | + * |
| 8 | + * This file is the MASTER. It is vendored into each hosted app by |
| 9 | + * scripts/sync-support-footer.sh — edit it HERE and re-run the sync, or the |
| 10 | + * copies drift and the apps start making slightly different promises. |
| 11 | + * |
| 12 | + * Why a script rather than markup pasted into nine templates: |
| 13 | + * - The apps are a React SPA (blend-calc, pixel-peeker, RFutils), a |
| 14 | + * hand-written landing page (pmse-to-wwb) and four recorded demos whose |
| 15 | + * HTML is a committed build artefact. There is no shared template. |
| 16 | + * - One file means the wording and the funding links have exactly one |
| 17 | + * definition. A copy-pasted footer is four dead links waiting to happen. |
| 18 | + * |
| 19 | + * Why plain classic script and not a module: |
| 20 | + * `document.currentScript` is null in a module, and the per-app config below |
| 21 | + * is read off the tag. Load it deferred, so <body> exists when it runs: |
| 22 | + * |
| 23 | + * <script src="/support-footer.js" defer |
| 24 | + * data-app="Pixel Peeker" |
| 25 | + * data-repo="https://github.com/stoatworks-labs/pixel-peeker"></script> |
| 26 | + * |
| 27 | + * Config, all optional: |
| 28 | + * data-app Name of the app, bolded in the first line. Defaults to <title>. |
| 29 | + * data-repo Source URL. Omit and the "source" link is left out rather than |
| 30 | + * pointed somewhere plausible-but-wrong. |
| 31 | + * data-note One extra sentence, app-specific, shown under the lead. Used for |
| 32 | + * things only true of some apps ("nothing leaves your browser"). |
| 33 | + * |
| 34 | + * Styling: the footer deliberately has no colours of its own. It inherits the |
| 35 | + * page's background and text colour and draws its rules and chips from |
| 36 | + * `currentColor`, so it lands correctly on the dark apps (blend-calc, |
| 37 | + * pixel-peeker, RFutils) and the light ones (flock, pmse-to-wwb in light mode) |
| 38 | + * without either being told which it is. An app that wants its accent on the |
| 39 | + * links sets `--sw-support-accent` in its own stylesheet. |
| 40 | + */ |
| 41 | +(() => { |
| 42 | + const script = document.currentScript; |
| 43 | + |
| 44 | + /** |
| 45 | + * The canonical set, matching stoatworks-backend/funding/FUNDING.yml and the |
| 46 | + * website's src/data/site.json. GitHub Sponsors is first because it is the |
| 47 | + * preferred route — it takes no extra account for anyone already signed in to |
| 48 | + * GitHub, which is everyone arriving from a repo link. |
| 49 | + */ |
| 50 | + const FUNDING = [ |
| 51 | + { name: 'GitHub Sponsors', url: 'https://github.com/sponsors/stoatworks-labs' }, |
| 52 | + { name: 'Ko-fi', url: 'https://ko-fi.com/stoatworkslabs' }, |
| 53 | + { name: 'Patreon', url: 'https://patreon.com/StoatworksLabs' }, |
| 54 | + { name: 'Liberapay', url: 'https://liberapay.com/stoatworks-labs' }, |
| 55 | + ]; |
| 56 | + |
| 57 | + const HOME = 'https://stoatworks-labs.com'; |
| 58 | + |
| 59 | + const CSS = ` |
| 60 | +.sw-support { |
| 61 | + --sw-rule: color-mix(in srgb, currentColor 14%, transparent); |
| 62 | + --sw-chip: color-mix(in srgb, currentColor 8%, transparent); |
| 63 | + --sw-chip-hover: color-mix(in srgb, currentColor 16%, transparent); |
| 64 | + box-sizing: border-box; |
| 65 | + margin-top: 2.5rem; |
| 66 | + border-top: 1px solid var(--sw-rule); |
| 67 | + padding: 1.25rem 1.25rem 1.6rem; |
| 68 | + font: 13px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
| 69 | + /* Inherits the page's own colours; only the emphasis is dialled down. */ |
| 70 | + opacity: 0.92; |
| 71 | +} |
| 72 | +/* Deliberately one column, chips under the text, at every width. A two-column |
| 73 | + "text left / chips right" version only actually fits in a narrow band of |
| 74 | + viewport widths — four chips need ~28rem beside 46rem of prose — so it spent |
| 75 | + most of its life wrapping into this layout anyway, just less predictably. */ |
| 76 | +.sw-support__inner { |
| 77 | + max-width: 62rem; |
| 78 | + margin: 0 auto; |
| 79 | + display: flex; |
| 80 | + flex-direction: column; |
| 81 | + align-items: flex-start; |
| 82 | + gap: 0.75rem; |
| 83 | +} |
| 84 | +.sw-support__say { margin: 0; max-width: 46rem; } |
| 85 | +.sw-support__say p { margin: 0 0 0.25rem; } |
| 86 | +.sw-support__say p:last-child { margin-bottom: 0; } |
| 87 | +.sw-support__note { opacity: 0.75; } |
| 88 | +.sw-support__ask { opacity: 0.75; } |
| 89 | +.sw-support a { color: var(--sw-support-accent, inherit); } |
| 90 | +.sw-support__say a { text-decoration: underline; text-underline-offset: 2px; } |
| 91 | +
|
| 92 | +.sw-support__links { |
| 93 | + list-style: none; |
| 94 | + margin: 0; |
| 95 | + padding: 0; |
| 96 | + display: flex; |
| 97 | + flex-wrap: wrap; |
| 98 | + gap: 0.4rem; |
| 99 | + align-items: center; |
| 100 | +} |
| 101 | +.sw-support__links a { |
| 102 | + display: inline-block; |
| 103 | + padding: 0.3rem 0.7rem; |
| 104 | + border: 1px solid var(--sw-rule); |
| 105 | + border-radius: 999px; |
| 106 | + background: var(--sw-chip); |
| 107 | + text-decoration: none; |
| 108 | + white-space: nowrap; |
| 109 | + transition: background 0.15s, border-color 0.15s; |
| 110 | +} |
| 111 | +.sw-support__links a:hover, |
| 112 | +.sw-support__links a:focus-visible { |
| 113 | + background: var(--sw-chip-hover); |
| 114 | + border-color: color-mix(in srgb, currentColor 30%, transparent); |
| 115 | +} |
| 116 | +
|
| 117 | +@media print { .sw-support { display: none; } } |
| 118 | +`; |
| 119 | + |
| 120 | + function build() { |
| 121 | + if (document.querySelector('.sw-support')) return; |
| 122 | + |
| 123 | + const app = script?.dataset.app || document.title || 'This tool'; |
| 124 | + const repo = script?.dataset.repo || ''; |
| 125 | + const note = script?.dataset.note || ''; |
| 126 | + |
| 127 | + const style = document.createElement('style'); |
| 128 | + style.textContent = CSS; |
| 129 | + document.head.appendChild(style); |
| 130 | + |
| 131 | + const footer = document.createElement('footer'); |
| 132 | + footer.className = 'sw-support'; |
| 133 | + // Not role="contentinfo": several of these apps already have a <footer> or a |
| 134 | + // landmark of their own, and two contentinfo landmarks in one document is |
| 135 | + // worse for a screen reader than none. |
| 136 | + footer.setAttribute('aria-label', 'About and support'); |
| 137 | + |
| 138 | + const say = document.createElement('div'); |
| 139 | + say.className = 'sw-support__say'; |
| 140 | + |
| 141 | + const lead = document.createElement('p'); |
| 142 | + const name = document.createElement('strong'); |
| 143 | + name.textContent = app; |
| 144 | + lead.append(name, ' is free to use, from '); |
| 145 | + lead.append(link(HOME, 'Stoatworks Labs')); |
| 146 | + lead.append(repo ? ' — open source, and the ' : ' — open source.'); |
| 147 | + if (repo) lead.append(link(repo, 'source is on GitHub'), '.'); |
| 148 | + say.appendChild(lead); |
| 149 | + |
| 150 | + if (note) { |
| 151 | + const p = document.createElement('p'); |
| 152 | + p.className = 'sw-support__note'; |
| 153 | + p.textContent = note; |
| 154 | + say.appendChild(p); |
| 155 | + } |
| 156 | + |
| 157 | + const ask = document.createElement('p'); |
| 158 | + ask.className = 'sw-support__ask'; |
| 159 | + ask.textContent = "If it's useful to you, supporting the work keeps it coming."; |
| 160 | + say.appendChild(ask); |
| 161 | + |
| 162 | + const list = document.createElement('ul'); |
| 163 | + list.className = 'sw-support__links'; |
| 164 | + for (const f of FUNDING) { |
| 165 | + const li = document.createElement('li'); |
| 166 | + li.appendChild(link(f.url, f.name)); |
| 167 | + list.appendChild(li); |
| 168 | + } |
| 169 | + |
| 170 | + footer.append(say, list); |
| 171 | + document.body.appendChild(footer); |
| 172 | + clearDemoBanner(footer); |
| 173 | + } |
| 174 | + |
| 175 | + /** |
| 176 | + * The four recorded demos carry the demo shim's banner: fixed to the bottom |
| 177 | + * edge of the viewport, and never optional, because a demo must not be |
| 178 | + * mistakable for live equipment. |
| 179 | + * |
| 180 | + * The shim reserves room for it with `body { padding-bottom }`. That is right |
| 181 | + * for an app that ends where the body ends, but this footer is the body's last |
| 182 | + * child — so scrolled to the bottom, the banner lands on top of the funding |
| 183 | + * chips, which are the one part of the footer that has to be clickable. |
| 184 | + * |
| 185 | + * Rather than have two scripts fight over the same body padding, the footer |
| 186 | + * carries the banner's height as its own bottom padding. Watched, not measured |
| 187 | + * once: the banner appears only after the shim's fixtures have loaded, which is |
| 188 | + * well after this runs, and it grows a second line whenever a write is |
| 189 | + * attempted. |
| 190 | + */ |
| 191 | + function clearDemoBanner(footer) { |
| 192 | + const BASE = '1.6rem'; |
| 193 | + let observer = null; |
| 194 | + |
| 195 | + const track = (banner) => { |
| 196 | + const apply = () => { |
| 197 | + footer.style.paddingBottom = `calc(${BASE} + ${banner.offsetHeight}px)`; |
| 198 | + }; |
| 199 | + apply(); |
| 200 | + if (window.ResizeObserver) new ResizeObserver(apply).observe(banner); |
| 201 | + window.addEventListener('resize', apply); |
| 202 | + }; |
| 203 | + |
| 204 | + const found = document.getElementById('stoatworks-demo-banner'); |
| 205 | + if (found) return track(found); |
| 206 | + |
| 207 | + // No banner in this document yet. On an app that has none — every one of |
| 208 | + // these except the demos — the observer simply never fires and is |
| 209 | + // disconnected on load, so nothing is left watching. |
| 210 | + if (!window.MutationObserver) return; |
| 211 | + observer = new MutationObserver(() => { |
| 212 | + const banner = document.getElementById('stoatworks-demo-banner'); |
| 213 | + if (!banner) return; |
| 214 | + observer.disconnect(); |
| 215 | + track(banner); |
| 216 | + }); |
| 217 | + observer.observe(document.body, { childList: true }); |
| 218 | + window.addEventListener('load', () => { |
| 219 | + setTimeout(() => observer && observer.disconnect(), 5000); |
| 220 | + }); |
| 221 | + } |
| 222 | + |
| 223 | + function link(href, text) { |
| 224 | + const a = document.createElement('a'); |
| 225 | + a.href = href; |
| 226 | + a.textContent = text; |
| 227 | + a.target = '_blank'; |
| 228 | + a.rel = 'noopener'; |
| 229 | + return a; |
| 230 | + } |
| 231 | + |
| 232 | + // `defer` already puts this after parsing, but the demos load the shim and the |
| 233 | + // app script in the same document and one of them may still be writing to |
| 234 | + // <body>; appending on DOMContentLoaded keeps the footer last either way. |
| 235 | + if (document.readyState === 'loading') { |
| 236 | + document.addEventListener('DOMContentLoaded', build, { once: true }); |
| 237 | + } else { |
| 238 | + build(); |
| 239 | + } |
| 240 | +})(); |
0 commit comments