ΕΛEN
ART-379 · Form Bubbles · desktop/mobile × football/basketball · MVP: no per-match click inside the mobile modals — scroll ↓

Form Bubbles

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).

MVP scope

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.

desktop football

variant=desktop · sport=football

Dark surface (hero banner). Hover, click or focus opens the match .fpop popover: venue · opponent, score, result + cards, mini 0–90′ timeline.

mobile football

variant=mobile · sport=football

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).

desktop basketball

variant=desktop · sport=basket

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 ΠΑΟΚ.

mobile basketball

variant=mobile · sport=basket

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.

Design → Dev Handover

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.

MVP scope

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.

Assets

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.

No file dependency at all — copy component.css + component.js and it works.

01The four variants

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.

desktopmobile
Modelhero-banner bubblesstandings-table bubbles
Surfacedark → underline #ffflight → underline var(--ink)
Indicatorcursor:pointer + hover translateY(-3px):active{transform:scale(.85)}
Detail.fpop popover, per bubble.fdmodal scrim + card, all matches
Opens onclick · mouse hover · focustap
Closes onpointerleave · blur · pointerdown outside · scrolltap on the scrim · .fdmodal-x
Alignmentflex defaultjustify-content:center
⚠️
The declared variant decides — NOT the viewport width. The 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.

02Anatomy & geometry

The geometry is shared across both variants — it is written once.

ElementValueNote
Diameter25 × 25px+ 2px white border → 29px on the outside
Diameter · compact20 × 20pxdata-size="sm" — border 1.5px, letter 10.5px, overlap -6px. Added in 1.5.0
Overlapmargin-left:-8pxon every bubble but the first; gap:0
Radius50%circle
LetterRoboto Mono 12px / 700var(--num), white
Underline2.5px, left/right 18%, bottom -5pxradius 4px
Tap feedback:active{transform:scale(.85)}transition .12s ease
⚠️
The underline marks the last PLAYED match. A .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.

02bSize — data-size new in 1.5.0

A 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"
bubble25 × 2520 × 20
border2px1.5px
letter12px10.5px
overlap-8px-6px
underlinebottom -5px · 2.5pxbottom -4px · 2px
ΝΝΙΗΝ

default · 25px

ΝΝΙΗΝ

sm · 20px

The consumer declares it — never the viewport. The component never changes size from a media query; that would break the «the declared variant decides» rule. If you want it on phones only, either switch the attribute or override it in your own scope. The analysis page does the latter for its hero:
@media (max-width:1199.98px){ .heromatch .formdots .fd{width:20px;height:20px;border-width:1.5px;font-size:10.5px;} }
The same page keeps 25px on desktop, where the room exists.

03Result colours

StateClassLetterFill
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

04Data contract

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.

FieldTypeMeaning
v"H" / "A"venue — home / away; omitted at a neutral venue
ostringopponent
dtstringdisplay date, e.g. "25 Μαρ"
gf / gaintgoals for / against (always from the team's point of view)
r"W"/"D"/"L"result
y / rdintyellow / red cards
earrayevents [[λεπτό, "g"|"y", "us"|"them"], …] for the timeline

ON THE .formdots

AttributeRole
data-variant"mobile" or "desktop"mandatory in the library
data-teamteam name for the modal header
data-crestcrest URL; optional

ON THE ? BUBBLE · AND LEGACY

AttributeFormat
data-fmnext"<ετικέτα>: vs <αντίπαλος> · <πότε>" — split on the first :, then on ·; final HH:MM = kick-off time
data-tiplegacy"Νίκη 3-0 vs Κόστα Ρίκα · 11 Ιουν". The init() converts it automatically into data-fm. Do not author new markup with it.
⚠️
The prototype searched the page's DOM (.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.

05API

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.

MemberDescription
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') });

06States

Statedesktopmobile
default25px circle, white letter
hovertranslateY(-3px) + z-index:8
focusfocus ring via tabindex="0"; on desktop it opens the popover
pressedscale(.85)
open.fpop open.fdmodal open
reduced motion@media (prefers-reduced-motion:reduce)transition:none

07Design tokens

The component declares no tokens — it only consumes them. Source: tokens/tokens.css.

TokenValueUsed for
--ink#1A1A1Aunderline on a light surface, text
--mut#666666dates, secondary text
--line#E6E6E6borders, timeline
--fill#F2F2F2.fq fill, modal header
--acc#CB2F01score chip, «Επόμενος αγώνας» label
--win#027A48end of the win gradient
--err#D92D20start of the loss gradient
--errdark#912018end of the loss gradient
--num'Roboto Mono'ALL the numbers and the bubble letters
ℹ️
The #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.

08Accessibility

  • Every .fd has tabindex="0" — reachable from the keyboard.
  • The popover has 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.

StateText onRatioAA 4.5
Win — gradient start#FFFFFF on #0398553.73
Draw#FFFFFF on #9999992.85(AA-large ✗ too)
Next .fq#808080 on #F2F2F23.53
Win — gradient end#FFFFFF on #027A485.41
Loss — start / end#FFFFFF on #D92D20 / #9120184.83 / 8.66
⚠️
Three of the four states fail AA. It pre-dates the library, from the prototype. Decision of 2026-07-29: port it as is, record it as debt in the 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.

09Markup — copy/paste

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>

3AMobile modal — row expansion out of MVP

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

ElementClassContent
Result row.fdacc-resdot + «Νίκη · Εκτός». 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-ax0′90′
Event chips.fdacc-evsone pill per event: 9′ Ολυμπιακός
⚠️
Goals = circles, cards = rectangles. Our goal 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.

3BBasketball — data-sport="basket" Q1–Q4 out of MVP

Same 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

FieldTypeMeaning
qarray[[δικά,αντιπάλου], …] per quarter — 4, or more with overtime
mvpstringoptional, e.g. "M. Lessort 17π 9ρ"

THE REST IS DERIVED — DO NOT SEND IT

QuantityComputed as
quarter marginδικά − αντιπάλου
match margingf − ga — shown in the result row as Νίκη +2
quarters woncount of quarters where δικά > αντιπάλου
final scorethe 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.

⚠️
The colours are the design system's, not the mockup's. The reference «Form Popup Mobile.html» (options 3a / 5a) uses a purple #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).

10What is NOT included

OutRatio
.fdacc shell, drag handle, @keyframes fdaccOpen, .fdacc-hd, .fdacc-xThe 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.
#formtipIt served legacy data-tip hover only; after the upgrade to data-fm the .fpop covers it.
.formbtn, .fcol-early, .stand.full.formfirstThe ΦΟΡΜΑ 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.
ℹ️
The prefix 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.

11Build checklist

  • Copy component.css and component.js. No dependency, no asset.
  • Make sure the --win / --err / --errdark exist in the tokens.
  • Every .formdots must to declare data-variant.
  • Give data-team (and data-crest if you have it) on every team — otherwise the modal header drops to the fallback.
  • The backend emits 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.
  • Call FormBubbles.init() after render and again after AJAX — idempotent since v1.3.1 (on older copies: one call and one only — see §05).
  • MVP: the per-match click inside the mobile modals is not required. The modal plus the 5 matches is enough. If you drop it, do not send 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.
  • Check: the underline on the last played match, in both cases (with and without ?).
91 automated tests in 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/.
21ΕΕΕΠ | 21+ | ΠΑΙΞΕ ΥΠΕΥΘΥΝΑ