@v4/ghost-basketball
The basketball UI library: schedules, standings, brackets, the Game Centre, stats, widgets and team, player and coach pages.
Overview
@v4/ghost-basketball is the basketball UI library: the presentational building blocks for
schedules, standings, brackets, the Game Centre, the stats hub, and player, coach and team
surfaces, built from the
ELB – Web – Basketball
Figma file (8Jl1miPEIZslMda8jZ8w0C).
It is new UI, not a fork. Unlike ghost-content and ghost-video there is no upstream Ghost file
to diff against — none of the file's component ids appear in any Ghost or ELB file — so the
preserved-id trap does not apply and fork-diff has nothing to compare. Every bridge names
8Jl1miPEIZslMda8jZ8w0C from birth, with one exception:
- The ECAL schedule picker (the Games page's "Add to calendar" choice) —
ecal-all-games,ecal-team-optionandecal-schedule-picker— is built from ELB – Web – EXT (Yqy0zItrtL4UptOVW54wVg, page "Updated E-Cal"), the client's concept file. Its frames carry no Token Studio data, so these three components' theme leaves are INFERRED from the global contract rather than extracted, and their bridges name the EXT file.node packages/token-parser/scripts/check-bridges.mjsaccepts it because the file is listed in.claude/manifest.json. (dialogbridges the Basketball file and records the EXT page only as an additional source.)
It is club-only, the @v4/wsc / @v4/eltv model: no domains.json entry and no club.config
domain, so /sync-upstream never offers or prunes it. Its registration lines in synced files
(Storybook config, the e2e theme-emission spec, the token-parser and ticket scripts, ghost-core's
global surface) are single, commented lines so a sync conflict on them is trivial to resolve.
Like the other siblings it composes @v4/ghost-core and @v4/ghost-content (chips, buttons,
drop-downs, carousel paddles, pagination, header blocks) rather than re-implementing them. It is
purely presentational: the data comes from the Competition Engine through
@v4/basketball-app, which maps the engine's views onto these props.
Components
139 components (packages/ghost-basketball/src/components/*, _internal excluded), built
leaves first in batches B-1 (atoms) to B-6 (pages) and then the Game Centre, widgets and stats
hub. The plan, the per-set inventory and the progress log are
.claude/reference/figma/ghost-basketball-plan.md, ghost-basketball-inventory.md and
ghost-basketball-progress.md.
| Family | Count | Components |
|---|---|---|
| Pages | 10 | page-all-players, page-all-teams, page-coach, page-game-centre, page-games-schedule, page-player, page-skeleton, page-standings, page-stats, page-team |
| Widgets | 21 | widget-card, widget-form, widget-game-link, widget-game-situation, widget-header, widget-list, widget-match-row, widget-mvp, widget-next-game, widget-next-games, widget-points-by-quarter, widget-record, widget-row, widget-stats, widget-table, widget-team, widget-team-comparison, widget-team-leaders, widget-teams-row, widget-tile, widget-versus |
| Game Centre | 43 | box-score, comparison-row, connection-row, connections, game-flow, game-flow-stat, game-header-hero, game-info, game-leader-category, game-leader-entry, game-leaders, game-media, game-ranking, game-ranking-entry, game-score, game-score-background, game-score-card, game-section, game-status, game-sticky-bar, game-summary, game-team-key, game-top-player, h2h-game-row, h2h-summary, head-to-head, lineup-row, lineups, module-info, play-by-play, play-card, play-game-end, play-row, rosters, season-record-row, season-records, season-standings, starters-bench, sticky-bar-quarter-strip, sticky-bar-quarter-table, sticky-bar-score, team-stats, top-player |
| Schedule | 7 | game, date-header, round-selector, schedule-empty-state, broadcaster, broadcaster-logo, filter-bar |
| Standings and brackets | 13 | standings-table, standings-row, standings-position, standings-zone, header-standings-table, header-standings-row, header-standings-position, header-stat-cell, bracket, bracket-champion, bracket-match, bracket-round-header, bracket-team |
| Teams, players and coaches | 28 | team-card, team-hero, club, roster-card, roster-row, player-card, player-hero, player-modal, player-thumbnail, player-game-cell, player-game-log, player-game-row, player-record-row, player-records-table, player-season-table, player-stats-row, player-stats-table, coach-hero, coach-record-table, bio-about, bio-panel, bio-timeline, bio-timeline-item, bio-timeline-marker, form, form-badge, form-result, form-row |
| Stats | 11 | leader-card, leader-card-header, leader-card-row, stat-card, stat-cell, stat-group-heading, stat-key, stat-key-entry, stat-tile, stats-hub-table, rank-chip |
| Shared controls and ECAL | 6 | dialog, filter-field, tooltip, ecal-all-games, ecal-schedule-picker, ecal-team-option |
src/components/_internal/ holds the private layouts several components share — basketball-page
(the page frame), stat-bar, table-scroller, widget-panel — plus two helper modules,
hero-widget-column.ts and widget-text.css.ts. Each public component built over one declares it
in its manifest's coverage.delegatedThemes.
Entry points
package.json exports 38 keys: the barrel, the stylesheet, ./self-emitted-themes and 35 per-component subpaths.
| Import | Contents |
|---|---|
@v4/ghost-basketball |
Every component, its prop types and mock props, and the package's TEST_ID |
@v4/ghost-basketball/init.css |
Owner-keyed side-effect stylesheet emitting the package's --ph__* CSS variables, and its OWN_THEMES list |
@v4/ghost-basketball/self-emitted-themes |
SELF_EMITTED_THEMES: the themes the precompiled stylesheets emit with their components (at :where(.ph__x-theme)), which the app's parts leave out |
@v4/ghost-basketball/<component> |
One component without the barrel (below) |
The per-component subpaths exist so a client module can compose one page or widget without
pulling in the package barrel — and with it every component's stylesheet. They began with
./page-team and ./page-all-teams when the teams pages became the package's first consumer in
the Next app, and grew with each consumer:
- Pages:
./page-team,./page-all-teams,./page-coach,./page-player,./page-game-centre,./page-all-players,./page-standings,./page-stats,./page-games-schedule,./page-skeleton; - Widgets (the team and player headers, the article header and the home hero):
./widget-form,./widget-next-game,./widget-record,./widget-list,./widget-stats,./widget-points-by-quarter,./widget-game-situation,./widget-mvp,./widget-team-leaders,./widget-team-comparison,./widget-next-games,./widget-versus; - The rest:
./player-records-table,./dialog,./filter-field,./ecal-schedule-picker,./game-sticky-bar,./stat-key,./player-stats-row,./player-game-row,./standings-row,./game,./game-score-card,./header-standings-table,./stats-hub-table.
A new subpath needs no other wiring: the build emits every component's dist/components/<name>/.
Consumers and the theme part
@v4/basketball-app (every data area, the article header widgets and the home hero),
@v4/code-of-conduct (the report screens), apps/web and apps/storybook depend on the package.
In the app the package's component themes are not in the every-page stylesheet. The club
emits them as a separate part (createClubThemePart, see
@v4/tokens-euro-league):
| Part | Emits | Loaded by |
|---|---|---|
apps/web/src/theme/tokens-basketball.css.ts |
the themes in OWN_THEMES that have no stylesheet of their own (the rest travel with their components, below) |
the teams, players, coaches, games, standings, stats and code-of-conduct layouts under apps/web/src/app/[locale]/ |
apps/web/src/theme/tokens-home-hero.css.ts |
HOME_HERO_THEMES: only the home hero's widgets and what they compose |
the root [locale]/layout.tsx, with the every-page layer (the homepage's routes have no layout of their own) |
apps/web/src/theme/tokens-article-widgets.css.ts |
ARTICLE_WIDGET_THEMES: the article header widgets' themes the home hero's part does not already emit |
the news/[...path] layout |
A route that renders a basketball component must load the part, or the component declares no
variables and renders unstyled with nothing failing. A widget added to the home hero must also
add its themes (and its children's) to HOME_HERO_THEMES. Why the club theme is emitted in parts
is on Theme emission and performance.
Tokens
Ghost 2.0 has no basketball namespace and no team colours, so ghost-core's global surface carries
a basketball extension (packages/ghost-core/src/theme/basketball-surface.ts):
components.basketball.*— Ghost'scomponents.footballtree (310 leaves, which the ELBComponent/Basketballset mirrors path for path) pluscolor.card.squadListingCard.teamBackgroundandcolor.teamHeader.teamBackground;teams.teams.<club>.{primary,secondary,gradient}— the 20 EuroLeague clubs of the ELBTeamsset (BASKETBALL_TEAM_KEYS, exported from@v4/ghost-core/theme), keyed exactly as the token parser generates them.
Ghost's defaults there are authored (football's values; every team on Ghost's primary ramp), and
@v4/tokens-euro-league resolves the extension direct from the club export
(pnpm --filter @v4/tokens-euro-league coverage fails if any leaf stops resolving). Components bind
g.components.basketball.* and g.teams.* contract paths, never raw tokens.*.
The design has no colour sets: a component with a ColourWay=Default | Inverse axis takes
colourSet default | inverse only, and one without the axis takes no colourSet at all.
How components are built
Every component goes through /generate-from-figma <node url> --package=ghost-basketball, which
resolves the component directory, bridge registry, --filter, registrations (including the
OWN_THEMES entry), the Storybook folder (stories/ghost-basketball/<category>/, titled
Ghost-Basketball/<Category>/<Name>) and the e2e folder (apps/e2e/tests/ghost-basketball/) from
the package. Coverage is measured with
v4-tokens coverage -c packages/ghost-basketball/src/components -n <name> --write.
Since 7 Nov 2026 the package's stylesheets are precompiled in its build, and each component's
theme is emitted with its own stylesheet (@v4/ghost-core's precompile-styles.mjs): a page
loads only the themes of the basketball components its route imports, and every part above
passes SELF_EMITTED_THEMES so it leaves those out.
Scripts: build (tsc -p tsconfig.build.json, then ../ghost-core/scripts/precompile-styles.mjs,
then scripts/check-dist.mjs, which fails when
OWN_THEMES and the package's theme modules disagree), dev, test / test:watch, lint,
check-types, check-dist. src/package-wiring.test.ts guards the wiring every component depends
on: the components.basketball and teams contract paths resolve, the 20 team keys, and the
TEST_ID registry's shape.
Utilities
Shared, package-private helpers in src/utils/:
| Utility | What it does |
|---|---|
team-colour.ts |
The club's colour for a team-coloured surface, written as an inline custom property from a team key |
row-link.ts |
The table rows' "the whole row is one link" pointer behaviour; the real link in the row stays the keyboard's way in |
keyed-child-test-id.ts |
A child test id keyed by an instance (<id>-<key>), over createChildTestIdDeriver |
ordinal.ts |
English ordinals (1st, 11th, 21st) |
use-scroll-edges.ts, use-scroll-padding.ts, use-scroll-nudge.ts, use-pinned-head.ts, use-overflows.ts, use-keep-in-view.ts |
The sideways-scrolling tables: edge fades, scroll-padding under pinned columns, the once-per-device scroll hint, pinned header rows, and keeping a disclosure's toggle under the pointer |
slot.ts, classnames.ts, use-choice-label.ts, use-wrapping-index.ts |
Slot, class-name and control helpers |
| The motion utilities | Below |
Motion
ELB's motion spec wins. Design delivered the EuroLeague Motion Spec v0.3 on 27 Oct 2026;
before it, every motion in this library was code-originated and "awaiting design confirmation".
The spec, its rules and how a motion is built and tested are on Motion (rule:
.claude/rules/frontend/motion.md; digest: .claude/reference/motion/elb-motion-spec.md; build
plan: .claude/plans/elb-motion-spec.md). Where the code and the spec disagree, the spec wins and
the change goes through the plan. Values from the spec's later v1.0 report (4 Nov 2026) are
already in the tokens, and the basketball page e2e is written against them.
Every animation names its spec pattern in a comment on the rule or hook
(// P-CASCADE — first 10 rows, stagger.list). Across 116 files under
packages/ghost-basketball/src the patterns in use are:
| Pattern | What it is (spec) |
|---|---|
P-LIVE |
A live change: the new number rolls up, the box highlights and fades over liveFlash (1.2s); never a repeating blink |
P-CASCADE |
Table rows cascade in, only the first 10; the header row never moves |
P-BAR |
Comparison bars grow from the centre (home left, away right); live, they slide old → new |
P-TAB |
The underline slides to the chosen tab; the panel slides in from the direction moved |
P-REVEAL |
A section fades up the first time it scrolls into view; a rail's cards follow; a bracket's rounds appear in turn |
P-REORDER |
On a sort or filter, rows slide from their old to their new place |
P-WAVE |
A grid ripples in diagonally, (row + column) × 40ms, capped at 400ms |
P-CHART |
The game flow reveals left to right once; live data extends the line, never redraws |
P-STICKY |
Pinned table header rows; the Game Centre score bar drops in once the game header has gone |
P-COUNT |
Headline numbers count up once — never in tables or live scores |
P-CHIP, P-SETTLE, P-HOVER / P-PRESS, P-COUNTDOWN |
Form chips popping oldest to newest, a hero photo settling 103% → 100%, card hover and press, a countdown's changing digit |
The rules, as this package applies them
Every one of them follows the rules on Motion: values come from ghost-core's
global motion.* contract (durations, easings, staggers and distances — club-overridable, so a
tuning pass changes one value), CSS goes through createTransition / createAnimation, only
transform and opacity move, the concurrency and cascade budgets hold, entrances play once per visit,
and nothing waits for motion. Worth restating here, with this package's own checks:
- Ads, logos (beyond a fade) and repeating blinks are never animated, and colour changes only for state tints.
- Reduced motion is a complete, instant page. Under
prefers-reduced-motion: reduceevery element of every basketball story readstransition-duration: 0sandanimation-name: none(apps/e2e/tests/ghost-basketball/reduced-motion.e2e.ts, generated from the Storybook index);motion-basketball-pages.e2e.tsasserts the page patterns run with their token durations. - The story suite reads final states (
data-motion-final), so a play function never meets a card held at opacity 0 and axe never measures text mid-fade. The e2e never sets it: it is where motion is tested.
Motion utilities
The hooks shared with every package live in @v4/ghost-core/motion (useRevealOnView,
useCascade, useFlipReorder, useIndicatorSlide, useCountUp, RollingDigits, useSettle…).
This package's own motion utilities in src/utils/:
| Utility | Pattern |
|---|---|
card-reveal.tsx, card-reveal.css.ts, card-reveal-parts.ts |
P-REVEAL — a card's slot in a grid arriving as it comes into view |
wave.tsx, wave.css.ts, wave-order.ts, wave-parts.ts, use-wave-on-view.ts, use-grid-filter-motion.ts |
P-WAVE — the diagonal grid ripple, and a search's non-matches shrinking away |
use-rows-on-view.ts |
Each row of a list entering the first time it scrolls into view (the bio timeline) |
use-live-cell-change.ts, use-changed-since-mount.ts |
P-LIVE — the tint on a cell whose value changed |
use-set-swap.ts, use-fade-on-change.ts |
The stats Overview's Players ↔ Teams and season ↔ all-time swap |
use-exiting-items.ts |
Rows fading out on a filter, capped per the concurrency budget |
panel-enter.css.ts, panel-enter-rules.ts, use-enter-direction.ts |
P-TAB — a tab or view panel arriving from the direction moved |
chip-pop.css.ts |
P-CHIP — W/L form chips popping in on motion.easing.pop |
hero-entrance.ts |
The team, player and coach headers' entrance, and the app's no-flash mark |
point-entrance.css.ts, ring-pulse.css.ts, triangle-glyph.css.ts |
A bio timeline point's arrival and its one-off ring pulse; the sort triangle |
For the theming model see Theming and colour sets.