fuzzyworld

north-star / commit

added dark mode

d9978ceba24ab95125df3d260bef24cfbb181166 · Pavel Safronov · 2026-03-15 16:06:15 -0700 · parents a402f6071eb2

added dark mode
 src/app.js     |  30 ++++++++++++++++-
 src/index.html |   1 +
 src/style.css  | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 132 insertions(+), 1 deletion(-)

diff --git a/src/app.js b/src/app.js
index ddab320..7ddb00a 100644
--- a/src/app.js
+++ b/src/app.js
@@ -14,6 +14,7 @@ const selfColorInput = document.getElementById('self-color-input');
 const roomCurrent = document.getElementById('room-current');
 const inviteRoomQrList = document.getElementById('invite-room-qr-list');
 const calibrateHeadingLink = document.getElementById('calibrate-heading-link');
+const darkModeToggle = document.getElementById('dark-mode-toggle');
 const createRoomLink = document.getElementById('create-room-link');
 const participantsPanel = document.getElementById('participants-panel');
 const participantsToggle = document.getElementById('participants-toggle');
@@ -34,12 +35,27 @@ const normalizeSignedAngle = (angle) => {
 const RENDER_INTERVAL_MS = 100;
 const LOCATION_SYNC_INTERVAL_MS = 3000;
 const HEADING_CORRECTION_STORAGE_KEY = 'north-star-heading-correction-deg';
+const DARK_MODE_STORAGE_KEY = 'north-star-dark-mode';
 let headingCorrectionDeg = (() => {
     const parsed = Number(localStorage.getItem(HEADING_CORRECTION_STORAGE_KEY));
     return Number.isFinite(parsed) ? parsed : 0;
 })();
 let isLocalCommit = true;
 let GIT_COMMIT = 'local-dev';
+
+const applyDarkMode = (dark) => {
+    document.body.classList.toggle('dark', dark);
+    if (darkModeToggle) darkModeToggle.textContent = dark ? '☀️' : '🌙';
+    localStorage.setItem(DARK_MODE_STORAGE_KEY, dark ? '1' : '0');
+};
+const storedDarkMode = localStorage.getItem(DARK_MODE_STORAGE_KEY);
+const prefersDark = storedDarkMode !== null
+    ? storedDarkMode === '1'
+    : window.matchMedia('(prefers-color-scheme: dark)').matches;
+applyDarkMode(prefersDark);
+if (darkModeToggle) {
+    darkModeToggle.addEventListener('click', () => applyDarkMode(!document.body.classList.contains('dark')));
+}
 let wakeLockSentinel = null;
 const hasWakeLockSupport = typeof navigator !== 'undefined' && 'wakeLock' in navigator;
 
@@ -534,6 +550,18 @@ const getReadableTextColor = (hexColor) => {
     return luminance > 145 ? '#0f172a' : '#f8fafc';
 };
 
+// In dark mode, swap colors that are too dark to see on a dark background to white.
+const getArrowDisplayColor = (hexColor) => {
+    if (!document.body.classList.contains('dark')) return hexColor;
+    const normalized = normalizeColor(hexColor);
+    if (!normalized) return '#ffffff';
+    const r = parseInt(normalized.slice(1, 3), 16) / 255;
+    const g = parseInt(normalized.slice(3, 5), 16) / 255;
+    const b = parseInt(normalized.slice(5, 7), 16) / 255;
+    const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+    return luminance < 0.2 ? '#ffffff' : hexColor;
+};
+
 const getDeviceHint = async () => {
     // Try high-entropy UA data first (Chromium only).
     if (navigator.userAgentData) {
@@ -1237,7 +1265,7 @@ const render = () => {
 
         existingArrow.currentAngle = currentArrowAngle;
         existingArrow.style.transform = `rotate(${currentArrowAngle}deg)`;
-        existingArrow.style.color = target.color;
+        existingArrow.style.color = getArrowDisplayColor(target.color);
 
         const arrowLabel = existingArrow.querySelector('.arrow-label');
         if (arrowLabel) {
diff --git a/src/index.html b/src/index.html
index c5b7aa6..672bae6 100644
--- a/src/index.html
+++ b/src/index.html
@@ -32,6 +32,7 @@
     <form id="name-form" class="name-form" autocomplete="off">
         <input id="self-name-input" name="selfName" type="text" maxlength="32" enterkeyhint="done" />
         <input id="self-color-input" name="selfColor" type="color" />
+        <button id="dark-mode-toggle" class="dark-mode-toggle" type="button" aria-label="Toggle dark mode">🌙</button>
     </form>
     <div id="default-arrow" style="display: none;" class="arrow">
         <div class="arrow-img"></div>
diff --git a/src/style.css b/src/style.css
index e9bd433..1d6532b 100644
--- a/src/style.css
+++ b/src/style.css
@@ -204,6 +204,24 @@ a {
     transform: translateY(1px);
 }
 
+.dark-mode-toggle {
+    width: 44px;
+    min-width: 44px;
+    height: 44px;
+    padding: 2px;
+    border: 1px solid #64748b;
+    border-radius: 10px;
+    background: rgba(248, 250, 252, 0.96);
+    font-size: 22px;
+    line-height: 1;
+    cursor: pointer;
+}
+
+body.dark .dark-mode-toggle {
+    background: #0f172a;
+    border-color: #475569;
+}
+
 #content {
     position: relative;
     width: 100%;
@@ -423,6 +441,90 @@ a {
     color: #94a3b8;
 }
 
+/* ── Dark mode ─────────────────────────────────────────────────── */
+body.dark {
+    background: #0f172a;
+    color: #f1f5f9;
+}
+
+body.dark a {
+    color: #60a5fa;
+}
+
+body.dark .room-current {
+    color: #94a3b8;
+}
+
+body.dark .room-qr-card {
+    background: #1e293b;
+    border-color: #334155;
+}
+
+body.dark .room-qr-title {
+    color: #94a3b8;
+}
+
+body.dark .room-qr {
+    background: #1e293b;
+    filter: invert(1);
+}
+
+body.dark .name-form {
+    background: rgba(30, 41, 59, 0.96);
+    border-color: #334155;
+}
+
+body.dark .name-form label {
+    color: #94a3b8;
+}
+
+body.dark .name-form input {
+    background: #0f172a;
+    border-color: #475569;
+    color: #f1f5f9;
+}
+
+body.dark .name-form input[type="color"] {
+    background: #0f172a;
+    border-color: #475569;
+}
+
+body.dark .participants-toggle {
+    background: rgba(30, 41, 59, 0.96);
+    border-color: #334155;
+    color: #f1f5f9;
+}
+
+body.dark .participants-toggle.compass-error {
+    background: rgba(124, 45, 18, 0.85);
+}
+
+body.dark .participants-panel {
+    background: rgba(30, 41, 59, 0.96);
+    border-color: #334155;
+}
+
+body.dark .participant-drag-handle {
+    background: #0f172a;
+    border-color: #475569;
+    color: #94a3b8;
+}
+
+body.dark #debug {
+    background: rgba(30, 41, 59, 0.96);
+    color: #94a3b8;
+    border-color: #334155;
+}
+
+body.dark .arrow-img {
+    filter: drop-shadow(0 0 2px #fff) drop-shadow(0 0 6px rgba(255, 255, 255, 0.5));
+}
+
+body.dark .arrow-label {
+    -webkit-text-stroke: 0.25px rgba(255, 255, 255, 0.9);
+    text-shadow: 0 1px 4px rgba(0, 0, 0, 0.95);
+}
+
 @media (max-width: 720px) {
     .panel-actions {
         left: 6px;