diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml
index 92c80a2..3150302 100644
--- a/.github/workflows/container.yml
+++ b/.github/workflows/container.yml
@@ -91,7 +91,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up QEMU
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
+ uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to Docker Hub
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d74af10..a7c2f47 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -98,7 +98,7 @@ jobs:
git push origin "refs/tags/v${RELEASE_VERSION}"
fi
- name: Set up QEMU
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
+ uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Log in to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
diff --git a/Dockerfile b/Dockerfile
index 8cd1924..68a2e10 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,9 +1,9 @@
-FROM node:26-alpine@sha256:2d984a15c9b54fd0aeb608b8e0d0d83529eb34d2966db27a1fb4f1edc3d298a3 AS dependencies
+FROM node:26-alpine@sha256:ef24c5053d50fdc3e4e56eb4e7ddb7861874ab0fdc797046ba897581deb8e868 AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
-FROM node:26-alpine@sha256:2d984a15c9b54fd0aeb608b8e0d0d83529eb34d2966db27a1fb4f1edc3d298a3
+FROM node:26-alpine@sha256:ef24c5053d50fdc3e4e56eb4e7ddb7861874ab0fdc797046ba897581deb8e868
ARG BUILD_VERSION=dev
ARG BUILD_CHANNEL=dev
ENV NODE_ENV=production PORT=8080
diff --git a/about.css b/about.css
new file mode 100644
index 0000000..4d12bf3
--- /dev/null
+++ b/about.css
@@ -0,0 +1,90 @@
+:root {
+ color-scheme:light;
+ --page:#fff4d6;
+ --ink:#192541;
+ --muted:#58647a;
+ --panel:#fff9e8;
+ --line:#192541;
+ --accent:#765cff;
+ --pink:#ff4fa3;
+ --pink-heading:#e13a87;
+ --pink-text:#a31558;
+ --mint:#6ef2d0;
+ --yellow:#ffd84d;
+ --sky:#56a8ff;
+ --focus:#4b2fc9;
+ --link-accent:#4b2fc9;
+ --shadow:#192541;
+}
+:root[data-color-mode="dark"] { color-scheme:dark;--page:#0a1020;--ink:#fff4d6;--muted:#b7c2dc;--panel:#111a33;--line:#fff4d6;--pink-heading:#ff70b7;--pink-text:#ff70b7;--focus:#6ef2d0;--link-accent:#ffd84d;--shadow:#030712; }
+* { box-sizing:border-box; }
+body { min-height:100vh;margin:0;color:var(--ink);background:linear-gradient(90deg,#ffffff06 1px,transparent 1px),linear-gradient(#ffffff06 1px,transparent 1px),radial-gradient(circle at 8% 0,#765cff38,transparent 34rem),var(--page);background-size:32px 32px,32px 32px,auto,auto;font-family:var(--pixel-body,ui-monospace,"SFMono-Regular",Consolas,monospace);overflow-x:hidden; }
+a { color:inherit; }
+.about-shell { width:min(1120px,calc(100% - 40px));margin:0 auto;padding:clamp(38px,6vw,78px) 0 72px; }
+.about-hero { display:grid;grid-template-columns:minmax(0,1.45fr) minmax(280px,.55fr);gap:clamp(32px,6vw,76px);align-items:center;margin-bottom:52px; }
+.eyebrow,.panel-kicker { margin:0 0 18px;color:var(--pink-text);font:700 12px/1.2 var(--pixel-font,ui-monospace,monospace);letter-spacing:.1em;text-transform:uppercase; }
+.eyebrow { display:inline-block;padding:8px 12px;color:#192541;background:var(--yellow);border:2px solid var(--line);box-shadow:4px 4px 0 var(--line); }
+h1,h2,.player-card strong,.button,.resource-links span,.social-grid strong { font-family:var(--pixel-font,ui-monospace,monospace); }
+h1 { max-width:740px;margin:0;font-size:clamp(40px,7vw,76px);font-weight:700;letter-spacing:-.065em;line-height:1.08;text-wrap:balance;text-shadow:5px 5px 0 var(--accent); }
+h1 span { color:var(--pink-heading); }
+.lede { max-width:720px;margin:28px 0 0;color:var(--muted);font-size:clamp(17px,2vw,20px);line-height:1.65; }
+.hero-actions { display:flex;flex-wrap:wrap;gap:14px;margin-top:30px; }
+.button { display:inline-flex;align-items:center;justify-content:center;gap:9px;min-height:48px;padding:10px 16px;border:2px solid var(--line);font-size:11px;font-weight:700;text-decoration:none;box-shadow:4px 4px 0 var(--line);transition:transform 140ms steps(2,end),box-shadow 140ms steps(2,end); }
+.button-primary { color:#192541;background:var(--mint); }
+.button-secondary { background:var(--panel); }
+.button:hover { transform:translate(4px,4px);box-shadow:0 0 0 var(--line); }
+.button:focus-visible,.resource-links a:focus-visible,.patent-list a:focus-visible,.social-grid a:focus-visible { outline:3px solid var(--focus);outline-offset:4px; }
+.player-card { position:relative;padding:28px;background:var(--panel);border:3px solid var(--line);box-shadow:8px 8px 0 var(--pink),12px 12px 0 var(--line);transform:rotate(1.5deg); }
+.player-card::after { position:absolute;top:14px;right:14px;width:42px;height:42px;background:linear-gradient(90deg,var(--yellow) 0 30%,transparent 30% 70%,var(--yellow) 70%),linear-gradient(var(--yellow) 0 30%,transparent 30% 70%,var(--yellow) 70%);content:"";opacity:.8; }
+.player-label { margin:0 0 10px;color:var(--pink-text);font-size:11px;font-weight:800;letter-spacing:.12em;text-transform:uppercase; }
+.player-card>strong { display:block;padding-right:46px;font-size:clamp(19px,3vw,27px); }
+.player-card dl { display:grid;gap:0;margin:24px 0 0; }
+.player-card dl div { display:grid;grid-template-columns:90px 1fr;gap:12px;padding:12px 0;border-top:1px solid color-mix(in srgb,var(--line) 22%,transparent); }
+.player-card dt { color:var(--muted);font-size:12px;text-transform:uppercase; }
+.player-card dd { margin:0;font-size:14px;font-weight:800; }
+.about-grid { display:grid;grid-template-columns:1.25fr .75fr;gap:24px; }
+.panel { position:relative;padding:clamp(25px,4vw,40px);background:var(--panel);border:3px solid var(--line);box-shadow:7px 7px 0 var(--accent),11px 11px 0 var(--line); }
+.panel::before { position:absolute;top:0;right:0;left:0;height:8px;background:repeating-linear-gradient(90deg,var(--accent) 0 20px,color-mix(in srgb,var(--accent) 42%,transparent) 20px 28px);content:""; }
+.panel h2,.social-section h2 { margin:0;font-size:clamp(26px,4vw,38px);letter-spacing:-.045em;line-height:1.2; }
+.panel>p:not(.panel-kicker) { margin:20px 0 0;color:var(--muted);font-size:16px;line-height:1.7; }
+.career-path { display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin:30px 0 0;padding:0;list-style:none; }
+.career-path li { position:relative;padding-top:19px;text-align:center; }
+.career-path li::before { position:absolute;top:3px;right:50%;left:-58%;height:3px;background:var(--accent);content:""; }
+.career-path li:first-child::before { left:50%; }
+.career-path li::after { position:absolute;top:0;left:calc(50% - 5px);width:10px;height:10px;background:var(--mint);border:2px solid var(--line);content:""; }
+.career-path span { font-size:12px;font-weight:800; }
+.community-panel { --accent:var(--mint); }
+.resource-links { display:grid;gap:12px;margin-top:28px; }
+.resource-links a { display:grid;grid-template-columns:1fr auto;gap:5px 12px;padding:15px;color:inherit;background:color-mix(in srgb,var(--accent) 9%,transparent);border:1px solid color-mix(in srgb,var(--line) 28%,transparent);text-decoration:none; }
+.resource-links span { font-size:13px; }
+.resource-links small { grid-column:1;color:var(--muted);font-size:12px;line-height:1.45; }
+.resource-links b { grid-area:1/2/3;font-size:20px;align-self:center; }
+.patent-panel { --accent:var(--yellow);display:grid;grid-template-columns:.4fr 1.6fr;gap:28px;margin-top:30px; }
+.patent-list { display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px; }
+.patent-list a { display:flex;flex-direction:column;gap:10px;min-height:170px;padding:18px;background:color-mix(in srgb,var(--yellow) 12%,transparent);border:2px solid var(--line);text-decoration:none; }
+.patent-list small { color:var(--muted);font-size:11px;font-weight:800;text-transform:uppercase; }
+.patent-list span { flex:1;font-size:15px;font-weight:800;line-height:1.45; }
+.patent-list b { color:var(--link-accent);font-size:12px; }
+.patent-list i { font-style:normal; }
+.social-section { margin-top:58px; }
+.social-grid { display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-top:24px; }
+.social-grid a { display:grid;grid-template-columns:38px 1fr;gap:3px 11px;align-items:center;min-height:92px;padding:16px;background:var(--panel);border:2px solid var(--line);box-shadow:4px 4px 0 var(--line);text-decoration:none;transition:transform 140ms steps(2,end),box-shadow 140ms steps(2,end); }
+.social-grid a:hover { transform:translate(3px,3px);box-shadow:1px 1px 0 var(--line); }
+.social-grid a>span { grid-row:1/3;display:grid;width:38px;height:38px;place-items:center;color:#192541;background:var(--sky);border:2px solid var(--line);font-size:13px;font-weight:950; }
+.social-grid a:nth-child(2n)>span { background:var(--mint); }
+.social-grid a:nth-child(3n)>span { background:var(--pink); }
+.social-grid strong { font-size:12px; }
+.social-grid small { color:var(--muted);font-size:11px; }
+:root[data-arcade-theme="cabinet"] .about-shell { width:min(1240px,calc(100% - 32px)); }
+:root[data-arcade-theme="cabinet"] :is(.player-card,.panel) { box-shadow:5px 5px 0 var(--yellow); }
+:root[data-arcade-theme="calm"] { --page:#eef4f1;--ink:#223a33;--muted:#61746d;--panel:#f8fbf9;--line:#527068;--accent:#356f61;--pink:#9b5548;--pink-heading:#9b5548;--pink-text:#854238;--mint:#789d90;--yellow:#d9b968;--sky:#668e9b;--focus:#315f54;--link-accent:#315f54;--shadow:#263c34; }
+:root[data-arcade-theme="calm"][data-color-mode="dark"] { --page:#111917;--ink:#e5efeb;--muted:#9fb0a9;--panel:#18231f;--line:#91a59d;--pink-heading:#e8a092;--pink-text:#e8a092;--focus:#d9b968;--link-accent:#d9b968;--shadow:#000; }
+:root[data-arcade-theme="calm"] body { background:var(--page);font-family:Georgia,"Times New Roman",serif; }
+:root[data-arcade-theme="calm"] .about-shell { width:min(1020px,calc(100% - 48px)); }
+:root[data-arcade-theme="calm"] :is(h1,h2,.player-card strong,.button,.resource-links span,.social-grid strong) { font-family:Georgia,"Times New Roman",serif; }
+:root[data-arcade-theme="calm"] h1 { text-shadow:none; }
+:root[data-arcade-theme="calm"] :is(.player-card,.panel,.social-grid a) { border-width:1px;box-shadow:none;transform:none; }
+:root[data-arcade-theme="calm"] .panel::before { height:3px; }
+@media (max-width:800px) { .about-hero,.about-grid,.patent-panel { grid-template-columns:1fr; } .player-card { width:min(100%,520px);transform:none; } .social-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
+@media (max-width:520px) { .about-shell,:root[data-arcade-theme="calm"] .about-shell { width:min(100% - 28px,1120px);padding-top:32px; } .about-hero { margin-bottom:38px; } .hero-actions { flex-direction:column; } .panel { padding:26px 20px;box-shadow:5px 5px 0 var(--accent),8px 8px 0 var(--line); } .career-path { grid-template-columns:1fr;gap:0; } .career-path li { padding:0 0 14px 25px;text-align:left; } .career-path li::before { top:0;bottom:0;right:auto;left:5px;width:3px;height:auto; } .career-path li:first-child::before { left:5px;top:5px; } .career-path li:last-child::before { bottom:calc(100% - 5px); } .career-path li::after { top:3px;left:0; } .patent-list,.social-grid { grid-template-columns:1fr; } .patent-list a { min-height:0; } }
+@media (prefers-reduced-motion:reduce) { .button,.social-grid a { transition:none; } }
diff --git a/about.html b/about.html
new file mode 100644
index 0000000..adb201c
--- /dev/null
+++ b/about.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+ About Mohamed Elsherif · JavaScript Playground
+
+
+
+
+
+
+
+
+
+
Meet the maker
+
Built by Mohamed Elsherif.
+
I’m a software architect with more than 20 years in software engineering. I build systems, share what I learn, and still make time for a good JavaScript game.
+
+
+
+
+
+
+
+ Career map
+ Building services that scale
+ I work on Apple’s Services Architecture and Efficiency team, helping online services scale reliably and run efficiently. Earlier chapters of my career include Azure identity at Microsoft, integrations at Okta, enterprise mobility at VMware, and Metrics and Reporting infrastructure at Hulu.
+
+ Hulu
+ VMware
+ Okta
+ Microsoft
+ Apple
+
+
+
+
+
+
+
+
+
Granted achievements
+
Patents
+
+
+
+
+
+ Continue the conversation
+ Find me online
+
+
+
+
+
+
+
diff --git a/arcade.css b/arcade.css
index 25f1219..02dbdbc 100644
--- a/arcade.css
+++ b/arcade.css
@@ -10,6 +10,7 @@ body:not(.arcade-has-topbar)::before { display:block; height:env(safe-area-inset
body.arena-fullscreen .arcade-topbar { display:none; }
body.arena-fullscreen .arcade-account { display:none; }
.arcade-account a, .arcade-account button { padding: 8px 11px; color: inherit; background: transparent; border: 0; border-radius: 9px; font: inherit; text-decoration: none; cursor: pointer; }
+.arcade-account a[aria-current="page"] { text-decoration:underline; text-decoration-thickness:2px; text-underline-offset:5px; }
.arcade-account button { color: #10271f; background: #83e6c3; }
.arcade-appearance-button::before { margin-right:6px; content:'◐'; }
.arcade-audio-button::before { margin-right:6px; content:'♪'; }.arcade-audio-button[data-audio-muted="true"]::before{content:'×';}.arcade-audio-button:disabled{cursor:not-allowed;opacity:.55}
@@ -67,7 +68,7 @@ body.arena-fullscreen .arcade-account { display:none; }
.result-share-cancel { color: #f7f8fc; background: rgba(255,255,255,.09); }
.result-share-confirm { color: #10271f; background: #83e6c3; }
@media (max-width: 760px) { .arcade-topbar-inner { align-items:flex-start; flex-direction:column; gap:11px; } .arcade-account { width:100%; justify-content:flex-start; overflow-x:auto; padding:2px 3px 5px; scrollbar-width:none; } .arcade-account::-webkit-scrollbar { display:none; } }
-@media (max-width: 520px) { .arcade-topbar { padding-inline:12px; } .arcade-home > span:last-child { font-size:14px; } .arcade-account > a:first-of-type { display:none; } .arcade-account a,.arcade-account button { white-space:nowrap; } }
+@media (max-width: 520px) { .arcade-topbar { padding-inline:12px; } .arcade-home > span:last-child { font-size:14px; } .arcade-account > .arcade-scores-link { display:none; } .arcade-account a,.arcade-account button { white-space:nowrap; } }
@media (max-width: 520px) { .arcade-install-hint { right:max(12px,env(safe-area-inset-right)); bottom:max(12px,env(safe-area-inset-bottom)); left:auto; } .arcade-install-dialog form { padding:19px; } }
@media (max-width: 480px) { .result-share-content { padding: 17px; } .result-share-actions button { flex: 1; } }
@media (prefers-reduced-motion: reduce) { .arcade-install-hint { animation:none; } }
diff --git a/arcade.js b/arcade.js
index 6dc0eed..7c269a7 100644
--- a/arcade.js
+++ b/arcade.js
@@ -149,7 +149,7 @@
home.setAttribute('aria-label', 'JavaScript Playground home');
home.innerHTML = 'JSPG JavaScript Playground ';
const account = document.createElement('nav');
- account.className = 'arcade-account'; account.setAttribute('aria-label', 'Arcade account and appearance');
+ account.className = 'arcade-account'; account.setAttribute('aria-label', 'Arcade navigation and account');
topbarInner.append(home, account); topbar.append(topbarInner);
const appearanceButton = document.createElement('button'); appearanceButton.type = 'button'; appearanceButton.className = 'arcade-appearance-button'; appearanceButton.textContent = 'Appearance';
const audioButton = document.createElement('button'); audioButton.type = 'button'; audioButton.className = 'arcade-audio-button'; audioButton.textContent = 'Sound'; audioButton.setAttribute('aria-haspopup', 'dialog');
@@ -316,7 +316,9 @@
if (game && audio) { updateAudioControls(); account.append(audioButton); }
if (saveManager) account.append(saveManager.button);
if (game) { const achievements = document.createElement('button'); achievements.type = 'button'; achievements.className = 'achievement-nav'; achievements.textContent = 'Achievements'; achievements.addEventListener('click', () => { achievementDialog.showModal(); loadAchievements().catch(() => {}); }); account.append(achievements); }
- const scores = document.createElement('a'); scores.href = `${rootPath}profile.html#leaderboards`; scores.textContent = 'Top scores'; account.append(scores);
+ const about = document.createElement('a'); about.href = `${rootPath}about.html`; about.className = 'arcade-about-link'; about.textContent = 'About';
+ if (location.pathname.endsWith('/about.html')) about.setAttribute('aria-current', 'page');
+ const scores = document.createElement('a'); scores.href = `${rootPath}profile.html#leaderboards`; scores.className = 'arcade-scores-link'; scores.textContent = 'Top scores'; account.append(about, scores);
if (currentUser) {
const profile = document.createElement('a'); profile.href = `${rootPath}profile.html`; profile.textContent = currentUser.gamertag;
const logout = document.createElement('button'); logout.type = 'button'; logout.textContent = 'Sign out'; logout.addEventListener('click', async () => { await api('/api/auth/logout', { method: 'POST', body: '{}' }); currentUser = null; render(); });
diff --git a/battle-tanks/styles.css b/battle-tanks/styles.css
index 80400d8..2d2b088 100644
--- a/battle-tanks/styles.css
+++ b/battle-tanks/styles.css
@@ -17,7 +17,7 @@
/* Landscape full screen uses a side command deck, leaving the 16:9 arena nearly full-height. */
@media(max-width:520px){.mobile-controls{display:grid}.controls>.readouts,.controls>.power-up-panel,.controls>.control-pad,.controls>.fire-button{display:none}}
-@media (min-width:521px) and (max-height:900px), (min-width:701px) and (max-width:850px){.game-battle-tanks .game-layout{grid-template-areas:"arena compact" "arena settings";grid-template-columns:minmax(0,1fr) minmax(300px,360px);grid-template-rows:auto auto;align-items:start}.game-battle-tanks .arena-wrap{grid-area:arena}.game-battle-tanks .mobile-controls{position:static;display:grid;grid-area:compact;grid-template-columns:repeat(3,minmax(0,1fr))}.game-battle-tanks .controls{grid-area:settings;max-height:none;gap:8px;overflow:visible}.game-battle-tanks .controls>.readouts,.game-battle-tanks .controls>.power-up-panel,.game-battle-tanks .controls>.control-pad,.game-battle-tanks .controls>.color-controls,.game-battle-tanks .controls>.fire-button,.game-battle-tanks .controls>#fullscreen,.game-battle-tanks .controls>.instructions{display:none}}
+@media (min-width:521px) and (max-height:900px) and (orientation:landscape), (min-width:701px) and (max-width:850px){.game-battle-tanks .game-layout{grid-template-areas:"arena compact" "arena settings";grid-template-columns:minmax(0,1fr) minmax(300px,360px);grid-template-rows:auto auto;align-items:start}.game-battle-tanks .arena-wrap{grid-area:arena}.game-battle-tanks .mobile-controls{position:static;display:grid;grid-area:compact;grid-template-columns:repeat(3,minmax(0,1fr))}.game-battle-tanks .controls{grid-area:settings;max-height:none;gap:8px;overflow:visible}.game-battle-tanks .controls>.readouts,.game-battle-tanks .controls>.power-up-panel,.game-battle-tanks .controls>.control-pad,.game-battle-tanks .controls>.color-controls,.game-battle-tanks .controls>.fire-button,.game-battle-tanks .controls>#fullscreen,.game-battle-tanks .controls>.instructions{display:none}}
@media (orientation:landscape){.arena-wrap:fullscreen,.arena-wrap.is-fullscreen{--battle-command-deck:clamp(150px,18vw,280px);box-sizing:border-box;grid-template-columns:minmax(0,1fr) var(--battle-command-deck);grid-template-rows:100%;place-items:center;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}.arena-wrap:fullscreen canvas,.arena-wrap.is-fullscreen canvas{grid-column:1;grid-row:1;width:min(100%,177.78dvh);height:auto;max-width:100%;max-height:100%;object-fit:contain}.arena-wrap:fullscreen .fullscreen-controls,.arena-wrap.is-fullscreen .fullscreen-controls{position:relative;inset:auto;box-sizing:border-box;display:grid;width:100%;height:100%;grid-column:2;grid-row:1;grid-template-columns:repeat(2,minmax(0,1fr));align-content:center;align-self:stretch;justify-self:stretch;gap:7px;padding:clamp(8px,1.2vw,16px);border-width:0 0 0 1px;border-radius:0;transform:none}.fullscreen-controls>.compact-readouts,.fullscreen-controls>.fullscreen-inventory,.fullscreen-controls>.compact-control-group,.fullscreen-controls>.compact-loadout,.fullscreen-controls>.fullscreen-fire{grid-column:1/-1}.arena-wrap:fullscreen .arena-health,.arena-wrap.is-fullscreen .arena-health,.arena-wrap:fullscreen .impact-callout,.arena-wrap.is-fullscreen .impact-callout{left:calc((100% - var(--battle-command-deck))/2)}.arena-wrap:fullscreen[data-active="1"] .active-flag,.arena-wrap.is-fullscreen[data-active="1"] .active-flag{right:calc(var(--battle-command-deck) + 12px)}.arena-wrap:fullscreen .arena-share,.arena-wrap.is-fullscreen .arena-share{left:calc((100% - var(--battle-command-deck))/2)}}
@media (orientation:landscape) and (max-height:500px){.arena-wrap:fullscreen,.arena-wrap.is-fullscreen{--battle-command-deck:clamp(148px,18vw,190px)}.arena-wrap:fullscreen .fullscreen-controls,.arena-wrap.is-fullscreen .fullscreen-controls{gap:4px;padding:6px}.fullscreen-controls button{min-height:44px;font-size:10px}.fullscreen-controls label,.fullscreen-controls select,.fullscreen-controls span{min-width:0;font-size:9px}.fullscreen-inventory{gap:4px}.fullscreen-inventory>strong{display:none}.fullscreen-inventory .inventory{overflow:hidden}.fullscreen-inventory .inventory button{min-height:44px;padding:3px 5px;font-size:9px}.fullscreen-controls .inventory:not(:has(button)){display:none}.fullscreen-controls .compact-loadout{grid-template-columns:1fr}.fullscreen-controls .compact-loadout label{display:none}.fullscreen-controls .compact-loadout select{min-height:44px}.fullscreen-controls .compact-loadout span{grid-column:1;text-align:center}}
@media(max-height:420px){.power-card,.result-card{border-radius:14px}.power-card{gap:2px;padding:8px 14px}.power-card-icon{font-size:26px}.power-card h2{font-size:1.25rem}.power-card #power-card-effect{font-size:.75rem}.result-card{gap:3px;padding:8px 14px}.result-burst{font-size:30px}.result-card h2{font-size:1.55rem}.result-card p:not(.result-kicker){font-size:.75rem}.result-actions button{min-height:44px}}
diff --git a/docs/architecture.md b/docs/architecture.md
index d8bb5d8..f9e9ff0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -21,7 +21,7 @@ flowchart LR
```mermaid
flowchart TB
subgraph Browser
- Pages[Home, profile, game pages]
+ Pages[Home, profile, about, game pages]
Arcade[arcade.js: account, appearance, results, achievements]
Events[game-events.js: browser domain events]
Shared[room UI, colors, sharing]
diff --git a/package-lock.json b/package-lock.json
index 30f053d..bb7986c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "javascript-playground",
- "version": "1.2.0",
+ "version": "1.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "javascript-playground",
- "version": "1.2.0",
+ "version": "1.2.5",
"dependencies": {
"ws": "8.21.3"
},
diff --git a/package.json b/package.json
index aaeea2e..45d23d0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "javascript-playground",
- "version": "1.2.0",
+ "version": "1.2.5",
"private": true,
"description": "Browser games with authoritative online multiplayer",
"main": "server/index.js",
diff --git a/releases.json b/releases.json
index 1982276..e2dea69 100644
--- a/releases.json
+++ b/releases.json
@@ -1,6 +1,21 @@
{
"schemaVersion": 1,
"releases": [
+ {
+ "version": "1.2.5",
+ "title": "JavaScript Playground 1.2.5",
+ "summary": "A new About page introduces Mohamed Elsherif and connects the arcade with his work across software architecture and the developer community.",
+ "highlights": [
+ "Meet Mohamed Elsherif through a game-native profile of his career, community work, granted patents, websites, and social profiles.",
+ "Open the About page from the shared arcade navigation on phones, tablets, and desktops."
+ ],
+ "fixes": [
+ "Keeps the About link available in compact phone navigation while moving the less essential Top scores shortcut out of the narrow layout."
+ ],
+ "technical": [
+ "Adds the responsive About page to every experience theme and the offline application shell, with regression coverage for content, links, navigation, and safe-area behavior."
+ ]
+ },
{
"version": "1.2.0",
"title": "JavaScript Playground 1.2.0",
@@ -44,7 +59,7 @@
"fixes": [
"Restored normal page scrolling in iPad browsers so players can reach content below the game area.",
"Reduced unused top spacing and compacted modern game layouts, including Tetris controls in phone landscape, so gameplay stays visible without scrolling when space permits.",
- "Restored the Battle Tanks full-screen action in compact tablet and short-viewport controls."
+ "Restored the Battle Tanks full-screen action in compact tablet and phone-landscape controls without shrinking narrow portrait battlefields."
],
"technical": [
"Applied shared short-viewport and tablet layout rules consistently across the modern arcade."
diff --git a/service-worker.js b/service-worker.js
index b308f35..ae1092f 100644
--- a/service-worker.js
+++ b/service-worker.js
@@ -1,9 +1,11 @@
'use strict';
-const CACHE_NAME = 'js-playground-v14';
+const CACHE_NAME = 'js-playground-v15';
const APP_SHELL = [
'./',
'./index.html',
+ './about.html',
+ './about.css',
'./arcade.css',
'./arcade.js',
'./fonts/Silkscreen-Regular.ttf',
diff --git a/tests/about-page.test.js b/tests/about-page.test.js
new file mode 100644
index 0000000..3fbf006
--- /dev/null
+++ b/tests/about-page.test.js
@@ -0,0 +1,71 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const test = require('node:test');
+
+const page = fs.readFileSync('about.html', 'utf8');
+const styles = fs.readFileSync('about.css', 'utf8');
+const shell = fs.readFileSync('arcade.js', 'utf8');
+const worker = fs.readFileSync('service-worker.js', 'utf8');
+
+function contrast(left, right) {
+ const luminance = color => {
+ const channels = color.slice(1).match(/../g).map(value => parseInt(value, 16) / 255).map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
+ return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
+ };
+ const values = [luminance(left), luminance(right)];
+ return (Math.max(...values) + 0.05) / (Math.min(...values) + 0.05);
+}
+
+test('shared arcade navigation exposes the about page', () => {
+ assert.match(shell, /about\.href = `\$\{rootPath\}about\.html`/);
+ assert.match(shell, /about\.setAttribute\('aria-current', 'page'\)/);
+});
+
+test('about page presents the owner, work, community projects, and patents', () => {
+ assert.match(page, /Mohamed Elsherif/);
+ assert.match(page, /Apple’s Services Architecture and Efficiency team/);
+ assert.match(page, /AskDeveloper Podcast/);
+ assert.match(page, /US11531532B2/);
+ assert.match(page, /US11956232B2/);
+});
+
+test('about page links to the public profile and social destinations safely', () => {
+ for (const destination of ['bashmohandes.com', 'askdeveloper.com', 'linkedin.com', 'github.com', 'youtube.com', 'soundcloud.com', 'tiktok.com', 'x.com', 'facebook.com']) {
+ assert.match(page, new RegExp(`href="https://(?:www\\.)?${destination.replace('.', '\\.')}`));
+ }
+ const externalLinks = page.match(/ {
+ assert.match(page, /src="theme-init\.js"[\s\S]*href="arcade\.css"[\s\S]*href="about\.css"/);
+ assert.match(styles, /data-arcade-theme="cabinet"/);
+ assert.match(styles, /data-arcade-theme="calm"/);
+ assert.match(styles, /@media \(max-width:520px\)/);
+});
+
+test('about page uses the current color-mode contract and contrast-safe text and focus colors', () => {
+ assert.match(styles, /:root\[data-color-mode="dark"\]/);
+ assert.doesNotMatch(styles, /data-theme=/);
+ assert.match(styles, /h1 span \{ color:var\(--pink-heading\)/);
+ assert.match(styles, /color:var\(--pink-text\)/);
+ assert.match(styles, /outline:3px solid var\(--focus\)/);
+ assert.match(styles, /\.patent-list b \{ color:var\(--link-accent\)/);
+ for (const [foreground, background, minimum] of [
+ ['#e13a87', '#fff4d6', 3],
+ ['#a31558', '#fff9e8', 4.5],
+ ['#4b2fc9', '#fff9e8', 3],
+ ['#315f54', '#f8fbf9', 4.5],
+ ['#ff70b7', '#111a33', 4.5],
+ ['#6ef2d0', '#111a33', 3],
+ ['#d9b968', '#18231f', 4.5]
+ ]) assert.ok(contrast(foreground, background) >= minimum, `${foreground} should meet ${minimum}:1 against ${background}`);
+});
+
+test('about page is available from the offline app shell', () => {
+ assert.match(worker, /'\.\/about\.html'/);
+ assert.match(worker, /'\.\/about\.css'/);
+});
diff --git a/tests/battle-tanks-page.test.js b/tests/battle-tanks-page.test.js
index 04304fe..e3b1061 100644
--- a/tests/battle-tanks-page.test.js
+++ b/tests/battle-tanks-page.test.js
@@ -91,7 +91,9 @@ test('Battle Tanks keeps mobile and short-landscape controls visible without cov
assert.match(styles, /fullscreen-inventory \.inventory button\{min-height:44px/);
assert.match(styles, /compact-loadout select\{min-height:44px/);
assert.match(styles, /fullscreen-inventory[^}]*overflow:hidden/);
+ assert.match(styles, /min-width:521px\) and \(max-height:900px\) and \(orientation:landscape\), \(min-width:701px\) and \(max-width:850px\)/);
assert.match(styles, /max-height:900px[\s\S]*?\.game-battle-tanks \.mobile-controls\{[^}]*position:static;[^}]*display:grid;[^}]*grid-template-columns:repeat\(3,minmax\(0,1fr\)\)/);
+ assert.doesNotMatch(styles, /min-width:521px\) and \(max-height:900px\),/);
assert.match(styles, /max-height:900px[\s\S]*?\.game-battle-tanks \.controls>\.readouts[^}]*display:none/);
});
diff --git a/tests/game-events.test.js b/tests/game-events.test.js
index 5abc292..6676a94 100644
--- a/tests/game-events.test.js
+++ b/tests/game-events.test.js
@@ -62,7 +62,7 @@ test('modern controllers publish domain facts without importing audio', () => {
test('shared shell notifications and extension boundaries use the event contract', () => {
const shell = read('arcade.js'), guide = read('docs/game-events.md'), adr = read('docs/adr/0014-browser-domain-event-bus.md');
- for (const page of ['index.html','profile.html']) assert.match(read(page), /scripts\/game-events\.js[\s\S]*arcade\.js/);
+ for (const page of ['index.html','profile.html','about.html']) assert.match(read(page), /scripts\/game-events\.js[\s\S]*arcade\.js/);
for (const type of ['system:theme-changed','account:user-changed','achievement:unlocked','score:top']) assert.match(shell, new RegExp(type));
assert.match(read('profile.js'), /account:user-changed/);
assert.match(guide, /not a command bus, durable event log, WebSocket protocol, or trust boundary/);
diff --git a/tests/online-rooms.test.js b/tests/online-rooms.test.js
index 4daa980..386497e 100644
--- a/tests/online-rooms.test.js
+++ b/tests/online-rooms.test.js
@@ -63,7 +63,7 @@ test('online pages cache-bust shared contract changes',()=>{
assert.match(page,new RegExp(`scripts/online-rooms\\.js\\?v=${rooms.CONTRACT_VERSION}`),`${file} must request the current shared contract`);
}
const worker=fs.readFileSync('service-worker.js','utf8');
- assert.match(worker,/CACHE_NAME = 'js-playground-v14'/);
+ assert.match(worker,/CACHE_NAME = 'js-playground-v15'/);
});
test('Battle Tanks ignores obsolete sockets and owns one reconnect timer',()=>{
const app=fs.readFileSync('battle-tanks/scripts/app.js','utf8');
diff --git a/tests/release-notes.test.js b/tests/release-notes.test.js
index 19ad5fb..3f919cb 100644
--- a/tests/release-notes.test.js
+++ b/tests/release-notes.test.js
@@ -29,6 +29,14 @@ test('release notes render tailored public and GitHub views', () => {
assert.match(markdown, /^## Technical notes$/m);
});
+test('current release notes introduce the new About page', () => {
+ const release = releaseForVersion(manifest, packageVersion);
+ assert.equal(release.version, '1.2.5');
+ assert.match(release.summary, /new About page/);
+ assert.ok(release.highlights.some(item => /Mohamed Elsherif[\s\S]*career[\s\S]*social profiles/.test(item)));
+ assert.doesNotMatch(releaseForVersion(manifest, '1.2.0').summary, /About page/);
+});
+
test('build information exposes release notes only for an exact stable version', () => {
assert.deepEqual(createBuildInformation({}, manifest), { version: 'dev', channel: 'dev', release: null });
assert.deepEqual(createBuildInformation({ BUILD_VERSION: 'sha-1234567', BUILD_CHANNEL: 'alpha' }, manifest), { version: 'sha-1234567', channel: 'alpha', release: null });
diff --git a/tests/shared-game-assets.test.js b/tests/shared-game-assets.test.js
index a7b8b3d..edd6bdc 100644
--- a/tests/shared-game-assets.test.js
+++ b/tests/shared-game-assets.test.js
@@ -162,6 +162,7 @@ test('result sharing preserves user activation when the preview is confirmed', (
test('every arcade page opts into shared iPhone safe-area handling', () => {
const pages = [
'index.html',
+ 'about.html',
'profile.html',
'pong/index.html',
'pong/classic/index.html',