@v4/ghost-basketball

The basketball UI library: schedules, standings, brackets, the Game Centre, stats, widgets and team, player and coach pages.

2026-11-07

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-option and ecal-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.mjs accepts it because the file is listed in .claude/manifest.json. (dialog bridges 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's components.football tree (310 leaves, which the ELB Component/Basketball set mirrors path for path) plus color.card.squadListingCard.teamBackground and color.teamHeader.teamBackground;
  • teams.teams.<club>.{primary,secondary,gradient} — the 20 EuroLeague clubs of the ELB Teams set (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: reduce every element of every basketball story reads transition-duration: 0s and animation-name: none (apps/e2e/tests/ghost-basketball/reduced-motion.e2e.ts, generated from the Storybook index); motion-basketball-pages.e2e.ts asserts 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.