Widget & embed
An organiser embeds their rating on their own site. Two rules: nothing of ours leaks onto the host, nothing of the host's leaks onto us. This is a scoping problem, not a density — though a widget is compact by definition, because an embed is a guest, not a page.
Three widgets
One score, one list, one carousel. They share the same shell, the same tokens and the same isolation; they differ only in how much of the feedback they show.
| Widget | Shows | Typical placement | Height |
|---|---|---|---|
score | The aggregate, the stars and the review count | Footer, sidebar, next to a registration button | ~120px |
list | The aggregate plus the most recent reviews, stacked | A dedicated "what athletes said" block | Grows with count |
carousel | The aggregate plus reviews, one at a time | Inside a landing page where vertical space is scarce | ~220px |
score
list
The default when an organiser wants the words, not just the number. Reviews are newest first, never curated by score — a widget that hides the low ratings is an advertisement, and athletes stop trusting the badge.
carousel
Same reviews, one at a time, when the host page cannot spare the height. It is the only widget with state, which makes it the only one with real accessibility obligations.
Carousel rules
- No autoplay. Not pausable autoplay, not "slow" autoplay — none. A testimonial that moves while it is being read is a testimonial nobody finishes, and WCAG 2.2.2 puts the burden on us the moment it moves.
- Real
<button>s at 44px, each with its ownaria-label. Not arrows made of divs, not swipe-only. - Wraps at both ends, so the controls never disable and the count never lies. The position line is the feedback: "2 of 412".
- One
aria-live="polite"region, on the counter. The review text is not announced on change — the counter is, and the reader moves to the text. Announcing both says everything twice. - The slide is not a link. A whole clickable card inside someone else's page is a dark pattern; the "read all" link at the foot is the way out.
- Height is fixed to the tallest slide, computed once. A carousel that resizes per slide reflows the host page under the reader's cursor.
Rules for all three
- Newest first, never score-sorted, never filtered by rating. The count and the aggregate must reconcile with what the widget shows.
- Truncate at four lines with a "read all" link. Never a "…more" that expands and reflows the host page.
- Stars are gold at every tier and always sit beside the number — the same rule as everywhere else.
- Every widget carries the Sporting Scouter mark and links back. An unattributed rating is a claim; an attributed one is evidence.
Two mechanisms, identical declarations
:hostThe primary mechanism. A shadow root carries the token block, so the host's cascade cannot reach in and ours cannot reach out.
.ss-widgetThe light-DOM fallback for hosts where a shadow root cannot be attached. Re-declares the same tokens plus the defensive resets a shadow root would have given free.
Change one, change the other — the same class of defect as the theme.css / tokens.css drift that shipped 1.6:1 input borders. Two mirrors is two chances to forget.
What the widget block does
- Re-anchors typography. The host's font-size and line-height are unknown and frequently hostile, so the widget states
--font-sans,--text-sm, 1.55 andtext-align: leftitself. - States its own ground.
surface-raisedplus--shadow-widget— the one place elevation is load-bearing. - Contains layout and paint.
contain: layout paint styleandisolation: isolate, so a host's* { transition: all }or stray grid cannot reflow us. - Never links out with a colour surprise. Links inside a widget use
--color-primary, underlined, so they read as ours on any ground.
Embedding
One snippet, one script tag, any number of widgets on the page.
<div data-ss-widget="carousel" <!-- score | list | carousel --> data-ss-event="maratona-lisboa" data-ss-count="6" <!-- list and carousel only --> ></div> <script src="https://widgets.sportingscouter.com/v1/embed.js" async></script>
The script attaches a shadow root per element, falls back to .ss-widget when it cannot, and renders nothing at all if the event has no reviews — an empty widget on someone else's site is worse than no widget.
No framework, ever
A widget runs inside a page we do not control, next to a stack we cannot predict — WordPress, Squarespace, Wix, a hand-written HTML page from 2014. The embed is plain HTML, plain CSS and vanilla JavaScript. It is the one part of the system that shares no build with the apps.
| Constraint | Rule |
|---|---|
| Dependencies | Zero. No React, no Preact-in-disguise, no polyfill bundle, no CSS-in-JS. |
| Output | One ES2019 IIFE plus one CSS string, both inside embed.js. Nothing is fetched to render the shell. |
| Budget | 12 KB gzipped for script, styles and mark together. A rating badge that costs more than the host's hero image will be removed. |
| Network | One GET for the event JSON. Nothing else — no fonts, no analytics, no beacon. |
| Storage | None. No cookies, no localStorage. A guest does not write to someone else's origin. |
| Type | System stack. The widget never loads DM Sans — a webfont request on a host page is a cost we have no right to impose. |
| Failure | Silent. A failed fetch removes the container; it never leaves a spinner or an error on an organiser's homepage. |
Everywhere else the answer is DM Sans. In an embed it is system-ui: the widget still owns its size, weight, line-height and colour, so it reads as ours — but it inherits the host's rendering rather than adding a font request to their critical path.
What widgets.sportingscouter.com serves
| Path | Contents | Cache |
|---|---|---|
/v1/embed.js | The loader, the three templates and the CSS string | immutable, versioned by path |
/v1/logo.svg | The mark used in every attribution lockup | immutable |
/v1/e/<slug>.json | Aggregate, category scores and the most recent reviews | 5 minutes at the edge |
/v1/ is in the path, not a query parameter: a breaking change ships as /v2/ and every existing embed keeps working forever. We cannot ask thousands of organisers to edit a script tag.
The three files
1 — HTML
Rendered by the script, but written as if by hand: semantic elements, real buttons, no div pretending to be a control. This is the carousel, in full.
<section class="ssw" aria-roledescription="carousel"
aria-label="Athlete reviews for Maratona de Lisboa">
<header class="ssw-head">
<span class="ssw-name">Maratona de Lisboa</span>
<span class="ssw-score">4.7</span>
<span class="ssw-stars" aria-hidden="true">★★★★★</span>
</header>
<article class="ssw-slide">
<p class="ssw-quote">Water every 5k and the finish was calm…</p>
<p class="ssw-by">Ana R. · Half marathon</p>
</article>
<footer class="ssw-foot">
<button type="button" class="ssw-btn" data-dir="-1" aria-label="Previous review">‹</button>
<button type="button" class="ssw-btn" data-dir="1" aria-label="Next review">›</button>
<p class="ssw-count" aria-live="polite">1 of 3</p>
<a class="ssw-mark" href="https://sportingscouter.com/e/maratona-lisboa">
<img src="https://widgets.sportingscouter.com/v1/logo.svg" alt="" width="11" height="15">
Sporting Scouter
</a>
</footer>
</section>2 — CSS
One string, injected into the shadow root. Only the tokens the widget actually uses — the full set would be most of a stylesheet for a 120px badge. Class names are ssw- prefixed so the light-DOM fallback cannot collide with a host's classes.
:host, .ss-widget {
/* only what the widget uses — literal values, no build step */
--ssw-surface: #ffffff; --ssw-text: #333333; --ssw-muted: #64748b;
--ssw-border: #e2e8f0; --ssw-star: #f59e0b; --ssw-link: #0077b3;
--ssw-excellent: #1f7a4d;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px; line-height: 1.55; color: var(--ssw-text);
text-align: left;
background: var(--ssw-surface);
border-radius: 4px;
box-shadow: 0 0 0 1px rgb(51 51 51 / .08), 0 2px 8px rgb(51 51 51 / .08);
contain: layout paint style;
isolation: isolate;
}
.ssw-btn {
min-width: 44px; min-height: 44px; /* the touch floor holds here too */
border: 1px solid #8595a9; border-radius: 4px;
background: none; color: inherit; cursor: pointer;
}
.ssw-btn:focus-visible { outline: 2px solid var(--ssw-link); outline-offset: 2px; }
.ssw-mark { display: inline-flex; align-items: center; gap: 6px;
color: var(--ssw-muted); text-decoration: none;
font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }The embed cannot @import the design system — there is no build and no shared cascade inside a stranger's page. So the hexes are written out, and keeping them in step with theme.css is a release-checklist item, not something the build can guarantee. Six values. If that list grows past a dozen, the widget is doing too much.
3 — JavaScript
Vanilla, roughly 150 lines. The shape of the loader, with the parts that matter:
(function () {
var API = 'https://widgets.sportingscouter.com/v1';
function mount(el) {
var kind = el.getAttribute('data-ss-widget') || 'score';
var slug = el.getAttribute('data-ss-event');
if (!slug) return;
fetch(API + '/e/' + encodeURIComponent(slug) + '.json')
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(function (data) {
if (!data.count) return el.remove(); // nothing to show: leave no trace
// Shadow root first; light DOM only where it is refused.
var root;
try { root = el.attachShadow({ mode: 'open' }); }
catch (e) { el.classList.add('ss-widget'); root = el; }
var style = document.createElement('style');
style.textContent = CSS; // the string above
root.appendChild(style);
root.appendChild(render(kind, data, el)); // returns a DocumentFragment
})
.catch(function () { el.remove(); }); // silent failure, always
}
function boot() { document.querySelectorAll('[data-ss-widget]').forEach(mount); }
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', boot)
: boot();
})();- No autoplay timer anywhere in the file. The carousel advances on click and on nothing else.
- All text goes in with
textContent, neverinnerHTML. Review bodies are user-written and land on someone else's domain. - Idempotent.
mount()marks the element, so a host that injects the script twice gets one widget. - Height is measured once after the first render and pinned, so advancing a slide never reflows the host page.
theme.css.