A team's recent form: overlapping circles with the result letter inside (Ν / Ι / Η) and ? for the next fixture. Four variants — desktop / mobile × football / basketball. The geometry is shared; only the behaviour and the sport's detail change. Click every one of them (and hover the desktop ones).
For the MVP the per-match click inside the mobile modals is not needed. It is enough that the modal opens and lists the 5 matches — with no row expanding on tap.
That is, out of MVP: the goal/card timeline in mobile football (§3A) and the Q1–Q4 quarter breakdown in mobile basketball (§3B). Both are built and work right here, but they can land in a later cycle. The desktop popovers are not affected.
Dark surface (hero banner). Hover, click or focus opens the match .fpop popover: venue · opponent, score, result + cards, mini 0–90′ timeline.
Light surface (standings table). A tap on any bubble opens the
.fdmodal with all the matches — no hover popover. It opens with every row closed; tap a row and out comes the timeline 0–90′ with goals and cards (ref 2b).
Same pattern as desktop football. The popover shows venue · opponent, score, result + margin and the top player — with no quarter breakdown. The per-quarter information exists only in the mobile modal. The third bubble is the turning-point match, ΠΑΟ 84-82 ΠΑΟΚ.
Same modal as mobile football. It opens with every row closed; tap a row and opens into a breakdown Q1–Q4: quarter margin, score bars, and «quarters won». Tap another and the breakdown moves to it.
Component form-bubbles v1.5.0 · ART-379 · library
foxbet-components/components/form-bubbles/. Ported from
foxbet-responsive.html. Where the prose and the HTML disagree, the HTML wins.
For the MVP the per-match click inside the mobile modals is not needed. It is enough that the modal opens and lists the 5 matches as a list — with no row expanding on tap.
Out of MVP: §3A the goal/card timeline (mobile football) · §3B the Q1–Q4 quarter breakdown (mobile basketball). Both are built and work in the prototype above, so they can go in whenever we want — neither is a blocker.
What stays in the MVP: the bubbles and the 4 variants, the modal with its 5 matches (score, venue, date), and the desktop popovers as they are.
This component uses no images. The bubbles are pure CSS plus Greek letters, so the assets in component.json is deliberately empty: [].
THE ONLY GRAPHIC — AND THE CONSUMER SUPPLIES IT
The crest in the modal header does not belong to the component: the page passes it via data-crest in .formdots. The component merely drops it into an
<img>. If it is missing, the header works fine with no image.
component.css + component.js and it works.Two independent axes on the .formdots: data-variant
(mobile / desktop) × data-sport
(football / basket, default football). The table below is the data-variantaxis; the sport's difference is in 3B.
| desktop | mobile | |
|---|---|---|
| Model | hero-banner bubbles | standings-table bubbles |
| Surface | dark → underline #fff | light → underline var(--ink) |
| Indicator | cursor:pointer + hover translateY(-3px) | :active{transform:scale(.85)} |
| Detail | .fpop popover, per bubble | .fdmodal scrim + card, all matches |
| Opens on | click · mouse hover · focus | tap |
| Closes on | pointerleave · blur · pointerdown outside · scroll | tap on the scrim · .fdmodal-x |
| Alignment | flex default | justify-content:center |
data-variant="mobile" opens a modal at any width; the
data-variant="desktop" keeps a popover at any width. The
matchMedia('(max-width:1199.98px)') is a fallback only when no
data-variant is declared (legacy responsive pages). In the prototype one responsive page changed behaviour at the breakpoint; here the consumer declares what it wants.The geometry is shared across both variants — it is written once.
| Element | Value | Note |
|---|---|---|
| Diameter | 25 × 25px | + 2px white border → 29px on the outside |
| Diameter · compact | 20 × 20px | data-size="sm" — border 1.5px, letter 10.5px, overlap -6px. Added in 1.5.0 |
| Overlap | margin-left:-8px | on every bubble but the first; gap:0 |
| Radius | 50% | circle |
| Letter | Roboto Mono 12px / 700 | var(--num), white |
| Underline | 2.5px, left/right 18%, bottom -5px | radius 4px |
| Tap feedback | :active{transform:scale(.85)} | transition .12s ease |
.fq at the end is not a result: the
.formdots .fd.fq:last-child::after{display:none} cancels it and
.formdots:has(.fq) .fd:nth-last-child(2)::after puts the underline on the previous one. Do not «simplify» these two rules into one.data-size new in 1.5.0A third, independent axis — just like data-sport. Any variant can be compact; this is not «the mobile variant is smaller».
| default (no attribute) | data-size="sm" | |
|---|---|---|
| bubble | 25 × 25 | 20 × 20 |
| border | 2px | 1.5px |
| letter | 12px | 10.5px |
| overlap | -8px | -6px |
| underline | bottom -5px · 2.5px | bottom -4px · 2px |
default · 25px
sm · 20px
@media (max-width:1199.98px){ .heromatch .formdots .fd{width:20px;height:20px;border-width:1.5px;font-size:10.5px;} }
| State | Class | Letter | Fill |
|---|---|---|---|
| Win | .fw | Ν | linear-gradient(135deg,#039855,var(--win)) |
| Draw | .fdr | Ι | #999999 |
| Loss | .fl | Η | linear-gradient(135deg,var(--err),var(--errdark)) |
| Next | .fq | ? | var(--fill) + text #808080 |
The normal input: data-fm JSON on every played bubble.
v1.5.0 — scorer. Each event in e may carry a fourth element, the player: [34,"g","us","Γκάκπο"]. The chip in the modal shows the player and the tooltip adds the team. Optional and backward compatible — without it the team name shows, as before. Send it: a minute plus a team alone tells the reader nothing they did not already know.
| Field | Type | Meaning |
|---|---|---|
v | "H" / "A" | venue — home / away; omitted at a neutral venue |
o | string | opponent |
dt | string | display date, e.g. "25 Μαρ" |
gf / ga | int | goals for / against (always from the team's point of view) |
r | "W"/"D"/"L" | result |
y / rd | int | yellow / red cards |
e | array | events [[λεπτό, "g"|"y", "us"|"them"], …] for the timeline |
ON THE .formdots
| Attribute | Role |
|---|---|
data-variant | "mobile" or "desktop" — mandatory in the library |
data-team | team name for the modal header |
data-crest | crest URL; optional |
ON THE ? BUBBLE · AND LEGACY
| Attribute | Format |
|---|---|
data-fmnext | "<ετικέτα>: vs <αντίπαλος> · <πότε>" — split on the first :, then on ·; final HH:MM = kick-off time |
data-tip | legacy — "Νίκη 3-0 vs Κόστα Ρίκα · 11 Ιουν". The init() converts it automatically into data-fm. Do not author new markup with it. |
.standwrap → <tr> → .tm .full) for the name and the crest, and the modal became appendChild in .phone. In the library those were replaced by data-team/data-crest and by
opts.mountThe old ancestor lookup remains as a fallback only, so existing pages keep working with no markup change. The hook for horizontally scrolling tables is now [data-fb-scroll], not .standwrap.One global: window.FormBubbles. The init() is idempotent since v1.3.1: each bubble binds once (data-fb-bound) and the modal listener binds once per root+mount (root.__fbModalBound).
Before v1.3.1 this did not fully hold. Only the per-dot binds were guarded; the initModal binds a delegated listener on the root, so a second init() opened two .fdmodal overlays on one tap, and the ✕ closed only its own — which read as «the modal does not close». Check: document.querySelectorAll('.fdmodal').length after a single tap must be 1. If your project carries a copy ≤ v1.3.0, call init() exactly once, or take v1.3.1.
| Member | Description |
|---|---|
init(opts) | opts.root (default document), opts.mount (default document.body — where the modal is mounted) |
MOBILE_QUERY | '(max-width:1199.98px)' — fallback only, with no data-variant |
parseTip(tip) | legacy string → data-fm payload· null if it does not match |
teamOf(group) | {nm, fl} from data-team/data-crest, with an ancestor fallback |
// μετά το markup, μία φορά FormBubbles.init(); // ή με ρητό mount point (π.χ. μέσα σε device frame) FormBubbles.init({ root: document.querySelector('.page'), mount: document.querySelector('.phone') });
| State | desktop | mobile |
|---|---|---|
| default | 25px circle, white letter | |
| hover | translateY(-3px) + z-index:8 | — |
| focus | focus ring via tabindex="0"; on desktop it opens the popover | |
| pressed | scale(.85) | |
| open | .fpop open | .fdmodal open |
| reduced motion | @media (prefers-reduced-motion:reduce) → transition:none | |
The component declares no tokens — it only consumes them. Source:
tokens/tokens.css.
| Token | Value | Used for |
|---|---|---|
--ink | #1A1A1A | underline on a light surface, text |
--mut | #666666 | dates, secondary text |
--line | #E6E6E6 | borders, timeline |
--fill | #F2F2F2 | .fq fill, modal header |
--acc | #CB2F01 | score chip, «Επόμενος αγώνας» label |
--win | #027A48 | end of the win gradient |
--err | #D92D20 | start of the loss gradient |
--errdark | #912018 | end of the loss gradient |
--num | 'Roboto Mono' | ALL the numbers and the bubble letters |
#039855 (start of the win gradient),
#999999 (draw), #808080, #FDB022 (yellow card) and
#FFEBE6/#FFD8CC (score chip) stay deliberately literal — they are hand-picked, as in odds-cta..fd has tabindex="0" — reachable from the keyboard.role="tooltip"; the modal's close button
aria-label="Κλείσιμο".prefers-reduced-motion disables the transition.ACCEPTED WCAG DEBT — MEASURED 2026-07-29
The letters are 12px/700. Bold 12px is not not «large text» under WCAG, so the threshold that applies is 4.5:1.
| State | Text on | Ratio | AA 4.5 |
|---|---|---|---|
| Win — gradient start | #FFFFFF on #039855 | 3.73 | ✗ |
| Draw | #FFFFFF on #999999 | 2.85 | ✗ (AA-large ✗ too) |
Next .fq | #808080 on #F2F2F2 | 3.53 | ✗ |
| Win — gradient end | #FFFFFF on #027A48 | 5.41 | ✓ |
| Loss — start / end | #FFFFFF on #D92D20 / #912018 | 4.83 / 8.66 | ✓ |
contrastKnownFail of component.json, and change no colours. The bubbles
do not rely on colour alone — the Ν/Ι/Η letter carries the result, which satisfies SC 1.4.1 (Use of Colour). The open issue is contrast, not encoding.MOBILE — STANDINGS (5 MATCHES)
<div class="formdots" data-variant="mobile" data-team="Ολυμπιακός" data-crest="/img/teams/olympiacos.svg"> <span class="fd fw" data-fm='{"v":"H","o":"ΑΕΚ","dt":"24 Αυγ","gf":2,"ga":0,"r":"W","y":0,"rd":0,"e":[[23,"g","us","Σίμονς"],[71,"g","us","Βέγκχορστ"]]}'>Ν</span> <span class="fd fw" data-fm='{"v":"A","o":"ΠΑΟΚ","dt":"31 Αυγ","gf":3,"ga":1,"r":"W","y":1,"rd":0,"e":[]}'>Ν</span> <span class="fd fdr" data-fm='{"v":"H","o":"Παναθηναϊκός","dt":"14 Σεπ","gf":1,"ga":1,"r":"D","y":2,"rd":0,"e":[]}'>Ι</span> <span class="fd fw" data-fm='{"v":"A","o":"Άρης","dt":"21 Σεπ","gf":2,"ga":1,"r":"W","y":1,"rd":0,"e":[]}'>Ν</span> <span class="fd fl" data-fm='{"v":"H","o":"ΟΦΗ","dt":"28 Σεπ","gf":0,"ga":1,"r":"L","y":1,"rd":1,"e":[]}'>Η</span> </div>
DESKTOP — HERO, WITH NEXT FIXTURE
<div class="formdots" data-variant="desktop" data-team="Ολλανδία"> <span class="fd fw" data-fm='{"v":"H","o":"Αυστρία","dt":"25 Μαρ","gf":3,"ga":0,"r":"W","y":1,"rd":0,"e":[[12,"g","us","Γκάκπο"]]}'>Ν</span> <span class="fd fq" data-fmnext="Επόμενος: vs Ιαπωνία · Σάβ 14 Ιουν 23:00">?</span> </div>
A CONTAINER THAT CLIPS THE POPOVER
<!-- ΚΑΘΕ γονιός με overflow hidden/auto/scroll κόβει το popover. Βάλε data-fb-scroll στο wrapper και το .fpop γίνεται position:fixed ώστε να ξεφύγει. Ισχύει για πίνακες με scroll ΚΑΙ για κάρτες με overflow:hidden — π.χ. τα demo αυτής της σελίδας. --> <div class="standwrap" data-fb-scroll> <table>…</table> </div>
The modal opens with every row closed, in both sports. Tapping a row gives it .is-open and reveals the detail underneath; tapping another moves it, tapping the same one closes it. A row is expandable only if it has data:
q for basketball, e for football.
FOOTBALL — GOAL & CARD TIMELINE ref 2b
| Element | Class | Content |
|---|---|---|
| Result row | .fdacc-res | dot + «Νίκη · Εκτός». Dot: win var(--win) · draw #999999 (.drew) · loss var(--err) (.lost) |
| Rail + markers | .fdacc-track | .rail + one .ev per event, positioned at its λεπτό/90 % |
| Axis | .fdacc-ax | 0′ … 90′ |
| Event chips | .fdacc-evs | one pill per event: 9′ Ολυμπιακός |
var(--win), theirs var(--err) — as in 2b. Cards: yellow
#FDB022, red var(--err), shaped .card. The shape difference is our own addition: 2b had no cards in its data, and without it a red card would look identical to an opponent's goal. Every marker and chip also carries a
title/aria-label («Κίτρινη κάρτα 52′ — Ολυμπιακός»), because the chip's own text is 52′ Ολυμπιακός for both a goal and a card.data-sport="basket" Q1–Q4 out of MVPSame bubbles, same geometry, same interaction pattern. The only thing that changes is the sport's
detail . Basketball has no draw, so only
.fw / .fl (Ν / Η) appear — never .fdr.
EXTRA FIELDS ON THE data-fm
| Field | Type | Meaning |
|---|---|---|
q | array | [[δικά,αντιπάλου], …] per quarter — 4, or more with overtime |
mvp | string | optional, e.g. "M. Lessort 17π 9ρ" |
THE REST IS DERIVED — DO NOT SEND IT
| Quantity | Computed as |
|---|---|
| quarter margin | δικά − αντιπάλου |
| match margin | gf − ga — shown in the result row as Νίκη +2 |
| quarters won | count of quarters where δικά > αντιπάλου |
| final score | the gf/ga must equal the sum of q |
DESKTOP — NO QUARTER BREAKDOWN
The popover shows
venue · opponent, score, result + margin and the top player — and nothing else. There is no quarter breakdown: the .fpop-tl (football's 0–90′ timeline) simply has no basketball equivalent. It keeps the normal width 196px. The per-quarter information exists only in the mobile modal.
MOBILE — QUARTER BREAKDOWN IN THE MODAL
The modal opens with
every row closed — just the list of 5 matches. Tapping a row gives it
.is-open and reveals underneath it .fdacc-res (dot + «Νίκη +2 · 2/4 περίοδοι · Εντός · player») and .fdacc-q with four .fdq: a margin chip, a pair of bars, the quarter score, a Q label. Tapping another row moves the breakdown; tapping the same one closes it.
#7A67E8that does not exist in the Foxbet DS. Here: team bar var(--acc) #CB2F01, opponent bar var(--line), positive-margin chip #D1FADF /
var(--win), negative #FEE4E2 / var(--err).| Out | Ratio |
|---|---|
.fdacc shell, drag handle, @keyframes fdaccOpen, .fdacc-hd, .fdacc-x | The hero accordion on mobile. Mobile follows the standings → modal path. The classes of the row (.fdacc-row/-date/-match/-score) ARE included — the modal uses them. |
#formtip | It served legacy data-tip hover only; after the upgrade to data-fm the .fpop covers it. |
.formbtn, .fcol-early, .stand.full.formfirst | The ΦΟΡΜΑ column's quick toggle — that is the table's behaviour, not the bubbles'. |
The sizes 22px / 20px | .tcard-hd and .pr-fx-score. Two variants were asked for, not a size scale. |
.form / .formcol | «Πρόσφατη φόρμα» cards — that is container layout, a separate component if we ever need it. |
fdacc- on the row classes is now misleading (the accordion is not included) but it is not being renamed — the verbatim-naming rule holds, so no page has to change.component.css and component.js. No dependency, no asset.--win / --err / --errdark exist in the tokens..formdots must to declare data-variant.data-team (and data-crest if you have it) on every team — otherwise the modal header drops to the fallback.data-fm JSON. Do not author new data-tip.data-fb-scroll on every wrapper that clips the popover — overflow hidden, auto or scroll. If the popover does not show, this is what is missing.FormBubbles.init() after render and again after AJAX — idempotent since v1.3.1 (on older copies: one call and one only — see §05).e / q in data-fm — a row becomes expandable only when it has data, so it stops opening by itself. No component change is needed.?).tools/test_build.py cover the contract, class scoping, the tokens and the contrast ratios. Run python -m unittest discover -s tools -p "test_*.py" from
foxbet-components/.