update bracket style
This commit is contained in:
+116
-148
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import Tree, { prewarmTreeCanvas } from '../Tree/Tree'
|
||||
import FallingLeaves from '../Tree/FallingLeaves'
|
||||
import ReplayCanvasPanel from './ReplayCanvas'
|
||||
import { buildBracket, parentPosition } from './bracket'
|
||||
|
||||
const numberFormat = new Intl.NumberFormat('en-GB')
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
@@ -3714,43 +3715,6 @@ function tournamentStatusLabel(status) {
|
||||
return raw.charAt(0).toUpperCase() + raw.slice(1)
|
||||
}
|
||||
|
||||
function sideFromMatch(match, context = {}) {
|
||||
const bracket = String(match?.type_bracket || '').toLowerCase()
|
||||
if (context.hasLoserSide && bracket.includes('semifinal')) return 'loser'
|
||||
const side = String(match?.side || '').toLowerCase()
|
||||
if (side) return side
|
||||
if (bracket.includes('swiss')) return 'swiss'
|
||||
if (bracket.includes('group')) return 'group'
|
||||
if (bracket.includes('looser') || bracket.includes('loser')) return 'loser'
|
||||
if (bracket.includes('final') || bracket.includes('semifinal')) return 'final'
|
||||
if (bracket.includes('winner')) return 'winner'
|
||||
return 'matches'
|
||||
}
|
||||
|
||||
function sideLabel(side) {
|
||||
const labels = {
|
||||
winner: 'Winner bracket',
|
||||
loser: 'Loser bracket',
|
||||
final: 'Finals',
|
||||
group: 'Group matches',
|
||||
swiss: 'Swiss matches',
|
||||
matches: 'Matches',
|
||||
}
|
||||
return labels[side] || tournamentStatusLabel(side)
|
||||
}
|
||||
|
||||
function sidePriority(side) {
|
||||
return { winner: 0, final: 1, loser: 2, group: 3, swiss: 4, matches: 5 }[side] ?? 6
|
||||
}
|
||||
|
||||
function compareTournamentMatches(a, b) {
|
||||
const roundA = a.round ?? Number.MAX_SAFE_INTEGER
|
||||
const roundB = b.round ?? Number.MAX_SAFE_INTEGER
|
||||
const posA = a.position ?? Number.MAX_SAFE_INTEGER
|
||||
const posB = b.position ?? Number.MAX_SAFE_INTEGER
|
||||
return roundA - roundB || posA - posB || String(a.match_id).localeCompare(String(b.match_id))
|
||||
}
|
||||
|
||||
function TournamentsPage({ navigate }) {
|
||||
const [state, setState] = useState({ status: 'loading', data: null, error: null })
|
||||
|
||||
@@ -3812,56 +3776,6 @@ function TournamentsPage({ navigate }) {
|
||||
)
|
||||
}
|
||||
|
||||
function groupMatchesBySide(matches) {
|
||||
const bySide = new Map()
|
||||
const context = {
|
||||
hasLoserSide: matches.some((match) => {
|
||||
const bracket = String(match?.type_bracket || '').toLowerCase()
|
||||
const side = String(match?.side || '').toLowerCase()
|
||||
return bracket.includes('looser') || bracket.includes('loser') || side === 'loser'
|
||||
}),
|
||||
}
|
||||
matches.forEach((match) => {
|
||||
const side = sideFromMatch(match, context)
|
||||
if (!bySide.has(side)) bySide.set(side, [])
|
||||
bySide.get(side).push(match)
|
||||
})
|
||||
return [...bySide.entries()]
|
||||
.map(([raw, sideMatches]) => ({
|
||||
raw,
|
||||
label: sideLabel(raw),
|
||||
isGroup: raw === 'group' || raw === 'swiss',
|
||||
matches: [...sideMatches].sort(compareTournamentMatches),
|
||||
}))
|
||||
.sort((a, b) => sidePriority(a.raw) - sidePriority(b.raw))
|
||||
}
|
||||
|
||||
function roundsForSide(matches) {
|
||||
const byRound = new Map()
|
||||
matches.forEach((match) => {
|
||||
const key = match.round ?? 'matches'
|
||||
if (!byRound.has(key)) byRound.set(key, [])
|
||||
byRound.get(key).push(match)
|
||||
})
|
||||
return [...byRound.entries()]
|
||||
.sort(([a], [b]) => {
|
||||
if (a === 'matches') return 1
|
||||
if (b === 'matches') return -1
|
||||
return Number(a) - Number(b)
|
||||
})
|
||||
.map(([round, roundMatches]) => ({
|
||||
round,
|
||||
matches: [...roundMatches].sort(compareTournamentMatches),
|
||||
}))
|
||||
}
|
||||
|
||||
function roundLabel(side, round, index, total) {
|
||||
if (side === 'final' && total === 1) return 'Final'
|
||||
if (round === 'matches') return 'Matches'
|
||||
if (side === 'final') return index === total - 1 ? 'Final' : `Round ${Number(round) + 1}`
|
||||
return `Round ${Number(round) + 1}`
|
||||
}
|
||||
|
||||
function TournamentMatchCard({ match, navigate }) {
|
||||
const winner = displayTeamName(match.winner_name).toLowerCase()
|
||||
const teamA = displayTeamName(match.team_a_name)
|
||||
@@ -3926,32 +3840,98 @@ function TournamentMatchCard({ match, navigate }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Map each match to the match it feeds into in the next column, so we can draw a
|
||||
// connector between them. Parent is found by slot position (faithful to the real
|
||||
// tree even with byes hidden); falls back to proportional index if the exact
|
||||
// parent slot was itself a hidden bye.
|
||||
function feederParent(match, columnIndex, columns) {
|
||||
const cur = columns[columnIndex]
|
||||
const next = columns[columnIndex + 1]
|
||||
if (!next) return null
|
||||
const pPos = parentPosition(Number(match.position), cur.matches.length, next.matches.length)
|
||||
const exact = next.matches.find((m) => Number(m.position) === pPos)
|
||||
if (exact) return exact
|
||||
const order = cur.matches.indexOf(match)
|
||||
const idx = Math.min(next.matches.length - 1, Math.floor((order * next.matches.length) / cur.matches.length))
|
||||
return next.matches[idx] || null
|
||||
}
|
||||
|
||||
function TournamentBracketSide({ side, navigate }) {
|
||||
const rounds = roundsForSide(side.matches)
|
||||
const gridRef = useRef(null)
|
||||
const nodeRefs = useRef(new Map())
|
||||
const [connectors, setConnectors] = useState({ width: 0, height: 0, paths: [] })
|
||||
|
||||
const nodeKey = (columnIndex, match) => `${columnIndex}:${match.match_id}`
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const grid = gridRef.current
|
||||
if (!grid) return undefined
|
||||
|
||||
const measure = () => {
|
||||
const gridBox = grid.getBoundingClientRect()
|
||||
const boxOf = (key) => {
|
||||
const el = nodeRefs.current.get(key)
|
||||
if (!el) return null
|
||||
const b = el.getBoundingClientRect()
|
||||
return {
|
||||
left: b.left - gridBox.left,
|
||||
right: b.right - gridBox.left,
|
||||
mid: b.top - gridBox.top + b.height / 2,
|
||||
}
|
||||
}
|
||||
const paths = []
|
||||
for (let c = 0; c < side.columns.length - 1; c += 1) {
|
||||
for (const match of side.columns[c].matches) {
|
||||
const parent = feederParent(match, c, side.columns)
|
||||
if (!parent) continue
|
||||
const from = boxOf(nodeKey(c, match))
|
||||
const to = boxOf(nodeKey(c + 1, parent))
|
||||
if (!from || !to) continue
|
||||
const midX = (from.right + to.left) / 2
|
||||
paths.push(`M ${from.right} ${from.mid} H ${midX} V ${to.mid} H ${to.left}`)
|
||||
}
|
||||
}
|
||||
setConnectors({ width: grid.scrollWidth, height: grid.scrollHeight, paths })
|
||||
}
|
||||
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(grid)
|
||||
return () => observer.disconnect()
|
||||
}, [side])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="tournament-bracket-grid">
|
||||
{rounds.map((round, roundIndex) => (
|
||||
<div className="tournament-round-column" key={round.round}>
|
||||
<div className="tournament-bracket-grid" ref={gridRef}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="tournament-bracket-lines"
|
||||
width={connectors.width}
|
||||
height={connectors.height}
|
||||
>
|
||||
{connectors.paths.map((d, index) => (
|
||||
<path d={d} key={index} />
|
||||
))}
|
||||
</svg>
|
||||
{side.columns.map((column, columnIndex) => (
|
||||
<div className="tournament-round-column" key={column.id}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
{roundLabel(side.raw, round.round, roundIndex, rounds.length)}
|
||||
{column.label}
|
||||
</p>
|
||||
<div className="flex flex-1 flex-col justify-around gap-3">
|
||||
{round.matches.map((match) => (
|
||||
{column.matches.map((match) => (
|
||||
<div
|
||||
className={[
|
||||
'tournament-match-node',
|
||||
roundIndex > 0 ? 'tournament-match-node-left' : '',
|
||||
roundIndex < rounds.length - 1 ? 'tournament-match-node-right' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
className="tournament-match-node"
|
||||
key={`${match.type_bracket}-${match.match_id}`}
|
||||
ref={(el) => {
|
||||
const key = nodeKey(columnIndex, match)
|
||||
if (el) nodeRefs.current.set(key, el)
|
||||
else nodeRefs.current.delete(key)
|
||||
}}
|
||||
>
|
||||
<TournamentMatchCard
|
||||
match={match}
|
||||
navigate={navigate}
|
||||
/>
|
||||
<TournamentMatchCard match={match} navigate={navigate} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -4013,20 +3993,23 @@ function TournamentStandings({ standings }) {
|
||||
function TournamentMatchList({ sides, navigate }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{sides.map((side) => (
|
||||
<div key={side.raw || 'matches'}>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{side.matches.map((match) => (
|
||||
<TournamentMatchCard
|
||||
key={`${match.type_bracket}-${match.match_id}`}
|
||||
match={match}
|
||||
navigate={navigate}
|
||||
/>
|
||||
))}
|
||||
{sides.map((side) => {
|
||||
const matches = side.columns.flatMap((column) => column.matches)
|
||||
return (
|
||||
<div key={side.key}>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{matches.map((match) => (
|
||||
<TournamentMatchCard
|
||||
key={`${match.type_bracket}-${match.match_id}`}
|
||||
match={match}
|
||||
navigate={navigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4053,10 +4036,11 @@ function TournamentDetailPage({ tournamentId, navigate }) {
|
||||
const data = state.data
|
||||
const matches = useMemo(() => data?.matches || [], [data])
|
||||
const format = useMemo(() => tournamentFormatMeta(data?.format, matches), [data?.format, matches])
|
||||
const sides = useMemo(() => groupMatchesBySide(matches), [matches])
|
||||
const elimSides = sides.filter((side) => !side.isGroup)
|
||||
const groupSides = sides.filter((side) => side.isGroup)
|
||||
const { sides } = useMemo(() => buildBracket(matches), [matches])
|
||||
const bracketSides = sides.filter((side) => side.kind === 'bracket')
|
||||
const listSides = sides.filter((side) => side.kind === 'list')
|
||||
const standings = data?.standings || []
|
||||
const hasStandings = standings.length > 0
|
||||
|
||||
return (
|
||||
<section className="space-y-6 pt-24 sm:pt-28">
|
||||
@@ -4101,44 +4085,28 @@ function TournamentDetailPage({ tournamentId, navigate }) {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{format.mode === 'standings' && matches.length ? (
|
||||
<>
|
||||
<TournamentStandings standings={standings} />
|
||||
<TournamentMatchList sides={sides} navigate={navigate} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{format.mode === 'bracket' && matches.length ? (
|
||||
<div className="space-y-6">
|
||||
{elimSides.map((side) => (
|
||||
<TournamentBracketSide key={side.raw || 'bracket'} side={side} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{format.mode === 'mixed' && matches.length ? (
|
||||
{matches.length ? (
|
||||
<div className="space-y-8">
|
||||
{groupSides.length ? (
|
||||
{hasStandings || listSides.length ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Group stage</h2>
|
||||
<TournamentStandings standings={standings} />
|
||||
<TournamentMatchList sides={groupSides} navigate={navigate} />
|
||||
{bracketSides.length ? <h2 className="text-lg font-semibold">Group stage</h2> : null}
|
||||
{hasStandings ? <TournamentStandings standings={standings} /> : null}
|
||||
{listSides.length ? <TournamentMatchList sides={listSides} navigate={navigate} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{elimSides.length ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Playoffs</h2>
|
||||
{elimSides.map((side) => (
|
||||
<TournamentBracketSide key={side.raw || 'bracket'} side={side} navigate={navigate} />
|
||||
|
||||
{bracketSides.length ? (
|
||||
<div className="space-y-6">
|
||||
{hasStandings || listSides.length ? (
|
||||
<h2 className="text-lg font-semibold">Playoffs</h2>
|
||||
) : null}
|
||||
{bracketSides.map((side) => (
|
||||
<TournamentBracketSide key={side.key} side={side} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{format.mode === 'matches' && matches.length ? (
|
||||
<TournamentMatchList sides={sides} navigate={navigate} />
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Pure bracket-layout logic for the tournament detail page.
|
||||
//
|
||||
// The TSS API hands us authoritative matches, but `round` is numbered *per
|
||||
// `type_bracket`* — the winner tree, the loser tree, and the special
|
||||
// `LooserFinal`/`Semifinal`/`Final` stages each restart at round 0. Grouping by
|
||||
// raw round therefore collapses unrelated stages into one column (e.g. the loser
|
||||
// Semifinal landing in loser "Round 1"). This module maps each match to a
|
||||
// (side, stage, round) coordinate so columns come out in true bracket order, and
|
||||
// drops structural byes so they never render as "Team vs BYE" nodes.
|
||||
//
|
||||
// Kept framework-free so it can be unit-tested with plain node.
|
||||
|
||||
function lower(value) {
|
||||
return String(value || '').toLowerCase()
|
||||
}
|
||||
|
||||
export function cleanName(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
// A "structural bye" is a slot that exists only to pad the bracket: a team that
|
||||
// advanced unopposed, or a placeholder whose feeders were themselves byes. We
|
||||
// hide these. A match with one known team and NO winner yet is *not* a bye —
|
||||
// it's a real pending matchup awaiting an upstream result (grand final, loser
|
||||
// bracket final), so it stays.
|
||||
export function isStructuralBye(match) {
|
||||
const a = cleanName(match.team_a_name)
|
||||
const b = cleanName(match.team_b_name)
|
||||
if (a && b) return false
|
||||
if (!a && !b) return true
|
||||
return Boolean(cleanName(match.winner_name)) || match.status === 'bye'
|
||||
}
|
||||
|
||||
// Map a TSS `type_bracket` to a display side + stage. `stage` orders the special
|
||||
// terminal columns after the numbered rounds within a side:
|
||||
// winner: Winner rounds (0) → grand/championship Final (1)
|
||||
// loser: Looser rounds (0) → LooserFinal (1) → Semifinal/lower final (2)
|
||||
export function classifyBracket(typeBracket, hasLoserSide) {
|
||||
const t = lower(typeBracket)
|
||||
if (t.includes('swiss')) return { side: 'swiss', stage: 0 }
|
||||
if (t.includes('group')) return { side: 'group', stage: 0 }
|
||||
if (t.includes('looserfinal') || t.includes('loserfinal')) return { side: 'loser', stage: 1 }
|
||||
if (t.includes('looser') || t.includes('loser')) return { side: 'loser', stage: 0 }
|
||||
if (t.includes('semifinal')) {
|
||||
// Double-elim: the Semifinal is the lower-bracket final (loser side). Single
|
||||
// elim: it's just a normal winner-side stage.
|
||||
return hasLoserSide ? { side: 'loser', stage: 2 } : { side: 'winner', stage: 0 }
|
||||
}
|
||||
if (t === 'final' || t.endsWith('final')) {
|
||||
// Terminal column of the winner side (championship / grand final).
|
||||
return { side: 'winner', stage: 1 }
|
||||
}
|
||||
if (t.includes('winner')) return { side: 'winner', stage: 0 }
|
||||
return { side: 'winner', stage: 0 }
|
||||
}
|
||||
|
||||
const SIDE_ORDER = { winner: 0, loser: 1, group: 2, swiss: 3, matches: 4 }
|
||||
const SIDE_LABELS = {
|
||||
winner: 'Winner bracket',
|
||||
loser: 'Loser bracket',
|
||||
group: 'Group matches',
|
||||
swiss: 'Swiss matches',
|
||||
matches: 'Matches',
|
||||
}
|
||||
|
||||
function asInt(value, fallback) {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
function compareMatches(a, b) {
|
||||
return asInt(a.position, Number.MAX_SAFE_INTEGER) - asInt(b.position, Number.MAX_SAFE_INTEGER)
|
||||
|| String(a.match_id).localeCompare(String(b.match_id))
|
||||
}
|
||||
|
||||
// Label a bracket column. Numbered rounds read "Round N" by their position in
|
||||
// the side; the terminal winner stage reads Final / Grand Final.
|
||||
function columnLabel(side, stage, index, total, hasLoserSide) {
|
||||
if (side === 'winner' && stage === 1) {
|
||||
return hasLoserSide ? 'Grand Final' : 'Final'
|
||||
}
|
||||
return `Round ${index + 1}`
|
||||
}
|
||||
|
||||
// Build the ordered bracket model from authoritative match rows.
|
||||
// Returns { sides: [{ key, label, kind, columns: [{ id, label, matches }] }] }
|
||||
// where kind is 'bracket' (elimination, drawn as a tree) or 'list' (group/swiss,
|
||||
// drawn as a flat card list).
|
||||
export function buildBracket(matches) {
|
||||
const cleaned = (matches || []).filter((m) => !isStructuralBye(m))
|
||||
|
||||
const hasLoserSide = cleaned.some((m) => {
|
||||
const t = lower(m.type_bracket)
|
||||
return t.includes('looser') || t.includes('loser') || lower(m.side) === 'loser'
|
||||
})
|
||||
|
||||
// Group by (side, stage, round) → one column each.
|
||||
const columnMap = new Map()
|
||||
for (const match of cleaned) {
|
||||
const { side, stage } = classifyBracket(match.type_bracket, hasLoserSide)
|
||||
const round = asInt(match.round, 0)
|
||||
const key = `${side}:${stage}:${round}`
|
||||
if (!columnMap.has(key)) {
|
||||
columnMap.set(key, { side, stage, round, matches: [] })
|
||||
}
|
||||
columnMap.get(key).matches.push(match)
|
||||
}
|
||||
|
||||
// Bucket columns by side, ordered by (stage, round).
|
||||
const sideMap = new Map()
|
||||
for (const column of columnMap.values()) {
|
||||
column.matches.sort(compareMatches)
|
||||
if (!sideMap.has(column.side)) sideMap.set(column.side, [])
|
||||
sideMap.get(column.side).push(column)
|
||||
}
|
||||
|
||||
const sides = [...sideMap.entries()]
|
||||
.map(([key, columns]) => {
|
||||
columns.sort((a, b) => a.stage - b.stage || a.round - b.round)
|
||||
const isList = key === 'group' || key === 'swiss' || key === 'matches'
|
||||
return {
|
||||
key,
|
||||
label: SIDE_LABELS[key] || key,
|
||||
kind: isList ? 'list' : 'bracket',
|
||||
columns: columns.map((column, index) => ({
|
||||
id: `${key}:${column.stage}:${column.round}`,
|
||||
label: columnLabel(key, column.stage, index, columns.length, hasLoserSide),
|
||||
matches: column.matches,
|
||||
})),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (SIDE_ORDER[a.key] ?? 9) - (SIDE_ORDER[b.key] ?? 9))
|
||||
|
||||
return { sides, hasLoserSide }
|
||||
}
|
||||
|
||||
// Which match in the next column a given match feeds into, by slot position.
|
||||
// Winner tree and loser "major" rounds halve (position → floor/2); loser "minor"
|
||||
// rounds (drop-ins, same width) map identically. We infer halving vs identity
|
||||
// from the column node counts. Returns the parent position, or null.
|
||||
export function parentPosition(childPosition, currentCount, nextCount) {
|
||||
if (childPosition == null) return null
|
||||
if (nextCount >= currentCount) return childPosition
|
||||
return Math.floor(childPosition / 2)
|
||||
}
|
||||
+20
-21
@@ -832,13 +832,33 @@ h3 {
|
||||
}
|
||||
|
||||
.tournament-bracket-grid {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 2.6rem;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/* Connector layer: absolutely sized to the full bracket, drawn behind the cards.
|
||||
Paths are measured from real node positions in App.jsx, so they stay correct
|
||||
with hidden byes and irregular loser-bracket round widths. */
|
||||
.tournament-bracket-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tournament-bracket-lines path {
|
||||
fill: none;
|
||||
stroke: color-mix(in srgb, var(--color-border) 60%, var(--color-fury-violet));
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
.tournament-round-column {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 190px;
|
||||
min-width: 190px;
|
||||
@@ -851,27 +871,6 @@ h3 {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tournament-match-node::before,
|
||||
.tournament-match-node::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 0;
|
||||
display: block;
|
||||
height: 1px;
|
||||
width: 1.3rem;
|
||||
background: color-mix(in srgb, var(--color-border) 78%, var(--color-fury-violet));
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tournament-match-node-left::before {
|
||||
right: calc(100% + 0.05rem);
|
||||
}
|
||||
|
||||
.tournament-match-node-right::after {
|
||||
left: calc(100% + 0.05rem);
|
||||
}
|
||||
|
||||
@keyframes scrollPulse {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
|
||||
Reference in New Issue
Block a user