Skip to content

Commit 0902e91

Browse files
Add the support footer to the hosted demo
Vendored from stoatworks-backend -- edit support-footer.js and build-demo.sh there and re-sync, never the copies here. build-demo.sh injects the tag and takes the app name and repo URL from the fixtures' own meta, the same values the shim's banner uses, so the footer and the banner cannot end up naming different projects. The footer reserves the banner's height as its own bottom padding. The shim reserves room with body{padding-bottom}, which is right for an app that ends where the body ends -- but the footer IS the body's last child, so that reservation landed behind it and the banner covered the funding chips at the bottom of the scroll. Two scripts writing the same property would have fought; this way neither has to know about the other's reservation. demo/dist is rebuilt from the committed fixtures: one line of index.html plus the new asset, nothing else moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3ac7d1e commit 0902e91

4 files changed

Lines changed: 525 additions & 3 deletions

File tree

demo/build-demo.sh

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,22 @@ cp -R "$SRC"/. "$OUT"/
3838
cp "$HERE/demo-shim.js" "$OUT/demo-shim.js"
3939
cp "$FIXTURES" "$OUT/demo-fixtures.json"
4040

41+
# The support footer, if the repo has been synced with it. Optional on purpose:
42+
# an older checkout without the file should still build a demo rather than fail.
43+
# Its app name and repo URL are read out of the fixtures below, from the same
44+
# meta the shim's banner uses, so the two can never name different projects.
45+
if [ -f "$HERE/support-footer.js" ]; then
46+
cp "$HERE/support-footer.js" "$OUT/support-footer.js"
47+
fi
48+
4149
# These apps are served from their backend's root, so their markup references
4250
# /app.js and /style.css absolutely. That is already right for a Cloudflare
4351
# Pages project (it serves at the root of its own domain); --base only matters
4452
# when hosting under a subdirectory, where those paths need rewriting. Either
4553
# way the shim has to load before the app's own script.
46-
python3 - "$OUT/index.html" "$BASE" <<'PY'
47-
import re, sys
48-
path, base = sys.argv[1], sys.argv[2]
54+
python3 - "$OUT/index.html" "$BASE" "$OUT" <<'PY'
55+
import json, os, re, sys
56+
path, base, out = sys.argv[1], sys.argv[2], sys.argv[3]
4957
html = open(path, encoding='utf-8').read()
5058
5159
if base != '/':
@@ -85,6 +93,39 @@ if 'demo-shim.js' not in html:
8593
else:
8694
html = html.replace('</body>', shim + '</body>')
8795
96+
# The support footer goes LAST, after the app's own scripts — it is in-flow
97+
# content appended to <body>, and nothing else waits on it. Relative src, like
98+
# the shim's, so the --base rewrite above does not have to know about it.
99+
#
100+
# The app name and repo come from the fixtures' meta rather than new arguments,
101+
# so the footer and the shim's banner always name the same project. No
102+
# data-note: these demos are recorded against simulated devices, and every note
103+
# worth writing ("nothing leaves your browser") is a claim about a real backend.
104+
footer_js = os.path.join(out, 'support-footer.js')
105+
if os.path.exists(footer_js) and 'support-footer.js' not in html:
106+
meta = {}
107+
try:
108+
with open(os.path.join(out, 'demo-fixtures.json'), encoding='utf-8') as fh:
109+
meta = json.load(fh).get('meta', {}) or {}
110+
except (OSError, ValueError) as err:
111+
print(f' warning: could not read fixtures meta for the footer: {err}')
112+
113+
attrs = ['src="support-footer.js"', 'defer']
114+
for name, key in (('data-app', 'app'), ('data-repo', 'repo')):
115+
value = meta.get(key)
116+
if value:
117+
attrs.append(f'{name}="{value}"')
118+
footer = '<script ' + ' '.join(attrs) + '></script>\n'
119+
120+
if '</body>' in html:
121+
html = html.replace('</body>', footer + '</body>')
122+
else:
123+
# Several of these documents have no <body> tag at all — they are
124+
# fragments the backend served with the right content type. Appending is
125+
# correct there: the parser puts it at the end of the implied body.
126+
html = html.rstrip() + '\n' + footer
127+
print(f' injected support-footer.js for {meta.get("app", "the app")}')
128+
88129
open(path, 'w', encoding='utf-8').write(html)
89130
print(f' injected demo-shim.js, base={base}')
90131
PY

demo/dist/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,5 +496,6 @@ <h2>Destinations</h2>
496496
loadAvailableTransports();
497497
tick().finally(connectLive);
498498
</script>
499+
<script src="support-footer.js" defer data-app="srt-router" data-repo="https://github.com/stoatworks-labs/srt-router"></script>
499500
</body>
500501
</html>

demo/dist/support-footer.js

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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

Comments
 (0)