diff --git a/app/web/charts.py b/app/web/charts.py
index d9053e3..f8edd4f 100644
--- a/app/web/charts.py
+++ b/app/web/charts.py
@@ -10,6 +10,7 @@
builders.
"""
+from datetime import date
from functools import lru_cache
import plotly.graph_objects as go
@@ -21,7 +22,7 @@
TeamHitsLeagueComparison,
TeamStrikeoutsAnalysis,
)
-from app.web.formatting import format_long_date, format_matchup
+from app.web.formatting import format_long_date, format_matchup, format_short_date
CHART_DIV_ID = "team-hits-chart"
RAW_HITS_TRACE_NAME = "Game Hits"
@@ -48,7 +49,14 @@
_AMBER = "#b26a00"
_RAW_LINE = "#b7c7d8"
_RAW_MARKER = "#7c93ab"
-_GRID = "#e6ebf1"
+_GRID = "#dbe2ea"
+_AXIS_LINE = "#c9d3de"
+_AXIS_INK = "#5b6b7c"
+# The right gutter holds the reference-line label; the rest is sized by the
+# axes themselves.
+_MARGIN = {"l": 8, "r": 78, "t": 8, "b": 8}
+_AXIS_TITLE_FONT = {"size": 12, "color": _AXIS_INK}
+_TICK_FONT = {"size": 11, "color": _AXIS_INK}
CHART_CONFIG = {
"responsive": True,
@@ -63,6 +71,62 @@ def rolling_average_trace_name(rolling_window: int) -> str:
return f"{rolling_window}-Game Average"
+def _season_game_ticks(
+ game_numbers: list[int],
+ game_dates: list[date],
+) -> tuple[list[int], list[str]]:
+ """Label about ten games across the season with their number and date.
+
+ A season game number alone does not tell a reader when in the season a
+ stretch happened, so each labelled tick carries the date of that game. The
+ last game is always labelled, because where the stored data ends is the
+ thing a reader most often wants to place in time.
+ """
+ count = len(game_numbers)
+ step = max(1, round(count / 10))
+ indexes = list(range(0, count, step))
+ # Replace the final stepped tick when it would crowd the last game rather
+ # than adding a label on top of it.
+ if count - 1 - indexes[-1] < step / 2:
+ indexes[-1] = count - 1
+ else:
+ indexes.append(count - 1)
+
+ return (
+ [game_numbers[index] for index in indexes],
+ [
+ f"{game_numbers[index]}
{format_short_date(game_dates[index])}"
+ for index in indexes
+ ],
+ )
+
+
+def _label_reference_line(
+ figure: go.Figure,
+ *,
+ x: int,
+ y: float,
+ name: str,
+) -> None:
+ """Name a horizontal reference line at the right edge of the plot.
+
+ The legend says which line is which, but the value itself is what a reader
+ compares against, and reading it off the axis is guesswork when two
+ reference lines sit close together.
+ """
+ figure.add_annotation(
+ x=x,
+ y=y,
+ text=f"{name}
{y:.2f}",
+ showarrow=False,
+ xanchor="left",
+ yanchor="middle",
+ xshift=8,
+ align="left",
+ font={"size": 11, "color": _AXIS_INK},
+ )
+
+
def build_team_hits_figure(
analysis: TeamHitsAnalysis,
league_comparison: TeamHitsLeagueComparison | None = None,
@@ -74,6 +138,7 @@ def build_team_hits_figure(
average to draw, and the team's own chart must still render.
"""
game_numbers = [point.season_game_number for point in analysis.points]
+ game_dates = [point.game_date for point in analysis.points]
hits = [point.hits for point in analysis.points]
rolling = [point.rolling_average for point in analysis.points]
# Each hover box shows date, matchup, hits, and rolling average regardless
@@ -105,7 +170,14 @@ def build_team_hits_figure(
name=RAW_HITS_TRACE_NAME,
mode="lines+markers",
line={"color": _RAW_LINE, "width": 1.2},
- marker={"color": _RAW_MARKER, "size": 5},
+ # Open circles: the game markers sit on top of each other in a
+ # 162-game season, and an outline stays readable where filled
+ # dots merge into a blob.
+ marker={
+ "size": 5,
+ "color": "rgba(0,0,0,0)",
+ "line": {"color": _RAW_MARKER, "width": 1.2},
+ },
hovertemplate=hover_template,
)
)
@@ -146,12 +218,30 @@ def build_team_hits_figure(
)
)
+ # Only one of the two horizontal lines is labelled. They can sit within a
+ # tenth of a hit of each other, and two labels there would overlap.
+ if league_comparison is None:
+ _label_reference_line(
+ figure,
+ x=game_numbers[-1],
+ y=season_average,
+ name=TEAM_SEASON_AVERAGE_TRACE_NAME,
+ )
+ else:
+ _label_reference_line(
+ figure,
+ x=game_numbers[-1],
+ y=league_comparison.league.hits_per_game,
+ name=MLB_AVERAGE_TRACE_NAME,
+ )
+
+ tick_values, tick_labels = _season_game_ticks(game_numbers, game_dates)
figure.update_layout(
template="plotly_white",
- # Axis automargin sizes the gutters, which keeps the plot area as wide
- # as possible on a narrow phone screen.
- margin={"l": 8, "r": 8, "t": 8, "b": 8},
- height=440,
+ # Axis automargin sizes the left and bottom gutters, which keeps the
+ # plot area as wide as possible on a narrow phone screen.
+ margin=_MARGIN,
+ height=470,
hovermode="closest",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
@@ -159,21 +249,35 @@ def build_team_hits_figure(
legend={
"orientation": "h",
"yanchor": "bottom",
- "y": 1.02,
- "xanchor": "left",
- "x": 0,
- "font": {"size": 12},
+ "y": 1.04,
+ "xanchor": "center",
+ "x": 0.5,
+ "font": {"size": 12, "color": _AXIS_INK},
},
xaxis={
- "title": {"text": X_AXIS_TITLE, "standoff": 8},
- "gridcolor": _GRID,
+ "title": {"text": X_AXIS_TITLE, "standoff": 10, "font": _AXIS_TITLE_FONT},
+ "tickfont": _TICK_FONT,
+ "tickmode": "array",
+ "tickvals": tick_values,
+ "ticktext": tick_labels,
+ # Only the horizontal gridlines are drawn: they are what a reader
+ # measures a value against, and vertical lines only add noise.
+ "showgrid": False,
+ "showline": True,
+ "linecolor": _AXIS_LINE,
"zeroline": False,
"rangemode": "tozero",
"automargin": True,
},
yaxis={
- "title": {"text": Y_AXIS_TITLE, "standoff": 8},
+ "title": {
+ "text": Y_AXIS_TITLE,
+ "standoff": 10,
+ "font": _AXIS_TITLE_FONT,
+ },
+ "tickfont": _TICK_FONT,
"gridcolor": _GRID,
+ "griddash": "dot",
"zeroline": False,
"rangemode": "tozero",
# Whole numbers of hits; the range still grows with the data.
@@ -188,6 +292,7 @@ def build_team_hits_figure(
def build_team_strikeouts_figure(analysis: TeamStrikeoutsAnalysis) -> go.Figure:
"""Build the batting-strikeouts-per-game figure for one team-season."""
game_numbers = [point.season_game_number for point in analysis.points]
+ game_dates = [point.game_date for point in analysis.points]
strikeouts = [point.strikeouts for point in analysis.points]
rolling = [point.rolling_average for point in analysis.points]
# Each hover box shows date, matchup, batting strikeouts, and rolling
@@ -219,7 +324,14 @@ def build_team_strikeouts_figure(analysis: TeamStrikeoutsAnalysis) -> go.Figure:
name=RAW_STRIKEOUTS_TRACE_NAME,
mode="lines+markers",
line={"color": _RAW_LINE, "width": 1.2},
- marker={"color": _RAW_MARKER, "size": 5},
+ # Open circles: the game markers sit on top of each other in a
+ # 162-game season, and an outline stays readable where filled
+ # dots merge into a blob.
+ marker={
+ "size": 5,
+ "color": "rgba(0,0,0,0)",
+ "line": {"color": _RAW_MARKER, "width": 1.2},
+ },
hovertemplate=hover_template,
)
)
@@ -247,11 +359,18 @@ def build_team_strikeouts_figure(analysis: TeamStrikeoutsAnalysis) -> go.Figure:
hoverinfo="skip",
)
)
+ _label_reference_line(
+ figure,
+ x=game_numbers[-1],
+ y=season_average,
+ name=SEASON_AVERAGE_TRACE_NAME,
+ )
+ tick_values, tick_labels = _season_game_ticks(game_numbers, game_dates)
figure.update_layout(
template="plotly_white",
- margin={"l": 8, "r": 8, "t": 8, "b": 8},
- height=440,
+ margin=_MARGIN,
+ height=470,
hovermode="closest",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
@@ -259,21 +378,35 @@ def build_team_strikeouts_figure(analysis: TeamStrikeoutsAnalysis) -> go.Figure:
legend={
"orientation": "h",
"yanchor": "bottom",
- "y": 1.02,
- "xanchor": "left",
- "x": 0,
- "font": {"size": 12},
+ "y": 1.04,
+ "xanchor": "center",
+ "x": 0.5,
+ "font": {"size": 12, "color": _AXIS_INK},
},
xaxis={
- "title": {"text": X_AXIS_TITLE, "standoff": 8},
- "gridcolor": _GRID,
+ "title": {"text": X_AXIS_TITLE, "standoff": 10, "font": _AXIS_TITLE_FONT},
+ "tickfont": _TICK_FONT,
+ "tickmode": "array",
+ "tickvals": tick_values,
+ "ticktext": tick_labels,
+ # Only the horizontal gridlines are drawn: they are what a reader
+ # measures a value against, and vertical lines only add noise.
+ "showgrid": False,
+ "showline": True,
+ "linecolor": _AXIS_LINE,
"zeroline": False,
"rangemode": "tozero",
"automargin": True,
},
yaxis={
- "title": {"text": STRIKEOUTS_Y_AXIS_TITLE, "standoff": 8},
+ "title": {
+ "text": STRIKEOUTS_Y_AXIS_TITLE,
+ "standoff": 10,
+ "font": _AXIS_TITLE_FONT,
+ },
+ "tickfont": _TICK_FONT,
"gridcolor": _GRID,
+ "griddash": "dot",
"zeroline": False,
# Starts at zero like the hits chart, and grows with the data. No
# fixed maximum: a team that strikes out 20 times must still fit.
diff --git a/app/web/formatting.py b/app/web/formatting.py
index 4712c66..13506e6 100644
--- a/app/web/formatting.py
+++ b/app/web/formatting.py
@@ -46,6 +46,11 @@ def format_long_date(value: date) -> str:
return f"{_MONTHS[value.month - 1]} {value.day}, {value.year}"
+def format_short_date(value: date) -> str:
+ """Format a date as ``May 8``, for axis ticks that already carry the year."""
+ return f"{_MONTHS[value.month - 1][:3]} {value.day}"
+
+
def format_matchup(opponent_name: str, home_away: HomeAway) -> str:
"""Describe an opponent the way a box score does: ``vs`` home, ``at`` away."""
prefix = "vs" if home_away == "home" else "at"
diff --git a/app/web/routes.py b/app/web/routes.py
index 06c6f27..55b0e14 100644
--- a/app/web/routes.py
+++ b/app/web/routes.py
@@ -76,6 +76,12 @@ def _coerce_window(value: object) -> object:
RollingWindowParam = Annotated[RollingWindow, BeforeValidator(_coerce_window)]
PLOTLY_BUNDLE_PATH = "/vendor/plotly.min.js"
+# Club and league marks are fetched by the browser from MLB's public logo
+# host, keyed by the same team ids the application already stores. They are
+# decorative: every page states the team in text as well, and the layout holds
+# when the images do not load, which is what happens with no internet access.
+MLB_LOGO_URL = "https://www.mlbstatic.com/team-logos/league-on-dark/1.svg"
+TEAM_LOGO_URL_PREFIX = "https://www.mlbstatic.com/team-logos/"
IMPORT_COMMAND = (
"poetry run python scripts/import_team_season.py --team-id 136 --season 2025"
)
@@ -127,6 +133,8 @@ def index(
"selected_season": None,
"import_command": IMPORT_COMMAND,
"plotly_bundle_path": PLOTLY_BUNDLE_PATH,
+ "mlb_logo_url": MLB_LOGO_URL,
+ "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX,
"form_action": HITS_PATH,
"nav_links": build_nav_links(
current_path=HITS_PATH,
@@ -232,6 +240,8 @@ def strikeouts(
"selected_season": None,
"import_command": IMPORT_COMMAND,
"plotly_bundle_path": PLOTLY_BUNDLE_PATH,
+ "mlb_logo_url": MLB_LOGO_URL,
+ "team_logo_url_prefix": TEAM_LOGO_URL_PREFIX,
"form_action": STRIKEOUTS_PATH,
"nav_links": build_nav_links(
current_path=STRIKEOUTS_PATH,
@@ -383,6 +393,7 @@ def _render_schema_error(
name="error.html",
context={
"app_name": settings.app_name,
+ "mlb_logo_url": MLB_LOGO_URL,
"heading": "The database schema is not ready",
"message": str(error),
"commands": [MIGRATION_HINT],
diff --git a/app/web/static/css/app.css b/app/web/static/css/app.css
index a6001c7..fe9f473 100644
--- a/app/web/static/css/app.css
+++ b/app/web/static/css/app.css
@@ -2,15 +2,20 @@
--navy: #12263f;
--navy-deep: #0b1b2e;
--navy-soft: #35506f;
+ --header-bg: #0c2137;
--teal: #0f8b8d;
--teal-dark: #0b6d6f;
- --page-bg: #f4f6f9;
+ --page-bg: #f5f7fa;
--card-bg: #ffffff;
- --border: #dfe5ec;
+ --border: #e2e8f0;
--text: #1c2733;
--muted: #5b6b7c;
- --shadow: 0 1px 2px rgba(18, 38, 63, 0.06), 0 8px 24px rgba(18, 38, 63, 0.06);
- --radius: 10px;
+ --muted-soft: #8a99a8;
+ --info-bg: #eff6fd;
+ --info-border: #d3e3f7;
+ --info-ink: #1d4e89;
+ --shadow: 0 1px 2px rgba(18, 38, 63, 0.05), 0 6px 18px rgba(18, 38, 63, 0.05);
+ --radius: 12px;
}
* {
@@ -34,96 +39,122 @@ body {
line-height: 1.5;
}
+/* The shell spans the window rather than centring in a fixed column: the
+ chart is the content, and a season-long series uses every pixel it is
+ given. Reading measure is protected on the panels that hold prose. */
.shell {
width: 100%;
- max-width: 72rem;
margin: 0 auto;
- padding: 0 1.5rem;
+ padding: 0 2.5rem;
}
/* Header */
.site-header {
- background: var(--navy);
+ background: var(--header-bg);
color: #ffffff;
- border-bottom: 3px solid var(--teal);
}
.site-header__inner {
display: flex;
- align-items: baseline;
+ align-items: center;
justify-content: space-between;
- gap: 1rem;
- padding-top: 1rem;
- padding-bottom: 1rem;
+ flex-wrap: wrap;
+ gap: 0.75rem 1.5rem;
+ padding-top: 1.15rem;
+ padding-bottom: 1.15rem;
}
.brand {
- font-size: 1.15rem;
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ color: #ffffff;
+ font-size: 1.3rem;
font-weight: 700;
- letter-spacing: 0.01em;
+ letter-spacing: -0.01em;
+ text-decoration: none;
}
-.brand__section {
- font-size: 0.85rem;
- font-weight: 600;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: #9fd8d9;
+.brand__logo {
+ display: block;
+ width: 34px;
+ height: 34px;
+}
+
+.brand:hover {
+ color: #d8e6f2;
}
/* Navigation */
.site-nav {
- background: var(--navy-deep);
-}
-
-.site-nav__inner {
display: flex;
- gap: 0.25rem;
+ flex-wrap: wrap;
+ gap: 0.35rem;
}
.site-nav__link {
display: inline-block;
- padding: 0.65rem 0.9rem;
+ padding: 0.4rem 0.85rem;
+ border-radius: 999px;
color: #c8d6e5;
font-size: 0.95rem;
font-weight: 600;
text-decoration: none;
- border-bottom: 3px solid transparent;
}
.site-nav__link:hover {
color: #ffffff;
+ background: rgba(255, 255, 255, 0.08);
}
.site-nav__link--current {
color: #ffffff;
- border-bottom-color: var(--teal);
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.brand:focus-visible,
+.site-nav__link:focus-visible {
+ outline: 2px solid #7fd3d4;
+ outline-offset: 2px;
}
/* Page */
.page {
flex: 1;
- padding-top: 2rem;
- padding-bottom: 3rem;
+ padding-top: 2.25rem;
+ padding-bottom: 2.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
+.page-intro {
+ margin-bottom: 0.25rem;
+}
+
.page-intro h1 {
- margin: 0 0 0.35rem;
- font-size: 2rem;
- line-height: 1.2;
+ margin: 0 0 0.3rem;
+ font-size: 2.4rem;
+ font-weight: 800;
+ line-height: 1.15;
+ letter-spacing: -0.02em;
color: var(--navy);
}
.page-intro__subtitle {
margin: 0;
color: var(--muted);
- font-size: 1.05rem;
+ font-size: 1.02rem;
+}
+
+/* A decorative logo that failed to load is hidden by script. This beats the
+ display rules the individual logos set, which would otherwise win against
+ the browser's own `[hidden]` rule. */
+.js-logo[hidden] {
+ display: none;
}
.card {
@@ -139,8 +170,17 @@ body {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
+ justify-content: space-between;
+ gap: 1rem 1.5rem;
+ padding: 1.1rem 1.35rem;
+}
+
+.controls__fields {
+ display: flex;
+ flex-wrap: wrap;
gap: 1rem 1.5rem;
- padding: 1.15rem 1.25rem;
+ flex: 1 1 34rem;
+ min-width: 0;
}
.control {
@@ -151,22 +191,34 @@ body {
min-width: 0;
}
-.control--action {
- flex: 0 0 auto;
-}
-
.control label {
- font-size: 0.75rem;
- font-weight: 700;
- letter-spacing: 0.07em;
- text-transform: uppercase;
+ font-size: 0.8rem;
+ font-weight: 600;
color: var(--muted);
}
+/* The club logo sits inside the select box, so the control reads as one
+ field rather than an image parked next to a dropdown. */
+.control__field {
+ position: relative;
+ display: flex;
+ min-width: 0;
+}
+
+.control__logo {
+ position: absolute;
+ top: 50%;
+ left: 0.7rem;
+ width: 22px;
+ height: 22px;
+ transform: translateY(-50%);
+ pointer-events: none;
+}
+
.control select {
appearance: none;
width: 100%;
- padding: 0.6rem 2rem 0.6rem 0.75rem;
+ padding: 0.62rem 2rem 0.62rem 0.8rem;
border: 1px solid var(--border);
border-radius: 8px;
background-color: #fff;
@@ -180,6 +232,21 @@ body {
font-size: 0.95rem;
}
+/* Room for the club mark inside the field. Declared after the rule above,
+ which sets padding with a shorthand at the same specificity. */
+.control__field select {
+ padding-left: 2.6rem;
+}
+
+/* A mark that failed to load is hidden, and the field closes the gap. */
+.control__logo[hidden] + select {
+ padding-left: 0.8rem;
+}
+
+.controls__action {
+ flex: 0 0 auto;
+}
+
.control select:focus-visible,
.controls button:focus-visible {
outline: 2px solid var(--teal);
@@ -204,23 +271,29 @@ body {
/* Chart */
.chart-card {
- padding: 1.25rem;
+ padding: 1.4rem 1.5rem 1.1rem;
}
.chart-card__header {
- margin-bottom: 0.5rem;
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 0.25rem 1rem;
+ margin-bottom: 0.75rem;
}
.chart-card__header h2 {
margin: 0;
- font-size: 1.3rem;
+ font-size: 1.25rem;
+ font-weight: 700;
color: var(--navy);
}
.chart-card__season {
- margin: 0.15rem 0 0;
+ margin: 0;
color: var(--muted);
- font-size: 0.9rem;
+ font-size: 0.85rem;
}
/* Plotly will not draw this figure below roughly 27rem, so on a phone the
@@ -243,57 +316,103 @@ body {
}
.summary-card {
- padding: 1rem 1.15rem;
- border-top: 3px solid var(--teal);
+ padding: 1.1rem 1.25rem 1.15rem;
}
.summary-card__label {
margin: 0;
- font-size: 0.75rem;
- font-weight: 700;
- letter-spacing: 0.06em;
- text-transform: uppercase;
+ font-size: 0.82rem;
+ font-weight: 600;
color: var(--muted);
}
.summary-card__value {
- margin: 0.3rem 0 0;
- font-size: 2rem;
+ margin: 0.4rem 0 0;
+ font-size: 2.2rem;
font-weight: 700;
line-height: 1.1;
- color: var(--navy);
+ letter-spacing: -0.02em;
+ color: var(--teal-dark);
font-variant-numeric: tabular-nums;
}
.summary-card__caption {
- margin: 0.15rem 0 0;
- font-size: 0.85rem;
+ margin: 0.25rem 0 0;
+ font-size: 0.82rem;
color: var(--muted);
}
-/* Explanation and notices */
+/* Explanation panel */
+
+.about {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.85rem;
+ padding: 1.15rem 1.35rem;
+ border: 1px solid var(--info-border);
+ border-radius: var(--radius);
+ background: var(--info-bg);
+}
+
+.about__icon {
+ flex: 0 0 auto;
+ display: flex;
+ color: var(--info-ink);
+}
+
+.about__icon svg {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+.about__body {
+ min-width: 0;
+}
+
+.about h2 {
+ margin: 0 0 0.4rem;
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--info-ink);
+}
+
+.about h3 {
+ margin: 0.9rem 0 0.35rem;
+ font-size: 0.92rem;
+ font-weight: 700;
+ color: var(--navy);
+}
+
+.about p {
+ margin: 0 0 0.5rem;
+ color: var(--muted);
+ font-size: 0.92rem;
+ max-width: 84ch;
+}
+
+.about p:last-child {
+ margin-bottom: 0;
+}
+
+/* Notices */
-.about,
.notice {
- padding: 1.15rem 1.25rem;
+ padding: 1.15rem 1.35rem;
}
-.about h2,
.notice h1,
.notice h2 {
margin: 0 0 0.5rem;
- font-size: 1.1rem;
+ font-size: 1.15rem;
color: var(--navy);
}
-.about p,
.notice p {
margin: 0 0 0.5rem;
color: var(--muted);
- max-width: 62ch;
+ max-width: 72ch;
}
-.about p:last-child,
.notice p:last-child {
margin-bottom: 0;
}
@@ -329,41 +448,47 @@ body {
.site-footer {
border-top: 1px solid var(--border);
- background: #fff;
- padding: 1.25rem 0;
- color: var(--muted);
- font-size: 0.85rem;
+ padding: 1rem 0 1.5rem;
+ color: var(--muted-soft);
+ font-size: 0.82rem;
}
-.site-footer p {
- margin: 0 0 0.2rem;
+.site-footer__meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem 0.85rem;
+ margin: 0;
+}
+
+/* Separator between the footer facts, so they read as one compact line. It
+ trails the fact it follows, so a wrap never starts a line with a bullet. */
+.site-footer__meta span:not(:last-child)::after {
+ content: "•";
+ margin-left: 0.85rem;
}
.site-footer__disclaimer {
- color: #8a99a8;
+ margin: 0.3rem 0 0;
}
/* Responsive */
-@media (max-width: 62rem) {
+@media (max-width: 72rem) {
.summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
-@media (max-width: 40rem) {
+@media (max-width: 48rem) {
+ /* Only the inline padding changes with width. A `padding` shorthand here
+ would also reset the vertical padding `.page` sets. */
.shell {
- padding: 0 1rem;
- }
-
- .site-header__inner {
- flex-direction: column;
- align-items: flex-start;
- gap: 0.15rem;
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
}
.page-intro h1 {
- font-size: 1.6rem;
+ font-size: 1.85rem;
}
.controls {
@@ -371,6 +496,11 @@ body {
align-items: stretch;
}
+ .controls__fields {
+ flex-direction: column;
+ flex: 1 1 auto;
+ }
+
.control {
flex: 1 1 auto;
}
@@ -378,12 +508,43 @@ body {
.controls button {
width: 100%;
}
+}
+
+@media (max-width: 34rem) {
+ .site-header__inner {
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .site-nav {
+ margin-left: -0.85rem;
+ }
+
+ .page-intro h1 {
+ font-size: 1.65rem;
+ }
.summary {
grid-template-columns: minmax(0, 1fr);
}
.summary-card__value {
- font-size: 1.75rem;
+ font-size: 1.9rem;
+ }
+
+ /* Too narrow for one line: the facts stack, so the separator is dropped. */
+ .site-footer__meta {
+ flex-direction: column;
+ gap: 0.15rem;
+ }
+
+ .site-footer__meta span:not(:last-child)::after {
+ content: none;
+ }
+
+ .chart-card,
+ .about,
+ .notice {
+ padding: 1.05rem 1.1rem;
}
}
diff --git a/app/web/static/js/logos.js b/app/web/static/js/logos.js
new file mode 100644
index 0000000..8d28a92
--- /dev/null
+++ b/app/web/static/js/logos.js
@@ -0,0 +1,39 @@
+// The club and league marks are decorative, and they are fetched from MLB's
+// logo host, so any of them can fail: no internet access, a blocked host, a
+// team id the host has no file for. A failed decorative image must leave
+// nothing behind rather than a browser's broken-image icon.
+//
+// This also swaps the club logo as soon as the reader picks a different team,
+// so the selector does not describe the previous selection until the form is
+// submitted.
+(function () {
+ "use strict";
+
+ function hide(logo) {
+ logo.hidden = true;
+ }
+
+ var logos = document.querySelectorAll(".js-logo");
+ logos.forEach(function (logo) {
+ logo.addEventListener("error", function () {
+ hide(logo);
+ });
+ // A deferred script runs after the images have been requested, so one that
+ // already failed will never fire `error` for this listener to catch.
+ if (logo.complete && logo.naturalWidth === 0) {
+ hide(logo);
+ }
+ });
+
+ var teamSelect = document.getElementById("team_id");
+ var teamLogo = document.getElementById("team-logo");
+ if (!teamSelect || !teamLogo) {
+ return;
+ }
+
+ var prefix = teamLogo.dataset.urlPrefix;
+ teamSelect.addEventListener("change", function () {
+ teamLogo.hidden = false;
+ teamLogo.src = prefix + teamSelect.value + ".svg";
+ });
+})();
diff --git a/app/web/templates/_selector_form.html b/app/web/templates/_selector_form.html
index 0ae7518..53abacd 100644
--- a/app/web/templates/_selector_form.html
+++ b/app/web/templates/_selector_form.html
@@ -4,35 +4,50 @@