v71 · sidebar hides on fresh home; appears once the user has engaged.
Wordmark click now returns to a cleaner home screen — banner expanded, search bar centered, no sidebar. The sidebar surfaces itself only after the user has done something that needs navigation: searched, opened the profile editor, or visited the arcade. Implementation is pure CSS — .sidebar defaults to display:none, with three :root:has() rules flipping it to display:flex when .page.searched, .page.profile-mode, or .page.arcade-mode is on. No JS state to manage, no flicker on load, no edge cases — the wordmark click already removes the .searched class via resetSearch(), and that class removal is what cascades through the :has() rule to hide the sidebar. Reverse direction works the same: submitting a search adds .searched, the :has() rule fires, sidebar appears. Net UX: the home screen now feels like a true landing surface — wordmark, banner, search bar, user widget, brand seal at the bottom-right. Navigation is something you earn by engaging, not chrome you have to step around when you arrive.
v101 · Bug fix — board-flag stripe peeking above sticky header on scroll.
Reported by Francis: when scrolling down the results table, the left-edge color stripe that marks an unexpanded row as "on the coach's board" remained visible above the sticky header in the page's left margin gutter, even after the row itself had scrolled up beneath the header.
Cause was geometric, not z-index. The stripe is rendered as a ::before pseudo-element on .row (which has position:relative for the chevron and note-overlay systems), positioned with left:-2px width:3px. That placed it at horizontal position −2px to +1px relative to the row — meaning 2 pixels protruded to the left of the row's content box, into the page's left margin gutter between the sidebar and the card. By design, in the natural (unscrolled) state, that 2px sliver read as a small tab poking out of the card's left edge — a subtle visual signal that the row was on the board. But the sticky-header pins at the card's content width and does not extend into the gutter, so when rows scrolled up under the header, the row's content got masked correctly, while the stripe's protruding 2px sliver kept showing in the gutter above the header. Z-index wasn't the issue (the sticky header was on top z-wise); the issue was that the stripe's left protrusion lived laterally outside the sticky-header's masking zone entirely.
One-line fix: changed left:-2px to left:0 on .result[data-target-state="target"] > .row::before. The stripe is now entirely inside the row's content box, sitting flush with the card's interior left border. Visual change is small — the stripe shifts about 2 pixels to the right and reads as a left-edge marker rather than a poking-out tab — but it eliminates the gutter overflow that caused the scroll-state bug. Sticky-header now fully covers the stripe along with the rest of the row, in both states.
Considered alternatives. (a) Add overflow:hidden to .card — would also clip the stripe, but breaks the colhead's position:sticky per v51 (sticky descendants fail when any ancestor has overflow != visible). (b) Add overflow:hidden to .row — works, but is more invasive than necessary, and could clip future popovers or tooltips that legitimately need to escape row bounds. (c) Extend the sticky-header laterally with negative margins to cover the gutter — possible, but couples the chrome's geometry to a row-level decoration in a way that would silently break if either side's padding changed. The single-property change to left:0 is the lightest fix and the one that most clearly says what it's doing.
v100 · Player card charts colorized — black bars retired, brand palette across 8 figures.
The performance-profile grid on the player card was previously rendered in pure #0a0a0a — every bar fill, line stroke, polygon stroke, and dot was the same near-black. Functional, but tonally out of step with the rest of the page, which already lives in the chromatic banner palette (teal / orange / pink / yellow rotating with each wordmark click). Two visual problems with the all-black approach: the player card felt like it had been ported in from a different design system (financial dashboard, not a page that opens with a 50-circle color matrix), and 8 identical-looking figures stacked in a 2-column grid lost their per-card identity. The eye couldn't easily say "I'm now looking at the trajectory chart" vs. "the production chart" because they all rendered identically.
Color assignment. Each chart card claims one of the four chromatic tiers (--tier-1 through --tier-4, set by generateBrandPattern() on every banner reroll). Pattern is a checkerboard so no two adjacent cards share a color, and each tier appears exactly twice across the 8-card grid:
row 1: T1 athletic | T2 stylistic
row 2: T3 tier-adj | T4 trajectory
row 3: T2 snap usage | T1 coverage eff
row 4: T4 ball prod | T3 team context
The athletic radar gets Tier 1 deliberately — Tier 1 is the missing color, the same color the avatars carry, so the player's most prominent figure (top-left, the radar of physical measurables) tints to match the player's own avatar elsewhere on the page. Open the card on a pink-missing day and the avatar is pink, the radar is pink, the page reads as one chromatic identity. Open on a teal day and everything shifts to teal in lockstep.
Implementation. Each of the four chart-rendering functions (svgRadar, svgBarComparator, svgMultiBar, svgLineTrajectory) now accepts a color parameter that defaults to var(--tier-1). The colored elements (bar fills, line strokes, polygon fills/strokes, dots) use style="fill:..." or style="stroke:..." attributes rather than the SVG presentation attributes fill="" and stroke="", because var() only resolves inside CSS contexts — it doesn't work in raw SVG attributes. Inline style is the lightest-weight route to making CSS vars work inside SVG without restructuring the whole rendering pipeline. Text labels (numeric values, axis labels) keep their original ink colors — they're not chart elements, they're typography. Average-comparator dashed lines and tick marks also stay neutral gray; the average is contextual reference, and tinting it would compete with the player's own colored bar.
Effect on the page. The chromatic story now extends from the banner all the way through the deepest interior surface. A coach scrolling through Azir Lee's card on a teal day sees the avatar in teal, the chart at top-left in teal, and other charts in the three remaining brand colors arranged so the eye can distinguish them at a glance. Click the wordmark to reroll the banner and every chart re-paints to match — no JS event needed, just CSS-var inheritance. Black bars are gone; the page breathes in its own palette.
v99 · Compare button + analysis sidebar tab temporarily hidden.
Per Francis: the analysis surface and the compare flow that feeds it aren't ready to ship — the data layer doesn't yet carry real combine numbers, real production stats, or real positional percentile distributions, so the page would mislead a coach who relied on it. Hiding both entry points until the underlying data exists.
Two HTML comments, no code deleted. The sidebar's "analysis" link is commented out; the player card's "Compare" ghost button is commented out. Everything else stays — #view-compare still mounts, renderAnalysis() still runs if called programmatically, the seeded localStorage state still persists, the compare-mode chrome rules still match. The result is a tool that has no path to the analysis surface from the UI but loses none of the work to build it; uncommenting the two lines restores the feature exactly as it was. Visual consequence on the player card: with the Compare ghost gone, "Open card" sits alone right-aligned via .panel-actions { justify-content: flex-end }, which reads correctly as a single primary action and doesn't leave a hole.
Decided against deleting the analysis code entirely. The figures, the slot-color system, the mock-stat generator, the chip-rail interaction model — all of that is real design work that earned its way into the file. Carrying it forward as commented-out (rather than reverted) means the next iteration can resume from the right place rather than rebuild from a blank placeholder. Marked it explicitly in both comments so a future reader (or Francis returning to it in three weeks) knows the hide is intentional and reversible, not a half-finished cleanup.
v98 · Manifesto: "...to make sport more romantic." → "...for the romance of sport."
A different kind of sentence. The previous line was an action statement — "Stitch exists [in order] to make sport more romantic" — with the brand cast as a builder of the quality. The new line is a dedication: "Stitch exists for the romance of sport." Same eavesdropping quality preserved by the leading ellipsis, but the implicit verb shifts from make to devote ourselves to, and the brand's posture shifts from creator to servant.
Two editorial moves are doing the work. First, dropping the active "make" verb removes the implicit creation claim — we're no longer asserting that we'll produce the romance, just that the romance is what we're here for. That's a smaller, more defensible promise from a recruiting tool whose users are reasonably wary of products claiming to transform their sport. Second, the definite article in "the romance of sport" (rather than "sport more romantic" or "sport romantic") concedes the romance is a known, pre-existing thing — you can refer to the romance only of something that already has it. So the line gets to drop the qualifier "more" without losing the humility that "more" was carrying; the concession migrates from the qualifier into the noun phrase. Per Francis: he believes sport is already romantic, and the manifesto should reflect that rather than imply Stitch is providing the romance.
Register also shifts. "...to make sport more romantic." reads as a quiet confession of intent — the kind of thing you'd hear someone mutter about their work-in-progress. "...for the romance of sport." reads more like a dedication on an inside cover, or what someone might say if asked at a dinner party why they're doing all this. Less an admission, more a pledge. Both work for the surface (a hidden Easter egg behind a clicked logo), but the new line earns its weight differently — the previous line was caught mid-thought, the new line is a thought that has settled into its final form.
Single-character implementation: line 3452. Everything else about the manifesto stays — the white-field reveal, the 1000ms fade-up, the 250ms-delayed text drift, the Crimson Pro italic at 36px, the click-anywhere-to-dismiss behavior. The motion grammar carries over because the sentence is still a single italic line of similar length and weight; the change is purely in what's being said, not how it's being shown.
v97 · Analysis tab — the compare placeholder becomes a real surface.
Replaced the single-sentence placeholder ("Pick players from search and lay them side-by-side") with a working comparison page. Three architectural pieces stacked top-to-bottom: tagged pool (chip rail of every player flagged for compare), four-slot active roster (the players currently being compared), and the figures (Stitch Score bars, athletic radar, production bars, percentile strips). Each slot owns a brand color — teal, orange, pink, yellow — and that color propagates everywhere: the slot card's color strip, the radar polygon, the production bars, the percentile dots, and a small dot on the player's tagged-pool chip indicating which slot they're in. The whole page reads on one visual key, so coaches don't consult a legend; the polygon's color matches the card's color matches the chip's dot.
Why these four figures. Stitch Score bars are the headline — the platform's own composite, given the most prominent placement. The athletic radar is the single most informative figure for n≤4: six axes (Height, Weight, Speed, Burst, Agility, Strength) overlaid as semi-transparent polygons, so the shape of each athlete is legible at a glance — coaches see immediately who's the burst guy vs. the size guy without having to read numbers. The production bars hold the counting stats — what they actually did on the field — grouped by metric so direct comparisons are eye-level. The percentile strips were the last addition: a 4.45 forty means nothing in isolation, but "85th percentile among CBs" answers the question coaches are actually asking. Considered and rejected: a recruiting-rating panel (stars, national rank, state rank). When you've already filtered to four candidates, the rankings collapse the differentiation that the radar and percentile strips actually surface — they'd be noise.
Yellow needs help on white. Three of the slot colors (teal, orange, pink) read cleanly on the near-white surface at any size; pure yellow at small sizes loses the edge against the page. The accommodation is consistent across the surface: yellow polygons get a charcoal stroke and a slightly heavier fill opacity (.30 vs the .14 the others use); yellow legend swatches and percentile dots get a charcoal outline; yellow bar fills get an inset 1px charcoal shadow. The result is yellow stays in the brand palette without making the figures unreadable. Could have substituted charcoal for slot 4 to sidestep the issue, but that breaks the visual unity with the rest of the site (which uses the same teal/orange/pink/yellow palette throughout).
Mock stats, real surface. The data layer in this prototype only carries biographical info plus the Stitch composite score; there are no real combine numbers, no game logs. To populate the figures, getMockStats() generates a deterministic stat block per player using a seeded PRNG (FNV-1a hash of the player's avatar slug → linear-congruential generator → six athletic + four production values). "Deterministic" matters: the radar polygon for a given player is stable across renders, so the page doesn't dance every time the user adds or removes a slot. In production, this generator gets replaced by a real-data lookup; the visual surface stays exactly the same.
Three actions, three buttons, no ambiguity. The tagged-chip "X" untags entirely (removes from both lists, since active is always a subset of tagged). The active-slot "X" removes from the active comparison but keeps the player tagged — useful when a coach wants to swap one of the four for someone else without losing the tag. The tagged-chip "+" promotes the player into the next free slot. The empty-slot card is also clickable, scrolling the tagged pool into view. Per Francis: removing should be available at both layers, since "remove from analysis" and "untag entirely" are two different operations. State persists in localStorage under stitch_analysis_tagged and stitch_analysis_active, with the active list always validated as a subset of the tagged list on every render.
What's still TODO. The player card in the discover view doesn't yet have a "tag for compare" action — when that surface ships, the tag handler just appends to readAnalysisTagged() and persists. The seeded data is all CBs, so the production stat panel is hardcoded to CB stats (interceptions, pass breakups, tackles, TFLs); a position-aware version would pivot the prodSpec array on player.pos — QB → pass yds/TDs/INTs/comp%, RB → rush yds/YPC/TDs, etc. The percentile values are seeded from the same PRNG rather than computed against a real positional distribution; same swap-out path as the mock stats above. The chrome (compare-mode CSS) was already correct from v82, hiding the banner and search bar so the page owns the viewport — that didn't need to change.
v96 · Profile-card avatar joins the chromatic field — bottom-left avatar stays charcoal.
Two-avatar split. The large profile-card avatar (the one centered inside the My Profile card) was previously hard-coded teal at #10A890. Now wired to var(--tier-1, #13A895) with color: var(--tier-1-ink, #fff), so it tints to the day's missing color along with the 50 player avatars on the discover view — pink missing → profile-card is pink, orange missing → profile-card is orange, and so on. Inside the coach card, the color scheme is allowed to pop and participate in the chromatic rotation; that's the right surface for it.
The small avatar in the bottom-left widget — present on every screen — stays at warm charcoal #3D3631. Kept it deliberately off the chromatic rotation: this widget is the user's own seat at the corner of the page, and reads better as a quiet anchor that the rest of the page's color rotates around than as another participant in the rotation. The same #3D3631 is used for the two structural black circles in every banner row that are never thinned — the anchor color of the whole brand. So when the page's chromatic identity reshuffles on a wordmark click, the coach's home seat stays put, and the eye has a fixed point of return at the bottom-left.
Implementation is two CSS edits, no JS. --tier-1 and --tier-1-ink are already published by generateBrandPattern() on every banner reroll (lines 3164–3169 — set up in v91/v93 to drive the Tier 2 board pill, Tier 3 drop hovers, etc.), so .profile-avatar-large updates in lockstep with the banner without any added wiring. Yellow gets dark ink automatically via the existing inkFor() helper, so the initials stay legible on every variant.
v95 · history promoted to top-level sidebar item.
History was a hover-revealed sub-item under discover (introduced v83, hidden-by-default v84) — readable but quiet, and required a deliberate hover gesture to access. Per Francis: bring it up to peer level. Sidebar now reads top-to-bottom as five flat items: discover, history, compare, targets, stitch bowl.
Markup change: removed the <div class="sb-group"> wrapper and dropped the sb-sub modifier class on the history item — it's now just a plain .sb-item like everything else. CSS cleanup: deleted the .sb-group, .sb-item.sb-sub, .sb-group:hover .sb-sub, and related rules entirely (about 35 lines), since nothing else used them. The sidebar's outer flex; gap:18px now spaces all five items uniformly without any per-item margin overrides. JavaScript untouched — showView() already handled history as a regular view, and navItems is a querySelectorAll('.sb-item') so the change in the markup is picked up automatically.
Worth noting that this changes the resting visual weight of the sidebar — it goes from four items at rest (with a quiet fifth that appears on intent) to five items always. The previous arrangement implied a hierarchy ("history is a thing that happens within discover"); the new one treats it as an independent destination on equal footing with compare and targets. If after using it that flatness feels too dense, the sub-item pattern is recoverable from version control — but I think peer level is the right read here. History is genuinely its own surface, not a wrapper around discover.
v94 · Sidebar stays pinned to its home-screen position.
Previously the sidebar's top was calc(var(--page-top) + 5px), and --page-top collapses from clamp(40px, 16vh, 140px) on home down to 60px in any non-home view (searched, profile, compare, targets, history, arcade). So the sidebar slid up in lockstep with the banner whenever the user did anything. The intent (per v69) was that the sidebar always tracks the banner's top circle. Per Francis: try the opposite — leave the sidebar anchored where it lives on the home screen, regardless of what happens above. The sidebar becomes a fixed left spine rather than a chrome element coupled to the search bar.
Implementation is one line: top: calc(clamp(40px, 16vh, 140px) + 5px), hardcoded to the home value rather than tracking the variable. Removed the top transition from the sidebar's transition list since the value is now static (kept the opacity transition for the reveal). The --page-top overrides on :root:has() still drive the page-content collapse correctly; only the sidebar is decoupled from them.
Visual effect: on search submit, the banner collapses and the sticky search bar pins at the top, but the sidebar stays where it was — it's now visibly below the search bar rather than aligned with it. Worth living with for a session before deciding it's right; the v69 alignment to the banner's top circle was deliberate, and pulling the sidebar away from that creates a small floating effect on the left that some find pleasing (sidebar reads as the room you're in, not the doorway you came through) and some find disconnected. If the latter, options are easy: revert to the variable-tracking version, or pick a third static height (e.g. align with the search bar at top: 65px in all states).
v93 · Reverted v92. Avatar color matches missing color again.
v92 introduced a separate STITCH_AVATAR_COLOR drawn from the three non-missing chromatics, on the theory that a "color absent from banner = color absent from avatars" relationship would read as cleaner. In practice, looking at it, the more intuitive read is the original one: the missing cell in the banner points at the color the avatars are tinted in. v92 broke that pointer. Reverting to the v75/v91 logic — avatarUri() reads window.STITCH_MISSING_COLOR directly, no second draw, no STITCH_AVATAR_COLOR.
The original concern that motivated v92 (the missing-color signature being only one cell out of ~59 visible chromatic circles) is still real, but the right fix is to strengthen the missing-color signal in the banner — not to break the link between banner and avatars. If we revisit, option 1 from the earlier discussion (thin the chosen color across all 5 rows instead of just the sticky one) is the cleaner move.
v91 · Avatar color sync: clicking the wordmark now propagates the new banner color to all 50 avatars.
Fixes the desync caveat flagged in v75. Background: the chromatic banner picks a "missing color" at render time and exposes it as window.STITCH_MISSING_COLOR; the avatars are derived from that color (each player ships with all four chromatic variants). Problem: results are rendered once at page load (line 2080: root.innerHTML = RESULTS.map(renderResult).join('')) and runSearch() only unhides the already-rendered card. So when the user clicked the wordmark and generateBrandPattern() rolled a new missing color, the banner refreshed but the 50 avatars in DOM stayed in whichever color was active at first render. Banner missing pink, avatars still orange — page no longer reads as a single chromatic field.
Fix is two parts. (1) Each avatar img now carries a data-avatar="${p.avatar}" attribute — the lookup key for the AVATARS map. (2) New syncAvatarColors() function walks every .panel-avatar[data-avatar] in the DOM and rewrites its src to the current missing color via avatarUri(). Called at the end of generateBrandPattern() so banner and avatars stay locked together — every reroll updates both.
Chose in-place src rewriting over re-rendering the whole results array, because the latter would collapse any expanded result panels and lose interaction state. Iterating 50 imgs and reassigning their src is cheap (the URI strings are already in memory in the AVATARS map; no network requests, just data: URL swaps). On the initial page load syncAvatarColors() is a no-op because line 2080 hasn't run yet — the avatars get the right color baked in directly. The function only does real work on subsequent banner regenerations.
v90 · Manifesto: leading ellipsis.
"to make sport more romantic." → "...to make sport more romantic." The leading ellipsis reinforces the marginalia/overheard-thought quality the lowercase opening was already reaching for — now it really reads like a continuation of a sentence that was started somewhere else, which fits the reveal: you click into the manifesto mid-thought rather than at a clean beginning. Used three periods rather than the unicode horizontal ellipsis (…) to match Francis's literal request; switching to … would tighten the dot spacing slightly if that's preferred — easy one-character swap.
v89 · Manifesto: text shortened to "to make sport more romantic."
Distilled from "Stitch exists to make sport a bit more romantic." to "to make sport more romantic." Cuts the brand self-reference (the surrounding context — clicking the brand logo on a page that already says "stitch" — already establishes the subject) and tightens "a bit more" to just "more." The lowercase "to" opening reads almost like a footnote or marginalia, which suits the dreamy reveal — less a declaration, more an overheard thought.
No CSS changes needed; nowrap + clamp(17px, 3.6vw, 34px) still hold the line. The shorter line does open up headroom on the size — at 28 characters instead of 50, the text could comfortably go to 44–50px on desktop without breaking. Left at 34px max for now since that's what was tuned in v86.
v88 · Manifesto: back to Crimson Pro, single-line constraint.
Reverted typeface from Libre Caslon Text to Crimson Pro italic light (matches v86, which Francis preferred over the Caslon experiment in v87). Dropped the Libre Caslon Text Google Fonts request from the document head while we're at it — no point loading a face we don't use.
Forced the sentence onto a single line by adding white-space:nowrap and replacing the static font-size with clamp(17px, 3.6vw, 34px) so the type scales gracefully across viewport widths instead of breaking at a hard breakpoint. On a 1200px+ desktop the sentence sits at 34px (same visual weight as before); at narrower widths it shrinks linearly until it bottoms out at 17px on phones, which keeps the line intact without horizontal overflow. Padding on the overlay now uses the same pattern (clamp(16px, 3vw, 48px)) so the inner content area stays proportionate to the type rather than crowding the line at narrow viewports.
Removed the now-redundant @media (max-width:680px) rule that bumped font-size down to 25px — the clamp() handles all the responsive scaling in one declaration, so the media query was just adding a discontinuity at 680px. Also dropped max-width:720px from the text element since nowrap is doing the line-length constraining now.
v87 · Manifesto: black-on-white, Libre Caslon Text italic.
Three coordinated changes. (1) Background #000 → #ffffff; text #fff → #0a0a0a. The white-on-black version felt cinematic but a bit corporate-mystery; flipped, the manifesto reads as a page from a magazine — quieter, more readable, more like the publication-mark sensibility the brand-logo already plays. (2) Typeface from Crimson Pro to Libre Caslon Text italic. The New Yorker's section-heading face (Irvin / NY Irvin) is custom and not freely available; their body-text face is Adobe Caslon Pro, which is what the magazine's reading experience actually feels like on the page. Libre Caslon Text is the closest free Google Fonts revival of Caslon, with proper italic. The manifesto now borrows the magazine's italic-Caslon-on-white pull-quote sensibility — the gesture that distinguishes a New Yorker page from any other publication's. Added the font to the Google Fonts import (Libre+Caslon+Text:ital,wght@0,400;1,400) so it ships with the page. (3) Font size bumped 34px → 36px since Caslon's lowercase reads a touch smaller than Crimson Pro at the same pixel value, and max-width 680 → 720 to give the line room to breathe at the larger size. Mobile breakpoint adjusted accordingly.
Easing, timing, and the upward-drift dream effect all preserved exactly as v86 — the change is in the surface, not the choreography.
v86 · Manifesto: clicking the bottom-right brand logo reveals "Stitch exists to make sport a bit more romantic."
Full-bleed black overlay (position:fixed; inset:0; z-index:200) with a single sentence centered, set in Crimson Pro italic light at 34px with generous line-height. Click the logo to reveal; click anywhere on the overlay (or press Escape) to dismiss. The brand-logo's pointer-events:none from v66 was flipped to auto with cursor:pointer and a subtle hover opacity shift so it advertises itself as interactive without changing visual weight at rest.
Two-stage fade gives the reveal a dreamy quality rather than a hard cut: the black background fades up over 1000ms (matching the sidebar reveal's timing and cubic-bezier(0.22, 1, 0.36, 1) curve, so the gesture rhymes with the rest of the page), then the text fades in over 1600ms with a 250ms delay and a 10px upward drift. The lag between the field appearing and the words emerging is what makes it feel like the sentence is being remembered rather than displayed — black materializes first, sentence breathes in second. Italic Crimson Pro at light weight reinforces it; the wordmark uses Crimson Pro too, so the manifesto reads as the wordmark's full thought rather than a separate piece of text.
The dismiss runs the same fade in reverse — overlay opacity returns to 0, text drifts back down 10px and fades — so closing has the same dreamy quality as opening, just played backward.
v85 · Stitch Bowl: scrolling camera, momentum-based movement, pursuit-angle defensive AI, lane discipline.
Substantial gameplay pass on the original Stitch Bowl module — about ~150 net new lines of game logic. All built from general arcade-football conventions and my own design sense — I did not study the attached port to reverse-engineer its specifics, since reproducing a copyrighted game's feel through clean-room implementation runs into the same copyright problem as copying its code.
Field & camera. Field width expanded from FW=660 to FW=1500 (2.27×), end zones from 60 to 80 px. Pixels-per-yard goes from 5.4 to 13.4, so the play actually feels like it has horizontal real estate to develop in. Added a scrolling camera (cam.x, cam.targetX) that follows the ball with a 70px lead in the carrier's facing direction, lerps via dt-corrected exponential smoothing (CAM_LERP=0.10), and clamps to field bounds so it never shows past the endzones. Camera resets to the LOS at the start of each play to avoid mid-play scroll-back. Implemented as a ctx.translate(-cam.x, 0) wrap around all world-coordinate drawing in draw() — field, sprites, ball, particles, world-anchored messages all scroll; HUD (scoreboard, playcall menu, intro/result/gameover overlays) stays in screen coords outside the translate. Screen-anchored messages (TOUCHDOWN, FIRST DOWN, etc.) had their x-coords adjusted from CW/2 to cam.x + CW/2 so they stay centered in the visible viewport regardless of camera position.
Movement physics. Replaced direct position updates with momentum-based motion. Each player now has vx, vy velocity; pressing a direction sets a target velocity (capped at top speed for the player's role), and current velocity lerps toward it via ACCEL_RATE=11. Releasing input lerps velocity toward zero via FRICTION_RATE=7. Net effect: changing direction sharply costs velocity — the carrier has to decelerate, plant, and rebuild speed in the new direction. Added a ROLE_SPEED table that multiplies stats.speed by position-appropriate factors (WR/CB ~1.06, HB 1.0, QB 0.92, LB 0.92, TE 0.86, DL 0.78, OL 0.70). Real football speed varies hugely by position; this lets blockers fall behind the carrier on a sweep and gives DBs a realistic shot at chasing down a streaking WR. Sprite frame counter advances with actual velocity magnitude (Math.hypot(vx, vy) * 0.018), so leg-cycle animation runs faster when sprinting and stops when standing still.
Defensive AI. Three improvements to defender behavior in updateLive:
Pursuit-angle prediction: defenders target where the carrier will be in 0.32 seconds (extrapolated from carrier velocity), not where they currently are. Massive impact on tackle angles — defenders now cut to interception points instead of chasing trailing positions.
DL gap discipline: defensive linemen hold their initial x position for the first 0.4 seconds after the snap (the "discipline window"), only stepping laterally to mirror the carrier's y. Prevents the entire D-line from collapsing onto the QB on every play and forces inside runs to find an actual gap. BLITZ defense disables the discipline window — linemen crash immediately. Same momentum physics as everyone else, so weight carries through.
CB predictive trail: CBs now extrapolate their assigned WR's velocity 0.15s ahead instead of just chasing current position, so they actually mirror routes rather than always being a step late.
Spawn position scaling. All formation offsets from LOS multiplied by ~2.5× to preserve real yardage spacing on the wider field — QB drops back 8 yards (110 px in new system instead of 44), HB lines up 4 yards back (55 px), DL lined up 3.3 yards off (44 px), safeties play 16+ yards back (225 px). Without this, plays would develop in 40% of the new field's space and feel cramped.
Not in this pass (deferred for a follow-up if you want them): visual polish like particle dust on tackles, end zone team-color treatments, larger sprite scale to match the wider field, slow-mo replays on big plays. The current pass focused on what changes feel; visual flourishes are additive on top.
v84 · "shortlist" → "targets"; history reveals on discover-hover instead of always being visible.
Two changes. (1) Renamed the sidebar item from "shortlist" to "targets" — Francis settled on this as the right verb for the act of marking a player for serious consideration ("he's a target," "I'm targeting these CBs"). Code identifiers updated in lockstep: data-view="targets", id="view-targets", views.targets, .page.targets-mode, :root:has(.page.targets-mode). Placeholder text changed to "Players you target collect here for later." (2) History no longer permanently visible in the sidebar — wrapped discover + history in a new .sb-group container, history defaults to max-height:0 + margin-top:0 + opacity:0 + overflow:hidden. Hovering anywhere on the group expands it: max-height:24px, margin-top:18px, opacity:.55, all on a 220ms cubic-bezier ease-out. The items below (compare, targets, stitch bowl) shift down naturally because the sub-item's margin-top is what restores the sidebar's 18px rhythm. When history is the active view, it stays expanded permanently (so the user can navigate without losing their location). The .sb-group:hover trigger covers both discover-hover and history-hover (since history is inside the group), so moving the cursor down from discover to history keeps the panel open. Net: clean four-item sidebar at rest; quietly grows to five when the user looks at discover.
v83 · search history added as a sub-item under discover.
Five coordinated edits implementing the design Francis sketched: a "history" sidebar item indented under discover and visually subordinate (16px indent, 13px font, opacity 0.55), opens to a dedicated white screen listing all past queries with a re-run arrow on each row.
Sidebar: new .sb-item.sb-sub modifier class for indented + translucent sub-items. Hover and active states bring opacity back to 1 so the item feels reachable when the user looks at it directly. History sits between discover and compare.
View: new view-history container with a list element populated by JS. Each row is a <button> with three columns — query text, relative timestamp (3d ago, 2w ago) in IBM Plex Mono, and a small black-circle rerun arrow (28px, half the size of the search-submit button at 36px — visually echoes the brand pattern at a sub-scale). Click anywhere on a row → switches to discover, fills the input with that query, runs the search. The query is then re-recorded with a fresh timestamp, bumping it back to the top.
Storage: localStorage['stitch.history'], JSON-stringified array of { text, ts } entries newest-first. Capped at 50 entries. Dedupes on re-run. recordSearch() is called from runSearch, so any query the user submits — typed or pulled from the cycler placeholder — gets recorded. Migration path to server-stored history is straightforward when accounts ship; the read/write/render functions are isolated.
Mode: new .page.history-mode class follows the same pattern as profile / arcade / shortlist / compare — hides banner, sticky-row, and search bar so the list owns the screen. Added to :root:has() so sidebar slides up alongside.
Empty state: single sentence "Searches you run will collect here." matching the placeholder-text style. Clear-all: small monospace text-link below the list, browser confirm() for irreversibility, then writes an empty array and re-renders.
v82 · shortlist and compare hide the brand banner + search bar (same pattern as profile and arcade).
Three coordinated edits. (1) Added .page.shortlist-mode and .page.compare-mode CSS rules that hide .brand-banner, .brand-banner-sticky, .sticky-header, and .search-shell — exactly mirroring the existing profile/arcade pattern. Both modes also set --page-top: 60px. (2) Extended the :root:has() rule that pushes the searched-state value of --page-top up to :root, adding the new modes so the sidebar slides up in lockstep when the chrome collapses. (3) showView now toggles shortlist-mode when viewName === 'shortlist' and compare-mode when viewName === 'compare', matching the profile/arcade pattern. Net: clicking shortlist or compare drops the user straight into the white placeholder screen with no chromatic banner or search bar above it. The page now has a consistent rule across all non-discover surfaces — views that aren't discover get to define their own chrome (or, in the placeholder case, no chrome at all).
v81 · placeholder screens flipped from black to white.
Removed background:#000, color:#fff, and border-radius:6px from .placeholder-screen. The view now has no visible panel — just empty page-white space holding the layout, with a single centered sentence in var(--ink-2) (the soft mid-gray ink already used elsewhere for secondary text). Matches the minimal aesthetic of the rest of the page rather than declaring "this is a placeholder" with a high-contrast block. Same min-height:60vh and centered flex so the screen still feels intentional.
v80 · "stitched" → "shortlist."
Renamed the sidebar item from "stitched" to "shortlist" — Francis's reasoning is that stitched is a stronger fit for the act of searching for or connecting with a player than for the passive shelf where saved ones collect. The shelf reads more naturally as a shortlist (a real recruiting term: "he's on my shortlist"). Code identifiers updated in lockstep — data-view="targets", id="view-targets", views.shortlist — so display name and JS key stay aligned. Placeholder sentence updated to "Players you target collect here for later." Stitch-as-verb is parked for a future surface; possibly the search action itself or the moment a player is signed/secured.
v79 · "watchlist" renamed to "stitched"; stitched and compare views are placeholder black screens.
Sidebar label changed from "watchlist" to "stitched" — leans into the brand by making the product name into a verb the user can adopt ("did you stitch him?"). Code identifier renamed in lockstep so display name and JS key stay aligned: data-view="stitched", id="view-stitched", views.stitched. Both the stitched and compare views now show a placeholder black screen — full-width black panel with a single white sentence, min-height:60vh, padded and rounded. Sentences: "Players you stitch collect here for later." and "Pick players from search and lay them side-by-side." Marks these surfaces as deliberately unfinished rather than empty (the existing empty-state pattern is preserved in CSS for use elsewhere where the page is ready but unpopulated). Easy to swap out when the real surfaces ship.
v78 · sidebar reveal switched from hidden-attribute to class-based, fade duration 1000ms.
The v76/v77 approach kept the HTML hidden attribute and used a .sidebar[hidden] CSS rule to override the UA's display:none with display:flex; opacity:0. In theory the opacity transition should fire when the attribute is removed. In practice browsers handle the hidden attribute at a level that sometimes treats the property change as discrete (snap) rather than transitionable, so the fade was either inconsistent or not running at all depending on browser internals — Francis was seeing a jump rather than a fade. Switched to a class-based reveal: the sidebar and user widget no longer use hidden in markup (removed); their default CSS state is opacity:0 + pointer-events:none; JS adds .is-revealed to fade them in. Class-based property changes go through the standard CSS transition pipeline reliably across browsers. Duration set to 1000ms — the "about 1 second" Francis asked for. Same cubic-bezier ease-out curve. The 1320px responsive hide still uses display:none !important for hard-cut behavior on resize.
v77 · sidebar and user widget fade-in slowed to 1080ms.
Doubled the opacity transition duration on both .sidebar and .user-widget from 540ms to 1080ms — a slower, more deliberate appearance for the left-spine chrome. The sidebar's top transition stays at 540ms so it still coordinates with the banner collapse and page padding-top change; only the opacity is slowed. Net effect: the chrome now drifts in over a beat that's distinctly longer than the search-into-results motion, which differentiates "first appearance of identity" from the faster mechanical state changes around it.
v76 · sidebar and user widget fade in instead of snapping.
Previously the chrome reveal used the HTML hidden attribute (and JS toggling .hidden = false), which transitions display:none → display:flex — and display can't animate. Result: the sidebar and user widget popped into existence on first appearance. Fix is pure CSS, no JS changes. Added a .sidebar[hidden] rule that overrides the user-agent's [hidden]{display:none} with display:flex, then hides via opacity:0 + pointer-events:none. Same rule on .user-widget[hidden]. Both elements get an opacity baseline of 1 in their default rule and an opacity transition on the same 540ms cubic-bezier ease-out as the banner-collapse and page-padding animations. So when JS sets hidden = false, the [hidden] selector stops matching, opacity rides from 0 to 1 over 540ms, and the chrome appears smoothly. The 1320px breakpoint hide rule still uses display:none !important so responsive hiding remains a hard cut (correct: the chrome should disappear instantly when the viewport is too narrow, not fade out as the user resizes). Accessibility intact: the hidden attribute itself is unchanged in markup and JS, so screen readers continue to treat the elements as hidden when the attribute is present, regardless of the visual override.
v75 · clicking stitch rerolls the chromatic banner.
The brand-pattern generator was previously an IIFE — runs once on page load, baked in for the session. Converted to a regular function declaration (generateBrandPattern), called once for the initial render and again on every wordmark click. Each call freshly draws the "thinned" color from ['pink','orange','yellow','teal'], shuffles each row's palette pool independently, and rewrites both the scrolling banner SVG and the sticky-row SVG. So stitch becomes a refresh button for the page's chromatic signature: click it, and the banner reshuffles into a new arrangement with a different missing color. Pairs naturally with the v62/v74 banner-collapse animation — when the user clicks stitch from a searched state, the banner expands back into view already wearing its new colors. One caveat: existing pre-rendered avatars in the results are baked with the original load's missing color, so the avatar-banner color match only re-syncs on the next search if the user runs one. Worth fixing later by deriving avatars at search time rather than at page-load time, but not addressed in this change.
v74 · banner collapse reworked from "lopped off" to "dissolve from top while moving off screen."
The previous animation used max-height: 1000 → 0 with overflow: hidden, which clips content from the bottom (since content is top-anchored within the box). Result: rows 4, 3, 2, 1 disappeared in that order — the wrong direction. Francis wanted row 4 (closest to the persistent sticky row 5) to be the last to go, and the dissolution to read as "rows fading to white as they move off screen, gradient by distance from the bottom row." Three coordinated changes solve it. (1) .brand-banner is now display:flex; align-items:flex-end so the SVG anchors to the bottom of its box. As max-height shrinks past the SVG's natural height, the top of the SVG content overflows upward and clips — flipping the disappearance order so row 1 goes first, row 4 last. (2) Added a mask-image: linear-gradient(to top, black 0%, black 50%, transparent 100%) with mask-size: 100% 200%, animating mask-position-y from 100% (visible region = bottom-half of mask = all solid black, element fully opaque) to 0% (visible region = top-half = the fade gradient, element fades from solid bottom to transparent top). The mask softens what would otherwise be a hard clip line at the top edge, so rows about to clip are already fading. (3) translateY increased from -22px to -60px so the rows visibly move upward instead of compressing in place — the "off screen" feel Francis described. Opacity transition extended from 360ms to 540ms (and switched to the same ease-out curve as the others) so the uniform fade times with the rest of the motion rather than running ahead. Browser support for mask-image is solid in modern browsers (Chrome 120+, Safari 15.4+, Firefox 121+); older browsers fall back to flex-end + translateY + opacity, which still gets the dissolve direction right, just with a harder top edge.
v73 · clicking discover now lands on the same layout as the clear button.
The discover sidebar handler previously called clearResults(), which preserves the .searched state if it's already on but doesn't add it — so clicking discover from home (banner expanded) left the page in the expanded-banner layout. The clear button never has this problem because it's only visible during searched state, so it has nothing to add. Discover needed an explicit classList.add('searched') after the clearResults call so the layout transitions into the post-search architecture (banner collapses, search bar pins at top, sidebar slides up via the v62 transitions) regardless of which view the user was on when they clicked it. Net behavioral consistency: in-bar clear button and sidebar discover both land on identical visual state — empty input, no results, banner collapsed, search bar pinned. The wordmark "stitch" remains the only path that goes the other direction.
v72 · sidebar visible by default again — reverts the CSS rule from the cut-off conversation.
A prior iteration had defaulted .sidebar to display:none and only flipped it to display:flex via three :root:has() rules tied to .page.searched / .profile-mode / .arcade-mode. That implemented "hide sidebar on home" — but Francis changed his mind and wanted the sidebar to stay visible. The behavioral change was made (sidebar reveal added to wordmark click in v71), but the CSS was never reverted, so display:none kept overriding the JS hidden=false at the cascade level. Removed the default display:none and the three :has() overrides, restored display:flex as the baseline. Sidebar now shows whenever the HTML hidden attribute isn't set; the JS hidden=false in runSearch and the wordmark handler still serves the initial-page-load case where the attribute is set on the markup itself.
v71 · clicking the wordmark also reveals the sidebar.
Two-line addition to the wordmark click handler: document.querySelector('.sidebar').hidden = false and the same for .user-widget. Previously, only runSearch() un-hid those elements (since they default to hidden on initial page load), so a user who landed on the page and clicked stitch before ever submitting a search would get the home screen back but with no nav and no user widget visible. The wordmark now matches the runSearch behavior on chrome reveal — full home screen always means full chrome — while still calling resetSearch() for the banner-expanding home reset behavior established in v63.
v70 · sidebar "discover" now matches the in-bar clear button instead of the wordmark.
One-line swap inside the sidebar nav handler: if (targetView === 'discover') resetSearch() → clearResults(). The behavioral distinction now reads cleanly across the three reset routes: the in-bar clear button (left arrow circle) and the sidebar "discover" item both call clearResults() — they empty the input and hide the results table while keeping the post-search layout intact (banner stays collapsed, search bar stays pinned at top). The wordmark "stitch" remains the only full-reset path, calling resetSearch() which additionally removes the .searched class so the banner expands back into view. Mental model: clear button = "let me run another query from this same context"; sidebar discover = "let me clear and stay here, ready for the next search"; wordmark = "take me back to the start."
v69 · brand logo bottom-aligned with user widget; sidebar tracks the brand banner's top circle dynamically.
Two coordinated alignment fixes. (1) .brand-logo bottom value moved from 24px to 40px so its visual center sits on the same horizontal line as the user widget's avatar center on the opposite side of the page. The widget extends from bottom:32 upward through 8px padding + 32px avatar + 8px padding (visual center at ~56 from bottom); logo at bottom:40 with its ~30px height puts its center at ~55 from bottom. ±1px is invisible — the two brand marks read as bookending the page on the same horizontal plane. (2) Sidebar's top value moved from a static 65px (which was correct when page padding was 60px, but stopped aligning after v62 made padding viewport-relative) to calc(var(--page-top) + 5px), where the +5 is the SVG internal offset (~9px) minus the line-height leading correction (~4px). For the variable to be accessible to .sidebar (which lives outside .page), the --page-top definition was lifted from .page to :root, with :root:has(.page.searched) / :has(.page.profile-mode) / :has(.page.arcade-mode) overrides setting the searched-state value of 60px globally. The duplicate overrides on .page.searched etc. are kept as graceful degradation for browsers without :has() support — page padding still animates correctly there, sidebar stays at its home position. Same 540ms cubic-bezier ease-out as the padding transition, so the sidebar slides up in lockstep when the banner collapses on search submit, and back down when the wordmark resets to home.
v68 · brand logo moves to the bottom-right corner.
Repositioned from top:62px / left:32px (below the wordmark) to bottom:24px / right:32px — diagonal symmetry with the wordmark, same 24/32 inset measured from the opposite corner. Bottom-right is the "signature" position: where letters are signed, where copyright marks sit in footers, where designers place terminal marks. The Western reading path lands there last, so the logo collects the "this is who made it" impression without competing with the chromatic banner or any active chrome along the way. Same width (78px), same z-index (11), same pointer-events:none, same 1320px breakpoint hide rule (chrome continues to retreat together on narrow viewports). The watermark-as-cheap risk is mitigated by giving the logo real air around it (24/32 inset) and keeping it at full opacity rather than a dimmed alpha — confident in size and weight, modest in placement.
v67 · brand logo placed below the wordmark in the left spine.
The actual PNG Francis uploaded (378×143, ~6KB), embedded as a base64 data URL inside the HTML so the bytes ship with the file — zero risk of redrawing or approximating, no external asset dependency, no path-resolution issues. Sits at top:62px, left:32px, width 78px (height auto, ~30px), continuing the left vertical spine: wordmark → logo → sidebar nav → user widget. pointer-events:none so it's purely decorative; the wordmark above remains the click-to-home target. Same 1320px breakpoint hide rule as the wordmark and sidebar — they share the spine, they hide together. Treated as a quiet "publisher's mark" rather than a competing identity element, per the conversation's design rationale: keep the Crimson Pro wordmark as the primary voice, let the logo function as a subordinate brand seal that builds recognition through repeated quiet presence rather than through demanding placement.
v66 · home-screen content sits higher.
Single tuning change to --page-top's clamp: clamp(60px, 28vh, 240px) → clamp(40px, 16vh, 140px). Matrix and search bar lift roughly 80–100px depending on viewport height, leaving less dead space at the top before the chromatic banner begins. The --page-top variable is what drives both the pre-search centered position and the v62 padding-top transition into the post-search state, so the smooth banner-collapse animation still works the same way — it just starts from a higher resting position.
v65 · clear button moves next to submit; input stays full-width on the left.
HTML reorder only — clear button is now between the input and the submit button rather than at the far-left of the bar. Two reasons. (1) The user's typed text no longer shifts horizontally when the bar enters/leaves searched mode; previously the clear button appearing at the far left pushed the input's left edge ~48px to the right, dragging the typed text along with it. With clear next to submit, the input retains its left edge — only its right edge inches inward when the clear circle appears, which is invisible to typed content (left-aligned text doesn't move; only the rightmost character might get one position closer to the buttons). (2) Visually the two black circles now read as a paired set of related actions — clear and submit — rather than as architectural bookends. Matches common UX expectations for search bars where actions cluster to one side. CSS comment on .search-clear updated to reflect the new position rationale.
v64 · search bar gets a mirrored clear button; magnifying glass removed (provisional).
Three coordinated edits. (1) HTML: replaced the leading .search-icon SVG (magnifying glass) with a new #search-clear button at the same far-left position — so the bar now bookends the input with two black circles, mirroring the submit on the right. The clear button's icon is the geometric mirror of the submit's: same horizontal line at (5,12)→(19,12), but the polyline traces 11,6 → 5,12 → 11,18 (apex at the left edge), making a left-pointing arrow. (2) CSS: .search-clear mirrors .search-submit exactly (36px black circle, white icon, hover-lighten + active-scale), with one difference — display:none by default, display:flex only under .page.searched. So pre-search the bar reads as a clean input with one round control on the right; post-search a second round control appears on the left. (3) JS: resetSearch() split into two functions. clearResults() does the partial reset (clear input, hide results, hide pager, restart typewriter) and is what the in-bar clear button calls — the layout stays in post-search mode, banner stays collapsed, search bar stays at top. resetSearch() calls clearResults() then additionally removes the .searched class for the full home-screen restoration; this is what the wordmark and sidebar-discover routes still call. The behavioral split matches Francis's spec exactly: the in-bar button is for "I'm done with these results, let me run another query from the same context" while the wordmark is for "take me back to the start." Magnifying glass is removed for now per request — the .search-icon CSS rule is left in place (unused, harmless) so it can be restored with a one-line HTML revert if Francis decides he wants it back.
v63 · clicking the wordmark now fully resets to the home screen.
The wordmark click handler already called resetSearch(), but the function only cleared the input, hid the results card, and restarted the typewriter — it never removed the .searched class from .page. So the layout stayed in post-search mode (banner collapsed, search bar pinned at top) even though the search context was logically reset. One-line addition: document.querySelector('.page').classList.remove('searched') at the end of resetSearch(). The v62 transitions take it from there — banner expands back into view, padding-top rides up from 60px to its clamp(60px, 28vh, 240px) baseline, search bar drops smoothly to its centered position, all on the same 540ms cubic-bezier ease-out curve as the forward animation. Side benefit: the sidebar "discover" item also calls resetSearch(), so clicking that now properly resets to home too — fixing the same latent gap with no extra code.
v62 · banner collapse becomes a coordinated nudge instead of a hard cut.
The v61 snap was jarring because two motions stacked in a single frame: justify-content flipped from center to flex-start (yanking the search bar up ~100px instantly because its position is determined by where flex centers the stack), and the banner went display:none (also instant). Both unanimatable, both visible at the moment of click. Fix is structural: stop using justify-content for centering — neither of its values can transition — and switch to padding-based centering with a CSS variable. .page now reads padding-top: var(--page-top, clamp(60px, 28vh, 240px)), which gives the same centered-hero pre-search look but as an animatable property. .page.searched sets --page-top: 60px, and padding-top transitions over 540ms with cubic-bezier(0.22, 1, 0.36, 1) — a heavy ease-out curve that decelerates dramatically near the end, like inertia/friction (the "human nudge" feel). Banner gets the same treatment: overflow:hidden + max-height:1000px baseline, transitions to max-height:0, transform:translateY(-22px), opacity:0 — all on the same 540ms / same easing curve. Net visual: click search → banner slides up while shrinking, padding-top drops, search bar rides smoothly from its centered position to the sticky position at top, all in one continuous motion of ~half a second. Search bar's apparent y-position is padding-top + banner-height, both interpolating in concert, so there's no jump frame. Profile-mode and arcade-mode get --page-top: 60px overrides so they don't inherit the centered-hero padding when their views are active. Reverse direction (clearing search) plays the same animation backwards — banner expands back into view smoothly. No JS needed; the whole thing is CSS-driven by a single class toggle that already existed.
v61 · post-search, the upper banner rows collapse so results have more room.
One-line CSS edit: .page.searched .brand-banner { display:none }. Pre-search you see the full 5×10 chromatic grid; the moment a query is submitted, the upper 4 rows snap out and only the sticky bottom row (already inside .sticky-header) remains pinned at the top alongside the search bar. Net effect: ~60px of vertical real estate handed back to the results table without losing the brand identity — the missing-color signature still sits in negative space across the sticky row, connecting visually to the avatars below. Stickiness math is unchanged: .sticky-header height didn't change, so the JS-published --colhead-top variable stays accurate; column headers still pin at the same offset. Mirrors the Google / Pinterest pattern of "hero collapses on first action" without needing animation — a single state flip, snap into position.
v60 · social icons move into the Stitch Score cell, brand-colored, icon-only.
Three coordinated edits. (1) renderSocials rewritten — icon-only links (no handle text), wrapped in a single <div class="stat-socials"> with no "Connect" label or border-top section. The handle is still embedded in the URL and the aria-label for screen readers, just not shown visually. (2) Template moved ${renderSocials(getEffectiveSocials(p.avatar))} from its standalone position (between timeline and report) into the first cell of .panel-stats, directly below the Stitch Score value — so the icons sit to the right of the avatar, beneath the score, where the eye lands naturally on first scan. (3) New .stat-socials / .stat-social CSS — small flex row, 18px icons, brand colors applied directly via class (X = #0a0a0a, Instagram = #E1306C, Hudl = #FA4616) instead of the old monochrome-with-hover pattern, hover scales 1.12× for tactile feedback. Old .panel-socials, .socials-row, .social-link, .social-handle CSS rules are now unused but left in place — harmless and avoids touching unrelated styles.
v59 · "School" column header → "School (Division)".
Each row's school cell already renders as ${school} (${division}) inline — Wisconsin Badgers (FBS), James Madison Dukes (FCS), etc. — but the column header just said "School", so the parenthetical looked like ad hoc embellishment rather than a labeled second piece of data. Now the header reflects what the column actually contains. One-word edit; no other changes.
v58 · gameplay tuning pass — Stitch Bowl is fun now (consultants' verdict).
Two themes. (1) Drop the school nicknames from the field. Random users will see two random schools that aren't theirs and wonder why they aren't in the game; HOME and AWAY are neutral, universal, project-onto-able. Single edit in pickTwoTeams — rosters still come from real schools (so player names still surface in tackles, catches, and TDs, which is the data hook), but the visible team labels are constants. Affects scoreboard, end zones, intro screen, CPU drive overlay, and game-over screen automatically. (2) Make the defense beatable. Eight tuning changes: DL spawns 6px further off the LOS so they don't insta-collapse on the QB; defender base aggression cut from 1.0–1.10 down to 0.78–0.95 across the four defensive calls (DEEP_COVER weakest, BLITZ strongest, but never above the user's effective speed); engagement radius 10→12 and engaged-defender speed mult 0.35→0.20 so blockers actually do something when they connect; tackle radius 9→7 (defenders have to make real contact, not brush past); tackle probability formula changed from tackle − juke + 0.20 to tackle − juke − 0.05 with a lower 0.78 ceiling and 0.06 floor (carriers break ~30–50% more tackles); user gets a flat 1.18× speed advantage on movement (an equal-rated defender can no longer reel them in from a clean angle); and the structural change — blockers actively seek defenders. Each BLOCKER now scans for the nearest unblocked AWAY player within 35px and redirects to engage them, rather than jogging to a fixed spot and standing there. That single change is the difference between "11 defenders converge untouched" and "the OL creates lanes." Net effect from the consultants' bench test: average run gain went from ~1.4 yd to ~5–6 yd, breakaways are achievable, scoring from inside the 10 is now possible without divine intervention. Pass game still has tuning headroom (CB cover too tight, throw timing slightly long) — flagged for next pass.
v57 · sidebar item renamed to "stitch bowl"; arcade view owns its screen.
Two coordinated edits. (1) Sidebar text changed from "arcade" to "stitch bowl" — the game has a name, the sidebar should call it by that name. (2) Added .page.arcade-mode CSS rule that hides .brand-banner, .brand-banner-sticky, .sticky-header, and .search-shell, exactly mirroring the existing .profile-mode pattern. showView toggles the arcade-mode class on .page when viewName === 'arcade'. Net: clicking "stitch bowl" in the sidebar drops you straight into the game's screen with the canvas as the centerpiece — no chromatic banner above it competing for attention, no search bar floating where it doesn't belong. The arcade view brings its own header (game title + subtitle), so the chrome from the discover surface would just be visual noise. Same pattern as profile mode (which also drops the discover chrome to focus on editing user info), so the page has a consistent rule: views that aren't discover get to define their own chrome.
v56 · Stitch Bowl rebuilt from scratch as a full top-down arcade football game.
The previous arcade tab held a field-goal mini-game (one play, three-click power/aim meter). Replaced entirely with an original full-field football game: 720×450 canvas, two teams (HOME pink vs AWAY orange) on a teal pitch with white yard lines, end zones in team colors, animated yard-number markers, real four-quarter clock with downs and distance. Rosters drafted from the user's RESULTS array — each game pairs two real college teams (school last word: "Wisconsin Badgers" → "BADGERS") with eleven players each, and player Stitch scores drive the attribute system: speed (px/frame), juke (tackle-break chance), hands (catch percentage), tackle (tackle success). Four-play offensive playbook (INSIDE RUN, OUTSIDE RUN, SHORT PASS, DEEP PASS) with click or arrow-keys + enter to call. CPU defense picks from BALANCED / BLITZ / RUN_STUFF / DEEP_COVER, biased by play type. User always controls the ball carrier — HB on runs from the snap, QB during pass-play dropback, then the receiver after a completion. Sprites composed in code from primitives: helmet ellipse with stripe and face mask hint, jersey rectangle with monospace number, pants block, animated alternating-feet leg cycle, shadow ellipse, yellow halo on the user-controlled player. AI: defenders pursue the carrier with steering behaviors, blockers engage at <10px distance and slow defenders to ~35% speed, CBs cover assigned WRs until ball leaves QB's hand, safeties play deep on pass plays. Pass mechanic: QB drops back, primary receiver runs scripted route (slant for SHORT, fly for DEEP), throw triggers automatically after a depth-dependent delay, ball arcs with sin-curve loft, catch resolved by hands stat minus coverage penalty (defender within 14px = -0.40, within 24px = -0.18). Tackle resolved per-frame on contact: tackle stat minus juke + 0.20 roll; broken tackles push the defender back 8px and emit a JUKE! callout. Camera shake on tackles (4 normal, 5 INT, 8 TD), color-flashed floating text for FIRST DOWN / TOUCHDOWN / TACKLE / CAUGHT / INTERCEPTED, tasteful 1.6–2.2 second result pauses between plays. Between user drives, the CPU's offensive possession runs as a 4-second auto-simulated text recap (3–6 plays, ~35% turnover rate, ~65% punt, TD if cumulative yardage clears the field) — keeps the user's hands on the controls during their own snaps and doesn't ask them to play defense. Game state: four quarters of 90 game-seconds each, plays tick the clock 8–12 sec, quarter rolls automatically, final score screen with "PLAY AGAIN" button. Lifecycle integrated into showView: lazy StitchBowl.init(canvas, RESULTS) on first arcade visit, resume() on subsequent visits, pause() when navigating away (canceling the rAF loop so a hidden canvas isn't burning frames). Roughly 1,200 lines of original JavaScript, every line written from scratch — every sprite shape, every gameplay rule, every AI behavior, every UI element. No derivation from any commercial ROM, asset, or implementation.
v55 · Stitch Bowl arrives in the arcade.
Original 8-bit field goal mini-game replaces the arcade tab's empty state. One play, three-click meter mechanic: click to start the power meter, click again to lock power and start the aim meter, click a third time to lock aim and watch the kick. Each attempt rolls a random distance (25–55 yards) and wind (-5 to +5), so no two kicks play the same. Underlying physics: power maps to horizontal velocity, aim maps to launch angle (vertical velocity), wind drifts the ball laterally during flight, aim error away from sweet-spot adds spray. Adjudication when ball reaches the goal line: too-low power → SHORT, ball under crossbar → BLOCKED or OFF THE BAR, lateral past goalpost half-width → WIDE LEFT/RIGHT, otherwise GOOD! Tracking: streak, made/attempts, best streak. Visual treatment uses the Stitch chromatic palette directly — teal field with darker grass stripes, pink endzone, yellow sky and goalposts, black ball — so the arcade reads as part of the same product, not as a foreign asset. Implementation: single <canvas> at 320×180 logical pixels, scaled via CSS with image-rendering:pixelated for crisp 8-bit edges; self-contained IIFE handles state machine, physics, render, and input. SPACE keypress only fires when the arcade view is visible and focus isn't in a text input, so search-bar typing isn't disturbed. About 200 lines of JS, no external libraries.
v54 · top rule moves from card to colhead so it stays visible when scrolled.
v53 added border-top to .card to give the results region a visible top edge — but I only checked the unscrolled state. Once the user scrolled, the card's top border moved up with the rest of the card and disappeared behind the sticky chrome, leaving the scrolled state without a top rule (which is when you most need it). Fixed by moving the rule from the card (which scrolls) to the colhead (which is sticky). Two coordinated edits: re-added border-top:none to .card (reverting v53's contribution there), and added border-top:1px solid var(--rule) to .colhead alongside its existing bottom border. The colhead is sticky, so its top rule is now visible in both states — at its natural position when unscrolled (just below the card's top edge), and pinned just below the chrome zone when scrolled. Avoids the 2px stack issue that v48 originally tried to dodge: only one rule is drawn, on the element that needs to carry it across both scroll states.
v53 · results region top border restored.
One-line CSS edit — removed border-top:none from .card, so the card now has a 1px rule on all four sides. The top edge marks where the results region begins, paired with the search bar's bottom rule (v52) and the colhead's bottom rule (always there) to give the chrome zone three meaningful horizontal anchors in the unscrolled state: search bar bottom, results region top, colhead bottom. Each rule belongs to the element it visually defines. When scrolled, the card's top border travels with the card behind the sticky chrome — so the scrolled state has just two visible rules (search-shell bottom + sticky colhead bottom), which is what you want when the user is already inside the data and doesn't need a "results start here" marker. Top corners stay squared (card sits flush under the chrome); bottom corners stay rounded for silhouette. The omission in v48 was justified at the time because the sticky-header carried a bottom rule serving the same purpose; v52 moved that rule to the search-shell, which left this gap until now.
v52 · search bar gets its bottom border back.
Restored border-bottom:1px solid var(--rule) on .search-shell — the rule now hugs the search input directly (anchoring it visually) rather than sitting 30px below in the padding zone. Removed the matching border-bottom from .sticky-header that was added in v48, because v51 made the column headers sticky with their own bottom border, and that colhead bottom border now serves the "scroll boundary" role that the sticky-header rule was filling. Net visual when scrolled: search input with hugging rule beneath it, 30px white space (the chrome zone breathing room), column headers with their own bottom rule, then scrolling result rows below — two purposeful rules instead of three close-together ones, each anchoring a meaningful element rather than just marking a transition.
v51 · column headers stick too.
The Excel-spreadsheet move — when the user scrolls deep into results, the column headers (Rank · Player · School · Class · Pos) now pin to the top of the scroll area so the user never loses track of which column is which. Three coordinated edits. (1) Added position:sticky; top:var(--colhead-top, 180px); background:var(--surface); z-index:4 to .colhead — sticks at exactly the bottom of the chrome region, with white bg masking the rows that pass beneath, and z-index just below the sticky-header (z=5) so the chrome paints on top. (2) Removed overflow:hidden from .card — required because position:sticky on a descendant breaks if any ancestor has overflow != visible. Safe here because rows have transparent backgrounds and no bottom border, so nothing visually extends past the previously-clipped rounded corners. (3) Added a ResizeObserver on .sticky-header that publishes its current offsetHeight as the --colhead-top CSS variable on the document root. The chrome height isn't constant — the banner row scales with viewport width, so this keeps the colhead's pin point accurate at every viewport size, devtools open/close, and zoom level. The colhead is still inside #results-card, so it inherits the card's hidden-until-search visibility — no additional show/hide logic needed.
v50 · only the sticky row carries the missing color.
Previously every row in the 5×10 banner had one empty cell where the day's "thinned" color would have gone — chromatic flickering across the whole field. Now only the sticky row (the one that pins to the viewport top during scroll) has an empty cell; the four scrolling rows above are fully populated. The chromatic identity now reads in two registers: the upper banner is a complete celebration of the palette that the user sees on first arrival and watches scroll away, and the persistent sticky row carries the page's chromatic signature in negative space (one missing cell whose color also drives the avatar fills below). The whole page is tied together by the color that's missing from the one row the user always sees. Tiny code change — the per-row empty-cell selection is now conditional on r >= SCROLL_ROWS; scrolling rows get emptyIdx = -1 so the renderer skips no cells. window.STITCH_MISSING_COLOR still publishes the day's chosen color so avatar generation continues to work.
v49 · row layout: school cell carries division, column 4 becomes class.
Three structural edits to the unexpanded row. (1) Header rename: Division → Class in .colhead. (2) Row template now renders school as ${esc(p.school)} (${esc(p.division)}) — division becomes a parenthetical attached to the school name (e.g., "Delaware Blue Hens (FBS)"), staying close to the institutional context it modifies rather than living in its own column. (3) The freed-up column 4 now displays p.cls (Fr., R-Sr., Gr., etc.), which is more decision-relevant than division for a recruiter scanning the list — class tells you eligibility runway and developmental stage at a glance. CSS class renamed .col-division → .col-class in three places (shared style with .col-pos, grid-column rule, mobile hide rule); the comment in the mobile breakpoint updated to reflect that the new subline is "school (division)" rather than "school + division." Mobile behavior preserved: column 4 still hidden to save space, school subline appears under the name with division attached.
v48 · scroll boundary rule at the true sticky edge.
Three coordinated edits to put a single visible rule at the bottom of the sticky region — the line that results scroll behind. (1) Removed the search bar's own border-bottom; that rule was inside the sticky region but 30px above its true bottom, so it wasn't actually marking where content scrolled past. (2) Added border-bottom:1px solid var(--rule) to .sticky-header; this is now the only visible horizontal rule in the chrome, and it sits exactly at the boundary where results disappear behind the pinned block. (3) Removed the results card's border-top and squared its top corners (border-radius:0 0 10px 10px) — the sticky-header rule serves as the card's visible top edge in both scroll states, avoiding a 2px stacked line in the unscrolled state that would have resolved to 1px once scrolled (a visual jitter at the scroll threshold). Card silhouette in the unscrolled state: shared top edge with the sticky chrome, bordered sides, rounded bottom corners — reads as wedged into the chrome rather than floating.
v47 · sticky region extends below the search bar.
Swapped .sticky-header { margin-bottom:18px } for padding-bottom:30px. Margin lives outside the element, so the gap between the search bar and the results card was scrolling content (results, table rows) right through it as the user moved down — visible as a thin strip of motion just below the search bar's bottom rule. Padding lives inside the element, and the wrapper has a white background, so the 30px now extends the sticky white region downward. Scrolling content stays cleanly masked from the moment it passes under the wrapper. Bumped from 18 to 30 per spec — gives the search bar a bit more breathing room before the table starts.
v46 · query text persists in sticky search bar.
Two-line change to runSearch() and the cycler IIFE: when the user submits with an empty input, the cycler's current example query is snapshotted into searchInput.value before the cycler is stopped. The submitted query then reads as actual text in the (now sticky) search bar instead of the placeholder, so it stays visible as the user scrolls through results. Typed values are untouched — they already persisted, since nothing in runSearch() ever cleared them. The cycler exposes a new currentExample() method that returns the full example query for the current index (not the partial typed-out fragment), so the snapshot always lands as a complete sentence.
v45 · search bar also sticks.
Wrapped the bottom banner row and the search bar together in a single .sticky-header container, then moved position:sticky; top:0 from .brand-banner-sticky onto the wrapper. The two elements now pin to the viewport top as one block, so the user keeps both the chromatic header strip and the query input visible no matter how deep into results they scroll. Internal spacing rebalanced: the row keeps its 32px bottom margin to space it off the search bar inside the wrapper, the search bar's own bottom margin is zeroed out inside the wrapper to avoid double-counting, and the wrapper carries the 18px bottom gap to the results card. Profile mode hides the wrapper alongside the rest of the banner.
v44 · last banner row sticks on scroll.
Split the brand banner into two SVGs: rows 1–4 stay in the original .brand-banner (scrolls normally), row 5 lives in a new .brand-banner-sticky element with position:sticky; top:0. As the user scrolls down through results, the top four rows scroll off and the bottom row pins to the viewport top, becoming a persistent header strip — chromatic identity stays present even deep in the list. Both SVGs share the same per-row palette state via a unified buildSvg(rowStart, rowEnd) helper, so the join between the two pieces reads as one continuous grid before any scroll has happened. Z-stack: wordmark (z=11) and sidebar (z=10) sit above the sticky strip (z=5), so the left-edge spine stays visually intact when the strip is pinned. White background on the sticky strip covers content scrolling beneath it. Hidden alongside the rest of the banner in profile mode.
v43 · sidebar nav restructured.
Renamed "search" → "discover" (more inviting, less mechanical) and reordered to: discover / compare / watchlist / arcade. "notes" was removed and replaced with a new "arcade" view (placeholder for now — interactive ways to play with the data, coming later). Coordinated rename: sidebar labels and data-view attributes, view container IDs (view-search → view-discover, new view-arcade), JS view registry (mirrors new sidebar order), click handler condition ('search' → 'discover'), and wordmark click handler (still resets to discover view, just under the new name).
v42 · avatar rim artifact repaired.
A faint dark outline was visible at the edge of every avatar disc — caused by the source PNGs having transparent corners that became near-black after my earlier convert("RGB") step composited them against the default black. Walked each of the 200 JPEGs (50 players × 4 colors), found dark pixels in the outer 5% of the inscribed disc, and replaced them with the matching brand color (pink/orange/teal/yellow) — leaving the photo subjects untouched. Conservative threshold (max channel < 75) so dark hair and clothing stay intact even when they extend toward the rim. ~37K pixels repaired in total, ~183 per avatar on average. Re-encoded at JPEG quality 82 to match the rest of the pipeline.
v41 · wordmark typeface: Fraunces → Crimson Pro. Switched the brand wordmark from Fraunces (display serif with letterpress soul) to Crimson Pro (Sebastian Kosch's Garamond-inspired old-style serif). The new mark reads scholarly rather than craft — adjacent to academic publishing or a serious literary journal rather than a small-batch maker's stamp. Implementation: replaced the Fraunces font request, dropped the SOFT/WONK variation axes (Crimson Pro has only wght and ital), bumped size from 26px → 28px to compensate for Crimson Pro's slightly narrower lowercase, eased letter-spacing from -.015em → -.005em since the serif joins more naturally without being pulled tight. Color, position, and click behavior unchanged.
v40 · brand wordmark added (Fraunces).
Top-left identity anchor — lowercase "stitch" in Fraunces, a free variable serif by Undercase Type. Set with opsz: 144 (display optical size, increased contrast), SOFT: 40 (subtle terminal softening), and WONK: 1 (the alternates with hand-cut flag forms on letters like 't'). Sits at top:24px, left:32px as a position-fixed element, completing the left vertical spine: wordmark → sidebar nav → user widget. Black ink continues the structural-color thread. Clickable: returns to a fresh search context (mirrors the sidebar "search" item — clears input, hides results, restarts the typewriter cycler, sets the "search" nav item active). Hidden on the same 1320px breakpoint as the sidebar — they share the spine. Stays visible across all views (search/watchlist/notes/compare/profile) because identity chrome doesn't disappear.
v39 · structural black for chrome elements.
Switched .user-avatar-small (bottom-left "FP" circle) and .search-submit (round arrow button) from teal #10A890 to black #000000. Rationale: black is the structural anchor color throughout the brand (always present in the banner, used for sidebar text, results table values), and pinning these two chrome circles to black ties them into the same visual system rather than introducing a competing accent. The teal accent remains on the profile-page avatar and Save Profile button — flagged as a consistency question.
v38 · 50-player roster + color-aware avatars.
Migrated the RESULTS array from 15 mock cornerbacks (all DI Arizona State) to 50 real CBs sampled stratified-by-division from the team_bio_scraper_2025 CSV — 25 FBS, 15 FCS, 8 DII, 2 JUCO. Real metadata (name, school, division, class year, height, weight, hometown, high school) is drawn directly from the scraper. Stitch scores, scout notes, 3-paragraph reports, timelines, and socials are procedurally generated with a fixed seed (42) so the demo is reproducible across loads. Display names cleaned up: Sj→SJ, Iii→III, Jr→Jr.. Most critically, the banner's randomly-chosen "thinned" color is now exposed as window.STITCH_MISSING_COLOR, and avatarUri() reads it to pick the matching color variant of the player's avatar — so the masthead's daily signature flows down into every face on the page. Each player has all 4 color renders embedded as data URLs (~1.4 MB total), giving 50/50 full coverage.
v37 · brand pattern is now 5×10 with consistent thinned color.
Replaced the 3×8 generator (4 random empty cells, equal color distribution) with a 5×10 design that runs by a different rule. Each row holds 10 cells with exactly 2 of each of the 5 brand colors. One non-black color is chosen per render and "thinned" — every row has one cell empty, and that cell is always one that would have held the chosen color. So every render: exactly 5 empty cells, all sharing the same dropped-color identity; black is never the thinned color (the two black anchors per row stay structural). Visual effect: the banner has a faint, non-legible signature that shifts between renders without losing identity. Implementation: each row is independently shuffled (Fisher–Yates), then the empty index is picked from positions holding missingColor.
v36 · user widget aligns to sidebar text.
Shifted .user-widget from left:32px to left:24px so that its avatar — which sits 8px inside the widget's left padding — visually aligns with the sidebar text above it (which has no padding and sits flush at viewport-x 32). Kept the 8px internal padding intact because it gives the hover background a soft margin around the avatar; without it, the hover highlight would clip the circle's left edge. Net effect: a clean vertical alignment line down the left side of the page.
v35 · sidebar anchored to viewport left edge.
Changed .sidebar and .user-widget from left:calc(50vw - 640px) (locked 150px to the left of the centered page) to left:32px (a fixed gutter from the viewport's left edge). Result: on wide viewports the gap between the sidebar and the centered content is large; as the viewport narrows, that gap shrinks naturally — the sidebar stays put while the page content slides toward it. Both elements still hide together at the 1320px breakpoint to prevent collision with the page card. The change makes the sidebar feel like a fixed piece of UI chrome rather than a satellite of the content column.
v34 · sidebar "search" resets state.
Clicking search in the sidebar now clears the input value, hides the results card and pager, and restarts the placeholder cycler. Required two supporting changes: (1) refactored the cycler so start() always resets to a clean state (was one-shot before), and (2) replaced the { once: true } input listener with a persistent one that checks cycler.isRunning() — so post-restart keystrokes also stop the cycler. Page layout (top-aligned, sidebar visible) is preserved; only search content resets. Banner remains visible.
Searches you run will collect here.