) so it holds
+// regardless of player-name length, same reasoning as Merge Card's version.
+const LINEMATE_META_COLUMN_COUNT = 2;
+
+function applyStickyLinemateColumns(table) {
+ const headerCells = table.querySelectorAll("thead th");
+ if (!headerCells.length) return;
+
+ const offsets = [];
+ let left = 0;
+ for (let i = 0; i < LINEMATE_META_COLUMN_COUNT && i < headerCells.length; i++) {
+ offsets.push(left);
+ left += headerCells[i].getBoundingClientRect().width;
+ }
+
+ table.querySelectorAll("tr").forEach((tr) => {
+ offsets.forEach((offsetLeft, i) => {
+ const cell = tr.children[i];
+ if (!cell) return;
+ cell.classList.add("linemate-table-frozen");
+ cell.classList.toggle("linemate-table-frozen-edge", i === offsets.length - 1);
+ cell.style.left = `${offsetLeft}px`;
+ });
+ });
+}
+
+// Value a roster row sorts by for a given column label — text columns
+// compare case-insensitively, Games/threshold_field/metric columns
+// numerically. Metric columns sort by the same percentile shown on screen
+// (never the raw stat), computed fresh per row since a metric's pool/rank
+// depends on that row's own position.
+function linemateSortValue(t, label, cat) {
+ if (label === "Player") return (t.abbr_name || t.player).toLowerCase();
+ if (label === "Position") return t.position;
+ if (label === "Games") return t.games ?? null;
+ if (label === cat.threshold_field) return t[cat.threshold_field] ?? null;
+ const meta = cat.metrics[label];
+ if (meta) {
+ const pool = positionPool(t.position);
+ const rank = rankAndPercentile(pool, label, meta.higher_is_better, t[label]);
+ return rank ? rank.percentile : null;
+ }
+ return null;
+}
+
+// Percentile color coding (six bins) for the roster table's metric cells —
+// same bins/colors/inset-chip treatment as Merge Card's percentileFillClass()
+// (merge-card.js), reimplemented locally per this file's own leaf-module
+// header comment rather than importing from merge-card.js. Reuses that same
+// file's .merge-pct-chip/.merge-pct-* CSS classes directly (not a parallel
+// linemate-prefixed copy) — same component, same visual language, only the
+// JS mapping function is duplicated, mirroring how .sortable-th/.sort-arrow
+// are already one shared CSS component across both card types. Deliberately
+// NOT used for the Line Summary table below (Min/Median/RSWA/Max are order
+// statistics across the roster, not an individual's performance grade — the
+// same bins would just encode Min's structural bias toward low values and
+// Max's toward high values, not real signal).
+function percentileFillClass(percentile) {
+ if (percentile < 35) return "merge-pct-red";
+ if (percentile < 50) return "merge-pct-orange";
+ if (percentile < 65) return "merge-pct-yellow";
+ if (percentile < 75) return "merge-pct-lightblue";
+ if (percentile < 90) return "merge-pct-darkblue";
+ return "merge-pct-violet";
+}
+
// Renders the visible slice of the roster (first 5 by snap count, or all of
-// it once "See more" is toggled) into listEl — percentiles only, each
-// against the linemate's own position pool, with the pool denominator shown
-// per BLUEPRINT.md §2.4.
-export function renderLinemateRoster(entry, roster, listEl) {
+// it once "See more" is toggled — the DEFAULT order and cap; sorting via a
+// header click, see sortRows()/makeSortableHeader() below, reorders the same
+// capped roster for display, it never re-qualifies/re-caps who's on it) into
+// tableEl as a DataFrame-style table — same column style/order as Merge
+// Card's (renderMergeCardBody() in merge-card.js), minus the NFL Team column
+// (redundant here — every roster row already shares the anchor's team by
+// construction, see computeLinemateRoster()) and minus a per-row Linemates
+// toggle (recursion guard, BLUEPRINT.md §2.3: this card never lets a row
+// drill into another Linemate Card). Position gets its own column instead of
+// NFL Team — a roster can mix positions (LINEMATE_POSITIONS) even though it
+// can't mix teams. Percentiles are each computed against the linemate's own
+// position pool.
+export function renderLinemateRoster(entry, roster, tableEl) {
const cat = appliedCategoryMeta();
- listEl.innerHTML = "";
- const visibleCount = entry.seeMore ? roster.length : Math.min(LINEMATE_VISIBLE_DEFAULT, roster.length);
+ tableEl.innerHTML = "";
+ const sortedRoster = sortRows(roster, entry.rosterSort, (t) => linemateSortValue(t, entry.rosterSort.key, cat));
+ const visibleCount = entry.seeMore ? sortedRoster.length : Math.min(LINEMATE_VISIBLE_DEFAULT, sortedRoster.length);
+ const metricKeys = Object.keys(cat.metrics);
- roster.slice(0, visibleCount).forEach((t) => {
+ const thead = document.createElement("thead");
+ const headRow = document.createElement("tr");
+ ["Player", "Position", "Games", cat.threshold_field, ...metricKeys].forEach((label) => {
+ headRow.appendChild(
+ makeSortableHeader(label, label, entry.rosterSort, () => renderLinemateRoster(entry, roster, tableEl))
+ );
+ });
+ thead.appendChild(headRow);
+
+ const tbody = document.createElement("tbody");
+ sortedRoster.slice(0, visibleCount).forEach((t) => {
const pool = positionPool(t.position);
- const row = document.createElement("div");
- row.className = "linemate-row";
-
- const nameWrap = document.createElement("div");
- nameWrap.className = "linemate-row-name-wrap";
-
- const name = document.createElement("span");
- name.className = "linemate-row-name";
- name.textContent = `${t.position} — ${t.abbr_name || t.player}`;
- nameWrap.appendChild(name);
-
- // Same fa-circle-info + attachAppTooltip pattern as the card title hint
- // and the summary table's RSWA header hint — not the CSS-only
- // .info-hint::after popup the Players filter uses, since that would get
- // clipped by this list's own scrolling ancestor.
- const snapsHint = document.createElement("i");
- snapsHint.className = "fa-solid fa-circle-info linemate-row-hint";
- const snapsText = `${t[cat.threshold_field]} ${thresholdFieldLabel(cat)}.`;
- snapsHint.setAttribute("aria-label", snapsText);
- attachAppTooltip(snapsHint, snapsText);
- nameWrap.appendChild(snapsHint);
-
- row.appendChild(nameWrap);
-
- const cellsWrap = document.createElement("div");
- cellsWrap.className = "linemate-row-cells";
- Object.entries(cat.metrics).forEach(([key, meta]) => {
+ const tr = document.createElement("tr");
+
+ // Frozen status (both this cell and Position below) is applied after
+ // append, by applyStickyLinemateColumns() — see the rAF call at the
+ // bottom of this function.
+ const nameTd = document.createElement("td");
+ nameTd.className = "linemate-table-name";
+ nameTd.textContent = t.abbr_name || t.player;
+ tr.appendChild(nameTd);
+
+ const positionTd = document.createElement("td");
+ positionTd.className = "linemate-table-position";
+ positionTd.textContent = t.position;
+ tr.appendChild(positionTd);
+
+ const gamesTd = document.createElement("td");
+ gamesTd.textContent = t.games ?? "—";
+ tr.appendChild(gamesTd);
+
+ const snapsTd = document.createElement("td");
+ snapsTd.textContent = t[cat.threshold_field] ?? "—";
+ tr.appendChild(snapsTd);
+
+ metricKeys.forEach((key) => {
+ const meta = cat.metrics[key];
const rank = rankAndPercentile(pool, key, meta.higher_is_better, t[key]);
- const cell = document.createElement("span");
- cell.className = "linemate-cell";
- cell.textContent = rank ? ordinal(rank.percentile) : "—";
+ const td = document.createElement("td");
+ td.className = "linemate-metric-cell";
+ if (rank) {
+ const chip = document.createElement("span");
+ chip.className = `merge-pct-chip ${percentileFillClass(rank.percentile)}`;
+ chip.textContent = ordinal(rank.percentile);
+ td.appendChild(chip);
+ } else {
+ td.textContent = "—";
+ }
+ // Displayed text stays percentile-only, same as Merge Card; the exact
+ // #rank/N is still hover-only (attachAppTooltip), since the column
+ // header already labels which metric this is — nothing left for a
+ // "tooltip each percentile" disclaimer to explain.
attachAppTooltip(
- cell,
+ td,
rank ? `${key}:\n#${rank.rank}/${rank.n} ${cat.positions[t.position] || t.position}` : key
);
- cellsWrap.appendChild(cell);
+ tr.appendChild(td);
});
- row.appendChild(cellsWrap);
- listEl.appendChild(row);
+ tbody.appendChild(tr);
});
+
+ tableEl.appendChild(thead);
+ tableEl.appendChild(tbody);
+
+ // On first build (openLinemateCard() calls this before appending cardEl to
+ // the DOM), tableEl isn't connected yet — column widths aren't measurable
+ // until a frame after that append lands, same reasoning as Merge Card's
+ // own deferred applyStickyMetaColumns() call. Every other caller (See
+ // more/unfold refreshes, and card-export.js's prepareClone rebuilding this
+ // table inside an *already-attached* off-screen export clone) has tableEl
+ // connected already, so measuring synchronously here — rather than always
+ // deferring — matters: card-export.js calls html2canvas right after
+ // prepareClone returns, with no frame in between for a deferred rAF to
+ // fire, so a rebuild that ran through the connected branch is the only way
+ // an export's roster table keeps its frozen columns.
+ if (tableEl.isConnected) {
+ applyStickyLinemateColumns(tableEl);
+ } else {
+ requestAnimationFrame(() => applyStickyLinemateColumns(tableEl));
+ }
}
// Fully rebuilds a Linemate Card's title tooltip, roster, and Line Summary
@@ -249,7 +375,7 @@ export function renderLinemateCardBody(entry) {
const cat = appliedCategoryMeta();
const cardEl = entry.el;
const titleEl = cardEl.querySelector(".linemate-card-title");
- const listEl = cardEl.querySelector(".linemate-roster");
+ const tableEl = cardEl.querySelector(".linemate-table");
const seeMoreBtn = cardEl.querySelector(".linemate-see-more");
const summaryTable = cardEl.querySelector(".linemate-summary-table");
@@ -257,55 +383,80 @@ export function renderLinemateCardBody(entry) {
const roster = entry.roster;
titleEl.textContent = `${entry.anchorRecord.player} Linemates`; // season now lives on .linemate-card-meta, set once at open
- // The roster-size/threshold sentence used to sit on its own line under the
- // title; it's now a tooltip on this icon instead, freeing that line for
- // the "All numbers are..." disclaimer and the "Tooltip each percentile..."
- // hint below it.
- const rosterSummary = `${roster.length} linemate${roster.length === 1 ? "" : "s"} ≥ ${appliedFilters.threshold} ${thresholdFieldLabel(cat)}.`;
- const titleHint = document.createElement("i");
- titleHint.className = "fa-solid fa-circle-info linemate-card-title-hint";
- titleHint.setAttribute("aria-label", rosterSummary);
- attachAppTooltip(titleHint, rosterSummary);
- titleEl.appendChild(titleHint);
+ // The roster-size/threshold sentence sits on its own line above the "All
+ // numbers are..." disclaimer, rather than behind a hover-only info icon.
+ const rosterNoteEl = cardEl.querySelector(".linemate-card-roster-note");
+ rosterNoteEl.textContent = `${roster.length} linemate${roster.length === 1 ? "" : "s"} with ≥ ${appliedFilters.threshold} ${thresholdFieldLabel(cat)}.`;
// A refresh that shrinks the roster to <= the default visible count
// resets "See more" back to collapsed — there's nothing left to hide, so
// a lingering "See less" state would just be confusing.
if (roster.length <= LINEMATE_VISIBLE_DEFAULT) entry.seeMore = false;
- renderLinemateRoster(entry, roster, listEl);
+ renderLinemateRoster(entry, roster, tableEl);
seeMoreBtn.hidden = roster.length <= LINEMATE_VISIBLE_DEFAULT;
seeMoreBtn.textContent = entry.seeMore ? "See less" : "See more";
// 3M + RSWA summary, computed over every qualifying (capped) linemate
// regardless of "See more" state.
+ renderLinemateSummary(entry, roster, summaryTable);
+}
+
+// Value a Line Summary row sorts by for a given column label — "Metric"
+// compares case-insensitively on the metric's own name, the four stat
+// columns compare numerically on the same rounded percentile shown on
+// screen.
+function summarySortValue(row, label) {
+ if (label === "Metric") return row.key.toLowerCase();
+ if (label === "Min") return row.min;
+ if (label === "Median") return row.median;
+ if (label === "RSWA") return row.rswa;
+ if (label === "Max") return row.max;
+ return null;
+}
+
+// Renders the Line Summary table (Min/Median/RSWA/Max per metric) from
+// `roster` — always the full capped roster regardless of "See more" state,
+// see computeThreeMRSWA()'s own header comment. Extracted from
+// renderLinemateCardBody() so a header click can re-run just this table
+// (sortRows()/makeSortableHeader(), same pattern as renderLinemateRoster()
+// above) without recomputing the roster table too.
+export function renderLinemateSummary(entry, roster, summaryTable) {
+ const cat = appliedCategoryMeta();
const summary = computeThreeMRSWA(roster, cat);
+ const summaryRows = Object.keys(cat.metrics).map((key) => ({ key, ...summary[key] }));
+ const sortedRows = sortRows(summaryRows, entry.summarySort, (row) =>
+ summarySortValue(row, entry.summarySort.key)
+ );
+
summaryTable.innerHTML = "";
- const theadRow = document.createElement("tr");
const RSWA_TOOLTIP =
"Relative Snaps Weighted Average — each linemate's percentile weighted by his own " +
`share of roster's total ${thresholdFieldLabel(cat)} (weight = his snaps ÷ the filtered roster's ` +
"total snaps), so a linemate who played more counts for more of line summary.";
- [
- { label: "Metric" },
- { label: "Min" },
- { label: "Median" },
- { label: "RSWA", title: RSWA_TOOLTIP },
- { label: "Max" },
- ].forEach(({ label, title }) => {
- const th = document.createElement("th");
- th.appendChild(document.createTextNode(label));
- if (title) {
- // A plain `title` on the alone is easy to miss — nothing next to
- // the text signals it's hoverable. The icon is the visible affordance;
- // attachAppTooltip() (not the native title's dwell-time popup, and not
- // the app's usual .info-hint ::after) is what actually shows the
- // explanation, since .linemate-summary-wrap scrolls with
- // overflow-x:auto and would otherwise clip it.
- const hint = document.createElement("i");
- hint.className = "fa-solid fa-circle-info linemate-summary-hint";
- hint.setAttribute("aria-label", title);
- attachAppTooltip(hint, title);
- th.appendChild(hint);
+ const theadRow = document.createElement("tr");
+ ["Metric", "Min", "Median", "RSWA", "Max"].forEach((label) => {
+ const th = makeSortableHeader(label, label, entry.summarySort, () =>
+ renderLinemateSummary(entry, roster, summaryTable)
+ );
+ if (label === "RSWA") {
+ // Shared InfoPopover (same component as the Players filter header) —
+ // click-toggled rather than attachAppTooltip's hover/focus, and its
+ // own click-outside handler dismisses it regardless of
+ // .linemate-summary-wrap's overflow-x:auto, unlike a hover popup which
+ // would need to survive inside that scroll clip. stopPropagation()
+ // inside info-popover.js's own click handler keeps this from also
+ // toggling the header's sort. Swaps out makeSortableHeader()'s plain
+ // "RSWA" text span for the InfoPopover trigger itself (label text +
+ // icon in one bordered button) rather than appending the trigger
+ // alongside it — a bare icon floating next to separately-styled plain
+ // text isn't one clickable unit. The sort-indicator arrow (added after
+ // this by makeSortableHeader when the column is the active sort) is
+ // untouched, so clicking it still sorts.
+ const plainLabel = th.querySelector("span:not(.sort-indicator)");
+ th.replaceChild(
+ createInfoPopover(RSWA_TOOLTIP, { ariaLabel: `RSWA: ${RSWA_TOOLTIP}`, label: "RSWA" }),
+ plainLabel
+ );
}
theadRow.appendChild(th);
});
@@ -313,12 +464,11 @@ export function renderLinemateCardBody(entry) {
thead.appendChild(theadRow);
const tbody = document.createElement("tbody");
- Object.keys(cat.metrics).forEach((key) => {
- const row = summary[key];
+ sortedRows.forEach((row) => {
const tr = document.createElement("tr");
const labelTd = document.createElement("td");
labelTd.className = "linemate-summary-metric";
- labelTd.textContent = key;
+ labelTd.textContent = row.key;
tr.appendChild(labelTd);
[row.min, row.median, row.rswa, row.max].forEach((v) => {
const td = document.createElement("td");
@@ -345,7 +495,7 @@ export function openLinemateCard(anchorRecord) {
const closeBtn = cardEl.querySelector(".scout-close");
const foldBtn = cardEl.querySelector(".scout-fold");
const dragHandle = cardEl.querySelector(".scout-drag-handle");
- const listEl = cardEl.querySelector(".linemate-roster");
+ const tableEl = cardEl.querySelector(".linemate-table");
const seeMoreBtn = cardEl.querySelector(".linemate-see-more");
const logoImg = cardEl.querySelector(".scout-logo");
const badge = cardEl.querySelector(".scout-badge");
@@ -379,6 +529,12 @@ export function openLinemateCard(anchorRecord) {
el: cardEl,
folded: false,
seeMore: false,
+ // Excel-style click-to-sort state for the roster and Line Summary
+ // tables (table-sort.js) — independent of each other and of seeMore,
+ // and persists across a threshold-only refresh the same way seeMore
+ // does (renderLinemateCardBody() never resets either).
+ rosterSort: { key: null, dir: "asc" },
+ summarySort: { key: null, dir: "asc" },
};
// Wired once — reads entry.roster/entry.seeMore fresh on every click, so
@@ -387,7 +543,7 @@ export function openLinemateCard(anchorRecord) {
seeMoreBtn.addEventListener("click", () => {
entry.seeMore = !entry.seeMore;
seeMoreBtn.textContent = entry.seeMore ? "See less" : "See more";
- renderLinemateRoster(entry, entry.roster, listEl);
+ renderLinemateRoster(entry, entry.roster, tableEl);
});
renderLinemateCardBody(entry);
@@ -402,7 +558,7 @@ export function openLinemateCard(anchorRecord) {
const onUnfold = () => {
entry.seeMore = false;
seeMoreBtn.textContent = "See more";
- renderLinemateRoster(entry, entry.roster, listEl);
+ renderLinemateRoster(entry, entry.roster, tableEl);
};
attachScoutResize(cardEl, entry, onUnfold);
@@ -415,7 +571,10 @@ export function openLinemateCard(anchorRecord) {
cardEl.addEventListener("pointerdown", () => bringScoutCardToFront(cardEl));
attachCardSave(
cardEl,
- () => `LinesShines_${sanitizeForFilename(anchorRecord.player)}_Linemates_${appliedFilters.season}`,
+ // abbr_name shortens the first name to an initial, keeping the last name
+ // intact (e.g. "D. Hall") — same convention already shown on the card's
+ // own title, just applied to the downloaded filename too.
+ () => `LinesShines_${sanitizeForFilename(anchorRecord.abbr_name || anchorRecord.player)}_Linemates_${appliedFilters.season}`,
// "See more" only ever renders roster.slice(0, visibleCount) into the DOM
// in the first place (see renderLinemateRoster) — a collapsed roster's
// hidden rows don't exist for html2canvas to reveal via CSS. Re-render
@@ -423,8 +582,24 @@ export function openLinemateCard(anchorRecord) {
// same entry.roster the live card already computed, so the export always
// shows the full capped roster regardless of the on-screen toggle state.
(clone) => {
- const cloneListEl = clone.querySelector(".linemate-roster");
- if (cloneListEl) renderLinemateRoster({ seeMore: true }, entry.roster, cloneListEl);
+ const cloneTableEl = clone.querySelector(".linemate-table");
+ // Carries the live card's current sort along into the export (its own
+ // { key, dir } object, not a fresh one) rather than resetting it — the
+ // export should look exactly like what's on screen, just unfolded.
+ if (cloneTableEl) {
+ renderLinemateRoster({ seeMore: true, rosterSort: entry.rosterSort }, entry.roster, cloneTableEl);
+ // renderLinemateRoster() wipes and rebuilds every cell, which re-adds
+ // .linemate-table-frozen (position:sticky) via applyStickyLinemateColumns()
+ // — undoing buildExportClone()'s static-position fix, which only ever
+ // touched the pre-rebuild cells it can no longer see. Re-apply it here
+ // to the freshly built ones, or the Player/Position columns drift to
+ // the row's right edge in the exported PNG (same failure mode
+ // buildExportClone's own fix exists to prevent).
+ cloneTableEl.querySelectorAll(".linemate-table-frozen").forEach((cell) => {
+ cell.style.position = "static";
+ cell.style.left = "";
+ });
+ }
}
);
diff --git a/frontend/main.js b/frontend/main.js
index a60e3bb..12c3f8b 100644
--- a/frontend/main.js
+++ b/frontend/main.js
@@ -19,6 +19,7 @@ import {
setResetThresholdOnNextRange,
currentFilterState,
populateCategoryDependentControls,
+ resetThresholdToCategoryDefault,
populateTeamsChecklist,
updateTeamsSummary,
openTeamsDropdown,
@@ -36,6 +37,7 @@ import {
applyFilters,
} from "./filters.js";
import { render, exportChartPngWithFooter, setLogoRelayoutGuard, sanitizeForFilename } from "./render.js";
+import { createInfoPopover } from "./info-popover.js";
import { isDesktopScoutLayout, clearScoutCardDragPositions } from "./cards-base.js";
import {
openCreateMergePopup,
@@ -81,6 +83,13 @@ async function loadMetadata() {
}
function attachEvents() {
+ els.playersInfoSlot.appendChild(
+ createInfoPopover(
+ "Teams and Players combine as a union: a player is highlighted if either his team is chosen, or his name is selected.",
+ { label: "Players", labelId: "players-label" }
+ )
+ );
+
// Category/season/position/axes are all pending-only for the chart: picking
// a new value just updates the control itself (plus, for category, the
// option lists that depend on it) and lights up Apply — nothing fetches or
@@ -92,6 +101,10 @@ function attachEvents() {
els.category.addEventListener("change", () => {
setResetThresholdOnNextRange(true);
populateCategoryDependentControls();
+ // Snaps the slider + number input to the new category's default right
+ // away, rather than waiting for Apply — see resetThresholdToCategoryDefault()'s
+ // comment for why this can't wait like season/position changes do.
+ resetThresholdToCategoryDefault();
updatePendingState();
updatePlayerPool();
});
diff --git a/frontend/merge-card.js b/frontend/merge-card.js
index 9d694bd..6fe60fb 100644
--- a/frontend/merge-card.js
+++ b/frontend/merge-card.js
@@ -36,6 +36,7 @@ import {
logoSrc,
teamSwatch,
findRecordByPlayer,
+ thresholdFieldLabel,
} from "./data.js";
import { searchPlayersExcluding, pcsSearchPool } from "./search.js";
import { MERGE_CARD_MAX_MEMBERS, MERGE_QUOTA } from "./config.js";
@@ -55,6 +56,7 @@ import {
import { toggleLinemateCard, closeLinemateCard, attachAppTooltip } from "./linemate-card.js";
import { renderPlayerCardsSpace } from "./workspace.js";
import { attachCardSave, sanitizeForFilename } from "./card-export.js";
+import { sortRows, makeSortableHeader } from "./table-sort.js";
// Pinned Players Workspace (BLUEPRINT_PinnedPlayers.md §1/§3) — the
// persistent Single Cards list, keyed by the same full "player" string as
@@ -340,47 +342,97 @@ export function makeLinemateCell(record) {
return td;
}
+// Value a member row sorts by for a given column label — Player/Team
+// compare case-insensitively, Games/threshold_field/metric columns
+// numerically. Metric columns sort by the same percentile shown on screen
+// (never the raw stat), same reasoning as Linemate Card's own sort value fn.
+function mergeSortValue({ record, pool }, label, cat) {
+ if (label === "Player") return (record.abbr_name || record.player).toLowerCase();
+ if (label === "Team") return record.team;
+ if (label === "Games") return record.games ?? null;
+ if (label === cat.threshold_field) return record[cat.threshold_field] ?? null;
+ const meta = cat.metrics[label];
+ if (meta) {
+ const rank = rankAndPercentile(pool, label, meta.higher_is_better, record[label]);
+ return rank ? rank.percentile : null;
+ }
+ return null;
+}
+
+// Merge Card-only percentile color coding (six bins) — same bins/colors as
+// Player Card's percentileChipClass() (scout-card.js), reimplemented locally
+// per this file's own header comment on staying a leaf. Renders as an inset
+// chip (span) inside .merge-metric-cell, same visual language as Player
+// Card's .scout-pct-chip, NOT a full edge-to-edge cell fill — an earlier
+// pass applied the bin class straight to the , which both looked wrong
+// for this dense grid (no cell/divider visible around the color) and had a
+// real bug: .merge-table td's own `color: var(--chalk)` rule is MORE
+// specific than a single class like .merge-pct-yellow, so it silently won
+// over the intended dark text on light backgrounds. Setting color on the
+// chip span instead sidesteps that entirely — a span is never itself a
+// `.merge-table td`, so the two rules never compete on the same element.
+function percentileFillClass(percentile) {
+ if (percentile < 35) return "merge-pct-red";
+ if (percentile < 50) return "merge-pct-orange";
+ if (percentile < 65) return "merge-pct-yellow";
+ if (percentile < 75) return "merge-pct-lightblue";
+ if (percentile < 90) return "merge-pct-darkblue";
+ return "merge-pct-violet";
+}
+
// One row per player, one column per metric, percentile-only cells
-// (BLUEPRINT.md §1.2) — never the raw value, never #rank/N.
-// Builds/rebuilds a Merge Card's title, subtitle, and percentile table from
-// memberRecords — used both at creation (openMergeCard) and by the Edit
-// popup (rebuildMergeCardFromMembers, BLUEPRINT_PinnedPlayers.md §5) to
-// update an existing card in place after its membership changes, instead of
-// destroying/recreating the floating card. One row per player, one column
-// per metric, percentile-only cells (BLUEPRINT.md §1.2) — never the raw
-// value, never #rank/N.
-export function renderMergeCardBody(cardEl, memberRecords) {
+// (BLUEPRINT.md §1.2) — never the raw value, never #rank/N. Every column is
+// sortable (table-sort.js, Excel-style click-to-sort) except Linemates,
+// which holds a button, not data. Builds/rebuilds a Merge Card's title,
+// subtitle, and percentile table from memberRecords — used both at creation
+// (openMergeCard) and by the Edit popup (rebuildMergeCardFromMembers,
+// BLUEPRINT_PinnedPlayers.md §5) to update an existing card in place after
+// its membership changes, instead of destroying/recreating the floating
+// card. `sortState` is the entry's own persistent { key, dir } (see
+// openMergeCard()) — passed in explicitly rather than read off an `entry`
+// object because this function only ever receives `cardEl`, and on first
+// mount `entry.el` isn't set yet (mountMergeCardElement() assigns it only
+// after this call returns).
+export function renderMergeCardBody(cardEl, memberRecords, sortState) {
const cat = appliedCategoryMeta();
const titleEl = cardEl.querySelector(".merge-card-title");
const subtitleEl = cardEl.querySelector(".merge-card-subtitle");
const poolEl = cardEl.querySelector(".merge-card-pool");
const table = cardEl.querySelector(".merge-table");
- titleEl.textContent = `Merged Card · ${appliedFilters.season}`;
+ titleEl.textContent = `Player Comparison · ${appliedFilters.season}`;
subtitleEl.textContent = memberRecords.map((r) => r.abbr_name || r.player).join(" + ");
const pools = memberRecords.map((record) => ({ record, pool: positionPool(record.position) }));
const sharedPosition = pools.every((p) => p.record.position === pools[0].record.position)
? pools[0].record.position
: null;
+ const thresholdClause = `with ≥ ${appliedFilters.threshold} ${thresholdFieldLabel(cat)}`;
poolEl.textContent = sharedPosition
- ? `Percentiles calculated among ${pools[0].pool.length} ${sharedPosition}.`
+ ? `Percentiles calculated among ${pools[0].pool.length} ${sharedPosition} ${thresholdClause}.`
: `Percentiles calculated among ${pools
.map((p) => `${p.pool.length} ${p.record.position} (${p.record.abbr_name || p.record.player})`)
- .join(", ")}.`;
+ .join(", ")} ${thresholdClause}.`;
+
+ const sortedPools = sortRows(pools, sortState, (p) => mergeSortValue(p, sortState.key, cat));
const metricKeys = Object.keys(cat.metrics);
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
+ const rerender = () => renderMergeCardBody(cardEl, memberRecords, sortState);
["Player", "Team", "Linemates", "Games", cat.threshold_field, ...metricKeys].forEach((label) => {
- const th = document.createElement("th");
- th.textContent = label;
- headRow.appendChild(th);
+ if (label === "Linemates") {
+ const th = document.createElement("th");
+ th.textContent = label;
+ headRow.appendChild(th);
+ } else {
+ headRow.appendChild(makeSortableHeader(label, label, sortState, rerender));
+ }
});
thead.appendChild(headRow);
const tbody = document.createElement("tbody");
- pools.forEach(({ record, pool }) => {
+ sortedPools.forEach(({ record, pool }) => {
const tr = document.createElement("tr");
const nameTd = document.createElement("td");
@@ -404,7 +456,14 @@ export function renderMergeCardBody(cardEl, memberRecords) {
const rank = rankAndPercentile(pool, key, meta.higher_is_better, record[key]);
const td = document.createElement("td");
td.className = "merge-metric-cell";
- td.textContent = rank ? ordinal(rank.percentile) : "—";
+ if (rank) {
+ const chip = document.createElement("span");
+ chip.className = `merge-pct-chip ${percentileFillClass(rank.percentile)}`;
+ chip.textContent = ordinal(rank.percentile);
+ td.appendChild(chip);
+ } else {
+ td.textContent = "—";
+ }
// Displayed text stays percentile-only (BLUEPRINT.md §1.2); the exact
// #rank/N is tooltip-only, same attachAppTooltip pattern as Linemate
// Cards' percentile cells.
@@ -419,10 +478,18 @@ export function renderMergeCardBody(cardEl, memberRecords) {
table.appendChild(thead);
table.appendChild(tbody);
- // Deferred a frame: on first build, table is still off-DOM here (the
- // caller appends cardEl to els.scoutCards after this returns), so column
- // widths aren't measurable yet. rAF fires after that append lands.
- requestAnimationFrame(() => applyStickyMetaColumns(table));
+ // On first build, table is still off-DOM here (the caller appends cardEl
+ // to els.scoutCards after this returns) — column widths aren't measurable
+ // until a frame after that append lands. Every other caller (a sort click,
+ // an Edit-popup membership change) has table connected already, so
+ // measuring synchronously then matters for responsiveness — same
+ // connected-vs-not branch as Linemate Card's applyStickyLinemateColumns()
+ // call.
+ if (table.isConnected) {
+ applyStickyMetaColumns(table);
+ } else {
+ requestAnimationFrame(() => applyStickyMetaColumns(table));
+ }
}
// Freezes the Player/Team/Linemates columns (BLUEPRINT.md §1.2 extended) in
@@ -467,7 +534,7 @@ export function mountMergeCardElement(entry, memberRecords) {
const foldBtn = cardEl.querySelector(".scout-fold");
const dragHandle = cardEl.querySelector(".scout-drag-handle");
- renderMergeCardBody(cardEl, memberRecords);
+ renderMergeCardBody(cardEl, memberRecords, entry.sort);
els.scoutCards.appendChild(cardEl);
cascadeScoutCardPosition(cardEl); // reads openCardsCount(), so must run before entry.el is set below
@@ -497,7 +564,19 @@ export function mountMergeCardElement(entry, memberRecords) {
// entry" reasoning as the fold listener's applyStickyMetaColumns() above.
attachCardSave(
cardEl,
- () => `LinesShines_MergedCard_${entry.memberKeys.map(sanitizeForFilename).join("_")}_${appliedFilters.season}`
+ // abbr_name shortens each member's first name to an initial, keeping the
+ // last name intact (e.g. "D. Hall") — same convention already shown on
+ // every card title/table cell, just applied to the downloaded filename
+ // too. Falls back to the raw key (a full player-name string) in the
+ // unexpected case a member's record can't be found, same fallback
+ // pattern as this file's other findRecordByPlayer() call sites.
+ () =>
+ `LinesShines_MergedCard_${entry.memberKeys
+ .map((key) => {
+ const record = findRecordByPlayer(key);
+ return sanitizeForFilename((record && (record.abbr_name || record.player)) || key);
+ })
+ .join("_")}_${appliedFilters.season}`
);
updateScoutEmptyHint();
@@ -506,7 +585,11 @@ export function mountMergeCardElement(entry, memberRecords) {
export function openMergeCard(memberRecords) {
const id = nextCardId();
const memberKeys = memberRecords.map((r) => r.player);
- const entry = { id, origin: "merge", memberKeys, el: null, folded: false };
+ // Excel-style click-to-sort state (table-sort.js) — persists across
+ // membership edits (addMergeMember()/removeMergeMember() both rebuild via
+ // rebuildMergeCardFromMembers(), which reuses this same object) and across
+ // a fold/unfold, since it's never reset anywhere but here.
+ const entry = { id, origin: "merge", memberKeys, el: null, folded: false, sort: { key: null, dir: "asc" } };
mergeCards.set(id, entry);
mountMergeCardElement(entry, memberRecords);
renderPlayerCardsSpace();
@@ -715,7 +798,7 @@ export function runMergeEditSearch() {
export function rebuildMergeCardFromMembers(entry) {
if (!entry.el) return; // floating card closed (BLUEPRINT_PinnedPlayers.md §4-style) — nothing on screen to update
const memberRecords = entry.memberKeys.map(findRecordByPlayer).filter(Boolean);
- renderMergeCardBody(entry.el, memberRecords);
+ renderMergeCardBody(entry.el, memberRecords, entry.sort);
}
// §5 Edit popup rules: blocks + explains a duplicate add, a 6th member, the
diff --git a/frontend/render.js b/frontend/render.js
index 1479731..20feed3 100644
--- a/frontend/render.js
+++ b/frontend/render.js
@@ -356,7 +356,14 @@ export function render() {
textfont: {
color: isDimmed.map((dim) => `rgba(241,236,221,${dim ? DIM_OPACITY.label : LABEL_ALPHA})`),
size: 10,
- family: "IBM Plex Mono, monospace",
+ // Plotly's textfont has no weight field. index.html's Google Fonts
+ // link now loads two Oswald instances (wght@450;600 — 600 is for the
+ // Pinned/Manage bar, see style.css .pcs-quota/.pcs-inspect-btn). This
+ // text sets no font-weight, so it resolves to CSS "normal" (400); per
+ // the CSS font-matching algorithm that picks 450 over 600 (nearest
+ // weight above 400, capped at 500), so these labels still render at
+ // 450 without needing an explicit weight here.
+ family: "Oswald, sans-serif",
},
marker: {
color: colors,
diff --git a/frontend/scout-card.js b/frontend/scout-card.js
index a411907..f04c08c 100644
--- a/frontend/scout-card.js
+++ b/frontend/scout-card.js
@@ -10,6 +10,7 @@ import {
teamColor,
teamName,
logoSrc,
+ thresholdFieldLabel,
} from "./data.js";
import {
ordinal,
@@ -46,6 +47,20 @@ export function formatValue(value, meta) {
return `${rounded}${unit}`;
}
+// Player Card-only percentile color coding (six bins) for the rank/percentile
+// chip in .scout-stat-rank — scoped here rather than in a shared module since
+// Linemate/Merge Card rank cells (and the Line Summary table) don't get this
+// treatment. Bin edges are half-open on the low end, closed on the high end
+// at 100, matching how rankAndPercentile()'s ordinal percentile is computed.
+function percentileChipClass(percentile) {
+ if (percentile < 35) return "scout-pct-red";
+ if (percentile < 50) return "scout-pct-orange";
+ if (percentile < 65) return "scout-pct-yellow";
+ if (percentile < 75) return "scout-pct-lightblue";
+ if (percentile < 90) return "scout-pct-darkblue";
+ return "scout-pct-violet";
+}
+
// Builds/rebuilds a Player Card's stat rows (value + rank/percentile) — used
// both at open time and to refresh an already-open card after Apply, since
// currentFiltered/appliedFilters.xMetric/yMetric can all change without the
@@ -60,12 +75,18 @@ export function renderScoutCardStats(cardEl, record) {
const poolEl = cardEl.querySelector(".scout-card-pool");
poolEl.textContent = `Percentiles calculated among ${currentFiltered.length} ${record.position}.`;
+ const thresholdEl = cardEl.querySelector(".scout-card-threshold");
+ thresholdEl.textContent = `Threshold: ≥ ${appliedFilters.threshold} ${thresholdFieldLabel(cat)}.`;
+
// Three grid children per row (dt, value dd, rank dd) so the grid's
// row-major auto-placement stays aligned — a row that only emitted two
// children when it has no rank would shift every following row's columns.
// Games / the threshold field get an empty rank cell for exactly this
- // reason, not because rank text was omitted by accident.
- const addRow = (label, value, rankText) => {
+ // reason, not because rank text was omitted by accident. `percentile`
+ // is only passed for metric rows (Games/threshold field have none) — when
+ // present, the whole "#rank/N · Nth pct" string renders inside a colored
+ // chip span rather than as plain text.
+ const addRow = (label, value, rankText, percentile) => {
const dt = document.createElement("dt");
dt.textContent = label;
const dd = document.createElement("dd");
@@ -73,7 +94,14 @@ export function renderScoutCardStats(cardEl, record) {
dd.textContent = value;
const rankDd = document.createElement("dd");
rankDd.className = "scout-stat-rank";
- rankDd.textContent = rankText || "—";
+ if (rankText && percentile != null) {
+ const chip = document.createElement("span");
+ chip.className = `scout-pct-chip ${percentileChipClass(percentile)}`;
+ chip.textContent = rankText;
+ rankDd.appendChild(chip);
+ } else {
+ rankDd.textContent = rankText || "—";
+ }
statsEl.appendChild(dt);
statsEl.appendChild(dd);
statsEl.appendChild(rankDd);
@@ -87,7 +115,7 @@ export function renderScoutCardStats(cardEl, record) {
Object.entries(cat.metrics).forEach(([key, meta]) => {
const rank = rankAndPercentile(currentFiltered, key, meta.higher_is_better, record[key]);
const rankText = rank ? `#${rank.rank}/${rank.n} · ${ordinal(rank.percentile)} pct` : "";
- addRow(key, formatValue(record[key], meta), rankText);
+ addRow(key, formatValue(record[key], meta), rankText, rank ? rank.percentile : null);
});
}
@@ -149,7 +177,10 @@ export function openScoutCard(record) {
// overlapped card's stats brings it to front too.
cardEl.addEventListener("pointerdown", () => bringScoutCardToFront(cardEl));
linemateBtn.addEventListener("click", () => toggleLinemateCard(record));
- attachCardSave(cardEl, () => `LinesShines_${sanitizeForFilename(record.player)}_${appliedFilters.season}`);
+ // abbr_name shortens the first name to an initial, keeping the last name
+ // intact (e.g. "D. Hall") — same convention already shown on every card
+ // title/table cell, just applied to the downloaded filename too.
+ attachCardSave(cardEl, () => `LinesShines_${sanitizeForFilename(record.abbr_name || record.player)}_${appliedFilters.season}`);
scoutCards.set(record.player, entry);
updateScoutEmptyHint();
diff --git a/frontend/style.css b/frontend/style.css
index 21f0ba0..84d86f5 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -20,6 +20,7 @@
--font-display: "Anton", "Arial Narrow", sans-serif;
--font-body: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: "IBM Plex Mono", "SF Mono", monospace;
+ --font-oswald: "Oswald", sans-serif;
}
* { box-sizing: border-box; }
@@ -70,31 +71,46 @@ body {
gap: 20px;
}
+/* v1.4.0 neubrutalism: same 2px solid #f0ede4 border language as
+ .apply-btn/.scout-card-panel elsewhere, so the logo reads as part of the
+ same bordered system rather than floating bare — border-radius (and
+ everything else about the image) is untouched, since the artwork itself
+ already carries its own circular sunburst-badge composition; cropping the
+ ` ` to an actual circle would clip the "LinesShines"/"Heroes In
+ Trenches" wordmark baked into the bottom of the asset. */
.hero-logo {
flex: none;
height: 210px;
width: auto;
+ border: 2px solid #f0ede4;
border-radius: 10px;
}
.hero-text { min-width: 0; }
+/* Wrapped in the same bordered-container language as the rest of the
+ neubrutalist system (.pcs-row-remove/-edit/-dismiss: 2px solid #f0ede4,
+ minimal radius, transparent fill) rather than a bare floating icon. */
.hero-github {
position: absolute;
- top: 20px;
- right: 24px;
+ top: 16px;
+ right: 20px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
+ padding: 6px;
+ border: 2px solid #f0ede4;
+ border-radius: 4px;
color: var(--chalk);
opacity: 0.8;
- transition: color 0.15s ease, opacity 0.15s ease;
+ transition: color 0.15s ease, opacity 0.15s ease, border-color 0.15s ease;
}
.hero-github svg { width: 20px; height: 20px; display: block; }
.hero-github:hover,
.hero-github:focus-visible {
color: var(--flag-gold-bright);
+ border-color: var(--flag-gold);
opacity: 1;
}
@@ -144,7 +160,7 @@ body {
justify-content: center;
row-gap: 4px;
margin: 0;
- padding: 10px 24px;
+ padding: 4px 24px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
@@ -165,7 +181,7 @@ body {
@media (max-width: 480px) {
.meta-band {
font-size: 8px;
- padding: 10px 10px;
+ padding: 4px 10px;
}
}
@@ -214,10 +230,9 @@ body {
}
/* --- control bar --- */
-/* No border-top: that edge used to sit right under the hero's own
- border-bottom, bracketing the hero/filters gap with a second rule. The
- .meta-band now living in that gap is the only divider — see its
- comment above. */
+/* v1.4.0 neubrutalism: 2px solid #f0ede4, same border language/color as
+ every other top-level container (cards, the header logo badge, the
+ GitHub icon) — background and radius are unchanged. */
.control-bar {
display: flex;
flex-wrap: wrap;
@@ -225,8 +240,7 @@ body {
align-items: end;
padding: 18px 20px;
background: var(--turf-800);
- border: 1px solid var(--turf-600);
- border-top: none;
+ border: 2px solid #f0ede4;
border-radius: 10px;
margin-bottom: 22px;
}
@@ -239,7 +253,8 @@ body {
}
.control label {
- font-family: var(--font-mono);
+ font-family: var(--font-oswald);
+ font-weight: 600;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -546,69 +561,101 @@ body {
gap: 6px;
}
-/* Sits before the Players label to flag that Teams and Players aren't two
- independent filters — see the isDimmed union in app.js's render() — a
- selected team highlights all its players regardless of the Players list,
- and vice versa. Hover/focus reveals the explanation below; `title` is a
- plain-text fallback for anything that doesn't run the CSS (screen
- readers, no-hover touch devices long-pressing for the native tooltip). */
-.info-hint {
- flex: none;
- width: 14px;
- height: 14px;
- display: flex;
+/* Sits before the Players reset button to flag that Teams and Players
+ aren't two independent filters — see the isDimmed union in app.js's
+ render() — a selected team highlights all its players regardless of the
+ Players list, and vice versa. Populated at boot by createInfoPopover()
+ (info-popover.js) rather than static markup, so this slot and the RSWA
+ column header below share one component instead of two hand-rolled
+ tooltip implementations. The "Players" text itself now lives inside that
+ component (its .info-popover-label span, id="players-label") rather than
+ a sibling — aria-labelledby on the players toggle button/input
+ still resolves to it by that same id. */
+.info-popover-slot { display: contents; }
+
+/* Shared InfoPopover trigger (info-popover.js) — click-toggled, not
+ hover-only, since the Players filter row is desktop/tablet-only already
+ and a touch device has no hover to dwell on. Just the label text inside
+ this (.info-popover-label), no separate "i" icon — the border
+ wraps the whole clickable label rather than needing an icon to flag it as
+ interactive. */
+.info-popover-trigger {
+ appearance: none;
+ -webkit-appearance: none;
+ display: inline-flex;
align-items: center;
- justify-content: center;
- border-radius: 50%;
- border: 1px solid var(--chalk-dim);
- color: var(--chalk-dim);
- font-family: var(--font-mono);
- font-style: italic;
- font-size: 10px;
+ gap: 6px;
+ border: 2px solid #f0ede4;
+ border-radius: 4px;
+ padding: 6px 12px;
+ background: transparent;
+ color: #f0ede4;
+ font-family: var(--font-oswald);
+ font-weight: 600;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
line-height: 1;
- cursor: help;
- position: relative;
+ cursor: pointer;
}
-.info-hint:hover,
-.info-hint:focus-visible {
+.info-popover-label { white-space: nowrap; }
+.info-popover-trigger:hover,
+.info-popover-trigger:focus-visible,
+.info-popover-trigger[aria-expanded="true"] {
border-color: var(--flag-gold);
+ background: rgba(211, 167, 61, 0.18);
color: var(--flag-gold);
outline: none;
}
-.info-hint::after {
- content: attr(data-tooltip);
- position: absolute;
- bottom: calc(100% + 8px);
- left: 0;
+
+/* The popup itself — appended to document.body by info-popover.js, not a
+ CSS ::after, so it can't be clipped by a scrolling ancestor
+ (.linemate-summary-wrap) the way the old .info-hint::after would've been
+ had it been reused there. Same 2px #f0ede4 neubrutalist border as the
+ trigger; body copy is IBM Plex Mono per the shared component's spec. */
+.info-popover-content {
+ position: fixed;
+ z-index: 200;
width: 240px;
+ max-width: calc(100vw - 16px);
background: var(--turf-800);
- color: var(--chalk);
- border: 1px solid var(--turf-600);
+ border: 2px solid #f0ede4;
border-radius: 6px;
- padding: 8px 10px;
- font-family: var(--font-body);
- font-style: normal;
- font-size: 12px;
- line-height: 1.4;
- letter-spacing: normal;
- text-transform: none;
+ padding: 10px 26px 10px 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
- z-index: 60;
- opacity: 0;
- visibility: hidden;
- transition: opacity 0.12s ease;
- pointer-events: none;
}
-.info-hint:hover::after,
-.info-hint:focus-visible::after {
- opacity: 1;
- visibility: visible;
+.info-popover-text {
+ margin: 0;
+ color: var(--chalk);
+ font-family: var(--font-mono);
+ font-size: 11.5px;
+ line-height: 1.5;
+}
+.info-popover-close {
+ appearance: none;
+ -webkit-appearance: none;
+ position: absolute;
+ top: 4px;
+ right: 6px;
+ background: transparent;
+ border: none;
+ color: var(--chalk-dim);
+ font-size: 16px;
+ line-height: 1;
+ cursor: pointer;
+ padding: 2px;
+}
+.info-popover-close:hover,
+.info-popover-close:focus-visible {
+ color: var(--flag-gold);
+ outline: none;
}
/* Body-appended tooltip (attachAppTooltip()/showAppTooltip() in app.js) for
- triggers where .info-hint's own ::after popup would get clipped by a
- scrolling ancestor (.linemate-summary-wrap, .scout-card-panel) — living
- outside the card's DOM subtree means no ancestor's overflow can touch it.
+ the app's other, hover/focus-only info triggers (linemate row snap counts,
+ percentile cells, the Linemate Card title hint) — distinct from
+ .info-popover-content above, which is click-toggled. Living outside the
+ card's DOM subtree means no ancestor's overflow can clip either one.
Positioned via inline left/top computed from the trigger's own rect. */
.app-tooltip {
position: fixed;
@@ -793,14 +840,17 @@ body {
all sit "pending" in their own inputs until this is clicked), so it's
styled as the form's one primary action rather than tucked next to the
threshold slider like the old threshold-only Set button was. */
+/* v1.4.0 neubrutalism: 2px solid border (up from 1px) and a minimal 4px
+ radius, matching the flat 2px card border language in .scout-card-panel
+ above — no shadows/gradients on the resting button. */
.apply-btn {
flex: none;
background: var(--flag-gold);
color: var(--turf-950);
- border: 1px solid var(--flag-gold);
- border-radius: 6px;
+ border: 2px solid var(--flag-gold);
+ border-radius: 4px;
padding: 9px 20px;
- font-family: var(--font-mono);
+ font-family: var(--font-oswald);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
@@ -814,9 +864,14 @@ body {
}
/* Lit up while any control (category/season/position/axes/threshold) holds
a value that differs from what's actually drawn on the chart — the only
- cue that pending selections are sitting unapplied. */
+ cue that pending selections are sitting unapplied. Swaps the resting
+ gold fill for burnt orange rather than adding a ring/shadow, so the
+ pending state reads as "this button now does something" instead of a
+ decoration on top of the gold button. */
.apply-btn.pending {
- box-shadow: 0 0 0 2px var(--turf-950), 0 0 0 4px var(--flag-gold-bright), 0 0 10px rgba(232, 193, 94, 0.55);
+ background: #d9622b;
+ border-color: #d9622b;
+ color: #f0ede4;
}
/* Secondary action next to Apply — black bg + chalk text, gold reserved for
Apply since that's the primary action. Exports whatever's currently
@@ -829,10 +884,10 @@ body {
gap: 6px;
background: #000;
color: var(--chalk);
- border: 1px solid var(--turf-600);
- border-radius: 6px;
+ border: 2px solid var(--turf-600);
+ border-radius: 4px;
padding: 9px 20px;
- font-family: var(--font-mono);
+ font-family: var(--font-oswald);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
@@ -871,7 +926,8 @@ body {
display: flex;
align-items: center;
gap: 8px;
- font-family: var(--font-body);
+ font-family: var(--font-oswald);
+ font-weight: 600;
font-size: 13px;
color: var(--chalk-dim);
text-transform: none;
@@ -892,7 +948,9 @@ body {
.chart-panel {
background: var(--turf-800);
- border: 1px solid var(--turf-600);
+ /* v1.4.0 neubrutalism: 2px solid #f0ede4, same border language/color as
+ every other top-level container — background and radius unchanged. */
+ border: 2px solid #f0ede4;
border-radius: 10px;
padding: 12px;
min-height: 720px;
@@ -997,7 +1055,7 @@ body {
/* Merge/Linemate cards reuse every .scout-card rule above (position, drag,
z-index, mobile stacking) via this base class, plus their own modifier
below for anything that needs to differ. */
-.scout-card--merge { width: 460px; }
+.scout-card--merge { width: 640px; }
.scout-card--linemate { width: 420px; }
/* Positioned relative to .scout-card itself (not .scout-card-panel) so the
@@ -1054,7 +1112,10 @@ body {
background: linear-gradient(165deg, rgba(30, 61, 40, 0.9), rgba(22, 48, 31, 0.94));
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
- border: 1px solid var(--turf-600);
+ /* v1.4.0 neubrutalism: 2px solid, up from 1px — shared by all three card
+ types (Player/Linemate/Merge) via this one base rule. Color matches the
+ card-title text (#f0ede4), not --turf-600. */
+ border: 2px solid #f0ede4;
border-radius: 0 8px 8px 8px;
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.35);
transition: max-height 0.28s ease, opacity 0.22s ease;
@@ -1080,7 +1141,7 @@ body {
mid-row clip. */
.scout-card.is-folded .scout-stats,
.scout-card.is-folded .merge-table-wrap,
-.scout-card.is-folded .linemate-roster,
+.scout-card.is-folded .linemate-table-wrap,
.scout-card.is-folded .linemate-see-more,
.scout-card.is-folded .linemate-summary-wrap {
display: none;
@@ -1162,7 +1223,7 @@ body {
align-items: center;
gap: 12px;
padding-bottom: 14px;
- border-bottom: 1px dashed var(--turf-600);
+ border-bottom: 1px solid var(--turf-600);
cursor: grab;
touch-action: none;
user-select: none;
@@ -1302,6 +1363,29 @@ body {
color: var(--chalk-dim);
min-width: 64px;
}
+/* Player Card-only percentile color coding (six bins, see
+ percentileChipClass() in scout-card.js) — the whole "#rank/N · Nth pct"
+ string sits on a solid background chip rather than just tinting the text,
+ so the color reads at a glance without needing to parse the string first.
+ Minimal 3px radius matches the app's neubrutalist language elsewhere
+ (not a rounded pill); text color per bin is contrast-matched against its
+ background rather than a single color for all six. Games/the threshold
+ field have no percentile and so render as plain .scout-stat-rank text,
+ never a chip — see renderScoutCardStats()'s addRow(). Scoped to Player
+ Card only: Linemate/Merge Card rank cells and the Line Summary table
+ don't get this treatment. */
+.scout-pct-chip {
+ display: inline-block;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-weight: 600;
+}
+.scout-pct-red { background: #e74c3c; color: #f0ede4; }
+.scout-pct-orange { background: #e67e22; color: #f0ede4; }
+.scout-pct-yellow { background: #f1c40f; color: #1a1a1a; }
+.scout-pct-lightblue { background: #5dade2; color: #1a1a1a; }
+.scout-pct-darkblue { background: #1f618d; color: #f0ede4; }
+.scout-pct-violet { background: #a569bd; color: #f0ede4; }
/* --- Player Card actions: linemate toggle (BLUEPRINT.md §2) ---
Sits between the header and the stats table, always visible — including
@@ -1319,9 +1403,40 @@ body {
justify-content: flex-start;
gap: 10px;
padding-bottom: 6px;
- border-bottom: 1px dashed var(--turf-600);
+ border-bottom: 1px solid var(--turf-600);
}
-.card-linemate-toggle,
+/* Player Card's own Linemates toggle: solid-gold, same button language as
+ .apply-btn/.pcs-inspect-btn, so it reads as the card's primary
+ call-to-action rather than a utility icon — distinct from the card's
+ structural #f0ede4 border and from the download/collapse/close icons in
+ the top-right corner. Deliberately its own rule, not shared with
+ .merge-row-linemate-toggle below: that one stays a small ghost/ouline
+ icon button scoped to a dense merge-table row, where a solid gold fill
+ per row would be too loud. */
+.card-linemate-toggle {
+ appearance: none;
+ -webkit-appearance: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: #d4af5a;
+ color: #1a1a1a;
+ border: none;
+ border-radius: 5px;
+ padding: 4px 10px;
+ font-family: var(--font-oswald);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+.card-linemate-toggle:hover,
+.card-linemate-toggle:focus-visible {
+ background: var(--flag-gold-bright);
+ outline: none;
+}
+
.merge-row-linemate-toggle {
appearance: none;
-webkit-appearance: none;
@@ -1332,22 +1447,19 @@ body {
color: var(--chalk-dim);
border: 1px solid var(--turf-600);
border-radius: 5px;
- padding: 4px 8px;
+ padding: 3px 6px;
font-family: var(--font-mono);
- font-size: 10.5px;
+ font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
}
-.card-linemate-toggle:hover,
-.card-linemate-toggle:focus-visible,
.merge-row-linemate-toggle:hover,
.merge-row-linemate-toggle:focus-visible {
color: var(--flag-gold-bright);
border-color: var(--flag-gold);
outline: none;
}
-.merge-row-linemate-toggle { padding: 3px 6px; font-size: 11px; }
/* --- Pinned Players (BLUEPRINT_PinnedPlayers.md) ---
Persistent Workspace management region — replaces the old merge-toolbar.
@@ -1358,6 +1470,11 @@ body {
}
.player-cards-space[hidden] { display: none; }
+/* v1.4.0 neubrutalism: 2px solid #f0ede4, same border language/color as
+ every other top-level container — applied to both of this bar's states
+ (collapsed .pcs-bar, expanded .pcs-panel below) so the frame stays
+ consistent regardless of which one is currently showing. Background and
+ radius are unchanged for both. */
.pcs-bar {
display: flex;
align-items: center;
@@ -1365,18 +1482,21 @@ body {
flex-wrap: wrap;
padding: 10px 16px;
background: var(--turf-800);
- border: 1px solid var(--turf-600);
+ border: 2px solid #f0ede4;
border-radius: 8px;
}
.pcs-quota {
- font-family: var(--font-mono);
- font-size: 12px;
+ font-family: var(--font-oswald);
+ font-weight: 600;
+ font-size: 14px;
letter-spacing: 0.04em;
color: var(--chalk-dim);
}
-/* Same size/typography as .apply-btn (Apply) — an expand/collapse panel
- toggle (§2: not a native select), but visually the row's primary action,
- same gold treatment Apply gets in the filter bar. */
+/* Same size, gold treatment, and now typography (Oswald/600) as .apply-btn
+ in the filter bar — an expand/collapse panel toggle (§2: not a native
+ select), visually the row's primary action. The scatter-plot label font
+ is Oswald too, but stays at its own lighter 450 weight, not this bar's
+ 600. */
.pcs-inspect-btn {
appearance: none;
-webkit-appearance: none;
@@ -1392,7 +1512,7 @@ body {
border: 1px solid var(--flag-gold);
border-radius: 6px;
padding: 9px 20px;
- font-family: var(--font-mono);
+ font-family: var(--font-oswald);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
@@ -1411,10 +1531,25 @@ body {
}
.pcs-inspect-btn[aria-expanded="true"] .chevron { transform: rotate(180deg); }
+/* Glass treatment (v1.4.0) — scoped to this one floating panel on purpose,
+ not the Player/Linemate/Merge Card bodies: those are data-dense stat
+ tables where a blurred background would hurt legibility. -webkit- prefix
+ is required for the iPad/WeChat in-app-browser (WKWebView) case this app
+ already targets. */
.pcs-panel {
+ /* backdrop-filter establishes a new stacking context, which otherwise
+ traps the Single Cards search dropdown's z-index:50 (below) inside it
+ — .board (the chart) sits later in the DOM at its own position:relative/
+ z-index:auto, so without an explicit z-index here .board would win that
+ comparison and paint over the dropdown. position+z-index below let this
+ whole panel (dropdown included) stack above .board again. */
+ position: relative;
+ z-index: 10;
margin-top: 8px;
- background: var(--turf-800);
- border: 1px solid var(--turf-600);
+ background: rgba(255, 255, 255, 0.07);
+ -webkit-backdrop-filter: blur(10px);
+ backdrop-filter: blur(10px);
+ border: 2px solid #f0ede4;
border-radius: 8px;
padding: 14px 16px;
}
@@ -1439,7 +1574,7 @@ body {
.pcs-column-title {
margin: 0 0 8px;
- font-family: var(--font-mono);
+ font-family: var(--font-oswald);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
@@ -1472,6 +1607,14 @@ body {
that no longer fits in this box at this width lives on its own static
line below instead, reusing .pcs-empty — see index.html.) */
.pcs-add-wrap { flex: none; width: 120px; margin-bottom: 0; }
+/* Glassmorphism reverted here (was rgba(255,255,255,0.07)/blur(10px)/
+ rgba(255,255,255,0.18), matching .pcs-panel above) — the blurred chart
+ title text bleeding through the dropdown clashed with the player-name
+ list underneath it, hurting readability. Solid background instead, same
+ #16281c as the rest of the workspace panel, with the v1.4.0 neubrutalist
+ 2px #f0ede4 border (matching the search input above it) rather than the
+ glass border. .pcs-panel itself keeps its own glass treatment — this
+ revert is scoped to just this floating suggestion list. */
.pcs-add-wrap .merge-edit-dropdown {
position: absolute;
top: calc(100% + 8px);
@@ -1479,8 +1622,30 @@ body {
z-index: 50;
width: 260px;
margin-top: 0;
+ background: #16281c;
+ border: 2px solid #f0ede4;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
+/* .merge-edit-input/.player-option(-name/-team) are reused wholesale here
+ (see comment above) but the base rules for those classes stay Inter/mono
+ — they're shared with the Players filter and the Create/Edit merge
+ popups, which this Oswald pass doesn't touch. Scope the override to just
+ the copy rendered inside Pinned Players instead of touching the shared
+ base rule. */
+#player-cards-space .merge-edit-input,
+#player-cards-space .player-option,
+#player-cards-space .player-option-team {
+ font-family: var(--font-oswald);
+}
+/* v1.4.0 neubrutalism, extended to the Single Cards add-search box — same
+ 2px solid #f0ede4/minimal-radius language as .apply-btn/.pcs-row-remove
+ above, scoped here rather than on the shared base .merge-edit-input rule
+ since that rule is also the Create/Edit merge popups' own input, which
+ this pass doesn't touch. */
+#player-cards-space .merge-edit-input {
+ border: 2px solid #f0ede4;
+ border-radius: 4px;
+}
.pcs-list {
display: flex;
@@ -1491,6 +1656,7 @@ body {
}
.pcs-empty {
margin: 0;
+ font-family: var(--font-oswald);
font-size: 12.5px;
color: var(--chalk-dim);
font-style: italic;
@@ -1516,6 +1682,7 @@ body {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ font-family: var(--font-oswald);
font-size: 13px;
color: var(--chalk);
}
@@ -1526,23 +1693,28 @@ body {
.pcs-row-spacer { flex: 1 1 auto; }
.pcs-row-meta {
flex: none;
- font-family: var(--font-mono);
+ font-family: var(--font-oswald);
font-size: 10.5px;
letter-spacing: 0.04em;
color: var(--chalk-dim);
}
+/* v1.4.0 neubrutalism, extended: same 2px solid #f0ede4/minimal-radius
+ language as .apply-btn/.scout-card-panel, but transparent (outline) rather
+ than filled — these are row-level secondary actions, so they shouldn't
+ carry the same visual weight as a filled primary action like Create/Apply
+ even once bordered the same way. */
.pcs-row-remove,
.pcs-row-edit,
.pcs-row-dismiss {
appearance: none;
-webkit-appearance: none;
flex: none;
- background: var(--turf-700);
+ background: transparent;
color: var(--chalk-dim);
- border: 1px solid var(--turf-600);
- border-radius: 5px;
+ border: 2px solid #f0ede4;
+ border-radius: 4px;
padding: 4px 8px;
- font-family: var(--font-mono);
+ font-family: var(--font-oswald);
font-size: 10.5px;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -1556,19 +1728,22 @@ body {
outline: none;
}
-/* Same size/typography as .png-btn (Save Plot) — black, the secondary-action
- treatment, gold reserved for Manage/Apply. min-width matches
- .pcs-inspect-btn's so Manage and Create render as the exact same size,
- not just the same padding/font. */
+/* Same size, and now same typography (Oswald/600), as .png-btn (Save Plot)
+ — black, the secondary-action treatment, gold reserved for Manage/Apply.
+ min-width matches .pcs-inspect-btn's so Manage and Create render as the
+ exact same size. v1.4.0 neubrutalism: 2px solid #f0ede4/4px radius, same
+ border weight as .pcs-row-remove/-edit/-dismiss above — the solid black
+ fill (vs. their transparent outline) is what marks this as the primary
+ action, not a heavier border. */
.merge-btn {
flex: none;
min-width: 120px;
background: #000;
color: var(--chalk);
- border: 1px solid var(--turf-600);
- border-radius: 6px;
+ border: 2px solid #f0ede4;
+ border-radius: 4px;
padding: 9px 20px;
- font-family: var(--font-mono);
+ font-family: var(--font-oswald);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
@@ -1589,25 +1764,87 @@ body {
/* --- Merge Card table --- one row per player, one column per metric,
percentile-only cells (BLUEPRINT.md §1.2). */
-/* Below the dashed .scout-card-top divider: the shared-pool sentence (Pool
+/* Below the .scout-card-top divider: the shared-pool sentence (Pool
column dropped from the table below, see renderMergeCardBody() — falls
back to listing each member's own pool if the card mixes positions), then
the scroll hint. One flex child of .scout-card-body so the two lines sit
- close together instead of each taking the body's full 16px gap. */
+ close together instead of each taking the body's full 16px gap. Rendered
+ as a bulleted list (.merge-card-note-list), same treatment as Linemate
+ Card's own two-line disclaimer (.linemate-card-note-list) — gold/italic
+ bullets rather than plain stacked paragraphs. */
.merge-card-notes {
display: flex;
flex-direction: column;
gap: 4px;
}
-.merge-card-pool,
-.merge-card-scroll-hint,
-.scout-card-pool {
+.merge-card-note-list {
margin: 0;
+ padding-left: 16px;
+ list-style: disc;
+}
+.merge-card-note {
font-family: var(--font-body);
font-size: 11px;
font-style: italic;
- color: var(--chalk-dim);
+ color: var(--flag-gold);
+}
+/* Player Card's own single-line pool sentence — same bulleted, gold/italic
+ treatment as .merge-card-note-list/.linemate-card-note-list above, rather
+ than the plain dim paragraph it used to be. Still just one ; the list
+ wrapper exists so a single line reads identically to the two-line notes
+ on Merge/Linemate Cards instead of looking like a different component. */
+.scout-card-note-list {
+ margin: 0;
+ padding-left: 16px;
+ list-style: disc;
}
+.scout-card-note {
+ font-family: var(--font-body);
+ font-size: 11px;
+ font-style: italic;
+ color: var(--flag-gold);
+}
+/* Excel-style click-to-sort headers (table-sort.js) — shared by Merge
+ Card's table, Linemate Card's roster table, and its Line Summary table.
+ `` itself is the click target (not just its text or the arrows), so
+ the whole cell needs to read as interactive. Every sortable column always
+ shows both a ▲ and a ▼ (stacked, text glyphs — not emoji, since emoji
+ ignore CSS `color` and can't carry the three states below), not just the
+ currently-sorted one, so a column's sortability is visible before it's
+ ever clicked. Kept as one shared block near the top of the DataFrame-table
+ styles below rather than repeated per table, since all three use
+ identical treatment. */
+.sortable-th {
+ cursor: pointer;
+ user-select: none;
+}
+.sortable-th:hover,
+.sortable-th.is-sorted {
+ color: var(--flag-gold);
+}
+.sortable-th:focus-visible {
+ outline: 1px solid var(--flag-gold);
+ outline-offset: -1px;
+}
+.sort-indicator {
+ display: inline-flex;
+ flex-direction: column;
+ margin-left: 4px;
+ vertical-align: middle;
+ line-height: 0.6;
+}
+/* Neutral (this column isn't the active sort): both arrows sit at the same
+ dim color. Active: makeSortableHeader() adds .is-active to whichever
+ arrow matches the current direction (gold); the other arrow drops to an
+ even dimmer shade via .is-sorted below rather than staying at the neutral
+ color, so the active direction reads unambiguously at a glance. */
+.sort-arrow {
+ font-size: 8px;
+ color: #5a6e5f;
+}
+.sort-arrow.is-active { color: #d4af5a; }
+.sortable-th.is-sorted .sort-arrow:not(.is-active) { color: #3f4f43; }
+
.merge-table-wrap { overflow-x: auto; }
.merge-table {
width: 100%;
@@ -1632,6 +1869,42 @@ body {
.merge-table td { color: var(--chalk); }
.merge-metric-cell { cursor: help; }
.merge-metric-cell:hover { color: var(--flag-gold); }
+/* Percentile color coding — same six bins/colors/text-contrast rule as
+ Player Card's .scout-pct-chip (scout-card.js's percentileChipClass()).
+ The JS mapping function is reimplemented locally per file
+ (percentileFillClass() in both merge-card.js and linemate-card.js) rather
+ than shared, matching this codebase's existing per-card-type styling
+ convention (e.g. .merge-table-frozen vs .linemate-table-frozen) — but
+ these CSS classes themselves ARE the one shared component both files'
+ copies point at, same reasoning as .sortable-th/.sort-arrow above being
+ one shared block for Merge Card's table and Linemate Card's roster table.
+ Deliberately NOT used on Linemate Card's Line Summary table (Min/Median/
+ RSWA/Max are order statistics across the roster, not an individual's
+ performance grade — see linemate-card.js's percentileFillClass() comment).
+ An inset chip span inside the — minimal-radius/small-padding, same
+ language as .scout-pct-chip — not a full edge-to-edge cell fill, so the
+ cell's own background and row divider stay visible around it. (An earlier
+ pass on Merge Card colored the directly for an Excel-style full-cell
+ fill; reverted — besides looking wrong for this grid, .merge-table td's
+ own `color: var(--chalk)` rule is MORE specific than a single class like
+ .merge-pct-yellow and silently won on the td, breaking dark-on-light text
+ for the yellow/light-blue bins. Setting color on the chip span instead
+ sidesteps that: a span is never itself a `.merge-table td`/`.linemate-
+ table td`, so the two rules never compete on the same element.) Games/
+ threshold-field columns never get one of these classes (see
+ renderMergeCardBody()/renderLinemateRoster()), so they stay uncolored. */
+.merge-pct-chip {
+ display: inline-block;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-weight: 600;
+}
+.merge-pct-red { background: #e74c3c; color: #f0ede4; }
+.merge-pct-orange { background: #e67e22; color: #f0ede4; }
+.merge-pct-yellow { background: #f1c40f; color: #1a1a1a; }
+.merge-pct-lightblue { background: #5dade2; color: #1a1a1a; }
+.merge-pct-darkblue { background: #1f618d; color: #f0ede4; }
+.merge-pct-violet { background: #a569bd; color: #f0ede4; }
.merge-table .merge-table-name,
.merge-table th:first-child {
text-align: left;
@@ -1673,87 +1946,86 @@ body {
thead .merge-table-frozen { z-index: 3; }
.merge-table-frozen-edge { box-shadow: 1px 0 0 var(--turf-600); }
-/* --- Linemate Association Card --- roster rows + 3M/RSWA summary
+/* --- Linemate Association Card --- roster table + 3M/RSWA summary
(BLUEPRINT.md §2). Header now mirrors the Player Card's two-row layout
- (.scout-card-top logo+name+meta, then a second dashed-border row) — see
+ (.scout-card-top logo+name+meta, then a second bordered row) — see
openLinemateCard()/renderLinemateCardBody(). .linemate-card-notes is that
second row's own modifier on .scout-card-actions, swapping its single-
- button flex-start layout for a stacked column of the two disclaimer
- lines that used to sit under the title. */
-.linemate-card-title-hint {
- margin-left: 6px;
- font-size: 13px;
- color: var(--chalk-dim);
- cursor: help;
-}
-.linemate-card-title:hover .linemate-card-title-hint {
- color: var(--flag-gold);
-}
+ button flex-start layout for the two disclaimer lines (roster size/
+ threshold, then the percentile note) that used to sit under the title. */
.linemate-card-notes {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
-.linemate-card-note {
+.linemate-card-note-list {
margin: 0;
- font-family: var(--font-body);
- font-size: 11px;
- font-style: italic;
- color: var(--flag-gold);
+ padding-left: 16px;
+ list-style: disc;
}
-.linemate-card-hint {
- margin: 0;
+.linemate-card-note {
font-family: var(--font-body);
font-size: 11px;
font-style: italic;
- color: var(--chalk-dim);
-}
-.linemate-roster {
- display: flex;
- flex-direction: column;
- gap: 10px;
- margin-bottom: 10px;
-}
-.linemate-row {
- display: flex;
- flex-direction: column;
- gap: 4px;
- padding-bottom: 8px;
- border-bottom: 1px dashed var(--turf-600);
+ color: var(--flag-gold);
}
-.linemate-row-name-wrap {
- display: flex;
- align-items: center;
- gap: 6px;
+/* Roster table — same DataFrame column style as Merge Card's .merge-table
+ (BLUEPRINT.md §2.1/§1.2: one row per teammate, one column per metric,
+ percentile-only cells), just without its Team column (redundant here —
+ every row already shares the anchor's team, see computeLinemateRoster())
+ or its Linemates-toggle column (no per-row drill-down on this card type,
+ BLUEPRINT.md §2.3's recursion guard). */
+.linemate-table-wrap { overflow-x: auto; margin-bottom: 10px; }
+.linemate-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-family: var(--font-mono);
+ font-size: 11.5px;
}
-.linemate-row-name {
- font-family: var(--font-body);
- font-size: 12.5px;
- font-weight: 600;
- color: var(--chalk);
+.linemate-table th,
+.linemate-table td {
+ padding: 6px 8px;
+ text-align: right;
+ white-space: nowrap;
+ border-bottom: 1px solid var(--turf-600);
}
-.linemate-row-hint {
+.linemate-table th {
color: var(--chalk-dim);
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
font-size: 10px;
- cursor: help;
-}
-.linemate-row-name-wrap:hover .linemate-row-hint {
- color: var(--flag-gold);
}
-.linemate-row-cells {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
+.linemate-table td { color: var(--chalk); }
+.linemate-metric-cell { cursor: help; }
+.linemate-metric-cell:hover { color: var(--flag-gold); }
+.linemate-table .linemate-table-name,
+.linemate-table th:first-child {
+ text-align: left;
+ font-family: var(--font-body);
+ font-weight: 600;
}
-.linemate-cell {
- font-family: var(--font-mono);
- font-size: 10.5px;
- color: var(--chalk-dim);
- background: rgba(241, 236, 221, 0.06);
- border-radius: 4px;
- padding: 2px 6px;
- cursor: help;
+/* Position — same left-align treatment as Merge Card's Team column
+ (.merge-table .merge-table-team, .merge-table th:nth-child(2)), just
+ plain text instead of a logo since there's no per-row team to show. */
+.linemate-table .linemate-table-position,
+.linemate-table th:nth-child(2) { text-align: left; }
+/* Freeze Panes, scoped to the Player + Position columns — both metadata,
+ same as Merge Card's own Player/Team/Linemates freeze. Left offsets are
+ set inline per column by applyStickyLinemateColumns() (linemate-card.js),
+ since they depend on Player's actual rendered width, not just Position's
+ fixed slot at 0. Opaque background masks the Games/PR Opp/metric columns
+ scrolling underneath, same reasoning as .merge-table-frozen — including
+ the real class (not just :first-child) card-export.js's buildExportClone()
+ needs to force these back to position:static, same reason it does for
+ .merge-table-frozen (see its comment there). */
+.linemate-table-frozen {
+ position: sticky;
+ z-index: 2;
+ background: var(--turf-800);
}
+thead .linemate-table-frozen { z-index: 3; }
+.linemate-table-frozen-edge { box-shadow: 1px 0 0 var(--turf-600); }
.linemate-see-more {
appearance: none;
-webkit-appearance: none;
@@ -1824,20 +2096,33 @@ thead .merge-table-frozen { z-index: 3; }
}
.linemate-summary-table th:first-child,
.linemate-summary-table td:first-child {
- width: 46%;
+ width: 40%;
white-space: normal;
}
-/* The RSWA header's info icon (BLUEPRINT.md §2.5's formula) — a native
- `title` tooltip, not the app's usual .info-hint ::after popup, since a
- popup here would get clipped by .linemate-summary-wrap's overflow-x:auto. */
-.linemate-summary-hint {
- margin-left: 4px;
- color: var(--chalk-dim);
- font-size: 10px;
- cursor: help;
-}
-.linemate-summary-table th:hover .linemate-summary-hint {
- color: var(--flag-gold);
+/* Min/Max hold the same short ordinal text as Median/RSWA ("41st", "95th")
+ but don't need as much room once Metric (above) claims more of the row
+ for its own long labels ("TPS Allowed Pressure %") — narrowed so that
+ extra width comes from here rather than shrinking Median/RSWA, which
+ still need to fit the RSWA header's InfoPopover trigger button. */
+.linemate-summary-table th:nth-child(2),
+.linemate-summary-table td:nth-child(2),
+.linemate-summary-table th:nth-child(5),
+.linemate-summary-table td:nth-child(5) {
+ width: 12%;
+}
+/* The RSWA header's InfoPopover trigger (BLUEPRINT.md §2.5's formula) —
+ shared .info-popover-trigger component (info-popover.js), same one the
+ Players filter header uses, standing in for that column's plain-text
+ label (see renderLinemateSummary()'s replaceChild). Sized down from the
+ component's own default padding/font-size here — this table's columns are
+ too narrow (see the 32%-first-column split above) for the full-size
+ button to fit without crowding the Min/Median/Max headers next to it. */
+.linemate-summary-table th .info-popover-trigger {
+ padding: 2px 6px;
+ gap: 3px;
+ font-size: 9.5px;
+ letter-spacing: 0.03em;
+ vertical-align: middle;
}
/* --- Workspace quota/cap notice popup --- same modal-overlay pattern as
diff --git a/frontend/table-sort.js b/frontend/table-sort.js
new file mode 100644
index 0000000..ad64da3
--- /dev/null
+++ b/frontend/table-sort.js
@@ -0,0 +1,100 @@
+/* Excel-style click-to-sort table headers — shared by Linemate Card's
+ * roster + Line Summary tables (linemate-card.js) and Merge Card's table
+ * (merge-card.js). Dependency-free, like info-popover.js, so every card
+ * module can import it without adding a cross-card-type dependency —
+ * linemate-card.js in particular stays a pure leaf with no dependency on
+ * merge-card.js (see that file's header comment).
+ */
+
+// Toggles a { key, dir } sort state object in place: clicking the
+// already-sorted column flips direction, clicking a different one starts it
+// ascending — matches Excel's own first-click convention. Returns the same
+// object so callers can chain.
+export function toggleSortState(state, key) {
+ if (state.key === key) {
+ state.dir = state.dir === "asc" ? "desc" : "asc";
+ } else {
+ state.key = key;
+ state.dir = "asc";
+ }
+ return state;
+}
+
+// Returns a new array (never mutates `rows`) ordered per `state` using
+// `valueFn(row)` for the current column — a no-op returning `rows` as-is
+// until a header's been clicked at least once (state.key is null). Strings
+// compare via localeCompare, numbers compare numerically; null/undefined
+// always sort last regardless of direction, so missing data (e.g. a metric
+// with no qualifying pool) doesn't scatter through the middle of a ranked
+// column.
+export function sortRows(rows, state, valueFn) {
+ if (!state.key) return rows;
+ const dir = state.dir === "desc" ? -1 : 1;
+ return rows.slice().sort((a, b) => {
+ const av = valueFn(a);
+ const bv = valueFn(b);
+ if (av == null && bv == null) return 0;
+ if (av == null) return 1;
+ if (bv == null) return -1;
+ if (typeof av === "string" || typeof bv === "string") {
+ return String(av).localeCompare(String(bv)) * dir;
+ }
+ return (av - bv) * dir;
+ });
+}
+
+// Builds one sortable : label text + a stacked ▲/▼ indicator that's
+// always present (not just on the active column) — text glyphs, never
+// emoji, since emoji ignore CSS `color` and so can't carry the
+// neutral/active-gold/active-dimmed states .sort-arrow's CSS relies on (see
+// style.css). The whole is the click target (label AND arrows both
+// bubble up to its own listener below), not just the arrow glyphs — a user
+// shouldn't have to aim for two 8px triangles to sort a column. Click (or
+// Enter/Space, for keyboard users) toggles `state` via toggleSortState() and
+// re-runs `onSort` — the owning render function, which rebuilds the whole
+// table from scratch, so every header's arrows reflect the new state
+// without this needing to touch sibling headers itself.
+export function makeSortableHeader(label, key, state, onSort) {
+ const th = document.createElement("th");
+ th.classList.add("sortable-th");
+ th.tabIndex = 0;
+ th.setAttribute("role", "button");
+ th.setAttribute("aria-label", `Sort by ${label}`);
+
+ const labelSpan = document.createElement("span");
+ labelSpan.textContent = label;
+ th.appendChild(labelSpan);
+
+ const isActive = state.key === key;
+ if (isActive) th.classList.add("is-sorted");
+
+ const indicator = document.createElement("span");
+ indicator.className = "sort-indicator";
+ indicator.setAttribute("aria-hidden", "true");
+
+ const up = document.createElement("span");
+ up.className = "sort-arrow sort-arrow-up";
+ up.textContent = "▲";
+ const down = document.createElement("span");
+ down.className = "sort-arrow sort-arrow-down";
+ down.textContent = "▼";
+ if (isActive) (state.dir === "desc" ? down : up).classList.add("is-active");
+
+ indicator.appendChild(up);
+ indicator.appendChild(down);
+ th.appendChild(indicator);
+
+ const activate = () => {
+ toggleSortState(state, key);
+ onSort();
+ };
+ th.addEventListener("click", activate);
+ th.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ activate();
+ }
+ });
+
+ return th;
+}
diff --git a/frontend/workspace.js b/frontend/workspace.js
index f9e3160..7d52753 100644
--- a/frontend/workspace.js
+++ b/frontend/workspace.js
@@ -188,6 +188,7 @@ export function renderPlayerCardsSpace() {
// not a per-row Merge action (v1.2.0 §4/§5 — see the Create popup instead).
export function renderSinglesList() {
const empty = workspaceSingles.size === 0;
+ els.pcsSinglesEmpty.hidden = !empty;
els.pcsSinglesList.hidden = empty;
els.pcsSinglesList.innerHTML = "";
if (empty) return;