The 2023 SCSS was functional but fixed — pixel-based sizes, no responsive system, no animations beyond the tile spawn. The 2025 migration replaced it with a breakpoint system that accounts for both viewport width and height, CSS custom properties for runtime board sizing, and GSAP for all animation. The React/Electron setup for this phase is in the companion devlog.
GSAP from CDN: Manual Type Definitions
GSAP is loaded from a CDN script tag in index.html rather than bundled as an npm package — which means there are no built-in TypeScript type definitions for it. A full manual declaration was written in src/renderer/src/types/global.ts:
declare global {
interface Window {
gsap: { fromTo, set, timeline, to }
Observer: Observer
}
}
declare namespace GSAPCore {
class Animation { kill; pause; play; reverse; progress; eventCallback; duration }
class Timeline extends Animation { fromTo; from; to; set; add }
}
export type GSAPTimeline = GSAPCore.Timeline
const gsap = window.gsap
export { gsap, _Observer as Observer }
GSAPObserver.ObserverVars is fully typed — onDown, onUp, onLeft, onRight, tolerance, type, lockTouch, preventDefault. The exports pull gsap and Observer off window and re-export them as named imports, so the rest of the codebase imports them as normal module dependencies even though they live on the global scope at runtime.
PageModal: CSS Variables Drive the GSAP Timeline
The modal animation system uses a single GSAP timeline with play() to open and reverse() to close. The approach is the same one that ended up in advi-ui’s modal component — build the timeline once on mount, paused, and let the CSS define the animation values.
createTimeline(): void {
const opacity = getCSSProperty(this.contentEl, '--opacity') // "0, 1"
const translateX = getCSSProperty(this.contentEl, '--translateX') // "-50%, 50%"
const scale = getCSSProperty(this.contentEl, '--scale')
this.timeline = gsap.timeline({ paused: true })
this.timeline.fromTo(this.contentEl,
{ autoAlpha: from(opacity), xPercent: from(translateX), scale: from(scale) },
{ autoAlpha: to(opacity), xPercent: to(translateX), scale: to(scale), duration }
)
}
Each CSS custom property holds a from, to pair read as a string and split at runtime. The modal variant — slide-from-right on mobile, zoom-from-center on desktop — is defined entirely in page-modal.scss:
/* Mobile: slides in from the right */
.page-modal {
--opacity: 1, 1;
--translateX: -50%, 50%;
--scale: 1, 1;
}
/* Desktop: zooms in from center */
@include bp(lg) {
@include bp-height(ml) {
.page-modal {
--opacity: 0, 1;
--translateX: 50%, 50%;
--scale: 0.5, 1;
}
}
}
Changing a modal animation is a CSS change, not a TypeScript change. The PageModal class reads these values when building the timeline and doesn’t need to know which variant it’s animating.
On breakpoint-crossing resize — mobile ↔ desktop — killTimeline() destroys and rebuilds the timeline so the animation reflects the current breakpoint’s CSS values, not the ones it was built under.
GSAP Observer: Touch and Swipe Input
Touch support uses GSAP Observer rather than raw touch events:
Observer.create({
target: boardEl,
type: 'touch',
tolerance: 40,
lockTouch: true,
onUp: () => game.eventsOnPlay('ArrowUp'),
onDown: () => game.eventsOnPlay('ArrowDown'),
onLeft: () => game.eventsOnPlay('ArrowLeft'),
onRight: () => game.eventsOnPlay('ArrowRight'),
})
tolerance: 40 filters out small incidental finger movements — casual taps don’t register as swipes. lockTouch: true prevents the same gesture from triggering both the Observer handler and a scroll event on the page. Without it, a swipe on the board scrolls the page simultaneously on mobile. Both settings came from real problems in early testing.
The Breakpoint System: Width and Height
global-mixins.scss defines two separate breakpoint mixins:
@mixin bp($breakpoint) {
/* sm: 376px, ml: 426px, md: 600px, tab: 769px, lg: 1024px, xl: 1440px */
}
@mixin bp-height($breakpoint) {
/* sm: 350px, ml: 650px, md: 750px, mx: 850px, lg: 1024px */
}
Width breakpoints alone don’t work for a game that must fit on screen. On landscape mobile the viewport is wide but short — standard width breakpoints would trigger desktop layouts that overflow vertically. Height breakpoints gate anything that requires vertical space. The fixed header, for example, is only position: fixed when the viewport is both 600px wide and 650px tall — on landscape mobile it falls back to position: static so it doesn’t consume a significant portion of the usable height.
The two mixins nest together to express conditions like “tablet-width AND enough height”:
.desktop-header {
display: none;
@include bp-height(ml) { @include bp(tab) { display: flex; } }
}
CSS Custom Properties for Board Sizing
The board dimension is set at runtime by Game2024.setMinBoardSize(), which calculates the available space and writes a single CSS custom property:
document.documentElement.style.setProperty('--board-side', `${size}px`)
Portrait and landscape get different calculations — portrait constrains against viewport width, landscape against viewport height. Everything that depends on board size derives from --board-side in CSS:
.game-board {
width: var(--board-side, 0);
height: var(--board-side, 0);
}
.grid-cell {
width: calc(var(--board-side, 0) * 0.22);
height: calc(var(--board-side, 0) * 0.22);
}
setMinBoardSize runs once on mount and again in eventsOnResize(), which is debounced to prevent layout thrashing during window drag. The resize handler sets one value; the cascade handles everything else.
Body Overlay via Animated CSS Custom Property
Modals need a darkening overlay behind them. Rather than a separate overlay element toggled per modal, the <body> has a ::after pseudo-element whose opacity is a CSS custom property animated by GSAP:
.body-tag {
--bg-opacity: 0;
&::after {
background: rgba($black, var(--bg-opacity));
height: calc(100% - 64px);
position: fixed; top: 64px;
pointer-events: none;
transition: none;
}
}
When a modal opens, GSAP tweens --bg-opacity from 0 to the target value. When it closes, GSAP tweens it back. One element, one property, one GSAP tween — covers every modal.
D-Pad Controller: Rotation Trick
The on-screen arrow controller is a diamond-shaped D-pad. The trick: a 2×2 flex grid rotated 45°, with each button rotated −45° to stay upright.
.game-controller {
transform: translateX(-14px) rotate(45deg);
width: $controller-size;
height: $controller-size;
display: flex;
flex-wrap: wrap;
row-gap: 14px;
}
.arrow-btn {
border-radius: 50%;
height: 48px;
width: 48px;
transform: rotate(-45deg);
}
The four buttons in a 2×2 grid form a square. Rotating the grid 45° makes it a diamond. Rotating each button −45° cancels the parent rotation so button content (the chevron SVG) stays upright. Each direction class then rotates only the SVG to point the correct way.
On non-touch devices the D-pad is hidden entirely — [data-touch-device='false'] on the body suppresses it. On touch devices, the text-based button panel is hidden and the D-pad is shown. The game detects touch capability once on load and sets the attribute; the CSS handles the rest.
grid-tiles: Responsive Font Sizes
The 2023 stylesheet had one fixed font size per digit count. The 2025 version adds breakpoints at each threshold:
@if string.length($tile-num) == 1 {
font-size: 36px;
@include bp(sm) { font-size: 42px; }
@include bp-height(ml) { @include bp(ml) { font-size: 50px; } }
}
Small mobile gets a smaller font; the full-size font only appears when both the viewport width and height indicate enough room. The same pattern applies to two-, three-, and four-digit tiles. Combined with the CSS custom property cell sizing, tiles scale correctly across phones, tablets, and desktop without a single hardcoded pixel dimension.
The SCSS and GSAP work is what makes the 2025 version feel different from the 2023 one. The game logic was ported unchanged. The visual layer — responsive across orientations, animated, touch-native — is where the rebuild time actually went.