diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..2b04b1f --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,133 @@ +name: Pages + +# Static-site deploy pipeline (shared verbatim with game-launcher-web / +# game-controller β€” keep the four copies in sync). +# +# push to main -> build + lint + stamp -> deploy to gh-pages ROOT +# (preview/ subfolders preserved) +# push to a feature branch-> build + lint + stamp -> deploy to +# gh-pages preview/-/ +# pull_request open/sync -> build + lint (the required status checks) + +# comment the preview URL +# pull_request closed -> remove that branch's preview folder, and if the +# PR was merged, delete the feature branch +# +# Pages must be set to "Deploy from branch: gh-pages /(root)" β€” see the wiki +# docs/todo.md for the one-time repo settings. + +on: + push: + branches: ["**"] + pull_request: + types: [opened, synchronize, reopened, closed] + +permissions: + contents: write # push to gh-pages, delete merged branches + pull-requests: write # comment the preview URL + +concurrency: + group: pages-${{ github.workflow }}-${{ github.ref }} + # Supersede in-progress feature-branch builds, but NEVER cancel a production + # (main) deploy β€” cancelling it leaves the site root un-deployed. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + build: + # Everything except PR-close (handled by the cleanup job). + if: github.event_name == 'push' || github.event.action != 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + # The repo intentionally does not commit package-lock.json (dev-tooling + # deps only, zero-build ethos), so use `npm install`, not `npm ci`. + - run: npm install --no-audit --no-fund + + # The full pipeline: lint, then build the stamped _dist/ (cache-busted). + - run: npm run lint + - run: npm run build + + - name: Resolve deploy target + id: target + env: + BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "is_root=true" >> "$GITHUB_OUTPUT" + else + slug=$(echo "$BRANCH" | tr 'A-Z' 'a-z' | sed 's#[^a-z0-9]#-#g; s#^-*##; s#-*$##') + hash=$(printf '%s' "$BRANCH" | sha1sum | cut -c1-4) + echo "is_root=false" >> "$GITHUB_OUTPUT" + echo "dir=${slug}-${hash}" >> "$GITHUB_OUTPUT" + fi + + # ---- Deploy (push events only; PRs just run the checks above) ---------- + - name: Deploy to root (main) + if: github.event_name == 'push' && steps.target.outputs.is_root == 'true' + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: _dist + clean: true + clean-exclude: | + preview + preview/** + + - name: Deploy preview (feature branch) + if: github.event_name == 'push' && steps.target.outputs.is_root == 'false' + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: _dist + target-folder: preview/${{ steps.target.outputs.dir }} + clean: true + + # ---- Preview URL comment (PR open/sync only) --------------------------- + - name: Comment preview URL + if: github.event_name == 'pull_request' && github.event.action != 'closed' + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ github.head_ref }} + run: | + slug=$(echo "$BRANCH" | tr 'A-Z' 'a-z' | sed 's#[^a-z0-9]#-#g; s#^-*##; s#-*$##') + hash=$(printf '%s' "$BRANCH" | sha1sum | cut -c1-4) + owner="${GITHUB_REPOSITORY_OWNER}" + repo="${GITHUB_REPOSITORY#*/}" + url="https://${owner}.github.io/${repo}/preview/${slug}-${hash}/" + gh pr comment "${{ github.event.pull_request.number }}" \ + --edit-last --create-if-none \ + --body "πŸš€ Preview for \`${BRANCH}\`: ${url}" + + cleanup: + # On PR close (merged or not): drop the preview folder; if merged, delete the branch. + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Remove preview folder and delete merged branch + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ github.head_ref }} + MERGED: ${{ github.event.pull_request.merged }} + run: | + slug=$(echo "$BRANCH" | tr 'A-Z' 'a-z' | sed 's#[^a-z0-9]#-#g; s#^-*##; s#-*$##') + hash=$(printf '%s' "$BRANCH" | sha1sum | cut -c1-4) + dir="preview/${slug}-${hash}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then + git fetch origin gh-pages + git worktree add gp gh-pages + if [ -d "gp/$dir" ]; then + git -C gp rm -r "$dir" + git -C gp commit -m "cleanup: remove preview for ${BRANCH}" + git -C gp push origin gh-pages + fi + fi + if [ "$MERGED" = "true" ]; then + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/heads/${BRANCH}" || true + fi diff --git a/assets/css/site.css b/assets/css/site.css new file mode 100644 index 0000000..ffbfefe --- /dev/null +++ b/assets/css/site.css @@ -0,0 +1,1145 @@ +/* ============================================================ + Space Console β€” landing page + Art direction: "Deep Space Broadcast" β€” a dark room with a + screen glowing in it. Near-black field, enormous display type, + asymmetric composition, blueβ†’violet light pooling behind key + elements, cyan reserved for what matters (code / link / CTA). + ============================================================ */ + +/* ---------------------------------------------------- tokens */ +:root { + --bg: #05060f; + --bg-2: #0b0e1f; + --surface: #121633; + --surface-2: #1b2150; + --text: #eef1ff; + --text-dim: #9aa3c7; + --accent: #4f8cff; + --accent-2: #b14bff; + --focus: #6ce0ff; + --good: #38e8a0; + + --grad: linear-gradient(135deg, var(--accent), var(--accent-2)); + --line: rgba(154, 163, 199, .16); /* hairline borders */ + --line-strong: rgba(154, 163, 199, .28); + + --font-display: "Space Grotesk", system-ui, sans-serif; + --font-body: "Inter", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + + --wrap: 1200px; + --gutter: clamp(1.25rem, 4vw, 3rem); + --section: clamp(5.5rem, 12vw, 10rem); /* vertical rhythm */ + + --r-sm: 10px; --r-md: 16px; --r-lg: 24px; +} + +/* ------------------------------------------------------ base */ +* { box-sizing: border-box; margin: 0; } + +html { scroll-behavior: smooth; } + +body { + background: var(--bg); + color: var(--text); + font: 400 1rem/1.65 var(--font-body); + -webkit-font-smoothing: antialiased; + overflow-x: clip; /* belt-and-braces: glows bleed wide */ +} + +img { display: block; max-width: 100%; height: auto; } + +a { color: inherit; } + +::selection { background: rgba(79, 140, 255, .35); } + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 3px; + border-radius: 4px; +} + +.mono { font-family: var(--font-mono); } + +h1, h2, h3 { + font-family: var(--font-display); + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.04; + text-wrap: balance; +} + +h2 { font-size: clamp(2rem, 4.6vw, 3.6rem); } +h3 { font-size: 1.25rem; letter-spacing: -0.01em; line-height: 1.25; } + +.grad { + background: linear-gradient(100deg, var(--accent) 10%, var(--accent-2) 85%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.kicker { + font-size: .78rem; + font-weight: 500; + letter-spacing: .22em; + text-transform: uppercase; + color: var(--focus); + margin-bottom: 1.1rem; +} + +.body { color: var(--text-dim); max-width: 56ch; } + +.section-head { max-width: var(--wrap); margin: 0 auto; padding: 0 var(--gutter); } +.section-head .body { margin-top: 1.1rem; } + +/* film-grain: one tiny turbulence tile, fixed, nearly invisible β€” + it kills the flat-black banding and makes the glows feel printed */ +body::after { + content: ""; + position: fixed; inset: 0; + z-index: 40; + pointer-events: none; + opacity: .05; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.55'/%3E%3C/svg%3E"); +} + +/* ----------------------------------------------------- buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .5rem; + padding: .85rem 1.6rem; + border: 1px solid transparent; /* ghost/primary share exact height */ + border-radius: 999px; + font: 600 .95rem/1 var(--font-display); + letter-spacing: .01em; + text-decoration: none; + transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; +} + +.btn-primary { + background: var(--grad); + color: #fff; + box-shadow: 0 8px 28px -8px rgba(79, 140, 255, .55); +} +.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 14px 36px -8px rgba(138, 100, 255, .65); } +.btn-primary:active { transform: translateY(0); } + +.btn-ghost { + border-color: var(--line-strong); + color: var(--text); + background: rgba(18, 22, 51, .35); +} +.btn-ghost:hover { border-color: var(--focus); transform: translateY(-2px); } + +.btn-big { padding: 1.1rem 2.4rem; font-size: 1.05rem; } + +/* -------------------------------------------------------- nav */ +.nav { + position: fixed; inset: 0 0 auto; + z-index: 50; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + background: rgba(5, 6, 15, .55); + border-bottom: 1px solid transparent; + transition: border-color .3s ease, background .3s ease; +} +.nav.is-scrolled { + border-bottom-color: var(--line); + background: rgba(5, 6, 15, .78); +} + +.nav-inner { + max-width: var(--wrap); + margin: 0 auto; + padding: .8rem var(--gutter); + display: flex; align-items: center; gap: 2rem; +} + +.brand { + display: flex; align-items: center; gap: .65rem; + text-decoration: none; + margin-right: auto; +} +.brand-mark { width: 30px; height: 30px; border-radius: 8px; } +.brand-word { font: 600 1.05rem var(--font-display); letter-spacing: -0.01em; } + +.nav-links { display: flex; gap: 1.7rem; } +.nav-links a { + font: 500 .88rem var(--font-body); + color: var(--text-dim); + text-decoration: none; + transition: color .18s ease; +} +.nav-links a:hover { color: var(--text); } + +.btn-nav { padding: .6rem 1.15rem; font-size: .85rem; } + +@media (max-width: 860px) { + .nav-links { display: none; } /* one-screen page; anchors still exist in footer */ +} + +/* ------------------------------------------------------- hero */ +.hero { + position: relative; + padding: calc(var(--section) + 4rem) 0 var(--section); + overflow: clip; +} + +/* the TV-lights-up-the-wall pool: two soft radials behind the stage */ +.hero-glow { + position: absolute; + inset: -20% -30% auto auto; + width: 90%; aspect-ratio: 1; + pointer-events: none; + background: + radial-gradient(42% 42% at 62% 45%, rgba(79, 140, 255, .17), transparent 70%), + radial-gradient(36% 36% at 78% 62%, rgba(177, 75, 255, .13), transparent 70%); + filter: blur(10px); +} + +.hero-inner { + position: relative; + max-width: 1320px; + margin: 0 auto; + padding: 0 var(--gutter); + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); + gap: clamp(2rem, 5vw, 4rem); + align-items: center; +} + +.hero-copy { padding-block: 2rem; } + +.hero h1 { + font-size: clamp(2.6rem, 5vw, 4.9rem); + font-weight: 700; + letter-spacing: -0.035em; +} +.hero h1 .grad { display: inline-block; padding-bottom: .08em; /* keep descenders inside the clip */ } + +.lede { + margin-top: 1.6rem; + font-size: clamp(1.02rem, 1.4vw, 1.2rem); + color: var(--text-dim); + max-width: 44ch; +} + +.cta-row { display: flex; flex-wrap: wrap; gap: .9rem; margin-top: 2.2rem; } + +/* narrow phones: two equal full-width CTAs, never two ragged pills */ +@media (max-width: 480px) { + .cta-row { display: grid; grid-template-columns: 1fr; gap: .8rem; } +} + +.trust { + margin-top: 1.8rem; + font-size: .78rem; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--text-dim); +} + +/* ------------------------------------------------- hero stage */ +/* Fixed 4:3 canvas: the TV, the phone and the beam SVG all live in + the same 800Γ—600 coordinate space, so the path always connects. */ +.stage { + position: relative; + aspect-ratio: 4 / 3; + min-width: 0; +} + +.tv { + position: absolute; + top: 4%; right: 0; + width: 86%; +} +.tv-screen { + border-radius: 12px; + border: 1px solid var(--line-strong); + background: #000; + padding: 1.2%; /* bezel β€” proportional, so the + frame's bottom edge lands on a + fixed viewBox y for the beam */ + box-shadow: + 0 0 0 1px rgba(0, 0, 0, .6), + 0 30px 80px -20px rgba(79, 140, 255, .28), + 0 60px 120px -30px rgba(177, 75, 255, .22); +} +.tv-screen img { border-radius: 7px; } +.tv-foot { + margin: 0 auto; + width: 18%; height: 4px; + border-radius: 0 0 6px 6px; + background: var(--surface-2); +} + +/* ------------------------------------------------ phone device */ +/* Drawn in CSS so it can glow and (in the showpiece) morph. */ +.device { + border-radius: 26px; + background: linear-gradient(160deg, #232a52, #10132b 60%); + padding: 7px; + box-shadow: + inset 0 1px 0 rgba(238, 241, 255, .18), + 0 24px 60px -18px rgba(0, 0, 0, .8), + 0 0 60px -12px rgba(108, 224, 255, .25); +} +.device-screen { + position: relative; + border-radius: 20px; + overflow: hidden; + background: + radial-gradient(120% 70% at 50% 110%, rgba(79, 140, 255, .16), transparent 60%), + var(--bg-2); + height: 100%; +} + +.device-hero { + position: absolute; + left: 2%; bottom: 0; + width: 26%; + aspect-ratio: 9 / 18.6; + z-index: 2; +} +.device-hero .device-screen { display: flex; flex-direction: column; } + +.pad-status { + display: flex; align-items: center; justify-content: space-between; + padding: 7% 8% 0; + font: 500 clamp(.5rem, .8vw, .68rem) var(--font-mono); + color: var(--text-dim); +} +.pad-status b { color: var(--focus); font-weight: 700; letter-spacing: .08em; } +.pad-leave { + border: 1px solid var(--line); + border-radius: 999px; + padding: .15em .7em; +} + +/* shared pad atoms ------------------------------------------- */ +.dpad { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 6%; + width: 72%; + aspect-ratio: 1; + margin-inline: auto; +} +.dk { + display: grid; place-items: center; + background: var(--surface); + border: 1px solid rgba(238, 241, 255, .07); + border-radius: 18%; + color: var(--text); + font-size: clamp(.55rem, 1vw, .8rem); +} +.dk-u { grid-area: 1 / 2; } .dk-l { grid-area: 2 / 1; } +.dk-r { grid-area: 2 / 3; } .dk-d { grid-area: 3 / 2; } + +.pk { + display: grid; place-items: center; + background: var(--surface); + border: 1px solid rgba(238, 241, 255, .08); + border-radius: 999px; + color: var(--text); + font: 600 clamp(.58rem, 1vw, .8rem) var(--font-display); + padding: .7em 0; +} +.pk-primary { background: var(--grad); border-color: transparent; } + +.hero-pad { + flex: 1; + display: flex; flex-direction: column; justify-content: flex-end; + gap: 9%; + padding: 0 10% 10%; +} +.menu-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8%; } + +/* ------------------------------------------------- beam (svg) */ +/* The signature idea: a live p2p link. Stroke + SMIL packets; + js pauses the svg entirely off-screen and on reduced motion. */ +.beam { position: absolute; pointer-events: none; } + +.beam-hero { inset: 0; width: 100%; height: 100%; z-index: 1; } + +.beam-line { + fill: none; + stroke: url(#beamg); + stroke-width: 1.4; + opacity: .55; +} +.beam-under { + fill: none; + stroke: rgba(108, 224, 255, .16); + stroke-width: 6; +} +.pk-dot { fill: var(--focus); } +.pk-glow { fill: rgba(108, 224, 255, .3); } +.beam-port { fill: var(--focus); opacity: .85; } + +.beam-tag { + position: absolute; + left: 30%; top: 84%; /* just under the arc's low point */ + font-size: .68rem; + letter-spacing: .14em; + color: var(--focus); + opacity: .75; +} + +/* faint product mark on empty screen glass β€” the phone is idling, + not blank */ +.screen-mark { + position: absolute; + left: 50%; top: 30%; + width: 34%; + transform: translate(-50%, -50%); + color: var(--text-dim); + opacity: .12; + pointer-events: none; + transition: opacity .3s ease; +} +.device-live.landscape .screen-mark { opacity: 0; } + +/* -------------------------------------------------- proof strip */ +.proof { border-block: 1px solid var(--line); } + +.proof-row { + max-width: var(--wrap); + margin: 0 auto; + padding: 1.4rem var(--gutter); + list-style: none; + display: flex; flex-wrap: wrap; + justify-content: space-between; + gap: .8rem 2rem; + font-size: .82rem; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--text-dim); +} +.proof-row b { color: var(--text); font-weight: 700; margin-right: .35em; } + +/* ------------------------------------------------ how it works */ +.how { padding: var(--section) 0; } + +.steps { + position: relative; + max-width: var(--wrap); + margin: clamp(3rem, 6vw, 5rem) auto 0; + padding: 0 var(--gutter); + display: grid; + gap: clamp(3rem, 6vw, 4.5rem); +} + +/* the motif recurs: a quiet vertical rail with one packet riding it */ +.steps-rail { + position: absolute; + inset: 0 auto 0 calc(var(--gutter) + 1.1rem); + width: 4px; +} +.steps-rail svg { width: 100%; height: 100%; } +.steps-rail path { stroke: var(--line); stroke-width: .6; fill: none; } + +.step { + display: grid; + grid-template-columns: 5rem minmax(0, 1.1fr) minmax(0, 1fr); + gap: clamp(1.25rem, 3vw, 2.5rem); + align-items: center; +} +/* off-grid: alternate steps push right, so the column never reads as a list */ +.step:nth-child(odd) { transform: translateX(0); } +.step:nth-child(even) .step-visual { justify-self: start; } +.step:nth-child(odd) .step-visual { justify-self: end; } + +.step-no { + font-size: clamp(2.2rem, 4vw, 3.4rem); + font-weight: 700; + color: transparent; + -webkit-text-stroke: 1px rgba(154, 163, 199, .45); + line-height: 1; + background: var(--bg); /* mask the rail passing behind */ + align-self: start; +} + +.step h3 { font-size: clamp(1.35rem, 2.2vw, 1.8rem); } +.step p { margin-top: .7rem; color: var(--text-dim); max-width: 46ch; } + +/* step visuals ------------------------------------------------ */ +.join-chip { + display: inline-flex; align-items: center; gap: .9rem; + /* This mirrors the pill on the TV, so it has to behave like one: a real + hostname is long, and wrapping it mid-domain (or orphaning a character of + the room code) makes the address unreadable β€” the exact failure the + launcher itself had. Stay on one line and let the whole chip scale down + instead; below the breakpoint it drops to a stacked layout. */ + max-width: 100%; + white-space: nowrap; + padding: .9rem 1.4rem; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--line); + box-shadow: 0 18px 50px -18px rgba(79, 140, 255, .4); +} +.join-label { color: var(--text-dim); font-size: .9rem; } +.join-host { font-size: .95rem; font-weight: 500; } + +/* Narrow columns can't hold the pill on one line at full size β€” shrink the + type before allowing any wrap, then break the pill into two rows. */ +@media (max-width: 1200px) { + .join-chip { gap: .7rem; padding: .8rem 1.15rem; } + .join-host { font-size: .85rem; } + .join-code { font-size: .95rem; letter-spacing: .14em; padding-left: .7rem; } +} +@media (max-width: 1100px) { + .join-chip { + display: grid; grid-template-columns: auto 1fr; gap: .35rem .7rem; + border-radius: 1.1rem; white-space: normal; + } + .join-host { overflow-wrap: anywhere; } + .join-code { + grid-column: 1 / -1; + padding-left: 0; padding-top: .55rem; + border-left: 0; border-top: 1px solid var(--line-strong); + } +} +.join-code { + color: var(--focus); + font-weight: 700; + font-size: 1.05rem; + letter-spacing: .18em; + padding-left: .9rem; + border-left: 1px solid var(--line-strong); +} + +.qr-card { + position: relative; + display: inline-flex; flex-direction: column; align-items: center; gap: .8rem; + padding: 1.4rem 1.6rem 1.1rem; + border-radius: var(--r-md); + background: var(--surface); + border: 1px solid var(--line); + overflow: hidden; +} +.qr-mark { width: 118px; height: 118px; } +.qr-eyes rect { stroke: var(--text); } +.qr-cells rect { fill: var(--text); } +.qr-caption { font-size: .7rem; letter-spacing: .2em; text-transform: uppercase; color: var(--focus); } + +/* the scan: one cyan line sweeping the QR β€” transform-only */ +.qr-scanline { + position: absolute; + left: 10%; right: 10%; top: 1.1rem; + height: 2px; + border-radius: 2px; + background: linear-gradient(90deg, transparent, var(--focus), transparent); + box-shadow: 0 0 12px var(--focus); + animation: scan 2.6s ease-in-out infinite alternate; +} +@keyframes scan { from { transform: translateY(0); } to { transform: translateY(112px); } } + +.roster { + list-style: none; + display: grid; gap: .6rem; + min-width: 220px; +} +.seat { + display: flex; align-items: center; gap: .7rem; + padding: .6rem 1rem; + border-radius: var(--r-sm); + background: var(--surface); + border: 1px solid var(--line); + font-weight: 500; + font-size: .95rem; +} +.seat-no { + font-size: .72rem; font-weight: 700; + color: var(--bg); + background: var(--good); + border-radius: 6px; + padding: .15em .45em; +} +.seat .crown { margin-left: auto; color: #ffd447; } +/* seats pop in staggered once revealed (see .reveal .in in js hooks) */ +.seat { opacity: 0; transform: translateY(8px); transition: opacity .5s ease, transform .5s ease; } +.step.in .s1 { transition-delay: .1s; } .step.in .s2 { transition-delay: .35s; } +.step.in .s3 { transition-delay: .6s; } .step.in .s4 { transition-delay: .85s; } +.step.in .seat { opacity: 1; transform: none; } + +/* -------------------------------------------- controller adapts */ +.adapt { + position: relative; + padding: var(--section) 0; + background: linear-gradient(180deg, var(--bg), var(--bg-2) 30%, var(--bg-2) 70%, var(--bg)); + overflow: clip; +} +.adapt-glow { + position: absolute; + right: -10%; top: 10%; + width: 60%; aspect-ratio: 1; + pointer-events: none; + background: radial-gradient(45% 45% at 50% 50%, rgba(177, 75, 255, .14), transparent 70%); +} + +.adapt-inner { + position: relative; + max-width: var(--wrap); + margin: 0 auto; + padding: 0 var(--gutter); + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); + gap: clamp(2.5rem, 6vw, 5rem); + align-items: center; +} + +.adapt-copy .body { margin-top: 1.2rem; } + +.chipset { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 2rem; } +.chip { + appearance: none; + cursor: pointer; + border: 1px solid var(--line-strong); + background: rgba(18, 22, 51, .5); + color: var(--text-dim); + border-radius: 999px; + padding: .55rem 1.1rem; + font: 600 .88rem var(--font-display); + transition: color .18s ease, border-color .18s ease, background .18s ease, transform .18s ease; +} +.chip:hover { color: var(--text); transform: translateY(-1px); } +.chip[aria-pressed="true"] { + background: var(--grad); + border-color: transparent; + color: #fff; +} + +.pad-caption { + margin-top: 1.4rem; + font-size: .8rem; + color: var(--focus); + min-height: 3.4em; /* two full lines reserved (1.65 line-height): + morphing captions never shift the layout */ + max-width: 46ch; +} + +/* the live device ------------------------------------------- */ +.adapt-stage { + position: relative; + display: grid; + place-items: center; + min-height: 560px; +} + +.device-live { + width: min(255px, 68vw); + aspect-ratio: 9 / 18.6; + transition: transform .65s cubic-bezier(.65, 0, .35, 1); +} +.device-live .device-screen { height: 100%; } +.device-live.landscape { transform: rotate(-90deg); } + +.device-ui { position: absolute; inset: 0; display: flex; flex-direction: column; } + +/* the tall device's middle earns its keep: a mono readout of what the + running game declared, refreshed with every layout push */ +.device-live .screen-mark { top: 14%; width: 18%; opacity: .1; } + +.pad-readout { + position: absolute; + left: 10%; right: 10%; top: 22%; + display: grid; + gap: .35rem; +} +.ro-head { + font-size: .55rem; + font-weight: 500; + letter-spacing: .22em; + text-transform: uppercase; + color: var(--focus); + padding-bottom: .35rem; + border-bottom: 1px solid var(--line); +} +.ro-row { + display: flex; gap: 1rem; align-items: baseline; + font-size: .62rem; +} +.ro-key { flex: none; width: 3.6rem; color: var(--text-dim); letter-spacing: .1em; } +.ro-val { color: var(--text); letter-spacing: .04em; } +.pad-readout.swap .ro-val { animation: ro-in .45s ease; } +@keyframes ro-in { + from { opacity: 0; transform: translateY(5px); } +} +/* the rc pad owns the whole landscape screen; the portrait readout + rotates away with the status bar */ +.pad-readout { transition: opacity .2s ease; } +.device-live.landscape .pad-readout { opacity: 0; } + +.pad { + position: absolute; + inset: 14% 0 0; /* below the status bar */ + padding: 8% 10% 10%; + display: flex; flex-direction: column; justify-content: flex-end; + opacity: 0; + transform: scale(.92); + transition: opacity .38s ease, transform .38s ease; + pointer-events: none; +} +.pad.is-on { opacity: 1; transform: none; transition-delay: .18s; } + +.pad-menu { gap: 9%; } + +/* pinball: two held flippers (top padding keeps them clear of the readout) */ +.pad-pinball { flex-direction: row; gap: 8%; align-items: stretch; padding-top: 56%; } +.flipper { + flex: 1; + display: flex; flex-direction: column; align-items: center; justify-content: flex-end; + gap: .4rem; + padding-bottom: 1.2rem; + border-radius: 18px; + background: var(--surface); + border: 1px solid rgba(238, 241, 255, .08); + font: 600 .85rem var(--font-display); +} +.flipper i { font-style: normal; font-size: 1.6rem; color: var(--accent); line-height: 1; } +.fl-r i { color: var(--accent-2); } +.flipper em { font-style: normal; font-size: .6rem; letter-spacing: .18em; text-transform: uppercase; color: var(--text-dim); } + +/* minesweeper: reveal + flag over a compact d-pad */ +.pad-mines { gap: 10%; } +.dpad-sm { width: 56%; } +.mine-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6%; } +.pk-flag { border-color: rgba(108, 224, 255, .5); color: var(--focus); } + +/* blackjack: 2Γ—2 action grid, bottom-anchored in the thumb zone */ +.pad-blackjack { justify-content: flex-end; } +.bj-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8%; aspect-ratio: 1.5; align-items: stretch; margin-bottom: 4%; } +.bj-grid .pk { padding: 0; font-size: clamp(.7rem, 1.1vw, .85rem); } + +/* rc rush: authored pre-rotated so it reads level once the device + itself rotates -90Β° to landscape. width/height are swapped and the + pad is turned +90Β° about its own top-left. NB: the rotate must beat + `.pad.is-on { transform: none }`, hence the compound selectors. */ +.pad.pad-rc, +.pad.pad-rc.is-on { + inset: auto; + width: calc(255px / 9 * 18.6 - 14px); /* β‰ˆ screen height minus bezel */ + height: calc(255px - 14px); /* β‰ˆ screen width minus bezel */ + top: 0; left: 0; + transform-origin: top left; + transform: rotate(90deg) translateY(-100%); + padding: 4% 5%; + justify-content: space-between; + gap: .5rem; +} +/* the portrait status bar rotates away with the device; the rc pad + carries its own, pre-rotated so it reads level in landscape */ +.device-live.landscape .pad-status { opacity: 0; transition: opacity .2s ease; } +.rc-status { + display: flex; align-items: center; justify-content: space-between; + font: 500 .62rem var(--font-mono); + color: var(--text-dim); +} +.rc-status b { color: var(--focus); font-weight: 700; letter-spacing: .08em; } +.rc-row { display: flex; align-items: center; justify-content: space-between; gap: 8%; } +.stick { + width: 92px; height: 92px; + border-radius: 50%; + border: 1px solid rgba(238, 241, 255, .12); + background: radial-gradient(60% 60% at 50% 40%, rgba(79, 140, 255, .18), transparent 70%), var(--surface); + display: grid; place-items: center; +} +.stick-knob { + width: 44px; height: 44px; + border-radius: 50%; + background: var(--grad); + box-shadow: 0 6px 14px -4px rgba(0, 0, 0, .7); +} +.rc-cluster { display: grid; gap: .5rem; flex: 1; max-width: 150px; } +.pk-gas { background: var(--grad); border-color: transparent; } +.pk-brake { border-color: rgba(238, 241, 255, .2); } +.pk-drift { border-color: rgba(108, 224, 255, .45); color: var(--focus); } +.rc-hint { font-size: .6rem; letter-spacing: .16em; text-transform: uppercase; color: var(--text-dim); text-align: center; } + +/* idle stick sway β€” the pad feels held, not printed */ +.pad-rc.is-on .stick-knob { animation: sway 2.8s ease-in-out infinite alternate; } +@keyframes sway { + from { transform: translate(-7px, 3px); } + to { transform: translate(7px, -3px); } +} + +.beam-mini { + width: min(300px, 70vw); + height: 70px; + position: static; + margin-top: .6rem; +} +.beam-tag-mini { + position: static; + text-align: center; + margin-top: -.4rem; + font-size: .66rem; +} + +/* ------------------------------------------------------ library */ +.library { padding: var(--section) 0 calc(var(--section) * .8); } + +.shelf { + margin-top: clamp(2.5rem, 5vw, 4rem); + display: grid; + gap: 1.1rem; +} + +.marquee { overflow: hidden; } + +.marquee-track { + list-style: none; + padding: 0; + display: flex; + gap: 1.1rem; + width: max-content; +} +/* animation only arms once js clones the row (class .ready) */ +.marquee.ready .marquee-track { animation: slide var(--dur, 70s) linear infinite; } +.m1 { --dur: 74s; } +.m2 { --dur: 92s; } .m2.ready .marquee-track { animation-direction: reverse; } +.m3 { --dur: 62s; } +.marquee:hover .marquee-track, +.marquee.paused .marquee-track { animation-play-state: paused; } + +@keyframes slide { to { transform: translateX(-50%); } } + +.marquee-track li { + position: relative; + width: clamp(128px, 13.5vw, 178px); + flex: none; + border-radius: var(--r-md); + overflow: hidden; + border: 1px solid var(--line); + background: var(--surface); +} +.marquee-track img { + width: 100%; + aspect-ratio: 3 / 4; + object-fit: cover; + height: auto; +} +.marquee-track .tag { + position: absolute; + inset: auto 0 0; + padding: 1.6rem .8rem .6rem; + font: 600 .78rem var(--font-display); + letter-spacing: .01em; + background: linear-gradient(transparent, rgba(5, 6, 15, .92)); +} + +/* no-js / reduced-motion fallback: rows just scroll sideways */ +.marquee:not(.ready) { overflow-x: auto; scrollbar-width: thin; } + +/* -------------------------------------------------- under the hood */ +.hood { + padding: var(--section) 0; + background: var(--bg-2); + border-block: 1px solid var(--line); +} + +.hood-inner { + max-width: var(--wrap); + margin: 0 auto; + padding: 0 var(--gutter); + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); + gap: clamp(2.5rem, 6vw, 5rem); + align-items: center; +} + +.hood-copy .body { margin-top: 1.2rem; } +.hood-copy code { color: var(--focus); font-size: .92em; } + +.hood-list { + list-style: none; + padding: 0; + margin-top: 2.2rem; + display: grid; +} +.hood-list li { + display: flex; gap: 1.2rem; align-items: baseline; + padding: .85rem 0; + border-top: 1px solid var(--line); + color: var(--text-dim); + font-size: .95rem; +} +.hl-key { + flex: none; + width: 5.2rem; + font-size: .72rem; + font-weight: 700; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--focus); +} + +/* the diagram ------------------------------------------------- */ +.hood-diagram { position: relative; } +#hoodSvg { width: 100%; height: auto; } + +.svg-mono { font-family: var(--font-mono); } + +.node rect { fill: var(--surface); stroke: var(--line-strong); } +.node .node-inner { fill: var(--bg); stroke: none; } +/* hairline content hints inside the devices β€” same weight as the + handshake lines, so they read as devices without stealing focus */ +.node-hint { fill: none; stroke: var(--text-dim); stroke-width: 1.1; opacity: .5; } +.node-label { fill: var(--text); font-size: 13px; letter-spacing: .12em; text-transform: uppercase; font-weight: 700; } +.node-sub { fill: var(--text-dim); font-size: 11px; } + +.hs-line { + fill: none; + stroke: var(--text-dim); + stroke-width: 1.2; + stroke-dasharray: 5 7; +} +.hs-label { fill: var(--text-dim); font-size: 11px; letter-spacing: .08em; } + +.p2p-under { fill: none; stroke: rgba(108, 224, 255, .14); stroke-width: 8; } +.p2p-line { fill: none; stroke: url(#p2pg); stroke-width: 2; } +.p2p-label { fill: var(--focus); font-size: 11px; letter-spacing: .14em; } + +.idle-tag { fill: var(--good); font-size: 10px; letter-spacing: .2em; text-transform: uppercase; } + +/* two phases, toggled by js: handshake ↔ direct link */ +#hoodSvg .hs, #hoodSvg .p2p, #hoodSvg .node-signal, #hoodSvg .idle-tag { + transition: opacity .7s ease; +} +#hoodSvg .p2p { opacity: 0; } +#hoodSvg .idle-tag { opacity: 0; } +#hoodSvg.phase-p2p .hs { opacity: .18; } +#hoodSvg.phase-p2p .p2p { opacity: 1; } +#hoodSvg.phase-p2p .node-signal { opacity: .45; } +#hoodSvg.phase-p2p .idle-tag { opacity: 1; } + +.hood-phase { + margin-top: .6rem; + text-align: center; + font-size: .72rem; + letter-spacing: .16em; + text-transform: uppercase; + color: var(--text-dim); +} + +/* --------------------------------------------------------- room */ +.room { padding: var(--section) 0; } + +/* the rejoin proof shot β€” a real mid-game frame, TV idiom */ +.room-proof { + max-width: min(880px, calc(100% - 2 * var(--gutter))); + margin: clamp(2.5rem, 5vw, 4rem) auto 0; +} +.room-shot { + border-radius: 12px; + border: 1px solid var(--line-strong); + background: #000; + padding: 1.2%; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, .6), + 0 30px 80px -20px rgba(79, 140, 255, .22), + 0 60px 120px -30px rgba(177, 75, 255, .16); +} +.room-shot img { border-radius: 7px; } +.room-proof figcaption { + margin-top: 1rem; + text-align: center; + font-size: .72rem; + letter-spacing: .1em; + color: var(--text-dim); +} +.room-proof figcaption b { color: var(--focus); font-weight: 700; letter-spacing: .12em; } + +.tiles { + max-width: var(--wrap); + margin: clamp(2.5rem, 5vw, 4rem) auto 0; + padding: 0 var(--gutter); + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.1rem; +} + +.tile { + padding: 1.8rem 1.6rem; + border-radius: var(--r-md); + background: linear-gradient(170deg, rgba(18, 22, 51, .8), rgba(11, 14, 31, .6)); + border: 1px solid var(--line); + transition: border-color .25s ease, transform .25s ease; +} +.tile:hover { border-color: rgba(108, 224, 255, .4); transform: translateY(-3px); } + +.tile-icon { + width: 30px; height: 30px; + stroke: var(--focus); + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; + margin-bottom: 1.1rem; +} +.tile p { margin-top: .55rem; color: var(--text-dim); font-size: .92rem; } + +/* ------------------------------------------------------- finale */ +.finale { + position: relative; + padding: var(--section) var(--gutter) calc(var(--section) * 1.1); + text-align: center; + overflow: clip; +} +.finale-glow { + position: absolute; + left: 50%; top: 42%; + width: min(900px, 120vw); aspect-ratio: 2 / 1; + transform: translate(-50%, -50%); + pointer-events: none; + background: + radial-gradient(45% 60% at 42% 50%, rgba(79, 140, 255, .16), transparent 70%), + radial-gradient(45% 60% at 60% 50%, rgba(177, 75, 255, .13), transparent 70%); +} + +.finale-code { + position: relative; + display: flex; justify-content: center; + gap: clamp(.5rem, 2vw, 1.1rem); + margin: 1.4rem 0 2rem; +} +.finale-code span { + display: grid; place-items: center; + width: clamp(3.4rem, 9vw, 5.6rem); + aspect-ratio: .84; + border-radius: var(--r-md); + border: 1px solid rgba(108, 224, 255, .35); + background: rgba(11, 14, 31, .8); + color: var(--focus); + font-size: clamp(1.9rem, 5vw, 3.2rem); + font-weight: 700; + text-shadow: 0 0 26px rgba(108, 224, 255, .5); +} + +.finale h2 { font-size: clamp(2.2rem, 5vw, 4rem); } +.finale .body { margin: 1.2rem auto 2.4rem; } + +/* ------------------------------------------------------- footer */ +.footer { + border-top: 1px solid var(--line); + padding: 3.5rem var(--gutter) 2.5rem; + background: var(--bg-2); +} +.footer-inner { + max-width: var(--wrap); + margin: 0 auto; + display: flex; flex-wrap: wrap; gap: 2.5rem; + justify-content: space-between; + align-items: flex-start; +} +.footer-brand { display: flex; gap: 1rem; max-width: 420px; } +.footer-name { font: 600 1.05rem var(--font-display); } +.footer-line { color: var(--text-dim); font-size: .9rem; margin-top: .35rem; } + +.footer-repos { display: flex; flex-wrap: wrap; gap: .5rem 1.4rem; } +.footer-repos a { + font-size: .82rem; + color: var(--text-dim); + text-decoration: none; + transition: color .18s ease; +} +.footer-repos a::before { content: "β–Έ "; color: var(--accent); } +.footer-repos a:hover { color: var(--focus); } + +.colophon { + max-width: var(--wrap); + margin: 2.8rem auto 0; + padding-top: 1.4rem; + border-top: 1px solid var(--line); + color: var(--text-dim); + font-size: .74rem; + letter-spacing: .08em; +} + +/* ----------------------------------------------- scroll reveals */ +.reveal { + opacity: 0; + transform: translateY(22px); + transition: opacity .8s ease, transform .8s ease; +} +.reveal.d1 { transition-delay: .1s; } +.reveal.d2 { transition-delay: .2s; } +.reveal.d3 { transition-delay: .3s; } +.reveal.d4 { transition-delay: .4s; } +.reveal.in { opacity: 1; transform: none; } + +/* --------------------------------------------------- responsive */ +@media (max-width: 1024px) { + .hero-inner { grid-template-columns: 1fr; } + .stage { max-width: 640px; margin-inline: auto; width: 100%; } + .hero-copy { padding-block: 0; } + + .adapt-inner, .hood-inner { grid-template-columns: 1fr; } + .adapt-stage { min-height: 520px; } + .hood-diagram { max-width: 560px; } + + .tiles { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 720px) { + .step { + grid-template-columns: 1fr; + gap: 1.2rem; + padding-left: 0; + } + .steps-rail { display: none; } + .step-no { font-size: 2rem; } + .step:nth-child(odd) .step-visual { justify-self: start; } + + .proof-row { justify-content: flex-start; gap: .7rem 1.6rem; font-size: .74rem; } + + .tiles { grid-template-columns: 1fr; } + + .beam-tag { display: none; } + .device-hero { width: 30%; } + + .adapt-stage { min-height: 400px; } + .device-live { width: 225px; } + /* keep the rotated rc pad in sync with the smaller device, and + shrink the landscape device so it never exceeds a 360px viewport */ + .pad.pad-rc, + .pad.pad-rc.is-on { + width: calc(225px / 9 * 18.6 - 14px); + height: calc(225px - 14px); + } + .device-live.landscape { transform: rotate(-90deg) scale(.72); } +} + +/* ----------------------------------------------- reduced motion */ +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + + .reveal { opacity: 1; transform: none; transition: none; } + .seat { opacity: 1; transform: none; transition: none; } + + .marquee.ready .marquee-track { animation: none; } + .marquee { overflow-x: auto; } + + .qr-scanline { display: none; } + .pad-rc.is-on .stick-knob { animation: none; } + .pad-readout.swap .ro-val { animation: none; } + .pad-readout { transition: none; } + + /* the svg clocks are paused by js; hide the packets outright so a + frozen dot never lingers mid-path (the beam lines stay, static) */ + .beam-packets, .beam .pk-dot, .beam .pk-glow, + .steps-rail .pk-dot, #hoodSvg .pk-dot, #hoodSvg .pk-glow { display: none; } + + .btn, .chip, .tile { transition: none; } + .device-live { transition: none; } + .pad { transition: none; } + + /* svgs are additionally frozen via pauseAnimations() in js; + show the hood diagram in its settled p2p state */ +} diff --git a/assets/icons/icon.svg b/assets/icons/icon.svg new file mode 100644 index 0000000..8444dde --- /dev/null +++ b/assets/icons/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/img/phone-candy-crush.jpg b/assets/img/phone-candy-crush.jpg new file mode 100644 index 0000000..597728d Binary files /dev/null and b/assets/img/phone-candy-crush.jpg differ diff --git a/assets/img/phone-menu.jpg b/assets/img/phone-menu.jpg new file mode 100644 index 0000000..1bd19df Binary files /dev/null and b/assets/img/phone-menu.jpg differ diff --git a/assets/img/phone-pinball.jpg b/assets/img/phone-pinball.jpg new file mode 100644 index 0000000..91ca0ae Binary files /dev/null and b/assets/img/phone-pinball.jpg differ diff --git a/assets/img/phone-pool.jpg b/assets/img/phone-pool.jpg new file mode 100644 index 0000000..486ec41 Binary files /dev/null and b/assets/img/phone-pool.jpg differ diff --git a/assets/img/phone-rc-rush.jpg b/assets/img/phone-rc-rush.jpg new file mode 100644 index 0000000..9a11b1b Binary files /dev/null and b/assets/img/phone-rc-rush.jpg differ diff --git a/assets/img/tv-candy-crush.jpg b/assets/img/tv-candy-crush.jpg new file mode 100644 index 0000000..b9bba9f Binary files /dev/null and b/assets/img/tv-candy-crush.jpg differ diff --git a/assets/img/tv-menu.jpg b/assets/img/tv-menu.jpg new file mode 100644 index 0000000..fe59d60 Binary files /dev/null and b/assets/img/tv-menu.jpg differ diff --git a/assets/img/tv-pinball.jpg b/assets/img/tv-pinball.jpg new file mode 100644 index 0000000..dd21d15 Binary files /dev/null and b/assets/img/tv-pinball.jpg differ diff --git a/assets/img/tv-pool.jpg b/assets/img/tv-pool.jpg new file mode 100644 index 0000000..1563812 Binary files /dev/null and b/assets/img/tv-pool.jpg differ diff --git a/assets/img/tv-rc-rush.jpg b/assets/img/tv-rc-rush.jpg new file mode 100644 index 0000000..d066cf6 Binary files /dev/null and b/assets/img/tv-rc-rush.jpg differ diff --git a/assets/img/tv-yardline.jpg b/assets/img/tv-yardline.jpg new file mode 100644 index 0000000..ec208dc Binary files /dev/null and b/assets/img/tv-yardline.jpg differ diff --git a/assets/js/site.js b/assets/js/site.js new file mode 100644 index 0000000..34e1d6b --- /dev/null +++ b/assets/js/site.js @@ -0,0 +1,238 @@ +/* ============================================================ + Space Console landing β€” one ES module, no dependencies. + Everything here is enhancement: the page reads fine with js off + (marquees become scrollable rows, reveals render visible, the + diagram shows its handshake state). + ============================================================ */ + +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; + +/* ---------------------------------------------------- nav border */ +const nav = document.querySelector('.nav'); +const onScroll = () => nav.classList.toggle('is-scrolled', scrollY > 8); +addEventListener('scroll', onScroll, { passive: true }); +onScroll(); + +/* ------------------------------------------------ scroll reveals */ +/* .step elements also get .in β€” the roster seats stagger off it. */ +{ + const targets = document.querySelectorAll('.reveal, .step'); + if (reduced || !('IntersectionObserver' in window)) { + targets.forEach(el => el.classList.add('in')); + } else { + const io = new IntersectionObserver((entries) => { + for (const e of entries) { + if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } + } + }, { threshold: 0.18, rootMargin: '0px 0px -8% 0px' }); + targets.forEach(el => io.observe(el)); + } +} + +/* -------------------------------------------- beams (SMIL guards) */ +/* SMIL ignores prefers-reduced-motion, so we pause the svg clocks + ourselves β€” permanently under reduced motion, and whenever a + beam is off-screen so the packets cost nothing. */ +{ + const svgs = document.querySelectorAll('.beam, #hoodSvg, .steps-rail svg'); + if (reduced) { + svgs.forEach(s => s.pauseAnimations && s.pauseAnimations()); + } else if ('IntersectionObserver' in window) { + const io = new IntersectionObserver((entries) => { + for (const e of entries) { + if (!e.target.pauseAnimations) continue; + e.isIntersecting ? e.target.unpauseAnimations() : e.target.pauseAnimations(); + } + }, { threshold: 0 }); + svgs.forEach(s => io.observe(s)); + } +} + +/* -------------------------------------------------- stylized QR */ +/* Fill the step-2 QR with a deterministic cell pattern (a hash of + "3WGL") so it draws no request and always renders the same. */ +{ + const cells = document.querySelector('.qr-cells'); + if (cells) { + let seed = 0x3258595a; // "3WGL" + const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; + const svgNS = 'http://www.w3.org/2000/svg'; + const frag = document.createDocumentFragment(); + for (let y = 0; y < 12; y++) { + for (let x = 0; x < 12; x++) { + const inEye = (x < 4 && y < 4) || (x > 7 && y < 4) || (x < 4 && y > 7); + if (inEye || rnd() < 0.55) continue; + const r = document.createElementNS(svgNS, 'rect'); + r.setAttribute('x', 6 + x * 7); r.setAttribute('y', 6 + y * 7); + r.setAttribute('width', 5); r.setAttribute('height', 5); + r.setAttribute('rx', 1); + frag.appendChild(r); + } + } + cells.appendChild(frag); + } +} + +/* --------------------------------------- the controller morphs */ +{ + const chips = [...document.querySelectorAll('.chip')]; + const pads = [...document.querySelectorAll('.pad')]; + const device = document.getElementById('liveDevice'); + const caption = document.getElementById('padCaption'); + const readout = document.getElementById('padReadout'); + const roVals = [document.getElementById('roProfile'), document.getElementById('roInput')]; + + /* copy is factual: cap/ro are what the game declares. NBSP glues the + caption tails so no line ends on a one- or two-word orphan. */ + const NB = '\u00a0'; + const INFO = { + menu: { cap: `menu β†’ d-pad Β· Select Β· Back β€” the fallback when a game declares${NB}nothing`, + ro: ['d-pad', 'select Β· back'], + aria: 'Phone controller showing the menu layout: d-pad with Select and Back' }, + pinball: { cap: `pinball β†’ two held flipper buttons, left${NB}and${NB}right`, + ro: ['flippers', 'left Β· right Β· hold'], + aria: 'Phone controller showing the pinball layout: two large hold buttons for the left and right flippers' }, + rc: { cap: `rc rush β†’ landscape driving pad: stick Β· gas Β· brake Β· drift, streamed as continuous${NB}frames`, + ro: ['driving', 'stick Β· gas Β· brake Β· drift'], + aria: 'Phone rotated to landscape showing the driving pad: steering joystick with gas, brake and drift buttons' }, + mines: { cap: `minesweeper β†’ Reveal + Flag over a compact${NB}d-pad`, + ro: ['d-pad', 'reveal Β· flag'], + aria: 'Phone controller showing the minesweeper layout: d-pad with Reveal and Flag buttons' }, + blackjack: { cap: `blackjack β†’ Hit Β· Stand Β· Double${NB}Β·${NB}Split`, + ro: ['buttons', 'hit Β· stand Β· double Β· split'], + aria: 'Phone controller showing the blackjack layout: Hit, Stand, Double and Split buttons' }, + }; + + const show = (id) => { + chips.forEach(c => c.setAttribute('aria-pressed', String(c.dataset.pad === id))); + pads.forEach(p => p.classList.toggle('is-on', p.dataset.pad === id)); + device.classList.toggle('landscape', id === 'rc'); + device.setAttribute('aria-label', INFO[id].aria); + caption.textContent = INFO[id].cap; + if (readout) { + roVals.forEach((el, k) => { el.textContent = INFO[id].ro[k]; }); + readout.classList.remove('swap'); + void readout.offsetWidth; // restart the value fade + readout.classList.add('swap'); + } + }; + + /* auto-cycle once the section scrolls into view; any user touch + of the chips hands control over for good. */ + const order = ['menu', 'pinball', 'rc', 'mines', 'blackjack']; + let i = 0, timer = null; + + const stopCycle = () => { clearInterval(timer); timer = null; }; + const startCycle = () => { + if (timer || reduced) return; + timer = setInterval(() => { i = (i + 1) % order.length; show(order[i]); }, 3000); + }; + + chips.forEach(c => c.addEventListener('click', () => { + stopCycle(); + startCycle.disabled = true; // never resume after interaction + i = order.indexOf(c.dataset.pad); + show(c.dataset.pad); + })); + + if (!reduced && 'IntersectionObserver' in window) { + const io = new IntersectionObserver((entries) => { + for (const e of entries) { + if (startCycle.disabled) { io.disconnect(); return; } + e.isIntersecting ? startCycle() : stopCycle(); + } + }, { threshold: 0.35 }); + io.observe(device); + } +} + +/* ------------------------------------------------------ marquees */ +/* Clone each row once for the seamless -50% loop, then arm the css + animation. Skipped under reduced motion (rows stay scrollable). */ +{ + const marquees = document.querySelectorAll('.marquee'); + if (!reduced) { + marquees.forEach(m => { + const track = m.querySelector('.marquee-track'); + const clone = track.cloneNode(true); + clone.setAttribute('aria-hidden', 'true'); + clone.querySelectorAll('img').forEach(img => img.alt = ''); + track.append(...clone.children); + m.classList.add('ready'); + }); + /* pause the whole shelf when off-screen β€” free scrolling is not */ + if ('IntersectionObserver' in window) { + const io = new IntersectionObserver((entries) => { + entries.forEach(e => e.target.classList.toggle('paused', !e.isIntersecting)); + }, { threshold: 0 }); + marquees.forEach(m => io.observe(m)); + } + } +} + +/* --------------------------------------- under-the-hood phases */ +/* Alternate handshake ↔ p2p while visible; settle on p2p (the + truthful steady state) under reduced motion or with js idle. */ +{ + const svg = document.getElementById('hoodSvg'); + const phase = document.getElementById('hoodPhase'); + if (svg) { + const LABELS = ['1 Β· handshake via signaling', '2 Β· direct link β€” server steps out']; + if (reduced) { + svg.classList.add('phase-p2p'); + phase.textContent = LABELS[1]; + } else { + let p = 0, timer = null; + const tick = () => { + p = 1 - p; + svg.classList.toggle('phase-p2p', p === 1); + phase.textContent = LABELS[p]; + }; + const io = new IntersectionObserver((entries) => { + for (const e of entries) { + if (e.isIntersecting && !timer) { + /* land on the p2p state quickly, then keep alternating */ + setTimeout(tick, 1600); + timer = setInterval(tick, 4200); + } else if (!e.isIntersecting && timer) { + clearInterval(timer); timer = null; + } + } + }, { threshold: 0.4 }); + io.observe(svg); + } + } +} + +/* -------------------------------------------- finale code flourish */ +/* The four slots spin through glyphs and settle on 2 X Y Z each time + the finale enters the viewport. Pure text swaps β€” no layout work. */ +{ + const code = document.getElementById('finaleCode'); + if (code && !reduced && 'IntersectionObserver' in window) { + const GLYPHS = 'ABCDEFGHJKMNPQRSTUVWXYZ2345679'; + const slots = [...code.children]; + let spinning = false; + + const spin = () => { + if (spinning) return; + spinning = true; + slots.forEach((el, idx) => { + const final = el.dataset.ch; + let n = 0; + const max = 6 + idx * 3; // later slots settle later + const t = setInterval(() => { + el.textContent = (++n >= max) + ? final + : GLYPHS[(Math.random() * GLYPHS.length) | 0]; + if (n >= max) { clearInterval(t); if (idx === slots.length - 1) spinning = false; } + }, 70); + }); + }; + + const io = new IntersectionObserver((entries) => { + entries.forEach(e => { if (e.isIntersecting) spin(); }); + }, { threshold: 0.6 }); + io.observe(code); + } +} diff --git a/assets/thumbs/blackjack.jpg b/assets/thumbs/blackjack.jpg new file mode 100644 index 0000000..c4d06eb Binary files /dev/null and b/assets/thumbs/blackjack.jpg differ diff --git a/assets/thumbs/block-blast.jpg b/assets/thumbs/block-blast.jpg new file mode 100644 index 0000000..0ae136b Binary files /dev/null and b/assets/thumbs/block-blast.jpg differ diff --git a/assets/thumbs/bridge.jpg b/assets/thumbs/bridge.jpg new file mode 100644 index 0000000..13dc6c7 Binary files /dev/null and b/assets/thumbs/bridge.jpg differ diff --git a/assets/thumbs/bubble-trouble.jpg b/assets/thumbs/bubble-trouble.jpg new file mode 100644 index 0000000..9b6883e Binary files /dev/null and b/assets/thumbs/bubble-trouble.jpg differ diff --git a/assets/thumbs/candy-crush.jpg b/assets/thumbs/candy-crush.jpg new file mode 100644 index 0000000..e893c99 Binary files /dev/null and b/assets/thumbs/candy-crush.jpg differ diff --git a/assets/thumbs/checkers.jpg b/assets/thumbs/checkers.jpg new file mode 100644 index 0000000..72befa6 Binary files /dev/null and b/assets/thumbs/checkers.jpg differ diff --git a/assets/thumbs/chess.jpg b/assets/thumbs/chess.jpg new file mode 100644 index 0000000..b043e81 Binary files /dev/null and b/assets/thumbs/chess.jpg differ diff --git a/assets/thumbs/dominoes.jpg b/assets/thumbs/dominoes.jpg new file mode 100644 index 0000000..4fb35f8 Binary files /dev/null and b/assets/thumbs/dominoes.jpg differ diff --git a/assets/thumbs/flappy-bird.jpg b/assets/thumbs/flappy-bird.jpg new file mode 100644 index 0000000..98f4fa5 Binary files /dev/null and b/assets/thumbs/flappy-bird.jpg differ diff --git a/assets/thumbs/freecell.jpg b/assets/thumbs/freecell.jpg new file mode 100644 index 0000000..5e003a9 Binary files /dev/null and b/assets/thumbs/freecell.jpg differ diff --git a/assets/thumbs/fruit-ninja.jpg b/assets/thumbs/fruit-ninja.jpg new file mode 100644 index 0000000..dc9a9ba Binary files /dev/null and b/assets/thumbs/fruit-ninja.jpg differ diff --git a/assets/thumbs/icy-tower.jpg b/assets/thumbs/icy-tower.jpg new file mode 100644 index 0000000..a5dc1d5 Binary files /dev/null and b/assets/thumbs/icy-tower.jpg differ diff --git a/assets/thumbs/imposter.jpg b/assets/thumbs/imposter.jpg new file mode 100644 index 0000000..49edbb7 Binary files /dev/null and b/assets/thumbs/imposter.jpg differ diff --git a/assets/thumbs/ludo.jpg b/assets/thumbs/ludo.jpg new file mode 100644 index 0000000..69f4016 Binary files /dev/null and b/assets/thumbs/ludo.jpg differ diff --git a/assets/thumbs/minesweeper.jpg b/assets/thumbs/minesweeper.jpg new file mode 100644 index 0000000..24bc8d5 Binary files /dev/null and b/assets/thumbs/minesweeper.jpg differ diff --git a/assets/thumbs/pinball.jpg b/assets/thumbs/pinball.jpg new file mode 100644 index 0000000..e01c15b Binary files /dev/null and b/assets/thumbs/pinball.jpg differ diff --git a/assets/thumbs/poker.jpg b/assets/thumbs/poker.jpg new file mode 100644 index 0000000..5d161c0 Binary files /dev/null and b/assets/thumbs/poker.jpg differ diff --git a/assets/thumbs/pool.jpg b/assets/thumbs/pool.jpg new file mode 100644 index 0000000..b0ce0d9 Binary files /dev/null and b/assets/thumbs/pool.jpg differ diff --git a/assets/thumbs/rc-rush.jpg b/assets/thumbs/rc-rush.jpg new file mode 100644 index 0000000..a9b65ef Binary files /dev/null and b/assets/thumbs/rc-rush.jpg differ diff --git a/assets/thumbs/scrabble.jpg b/assets/thumbs/scrabble.jpg new file mode 100644 index 0000000..cf5636c Binary files /dev/null and b/assets/thumbs/scrabble.jpg differ diff --git a/assets/thumbs/slots.jpg b/assets/thumbs/slots.jpg new file mode 100644 index 0000000..bb9780c Binary files /dev/null and b/assets/thumbs/slots.jpg differ diff --git a/assets/thumbs/snake.jpg b/assets/thumbs/snake.jpg new file mode 100644 index 0000000..7e720cf Binary files /dev/null and b/assets/thumbs/snake.jpg differ diff --git a/assets/thumbs/solitaire.jpg b/assets/thumbs/solitaire.jpg new file mode 100644 index 0000000..5042106 Binary files /dev/null and b/assets/thumbs/solitaire.jpg differ diff --git a/assets/thumbs/space-tower.jpg b/assets/thumbs/space-tower.jpg new file mode 100644 index 0000000..142f9f4 Binary files /dev/null and b/assets/thumbs/space-tower.jpg differ diff --git a/assets/thumbs/star-hopper.jpg b/assets/thumbs/star-hopper.jpg new file mode 100644 index 0000000..095a5e9 Binary files /dev/null and b/assets/thumbs/star-hopper.jpg differ diff --git a/assets/thumbs/sudoku.jpg b/assets/thumbs/sudoku.jpg new file mode 100644 index 0000000..f01e89f Binary files /dev/null and b/assets/thumbs/sudoku.jpg differ diff --git a/assets/thumbs/tetris.jpg b/assets/thumbs/tetris.jpg new file mode 100644 index 0000000..aa01278 Binary files /dev/null and b/assets/thumbs/tetris.jpg differ diff --git a/assets/thumbs/tic-tac-boom.jpg b/assets/thumbs/tic-tac-boom.jpg new file mode 100644 index 0000000..7d67276 Binary files /dev/null and b/assets/thumbs/tic-tac-boom.jpg differ diff --git a/assets/thumbs/tic-tac-toe.jpg b/assets/thumbs/tic-tac-toe.jpg new file mode 100644 index 0000000..a7483ad Binary files /dev/null and b/assets/thumbs/tic-tac-toe.jpg differ diff --git a/assets/thumbs/uno.jpg b/assets/thumbs/uno.jpg new file mode 100644 index 0000000..661515f Binary files /dev/null and b/assets/thumbs/uno.jpg differ diff --git a/assets/thumbs/video-poker.jpg b/assets/thumbs/video-poker.jpg new file mode 100644 index 0000000..daa7124 Binary files /dev/null and b/assets/thumbs/video-poker.jpg differ diff --git a/assets/thumbs/yahtzee.jpg b/assets/thumbs/yahtzee.jpg new file mode 100644 index 0000000..bf836ae Binary files /dev/null and b/assets/thumbs/yahtzee.jpg differ diff --git a/assets/thumbs/yardline-rc.jpg b/assets/thumbs/yardline-rc.jpg new file mode 100644 index 0000000..375c085 Binary files /dev/null and b/assets/thumbs/yardline-rc.jpg differ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..2e0b1ee --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,32 @@ +// Flat ESLint config for the zero-build landing page. +// Plain browser ES modules β€” no framework, no transpile (matches the sibling apps). +import js from "@eslint/js"; +import globals from "globals"; + +export default [ + js.configs.recommended, + { + files: ["assets/js/**/*.js", "scripts/**/*.mjs"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + }, + }, + rules: { + "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + }, + }, + { + // Node context for the CI stamp script. + files: ["scripts/**/*.mjs"], + languageOptions: { + globals: { ...globals.node }, + }, + }, + { + // Build outputs / vendored assets β€” never lint these. + ignores: ["_dist/**", "_site/**", "node_modules/**"], + }, +]; diff --git a/index.html b/index.html new file mode 100644 index 0000000..5bcfaa4 --- /dev/null +++ b/index.html @@ -0,0 +1,547 @@ + + + + + + Space Console β€” Your TV becomes the console. Every phone becomes a controller. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ +
+

A console made of web pages

+

Your TV is the console.
Every phone is a controller.

+

Open a web page on the TV. Scan a QR on your phone. You’re holding the controller β€” and every press travels peer-to-peer, straight to the screen.

+ +

no app Β· no download Β· no account

+
+ + +
+
+
+ Space Console launcher on a TV: Tetris hero tile, category chips, game rails, the join bar reading space-console.onrender.com with room code 3WGL and a scan-to-join QR, and three seats filled β€” P1 Veton wearing the crown, P2 Ana, P3 Rijad +
+ +
+ + + + + + +
+ +
+
+ + +
+
    +
  • 33 games
  • +
  • 6 categories
  • +
  • 0 downloads
  • +
  • 4 players
  • +
  • 1 QR scan to join
  • +
  • P2P input
  • +
+
+ + +
+
+

How it works

+

Three moves and the room is playing.

+
+ +
+ + +
+ +
+

Open it on the TV

+

Any smart-TV browser works β€” webOS, Tizen, Android TV, tvOS β€” or a laptop on HDMI, or plain desktop browser. The TV shows the game menu, a 4-character room code, and a QR.

+
+
+ +
+
+ +
+ +
+

Scan the code

+

Guests point their camera at the QR β€” or type the code. A web page opens on the phone, and that page is the controller. No app store, no download, no account.

+
+
+ +
+
+ +
+ +
+

Play

+

Seats fill on the TV roster as phones connect β€” names typed on the phone, a crown on the lead player. Pick a game; the pad on every phone reshapes itself to match.

+
+
+ +
+
+
+
+ + +
+ +
+ +
+

The showpiece

+

The controller reshapes itself for every game.

+

Each game declares the pad it needs. The TV pushes that layout down the DataChannel and the phone re-renders instantly β€” flippers for pinball, an analog driving pad for the racers. Games that declare nothing fall back to the d-pad.

+ +
+ + + + + +
+ +

menu β†’ d-pad Β· Select Β· Back β€” the fallback when a game declares nothing

+
+ +
+ + + +
+ +
+
+ + +
+
+

The library

+

33 games. Zero installs.

+

All built in-house, all playable. Puzzle 5 Β· Arcade 10 Β· Board & Strategy 8 Β· Cards 4 Β· Casino 4 Β· Party 2.

+
+ +
+
+
    +
  • TetrisTetris
  • +
  • RC RushRC Rush
  • +
  • PinballPinball
  • +
  • ChessChess
  • +
  • Candy CrushCandy Crush
  • +
  • 8-Ball Pool8-Ball Pool
  • +
  • MinesweeperMinesweeper
  • +
  • Fruit NinjaFruit Ninja
  • +
  • UnoUno
  • +
  • Space TowerSpace Tower
  • +
  • SnakeSnake
  • +
+
+
+
    +
  • Yardline RCYardline RC
  • +
  • BlackjackBlackjack
  • +
  • Icy TowerIcy Tower
  • +
  • ScrabbleScrabble
  • +
  • Flappy BirdFlappy Bird
  • +
  • LudoLudo
  • +
  • Texas Hold'emTexas Hold’em
  • +
  • Block BlastBlock Blast
  • +
  • SudokuSudoku
  • +
  • Star HopperStar Hopper
  • +
  • CheckersCheckers
  • +
+
+
+
    +
  • Bubble TroubleBubble Trouble
  • +
  • SolitaireSolitaire
  • +
  • ImposterImposter
  • +
  • YahtzeeYahtzee
  • +
  • SlotsSlots
  • +
  • DominoesDominoes
  • +
  • Tic-Tac-BoomTic-Tac-Boom
  • +
  • FreeCellFreeCell
  • +
  • BridgeBridge
  • +
  • Tic-Tac-ToeTic-Tac-Toe
  • +
  • Video PokerVideo Poker
  • +
+
+
+
+ + +
+
+
+

Under the hood

+

The server introduces you, then steps out.

+

The phone opens a WebRTC DataChannel straight to the TV. A small signaling service β€” web-api β€” only performs the introduction: room code, SDP/ICE handshake, done. Gameplay input never round-trips through a server.

+ +
    +
  • inputButton presses go peer-to-peer, phone β†’ TV.
  • +
  • shellGames run in a persistent iframe shell β€” launching, quitting and switching never drops the controller links.
  • +
  • stackZero-build, zero-framework. Plain ES modules and CSS; a static site plus one thin Node signaling service.
  • +
  • screenRuns in any TV browser β€” webOS, Tizen, Android TV, tvOS β€” or any laptop or desktop.
  • +
  • fallbackKeyboard, TV remote and gamepad work on the console too.
  • +
+
+ +
+ + + + + + + + + + + + offer / ice + answer / ice + + + + + + + + + + + webrtc datachannel Β· p2p + + + + + + signaling + web-api + idle + + + + + + + + + + + phone + + + + + + + + + + + + + tv + + + +
+
+
+ + +
+
+

Party-console details

+

Built for a room full of people.

+
+ + +
+
+ RC Rush on the TV mid-game: the HUD keeps the room chip 3WGL, a scan-to-join-or-rejoin QR, and the full roster β€” P1 Veton wearing the crown, P2 Ana, P3 Rijad +
+
mid-game in rc rush β€” the HUD keeps the rejoin QR, room 3WGL, and every seat: P1 Veton β™› Β· P2 Ana Β· P3 Rijad
+
+ +
+
+ +

Roster & seats

+

Seats P1, P2, … fill on the TV as phones join. Names are typed on the phone; the lead player wears the crown.

+
+
+ +

Scan to rejoin mid-game

+

The in-game HUD keeps a room-code chip with a QR. A dropped phone rescans and takes its seat back β€” nobody leaves the game. Blips auto-rejoin silently.

+
+
+ +

2-player same-screen

+

Pool, Chess, Checkers, Tic-Tac-Toe, Scrabble, Space Tower, Tic-Tac-Boom and Bridge seat two players on one TV.

+
+
+ +

High scores kept

+

High scores and play stats persist server-side, so the leaderboard survives the night β€” and the next one.

+
+
+ +

A true 10-foot UI

+

Fluid type from laptop to 4K, TV-safe overscan margins, spatial navigation with a remote β€” and a dark palette on purpose, because bright areas bloom on TV backlights.

+
+
+ +

Any screen works

+

Not an app, not a download, not an account. It’s a web page you open on a screen β€” any screen with a browser.

+
+
+
+ + +
+ +

Tonight, on the biggest screen in the house

+
+ 2XYZ +
+

The room is open.

+

One web page on the TV, one scan on every phone.
Nothing to install β€” for you or your guests.

+ Open Space Console on your TV +
+ +
+ + +
+ +

33 games, built in-house Β· plain ES modules, no bundler Β· input travels peer-to-peer

+
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..8ace82d --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "landing", + "version": "0.1.0", + "private": true, + "description": "Marketing landing page for Space Console β€” explains the TV-as-console, phone-as-controller product to a first-time visitor.", + "type": "module", + "scripts": { + "dev": "live-server --port=5175 --no-browser --entry-file=index.html .", + "start": "serve --listen 5175 .", + "preview": "serve --listen 5175 .", + "lint": "eslint .", + "build": "node scripts/stamp.mjs" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "eslint": "^9.13.0", + "globals": "^15.11.0", + "live-server": "^1.2.2", + "serve": "^14.2.4" + }, + "engines": { + "node": ">=18" + } +} diff --git a/scripts/stamp.mjs b/scripts/stamp.mjs new file mode 100644 index 0000000..7d45280 --- /dev/null +++ b/scripts/stamp.mjs @@ -0,0 +1,90 @@ +// CI-only cache-bust stamp. +// +// Produces a deployable copy of the static site in `_dist/` with a per-build +// version query string appended to every asset the browser caches: +// - the entry