From b229a08222a63b6396e1bbfb0cfd885b001f5815 Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Thu, 8 Oct 2026 08:16:17 +0000 Subject: [PATCH 1/2] Add a dark mode The site follows the system setting on a first visit, and a Dark mode button in the sidebar switches it and remembers the choice. Colours that were typed into component styles are now theme tokens, so both themes come from one set of values. The R editor, code output, plots and the simulations keep their own colours. The accessibility scan now checks every page in both themes. --- e2e/accessibility.spec.ts | 47 +++++++----- index.html | 6 ++ src/App.css | 102 ++++++++++++++++++++----- src/components/AvatarTip.css | 8 +- src/components/ChoiceBlock.css | 18 ++--- src/components/CodeBlock.css | 8 +- src/components/Credits.css | 2 + src/components/Exercise.css | 18 ++--- src/components/FileUpload.css | 4 +- src/components/Sidebar.test.tsx | 13 ++++ src/components/Sidebar.tsx | 11 +++ src/components/workspace/Workspace.css | 19 +++-- src/pages/AvatarStudio.css | 2 +- src/pages/ModelChooser.css | 14 ++-- src/pages/SampleSize.css | 10 +-- src/sims/CI.css | 6 +- src/sims/CLT.css | 6 +- src/sims/Correlation.css | 8 +- src/sims/Distribution.css | 4 +- src/sims/LeastSquares.css | 4 +- src/sims/PValue.css | 6 +- 21 files changed, 212 insertions(+), 104 deletions(-) diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index e1b1d67..e2157c9 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -20,24 +20,31 @@ async function violations(page: Page) { return results.violations.map((v) => `${v.id} (${v.impact}): ${v.nodes.map((n) => n.target.join(' ')).slice(0, 5).join(' | ')}`); } -test('every page passes the automated WCAG 2.1 AA checks', async ({ page }) => { - test.setTimeout(600_000); - const found: string[] = []; - for (const path of PAGES) { - await page.goto(path); - await expect(page.locator('main h1').first()).toBeVisible(); - for (const v of await violations(page)) found.push(`${path}: ${v}`); - } - expect(found, found.join('\n')).toEqual([]); -}); +// Both themes: the dark one has its own colours, so its own contrast to get right. +for (const colorScheme of ['light', 'dark'] as const) { + test.describe(`${colorScheme} theme`, () => { + test.use({ colorScheme }); -test('answer feedback passes the automated WCAG 2.1 AA checks', async ({ page }) => { - await page.goto('./lesson/03-4'); - await expect(page.locator('main h1').first()).toBeVisible(); - // Answer every choice block so the right, wrong and coach bubbles are all on the page. - for (const block of await page.locator('.choice-block').all()) { - const option = block.getByRole('button').first(); - if (await option.isEnabled()) await option.click(); - } - expect(await violations(page)).toEqual([]); -}); + test('every page passes the automated WCAG 2.1 AA checks', async ({ page }) => { + test.setTimeout(600_000); + const found: string[] = []; + for (const path of PAGES) { + await page.goto(path); + await expect(page.locator('main h1').first()).toBeVisible(); + for (const v of await violations(page)) found.push(`${path}: ${v}`); + } + expect(found, found.join('\n')).toEqual([]); + }); + + test('answer feedback passes the automated WCAG 2.1 AA checks', async ({ page }) => { + await page.goto('./lesson/03-4'); + await expect(page.locator('main h1').first()).toBeVisible(); + // Answer every choice block so the right, wrong and coach bubbles are all on the page. + for (const block of await page.locator('.choice-block').all()) { + const option = block.getByRole('button').first(); + if (await option.isEnabled()) await option.click(); + } + expect(await violations(page)).toEqual([]); + }); + }); +} diff --git a/index.html b/index.html index e5afdfe..1e7e90b 100644 --- a/index.html +++ b/index.html @@ -19,6 +19,12 @@ +
diff --git a/src/App.css b/src/App.css index 726cfd0..9cb7c4c 100644 --- a/src/App.css +++ b/src/App.css @@ -20,6 +20,9 @@ --brand-2: #2563eb; --brand-soft: #eef0ff; --brand-ink: #3730a3; + --brand-line: #c7d2fe; + --brand-tint: #f5f7ff; + --on-brand: #fff; --gradient: linear-gradient(135deg, #6366f1 0%, #2563eb 55%, #0ea5e9 100%); --success: #16a34a; @@ -31,6 +34,20 @@ --teal: #0d9488; --teal-soft: #ccfbf1; + /* Text, border and pale-fill shades of each state colour. */ + --success-ink: #166534; + --success-line: #86efac; + --success-tint: #f0fdf4; + --warn-ink: #92400e; + --warn-line: #fcd34d; + --warn-tint: #fffbeb; + --danger-ink: #991b1b; + --danger-line: #fca5a5; + --danger-tint: #fef2f2; + --teal-ink: #0f766e; + --teal-line: #99f6e4; + --teal-tint: #f0fdfa; + --radius-sm: 8px; --radius: 12px; --radius-lg: 18px; @@ -43,6 +60,50 @@ --medium: 260ms; } +/* Dark theme. index.html sets data-theme before the first paint, from the + student's saved choice or else their system setting. Code output, R plots + and the simulations keep their own fixed colours: they are figures. */ +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #0b1120; + --surface: #131a2a; + --surface-2: #1c2436; + --ink: #e7ebf3; + --ink-2: #c5ccda; + --muted: #98a3b8; + --line: #273149; + --line-strong: #36425e; + + --brand: #8b93f8; + --brand-2: #6cb0fb; + --brand-soft: #232a52; + --brand-ink: #c7d2fe; + --brand-line: #3e4787; + --brand-tint: #181e3d; + --on-brand: #0b1120; + + --success-soft: #0e2618; + --warn-soft: #33250a; + --danger-soft: #3a1515; + --teal-soft: #0e2a28; + --success-ink: #86efac; + --success-line: #1f6b3d; + --success-tint: #0c1f15; + --warn-ink: #fcd34d; + --warn-line: #7a5a12; + --warn-tint: #241b08; + --danger-ink: #fca5a5; + --danger-line: #7f2a2a; + --danger-tint: #261010; + --teal-ink: #5eead4; + --teal-line: #1d5e57; + --teal-tint: #0b201e; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow: 0 4px 14px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 18px 40px rgba(0, 0, 0, 0.55); +} + /* Author `display` declarations outrank the user-agent [hidden] rule, so a component style can silently un-hide an element. This makes `hidden` win. It exists because a plot canvas once stayed visible when it should not. */ @@ -60,10 +121,10 @@ h2 { font-size: 1.45rem; font-weight: 750; } a { color: var(--brand-2); text-underline-offset: 2px; } a:hover { color: var(--brand); } :focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; border-radius: 6px; } -.skip-link { position: absolute; left: 0.75rem; top: -4rem; z-index: 100; padding: 0.6rem 1rem; border-radius: var(--radius-sm); background: var(--brand); color: #fff; font-weight: 650; text-decoration: none; } +.skip-link { position: absolute; left: 0.75rem; top: -4rem; z-index: 100; padding: 0.6rem 1rem; border-radius: var(--radius-sm); background: var(--brand); color: var(--on-brand); font-weight: 650; text-decoration: none; } .skip-link:focus { top: 0.75rem; } .app-main:focus { outline: none; } -::selection { background: #c7d2fe; } +::selection { background: var(--brand-line); } /* The system text cursor can be white and hard to see on a light page: use a white I-beam with a black outline. */ :root { --text-cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke-linecap='round'%3E%3Cpath d='M8 4h8M8 20h8M12 4v16' stroke='%23000' stroke-width='4'/%3E%3Cpath d='M8 4h8M8 20h8M12 4v16' stroke='%23fff' stroke-width='1.6'/%3E%3C/svg%3E") 12 12, text; } :where(p, li, h1, h2, h3, h4, h5, h6, td, th, pre, code, blockquote, figcaption, dd, dt, textarea, @@ -164,6 +225,7 @@ button { font: inherit; } .sidebar-lesson.complete .sidebar-lesson-dot { border-color: var(--success); background: var(--success); } .sidebar-lesson.active .sidebar-lesson-dot { box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2); } .sidebar-lesson-icon { flex: none; width: 1.1rem; text-align: center; } +button.sidebar-lesson { width: 100%; border: 0; background: none; font: inherit; font-size: 0.88rem; text-align: left; cursor: pointer; } /* R status: an error sits in the page, where it cannot be missed; the busy and ready states float as a small pill so they never push the lesson down. */ @@ -173,13 +235,13 @@ button { font: inherit; } animation: pill-in var(--medium) var(--ease) both; } .r-status.ready::before, .r-status.busy::before { content: ""; width: 0.55rem; height: 0.55rem; border-radius: 50%; flex: none; } -.r-status.ready { color: #166534; } +.r-status.ready { color: var(--success-ink); } .r-status.ready::before { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); } -.r-status.busy { color: #92400e; } +.r-status.busy { color: var(--warn-ink); } .r-status.busy::before { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; } .r-status.error { display: block; max-width: 48rem; margin: 0 auto 1.25rem; border-radius: var(--radius); padding: 0.9rem 1.1rem; - background: var(--danger-soft); color: #991b1b; border: 1px solid #fecaca; + background: var(--danger-soft); color: var(--danger-ink); border: 1px solid var(--danger-line); } .r-status.error p { margin: 0 0 0.5rem; } .r-status-detail { font-family: var(--mono); font-size: 0.75rem; word-break: break-all; } @@ -206,8 +268,8 @@ button { font: inherit; } .hero-eyebrow { display: inline-flex; align-items: center; gap: 0.45rem; margin: 0; padding: 0.25rem 0.7rem 0.25rem 0.35rem; border-radius: 999px; background: rgba(255, 255, 255, 0.16); font-size: 0.8rem; font-weight: 650; } .home-tagline { margin: 0 0 0.35rem; font-size: 1.15rem; font-weight: 600; color: #fff; } .hero-sub { margin: 0 0 1.4rem; max-width: 34rem; color: rgba(255, 255, 255, 0.85); font-size: 0.95rem; } -.hero .button-primary { background: #fff; color: var(--brand-ink); box-shadow: 0 8px 24px rgba(15, 23, 42, 0.25); } -.hero .button-primary:hover { color: var(--brand); } +.hero .button-primary { background: #fff; color: #3730a3; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.25); } +.hero .button-primary:hover { color: #4f46e5; } .hero-ring { position: relative; z-index: 1; } .progress-ring { width: 9.5rem; height: 9.5rem; } .progress-ring circle { fill: none; stroke-width: 11; } @@ -238,8 +300,8 @@ button { font: inherit; } padding: 1.1rem 1.15rem 0.9rem; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-sm); transition: transform var(--medium) var(--ease), box-shadow var(--medium), border-color var(--medium); } -.module-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); border-color: #c7d2fe; } -.module-card.done { border-color: #bbf7d0; } +.module-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); border-color: var(--brand-line); } +.module-card.done { border-color: var(--success-line); } .module-card-head { display: flex; align-items: center; gap: 0.7rem; } .module-card-head h3 { margin: 0; font-size: 1.05rem; } .module-card-number { flex: none; display: grid; place-items: center; width: 2.1rem; height: 2.1rem; border-radius: 10px; background: var(--brand-soft); color: var(--brand-ink); font-weight: 800; } @@ -272,18 +334,18 @@ button { font: inherit; } .lesson h1 { margin: 0; } .lesson-chips { margin: 0.8rem 0 0; } .chip { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.2rem 0.7rem; border-radius: 999px; font-size: 0.8rem; font-weight: 650; background: var(--brand-soft); color: var(--brand-ink); transition: background var(--medium), color var(--medium); } -.chip-done { background: var(--success-soft); color: #166534; } +.chip-done { background: var(--success-soft); color: var(--success-ink); } .lesson { font-size: 1.04rem; color: var(--ink-2); } .lesson h2 { margin: 2.75rem 0 0.9rem; padding-top: 0.25rem; font-size: 1.5rem; } .lesson h2::before { content: ""; display: block; width: 2.5rem; height: 4px; margin-bottom: 0.75rem; border-radius: 999px; background: var(--gradient); } .lesson p, .lesson li { max-width: 42rem; } .lesson strong { color: var(--ink); } .lesson blockquote { - margin: 1.5rem 0; padding: 0.9rem 1.2rem; border-radius: var(--radius); border: 1px solid #dbe2ff; - color: var(--ink-2); background: linear-gradient(180deg, #f5f7ff, #eef1ff); + margin: 1.5rem 0; padding: 0.9rem 1.2rem; border-radius: var(--radius); border: 1px solid var(--brand-line); + color: var(--ink-2); background: linear-gradient(180deg, var(--brand-tint), var(--brand-tint)); } .lesson blockquote p { margin: 0.3rem 0; } -.lesson code { background: var(--surface-2); color: #3b3f9e; padding: 0.1rem 0.35rem; border-radius: 5px; font-family: var(--mono); font-size: 0.86em; overflow-wrap: anywhere; } +.lesson code { background: var(--surface-2); color: var(--brand-ink); padding: 0.1rem 0.35rem; border-radius: 5px; font-family: var(--mono); font-size: 0.86em; overflow-wrap: anywhere; } .lesson table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 1.25rem 0; } .lesson th, .lesson td { padding: 0.45rem 0.9rem 0.45rem 0; text-align: left; border-bottom: 1px solid var(--line); } .lesson th { color: var(--ink); font-size: 0.9rem; } @@ -311,7 +373,7 @@ button { font: inherit; } .lesson-finish { max-width: 48rem; margin: 3.5rem auto 0; padding: 1.4rem; border-radius: var(--radius-lg); background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow); } .page-lesson .lesson-finish { margin-left: max(0px, calc((100% - 65rem) / 2)); } -.lesson-finish.complete { border-color: #bbf7d0; background: linear-gradient(180deg, #f0fdf4, var(--surface) 70%); } +.lesson-finish.complete { border-color: var(--success-line); background: linear-gradient(180deg, var(--success-tint), var(--surface) 70%); } .lesson-finish-status { display: flex; gap: 0.9rem; align-items: center; } .lesson-finish-icon { font-size: 2rem; } .lesson-finish.complete .lesson-finish-icon { animation: bounce-in 700ms var(--ease) both; } @@ -364,7 +426,7 @@ button { font: inherit; } border: 1px solid var(--line-strong); background: var(--surface); color: var(--ink-2); border-radius: 999px; padding: 0.35rem 0.8rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; } -.model-index-filters button[aria-pressed='true'] { background: var(--brand); border-color: var(--brand); color: #fff; } +.model-index-filters button[aria-pressed='true'] { background: var(--brand); border-color: var(--brand); color: var(--on-brand); } .model-index-empty { color: var(--muted); padding: 1.5rem 0; } .model-index-section { margin-top: 1.6rem; } .model-index-section h3 { display: flex; align-items: center; gap: 0.5rem; margin: 0; font-size: 1.05rem; } @@ -392,7 +454,7 @@ button { font: inherit; } } .model-card-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: auto; padding-top: 0.2rem; } .model-card-tags .model-badge { font-size: 0.7rem; padding: 0.1rem 0.5rem; } -.model-chooser-answer { border: 1px solid #99f6e4; border-radius: var(--radius-lg); padding: 1.2rem 1.3rem; max-width: 36rem; background: linear-gradient(180deg, #f0fdfa, var(--surface)); box-shadow: var(--shadow); animation: pop-in var(--medium) var(--ease) both; } +.model-chooser-answer { border: 1px solid var(--teal-line); border-radius: var(--radius-lg); padding: 1.2rem 1.3rem; max-width: 36rem; background: linear-gradient(180deg, var(--teal-tint), var(--surface)); box-shadow: var(--shadow); animation: pop-in var(--medium) var(--ease) both; } .model-chooser-answer pre { background: #0f172a; color: #e2e8f0; padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); overflow-x: auto; margin: 0; } .model-chooser-answer code, .model-chooser-help code { overflow-wrap: anywhere; } .model-chooser-code { display: flex; flex-direction: column; align-items: flex-end; gap: 0.4rem; margin: 0.9rem 0; } @@ -403,9 +465,9 @@ button { font: inherit; } .model-chooser-trail li:not(:last-child)::after { content: '\203A'; margin-left: 0.4rem; } .model-chooser-badges { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0 0 0.6rem; } .model-badge { display: inline-block; line-height: 1.4; font-size: 0.75rem; font-weight: 700; padding: 0.2rem 0.6rem; border-radius: 999px; border: 1px solid transparent; } -.model-badge.taught { background: #dcfce7; color: #166534; border-color: #86efac; } -.model-badge.beyond { background: #fef3c7; color: #92400e; border-color: #fcd34d; } -.model-badge.runs-here { background: var(--brand-soft); color: var(--brand-ink); border-color: #c7d2fe; } +.model-badge.taught { background: var(--success-soft); color: var(--success-ink); border-color: var(--success-line); } +.model-badge.beyond { background: var(--warn-soft); color: var(--warn-ink); border-color: var(--warn-line); } +.model-badge.runs-here { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand-line); } .model-badge.rstudio { background: var(--surface-2); color: var(--ink-2); border-color: var(--line-strong); } .link-button { border: none; background: none; color: var(--brand-2); text-decoration: underline; cursor: pointer; padding: 0; font-size: inherit; } @keyframes pop-in { from { opacity: 0; transform: translateY(8px) scale(0.98); } } @@ -454,7 +516,7 @@ button { font: inherit; } position: sticky; top: 0; z-index: 40; padding: 0.5rem max(0.75rem, env(safe-area-inset-right)) 0.5rem max(0.75rem, env(safe-area-inset-left)); padding-top: max(0.5rem, env(safe-area-inset-top)); - background: rgba(255, 255, 255, 0.82); backdrop-filter: saturate(1.6) blur(12px); -webkit-backdrop-filter: saturate(1.6) blur(12px); + background: color-mix(in srgb, var(--surface) 82%, transparent); backdrop-filter: saturate(1.6) blur(12px); -webkit-backdrop-filter: saturate(1.6) blur(12px); border-bottom: 1px solid var(--line); } .topbar-menu { diff --git a/src/components/AvatarTip.css b/src/components/AvatarTip.css index 1489243..0e467ea 100644 --- a/src/components/AvatarTip.css +++ b/src/components/AvatarTip.css @@ -2,19 +2,19 @@ .avatar-tip .avatar { flex: none; border-radius: 50%; box-shadow: var(--shadow-sm); } .avatar-tip-bubble { position: relative; flex: 1; min-width: 0; padding: 0.65rem 0.85rem; border-radius: var(--radius); - border: 1px solid #fcd34d; background: var(--warn-soft); color: #78350f; line-height: 1.45; + border: 1px solid var(--warn-line); background: var(--warn-soft); color: var(--warn-ink); line-height: 1.45; animation: pop-in var(--medium) var(--ease) both; } .avatar-tip-bubble::before { content: ""; position: absolute; left: -7px; top: 1rem; width: 12px; height: 12px; transform: rotate(45deg); background: inherit; border-left: inherit; border-bottom: inherit; } -.avatar-tip-error .avatar-tip-bubble { border-color: #fca5a5; background: var(--danger-soft); color: #7f1d1d; } +.avatar-tip-error .avatar-tip-bubble { border-color: var(--danger-line); background: var(--danger-soft); color: var(--danger-ink); } .avatar-tip-bubble p { margin: 0; } .avatar-tip-bubble p + p { margin-top: 0.4rem; } @media (prefers-reduced-motion: reduce) { .avatar-tip-bubble { animation: none; } } -.avatar-tip-coach .avatar-tip-bubble { border-color: #99f6e4; background: #f0fdfa; color: var(--ink); } -.avatar-tip-right .avatar-tip-bubble { border-color: #86efac; background: #f0fdf4; color: #14532d; } +.avatar-tip-coach .avatar-tip-bubble { border-color: var(--teal-line); background: var(--teal-tint); color: var(--ink); } +.avatar-tip-right .avatar-tip-bubble { border-color: var(--success-line); background: var(--success-tint); color: var(--success-ink); } .avatar-tip-title { font-size: 0.72rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.75; } .avatar-tip-bubble ul { margin: 0.2rem 0 0; padding-left: 1.2rem; } .avatar-tip-bubble li + li { margin-top: 0.3rem; } diff --git a/src/components/ChoiceBlock.css b/src/components/ChoiceBlock.css index a14b1ae..2c0cb53 100644 --- a/src/components/ChoiceBlock.css +++ b/src/components/ChoiceBlock.css @@ -1,12 +1,12 @@ .choice-block { - position: relative; border: 1px solid #dbe0ff; border-radius: var(--radius-lg); padding: 1.1rem 1.15rem; margin: 1.75rem 0; - background: linear-gradient(180deg, #f5f6ff, var(--surface) 6rem); box-shadow: var(--shadow-sm); + position: relative; border: 1px solid var(--brand-line); border-radius: var(--radius-lg); padding: 1.1rem 1.15rem; margin: 1.75rem 0; + background: linear-gradient(180deg, var(--brand-tint), var(--surface) 6rem); box-shadow: var(--shadow-sm); } -.choice-predict { border-color: #fde68a; background: linear-gradient(180deg, #fffbeb, var(--surface) 6rem); } -.choice-interpret { border-color: #99f6e4; background: linear-gradient(180deg, #f0fdfa, var(--surface) 6rem); } +.choice-predict { border-color: var(--warn-line); background: linear-gradient(180deg, var(--warn-tint), var(--surface) 6rem); } +.choice-interpret { border-color: var(--teal-line); background: linear-gradient(180deg, var(--teal-tint), var(--surface) 6rem); } .choice-label { display: inline-flex; align-items: center; gap: 0.35rem; margin: 0 0 0.45rem; padding: 0.15rem 0.6rem; border-radius: 999px; background: var(--brand-soft); font-size: 0.72rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; color: var(--brand-ink); } -.choice-predict .choice-label { background: var(--warn-soft); color: #92400e; } -.choice-interpret .choice-label { background: var(--teal-soft); color: #0f766e; } +.choice-predict .choice-label { background: var(--warn-soft); color: var(--warn-ink); } +.choice-interpret .choice-label { background: var(--teal-soft); color: var(--teal-ink); } .choice-question { margin: 0 0 0.85rem; font-weight: 650; color: var(--ink); } .choice-options { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; } .choice-options button { @@ -24,11 +24,11 @@ } .choice-text { padding-top: 0.1rem; } .choice-options button.chosen { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18); } -.choice-options button.chosen .choice-letter { background: var(--brand); color: #fff; } -.choice-options button.is-correct { border-color: #4ade80; background: #f0fdf4; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); } +.choice-options button.chosen .choice-letter { background: var(--brand); color: var(--on-brand); } +.choice-options button.is-correct { border-color: var(--success-line); background: var(--success-tint); box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); } .choice-options button.is-correct .choice-letter { background: var(--success); color: #fff; } .choice-options button.chosen.is-correct { animation: pop 420ms var(--ease); } -.choice-options button.is-wrong { border-color: #f87171; background: #fef2f2; box-shadow: none; animation: nudge 380ms ease-in-out; } +.choice-options button.is-wrong { border-color: var(--danger-line); background: var(--danger-tint); box-shadow: none; animation: nudge 380ms ease-in-out; } .choice-options button.is-wrong .choice-letter { background: var(--danger); color: #fff; } .points-pop { position: absolute; top: -0.7rem; right: 0.8rem; padding: 0.1rem 0.55rem; border-radius: 999px; font-size: 0.78rem; font-weight: 800; diff --git a/src/components/CodeBlock.css b/src/components/CodeBlock.css index 5cecb15..64b03da 100644 --- a/src/components/CodeBlock.css +++ b/src/components/CodeBlock.css @@ -1,14 +1,18 @@ .code-block { border: 1px solid var(--line); border-radius: var(--radius); padding: 0.8rem; margin: 1.5rem 0; background: var(--surface); box-shadow: var(--shadow-sm); } -.r-editor { border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; background: #fbfcfe; transition: border-color var(--fast), box-shadow var(--fast); } +.r-editor { border: 1px solid #e4e7ef; border-radius: var(--radius-sm); overflow: hidden; background: #fbfcfe; color: #0f172a; transition: border-color var(--fast), box-shadow var(--fast); } .r-editor:focus-within { border-color: #a5b4fc; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); } .r-editor .cm-editor { max-height: 24rem; font-family: var(--mono); font-size: 0.88rem; } .r-editor .cm-gutters { background: #f4f6fb; border-right: 1px solid var(--line); color: #94a3b8; } .r-editor .cm-activeLine { background: rgba(99, 102, 241, 0.05); } .r-editor .cm-activeLineGutter { background: rgba(99, 102, 241, 0.08); } +/* Dark theme: CodeMirror's syntax colours are chosen for a light page, so turn the + light editor inside out. hue-rotate puts each colour back on its own hue. Its + colours above are literals, not tokens, so that they invert from light ones. */ +:root[data-theme="dark"] .r-editor { filter: invert(1) hue-rotate(180deg); } .code-block-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0.65rem 0; } .code-block-actions button { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1.05rem; border-radius: 999px; - border: 1px solid var(--brand); background: var(--brand); color: #fff; cursor: pointer; font-size: 0.9rem; font-weight: 650; + border: 1px solid var(--brand); background: var(--brand); color: var(--on-brand); cursor: pointer; font-size: 0.9rem; font-weight: 650; transition: background var(--fast), transform var(--fast) var(--ease); } .code-block-actions button:not(.secondary)::before { content: "▶"; font-size: 0.65rem; } diff --git a/src/components/Credits.css b/src/components/Credits.css index 2f59771..ada0713 100644 --- a/src/components/Credits.css +++ b/src/components/Credits.css @@ -4,6 +4,8 @@ .credits-partner { display: inline-flex; align-items: center; color: var(--ink-2); text-decoration: none; } .credits-partner:hover .credits-wordmark { border-color: var(--brand); color: var(--brand-ink); } .credits-partner img { display: block; width: auto; max-width: 100%; } +/* The logos are dark on transparent: white on a dark page, hues kept. */ +:root[data-theme="dark"] .credits-partner img { filter: invert(1) hue-rotate(180deg); } .credits-wordmark { display: inline-block; padding: 0.3rem 0.65rem; border: 1px solid var(--line-strong); border-radius: var(--radius-sm); font-weight: 700; letter-spacing: 0.01em; white-space: nowrap; transition: border-color var(--fast), color var(--fast); diff --git a/src/components/Exercise.css b/src/components/Exercise.css index 68880f2..7a21361 100644 --- a/src/components/Exercise.css +++ b/src/components/Exercise.css @@ -1,10 +1,10 @@ .exercise { - position: relative; border: 1px solid #99f6e4; border-radius: var(--radius-lg); padding: 1.15rem; margin: 2rem 0; - background: linear-gradient(180deg, #f0fdfa, var(--surface) 7rem); box-shadow: var(--shadow); + position: relative; border: 1px solid var(--teal-line); border-radius: var(--radius-lg); padding: 1.15rem; margin: 2rem 0; + background: linear-gradient(180deg, var(--teal-tint), var(--surface) 7rem); box-shadow: var(--shadow); transition: border-color var(--medium), box-shadow var(--medium); } -.exercise-passed { border-color: #86efac; box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12), var(--shadow); } -.exercise-label { display: inline-flex; align-items: center; gap: 0.35rem; margin: 0 0 0.45rem; padding: 0.15rem 0.6rem; border-radius: 999px; background: var(--teal-soft); font-size: 0.72rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; color: #0f766e; } +.exercise-passed { border-color: var(--success-line); box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12), var(--shadow); } +.exercise-label { display: inline-flex; align-items: center; gap: 0.35rem; margin: 0 0 0.45rem; padding: 0.15rem 0.6rem; border-radius: 999px; background: var(--teal-soft); font-size: 0.72rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; color: var(--teal-ink); } .exercise-prompt { margin: 0 0 0.85rem; font-weight: 650; color: var(--ink); } .exercise-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.75rem 0; } .exercise-actions button { @@ -13,20 +13,20 @@ } .exercise-actions button:hover:not(:disabled) { background: #0f766e; } .exercise-actions button:active:not(:disabled) { transform: scale(0.97); } -.exercise-actions button.secondary { background: var(--surface); color: #0f766e; border-color: #99f6e4; } +.exercise-actions button.secondary { background: var(--surface); color: var(--teal-ink); border-color: var(--teal-line); } .exercise-actions button.secondary:hover:not(:disabled) { background: var(--teal-soft); } .exercise-actions button:disabled { opacity: 0.5; cursor: not-allowed; } .exercise-hints { margin: 0; } .exercise-hints li { animation: pop-in var(--medium) var(--ease) both; } .exercise-outcome { padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); margin: 0.7rem 0; animation: pop-in var(--medium) var(--ease) both; } .exercise-outcome p { margin: 0; } -.outcome-pass { background: var(--success-soft); border: 1px solid #86efac; color: #14532d; font-weight: 600; } +.outcome-pass { background: var(--success-soft); border: 1px solid var(--success-line); color: var(--success-ink); font-weight: 600; } .outcome-pass p::before { content: "✓ "; } -.outcome-fail { background: var(--warn-soft); border: 1px solid #fcd34d; color: #78350f; animation-name: nudge; } -.outcome-student-error { background: var(--danger-soft); border: 1px solid #fca5a5; color: #7f1d1d; } +.outcome-fail { background: var(--warn-soft); border: 1px solid var(--warn-line); color: var(--warn-ink); animation-name: nudge; } +.outcome-student-error { background: var(--danger-soft); border: 1px solid var(--danger-line); color: var(--danger-ink); } .outcome-broken-check { background: #ede9fe; border: 1px solid #c4b5fd; color: #4c1d95; } .exercise-solution { background: #0f172a; color: #e2e8f0; padding: 0.8rem; border-radius: var(--radius-sm); overflow-x: auto; font-family: var(--mono); font-size: 0.86rem; animation: pop-in var(--medium) var(--ease) both; } -.exercise-missing { color: var(--danger); font-weight: 600; } +.exercise-missing { color: var(--danger-ink); font-weight: 600; } @keyframes nudge { 0%, 100% { transform: none; } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } } .exercise-task .exercise-prompt { margin: 0; } .exercise-advice { font-size: 0.9rem; color: var(--muted); } diff --git a/src/components/FileUpload.css b/src/components/FileUpload.css index fd232c4..a91fe43 100644 --- a/src/components/FileUpload.css +++ b/src/components/FileUpload.css @@ -2,7 +2,7 @@ .file-upload-compact { margin: 0.5rem 0 0.75rem; padding: 0.55rem 0.7rem; } .file-upload-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.75rem; } .file-upload button { - padding: 0.4rem 0.95rem; border-radius: 999px; border: 1px solid var(--brand); background: var(--brand); color: #fff; + padding: 0.4rem 0.95rem; border-radius: 999px; border: 1px solid var(--brand); background: var(--brand); color: var(--on-brand); cursor: pointer; font-size: 0.88rem; font-weight: 650; transition: background var(--fast); } .file-upload button:hover:not(:disabled) { background: var(--brand-ink); } @@ -10,7 +10,7 @@ .file-upload button.secondary { background: var(--surface); color: var(--brand-ink); border-color: var(--line-strong); padding: 0.25rem 0.75rem; font-size: 0.82rem; } .file-upload button.secondary:hover:not(:disabled) { background: var(--brand-soft); } .file-upload-note { color: var(--muted); font-size: 0.85rem; flex: 1 1 16rem; } -.file-upload-error { margin: 0.6rem 0 0; padding: 0.5rem 0.75rem; border-radius: var(--radius-sm); background: var(--danger-soft); border: 1px solid #fca5a5; color: #7f1d1d; font-size: 0.88rem; } +.file-upload-error { margin: 0.6rem 0 0; padding: 0.5rem 0.75rem; border-radius: var(--radius-sm); background: var(--danger-soft); border: 1px solid var(--danger-line); color: var(--danger-ink); font-size: 0.88rem; } .file-upload-list { list-style: none; margin: 0.6rem 0 0; padding: 0; display: grid; gap: 0.45rem; } .file-upload-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 0.6rem; padding: 0.45rem 0.6rem; border-radius: var(--radius-sm); background: var(--surface); border: 1px solid var(--line); } .file-upload-name { font-weight: 650; color: var(--ink); font-size: 0.88rem; } diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index bcd3cc1..1dd1f6a 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -86,3 +86,16 @@ describe('Sidebar', () => { expect(link.className).toContain('complete'); }); }); + +test('the dark mode button switches the theme and remembers it', () => { + document.documentElement.dataset.theme = 'light'; + renderSidebar(); + const button = screen.getByRole('button', { name: 'Dark mode' }); + expect(button.getAttribute('aria-pressed')).toBe('false'); + act(() => button.click()); + expect(document.documentElement.dataset.theme).toBe('dark'); + expect(localStorage.getItem('statlab.theme')).toBe('dark'); + expect(button.getAttribute('aria-pressed')).toBe('true'); + act(() => button.click()); + expect(document.documentElement.dataset.theme).toBe('light'); +}); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 86c6374..b54c860 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,6 +9,9 @@ import Avatar from './Avatar'; import Credits from './Credits'; import Logo from './Logo'; +/** Read by the script in index.html, which applies the theme before React loads. */ +const THEME_KEY = 'statlab.theme'; + type Props = { /** Whether the drawer is showing, on screens narrow enough to have one. */ open?: boolean; @@ -22,6 +25,13 @@ export default function Sidebar({ open = false }: Props) { // useSyncExternalStore snapshot; a counter re-renders on each store write. useEffect(() => subscribeProgress(() => setTick((tick) => tick + 1)), []); const progress = getProgress(); + const [dark, setDark] = useState(() => document.documentElement.dataset.theme === 'dark'); + function toggleTheme() { + const next = dark ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + try { localStorage.setItem(THEME_KEY, next); } catch { /* Private mode: it still switches for this visit. */ } + setDark(!dark); + } const stats = courseStats(progress); // One module open at a time by default: the one holding the current lesson. @@ -122,6 +132,7 @@ export default function Sidebar({ open = false }: Props) {
  • How many participants?
  • Review quiz
  • R Workspace
  • +
  • diff --git a/src/components/workspace/Workspace.css b/src/components/workspace/Workspace.css index a1803e6..b2a5943 100644 --- a/src/components/workspace/Workspace.css +++ b/src/components/workspace/Workspace.css @@ -13,6 +13,7 @@ --ide-head: #eef1f6; --ide-edge: #d5dbe5; --ide-echo: #1d4ed8; + --ide-well: #e3e8ef; margin: 1rem 0 0; } @@ -25,7 +26,7 @@ box-sizing: border-box; border: 1px solid var(--ide-edge); border-radius: var(--radius); - background: #e3e8ef; + background: var(--ide-well); box-shadow: var(--shadow-sm); } .ide-column { display: flex; flex-direction: column; min-width: 0; min-height: 0; flex: 1 1 0; } @@ -65,7 +66,7 @@ body.ide-dragging-y { cursor: row-resize; } .ide-tab .ide-tab-close { padding: 0.2rem 0.45rem 0.2rem 0; font-size: 1rem; line-height: 1; } .ide-tab-title { padding: 0.3rem 0.65rem; font-size: 0.82rem; font-weight: 650; color: var(--ink); background: var(--surface); border: 1px solid var(--ide-edge); border-bottom: 0; border-radius: 5px 5px 0 0; margin-bottom: -1px; } .ide-tabs-aside { margin-left: auto; display: inline-flex; align-items: center; gap: 0.5rem; padding-bottom: 0.25rem; font-size: 0.78rem; } -.ide-busy { color: #92400e; font-weight: 600; } +.ide-busy { color: var(--warn-ink); font-weight: 600; } .ide-busy::before { content: ""; display: inline-block; width: 0.5rem; height: 0.5rem; margin-right: 0.35rem; border-radius: 50%; background: var(--warn); animation: pulse 1.2s ease-in-out infinite; } .ide-panel { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; } @@ -80,7 +81,7 @@ body.ide-dragging-y { cursor: row-resize; } } .ide-toolbar button:hover:not(:disabled), .ide-tab-action:hover, .ide-file-actions button:hover { background: var(--brand-soft); border-color: var(--brand); color: var(--brand-ink); } .ide-toolbar button:disabled { opacity: 0.45; cursor: not-allowed; } -.ide-toolbar button.danger { border-color: var(--danger); color: var(--danger); } +.ide-toolbar button.danger { border-color: var(--danger); color: var(--danger-ink); } .ide-pane-source .ide-toolbar button:first-child:not(:disabled) { border-color: var(--brand); color: var(--brand-ink); } .ide-pane-source .ide-toolbar button:first-child::before { content: "▶ "; font-size: 0.65rem; } .ide-toolbar input[type="search"] { @@ -92,14 +93,14 @@ body.ide-dragging-y { cursor: row-resize; } .ide-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; overscroll-behavior: contain; } .ide-empty { margin: 0; padding: 0.75rem; color: var(--muted); font-size: 0.85rem; } -.ide-error { flex: none; margin: 0.4rem; padding: 0.4rem 0.6rem; border-radius: 6px; background: var(--danger-soft); border: 1px solid #fca5a5; color: #7f1d1d; font-size: 0.82rem; } +.ide-error { flex: none; margin: 0.4rem; padding: 0.4rem 0.6rem; border-radius: 6px; background: var(--danger-soft); border: 1px solid var(--danger-line); color: var(--danger-ink); font-size: 0.82rem; } .ide-link { border: 0; background: none; padding: 0; font: inherit; color: var(--brand-2); cursor: pointer; text-align: left; } .ide-link:hover:not(:disabled) { text-decoration: underline; } .ide-link[aria-current] { color: var(--ink); font-weight: 650; cursor: default; } /* Source --------------------------------------------------------------------- */ .ide-editor { flex: 1 1 auto; min-height: 0; display: flex; } -.ide-editor .r-editor { flex: 1; min-width: 0; border: 0; border-radius: 0; background: var(--surface); } +.ide-editor .r-editor { flex: 1; min-width: 0; border: 0; border-radius: 0; background: #fff; } .ide-editor .r-editor:focus-within { box-shadow: none; } .ide-editor .r-editor, .ide-editor .cm-editor { height: 100%; } .ide-editor .r-editor .cm-editor { max-height: none; } @@ -112,8 +113,8 @@ body.ide-dragging-y { cursor: row-resize; } } .ide-console-line { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); } .ide-console-echo { color: var(--ide-echo); } -.ide-console-error { color: #b91c1c; } -.ide-console-warning, .ide-console-stderr { color: #b45309; } +.ide-console-error { color: var(--danger-ink); } +.ide-console-warning, .ide-console-stderr { color: var(--warn-ink); } .ide-console-message { color: var(--muted); } .ide-console-prompt { display: flex; gap: 0.5ch; color: var(--ide-echo); } .ide-console-prompt textarea { @@ -197,7 +198,7 @@ body.ide-dragging-y { cursor: row-resize; } @container ide (max-width: 640px) { .ide-switcher { display: flex; gap: 2px; padding: 3px; margin-bottom: 0.5rem; - border-radius: 10px; background: #e3e8ef; + border-radius: 10px; background: var(--ide-well); } .ide-switcher button { flex: 1 1 auto; min-width: 0; min-height: 40px; padding: 0.3rem 0.35rem; border: 0; border-radius: 8px; background: transparent; @@ -224,3 +225,5 @@ body.ide-dragging-y { cursor: row-resize; } .ide-install input { font-size: 16px; } .ide-console-prompt textarea, .ide-toolbar input[type="search"] { font-size: 16px; } } + +:root[data-theme="dark"] .ide { --ide-head: #1c2436; --ide-edge: #2c3650; --ide-echo: #93c5fd; --ide-well: #0b1120; } diff --git a/src/pages/AvatarStudio.css b/src/pages/AvatarStudio.css index 2a2b24f..c2cc672 100644 --- a/src/pages/AvatarStudio.css +++ b/src/pages/AvatarStudio.css @@ -43,7 +43,7 @@ border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow-sm); text-align: center; } .avatar-card .avatar { border-radius: 50%; } -.avatar-card.owned { background: var(--success-soft); border-color: #bbf7d0; } +.avatar-card.owned { background: var(--success-soft); border-color: var(--success-line); } .avatar-card .button { padding: 0.4rem 0.9rem; font-size: 0.88rem; } .avatar-card .button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } .avatar-short { font-size: 0.75rem; color: var(--muted); } diff --git a/src/pages/ModelChooser.css b/src/pages/ModelChooser.css index 098d78d..a9f5640 100644 --- a/src/pages/ModelChooser.css +++ b/src/pages/ModelChooser.css @@ -14,7 +14,7 @@ .mc-kicker { margin: 0; font-size: 0.72rem; font-weight: 750; letter-spacing: 0.09em; text-transform: uppercase; color: var(--brand); } /* The research-question box ------------------------------------------------ */ -.mc-matcher { background: linear-gradient(180deg, var(--brand-soft), var(--surface) 11rem); border-color: #dfe3ff; } +.mc-matcher { background: linear-gradient(180deg, var(--brand-soft), var(--surface) 11rem); border-color: var(--brand-line); } .mc-matcher textarea { display: block; width: 100%; box-sizing: border-box; resize: vertical; min-height: 4.2rem; font: inherit; font-size: 1.05rem; line-height: 1.5; color: var(--ink); @@ -22,7 +22,7 @@ transition: border-color var(--fast), box-shadow var(--fast); } .mc-matcher textarea:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.15); } -.mc-matcher textarea::placeholder { color: #94a3b8; } +.mc-matcher textarea::placeholder { color: var(--muted); } .mc-examples { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr)); gap: 0.45rem; margin: 0.9rem 0 0; } .mc-examples-label { grid-column: 1 / -1; font-size: 0.85rem; font-weight: 650; color: var(--muted); } @@ -46,7 +46,7 @@ transition: background var(--fast), border-color var(--fast), color var(--fast); } .mc-chip::before { content: '+'; font-weight: 800; } -.mc-chip[aria-pressed='true'] { border: 1px solid #99f6e4; background: var(--teal-soft); color: #115e59; } +.mc-chip[aria-pressed='true'] { border: 1px solid var(--teal-line); background: var(--teal-soft); color: var(--teal-ink); } .mc-chip[aria-pressed='true']::before { content: '\2713'; } .mc-chip[aria-pressed='true']:hover { text-decoration: line-through; } .mc-chip[aria-pressed='false']:hover { border-color: var(--brand); color: var(--brand-ink); } @@ -60,8 +60,8 @@ display: flex; flex-direction: column; gap: 0.35rem; animation: pop-in var(--medium) var(--ease) both; } .mc-suggestion.best { - grid-column: 1 / -1; padding: 1.2rem 1.3rem; border: 1.5px solid #a5b4fc; box-shadow: var(--shadow); - background: linear-gradient(135deg, #f5f7ff, var(--surface) 60%); + grid-column: 1 / -1; padding: 1.2rem 1.3rem; border: 1.5px solid var(--brand-line); box-shadow: var(--shadow); + background: linear-gradient(135deg, var(--brand-tint), var(--surface) 60%); } .mc-suggestion h3 { margin: 0; font-size: 1.05rem; } .mc-suggestion.best h3 { font-size: 1.35rem; } @@ -75,7 +75,7 @@ .mc-link-button { background: none; border: 0; padding: 0.3rem 0; cursor: pointer; color: var(--brand-2); font-weight: 600; font-size: 0.9rem; text-decoration: underline; text-underline-offset: 3px; } .mc-link-button:hover { color: var(--brand); } .mc-suggestion-detail:not([hidden]) { margin-top: 0.6rem; padding-top: 0.9rem; border-top: 1px solid var(--line); } -.mc-empty { margin: 1.2rem 0 0; padding: 0.9rem 1rem; border-radius: var(--radius); background: var(--warn-soft); color: #78350f; font-size: 0.95rem; } +.mc-empty { margin: 1.2rem 0 0; padding: 0.9rem 1rem; border-radius: var(--radius); background: var(--warn-soft); color: var(--warn-ink); font-size: 0.95rem; } /* The step-by-step questions ---------------------------------------------- */ .model-chooser .model-chooser-trail { @@ -124,7 +124,7 @@ .mc-option-example { font-size: 0.88rem; line-height: 1.45; color: var(--ink-2); font-style: italic; } .mc-option-example::before { content: 'e.g. '; font-style: normal; font-weight: 650; color: var(--muted); } .mc-option-tech { font-size: 0.72rem; font-weight: 650; letter-spacing: 0.02em; color: var(--muted); } -.mc-flag { align-self: flex-start; order: -1; font-size: 0.7rem; font-weight: 750; color: #fff; background: var(--brand); border-radius: 999px; padding: 0.05rem 0.55rem; } +.mc-flag { align-self: flex-start; order: -1; font-size: 0.7rem; font-weight: 750; color: var(--on-brand); background: var(--brand); border-radius: 999px; padding: 0.05rem 0.55rem; } .model-chooser .model-chooser-answer { max-width: none; margin-top: 0.4rem; } .model-chooser .model-chooser-legend { margin: 1.4rem 0 0; font-size: 0.85rem; line-height: 1.55; max-width: none; } diff --git a/src/pages/SampleSize.css b/src/pages/SampleSize.css index 1254021..6049a31 100644 --- a/src/pages/SampleSize.css +++ b/src/pages/SampleSize.css @@ -1,6 +1,6 @@ /* Sample size planner. Builds on the panels in ModelChooser.css. */ -.ss-tag { align-self: flex-start; font-size: 0.72rem; font-weight: 750; color: #065f46; background: #d1fae5; border-radius: 999px; padding: 0.05rem 0.55rem; } +.ss-tag { align-self: flex-start; font-size: 0.72rem; font-weight: 750; color: var(--teal-ink); background: var(--teal-soft); border-radius: 999px; padding: 0.05rem 0.55rem; } .ss-option-note { font-size: 0.9rem; line-height: 1.45; color: var(--ink-2); } .ss-input-step { margin-top: 0.4rem; } .ss-input-step .button-primary { margin-top: 0.4rem; } @@ -18,12 +18,12 @@ .ss-field small { color: var(--muted); font-size: 0.85rem; line-height: 1.4; } .ss-meaning { color: var(--ink-2); background: var(--surface-2); border-radius: var(--radius-sm); padding: 0.55rem 0.8rem; } -.ss-result { border-color: #c7d2fe; } +.ss-result { border-color: var(--brand-line); } .ss-result h4 { margin: 1.4rem 0 0.5rem; font-size: 1.05rem; } .ss-headline { display: flex; flex-wrap: wrap; gap: 0.6rem 2rem; margin: 0.3rem 0 0.8rem; } .ss-headline p { margin: 0; color: var(--ink-2); font-weight: 600; } .ss-big { display: block; font-size: 2.6rem; font-weight: 800; line-height: 1.1; color: var(--brand-ink); } -.ss-error { color: #7f1d1d; background: var(--danger-soft); border-radius: var(--radius-sm); padding: 0.55rem 0.8rem; } +.ss-error { color: var(--danger-ink); background: var(--danger-soft); border-radius: var(--radius-sm); padding: 0.55rem 0.8rem; } .ss-note { color: var(--muted); font-size: 0.92rem; } .ss-report { background: var(--surface-2); border-left: 4px solid var(--brand); padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); line-height: 1.55; } .ss-table { border-collapse: collapse; margin: 0.3rem 0; font-size: 0.95rem; } @@ -36,7 +36,7 @@ .ss-grid { stroke: var(--line); } .ss-target { stroke: var(--warn); stroke-dasharray: 5 4; stroke-width: 1.5; } .ss-line { fill: none; stroke: var(--brand); stroke-width: 2.5; } -.ss-mark { fill: var(--brand); stroke: #fff; stroke-width: 2; } +.ss-mark { fill: var(--brand); stroke: var(--surface); stroke-width: 2; } .ss-result pre { background: #0f172a; color: #e2e8f0; padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); overflow-x: auto; margin: 0; max-width: 100%; box-sizing: border-box; } .ss-result .model-chooser-code { min-width: 0; } -.ss-warning { color: var(--ink-2); background: #fef3c7; border-radius: var(--radius-sm); padding: 0.55rem 0.8rem; } +.ss-warning { color: var(--ink-2); background: var(--warn-soft); border-radius: var(--radius-sm); padding: 0.55rem 0.8rem; } diff --git a/src/sims/CI.css b/src/sims/CI.css index e972296..33b4301 100644 --- a/src/sims/CI.css +++ b/src/sims/CI.css @@ -7,8 +7,8 @@ .ci-svg { width: 100%; height: auto; background: #f8fafc; border-radius: 4px; } .ci-svg line { cursor: pointer; } .ci-readout { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-top: 0.5rem; } -.ci-readout th { text-align: left; font-weight: 500; color: #475569; padding: 0.2rem 0.5rem 0.2rem 0; } +.ci-readout th { text-align: left; font-weight: 500; color: var(--muted); padding: 0.2rem 0.5rem 0.2rem 0; } .ci-readout td { padding: 0.2rem 1.25rem 0.2rem 0; font-variant-numeric: tabular-nums; font-weight: 600; } -.ci-single { margin: 0.5rem 0 0; padding: 0.5rem 0.75rem; border-left: 3px solid #1d4ed8; background: #eff6ff; font-size: 0.9rem; } -.ci-note { margin: 0.5rem 0 0; color: #475569; font-size: 0.85rem; } +.ci-single { margin: 0.5rem 0 0; padding: 0.5rem 0.75rem; border-left: 3px solid #1d4ed8; background: var(--brand-tint); font-size: 0.9rem; } +.ci-note { margin: 0.5rem 0 0; color: var(--muted); font-size: 0.85rem; } diff --git a/src/sims/CLT.css b/src/sims/CLT.css index 90baa37..08aa179 100644 --- a/src/sims/CLT.css +++ b/src/sims/CLT.css @@ -3,12 +3,12 @@ .clt-controls label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; } .clt-slider input { width: 12rem; } .clt-controls button { padding: 0.4rem 0.95rem; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface); color: var(--brand-ink); cursor: pointer; font-weight: 600; transition: background var(--fast), border-color var(--fast); } -.clt-description { margin: 0 0 0.75rem; color: #475569; font-size: 0.9rem; } +.clt-description { margin: 0 0 0.75rem; color: var(--muted); font-size: 0.9rem; } .clt-figure { margin: 0 0 0.75rem; } -.clt-figure figcaption { font-size: 0.85rem; color: #334155; margin-bottom: 0.2rem; } +.clt-figure figcaption { font-size: 0.85rem; color: var(--ink-2); margin-bottom: 0.2rem; } .clt-svg { width: 100%; height: auto; background: #f8fafc; border-radius: 4px; } .clt-axis { font-size: 11px; fill: #475569; } .clt-readout { width: 100%; border-collapse: collapse; font-size: 0.85rem; } -.clt-readout th { text-align: left; font-weight: 500; color: #475569; padding: 0.2rem 0.5rem 0.2rem 0; } +.clt-readout th { text-align: left; font-weight: 500; color: var(--muted); padding: 0.2rem 0.5rem 0.2rem 0; } .clt-readout td { padding: 0.2rem 1.25rem 0.2rem 0; font-variant-numeric: tabular-nums; font-weight: 600; } diff --git a/src/sims/Correlation.css b/src/sims/Correlation.css index bd2476f..40688f5 100644 --- a/src/sims/Correlation.css +++ b/src/sims/Correlation.css @@ -5,10 +5,10 @@ .correlation-slider input[type="range"] { width: 11rem; } .correlation-slider span { font-variant-numeric: tabular-nums; min-width: 3rem; } .correlation-controls button { padding: 0.4rem 0.95rem; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface); color: var(--brand-ink); cursor: pointer; font-weight: 600; transition: background var(--fast), border-color var(--fast); } -.correlation-controls button:disabled { border-color: #cbd5e1; color: #94a3b8; cursor: default; } +.correlation-controls button:disabled { border-color: var(--line-strong); color: var(--muted); cursor: default; } .correlation-readout { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-top: 0.5rem; } -.correlation-readout th { text-align: left; font-weight: 500; color: #475569; padding: 0.2rem 0.5rem 0.2rem 0; } +.correlation-readout th { text-align: left; font-weight: 500; color: var(--muted); padding: 0.2rem 0.5rem 0.2rem 0; } .correlation-readout td { padding: 0.2rem 1.25rem 0.2rem 0; font-variant-numeric: tabular-nums; font-weight: 600; } -.correlation-score { margin: 0.5rem 0 0; color: #475569; font-size: 0.85rem; } -.correlation-verdict { margin: 0.35rem 0 0; padding: 0.5rem 0.75rem; border-left: 3px solid #b45309; background: #fffbeb; font-size: 0.88rem; } +.correlation-score { margin: 0.5rem 0 0; color: var(--muted); font-size: 0.85rem; } +.correlation-verdict { margin: 0.35rem 0 0; padding: 0.5rem 0.75rem; border-left: 3px solid var(--warn-ink); background: var(--warn-tint); font-size: 0.88rem; } diff --git a/src/sims/Distribution.css b/src/sims/Distribution.css index 1cac616..f9b56a3 100644 --- a/src/sims/Distribution.css +++ b/src/sims/Distribution.css @@ -6,7 +6,7 @@ .distribution-svg { width: 100%; height: auto; background: #f8fafc; border-radius: 4px; } .distribution-axis { font-size: 11px; fill: #475569; } .distribution-readout { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-top: 0.5rem; } -.distribution-readout th { text-align: left; font-weight: 500; color: #475569; padding: 0.2rem 0.5rem 0.2rem 0; } +.distribution-readout th { text-align: left; font-weight: 500; color: var(--muted); padding: 0.2rem 0.5rem 0.2rem 0; } .distribution-readout td { padding: 0.2rem 1.25rem 0.2rem 0; font-variant-numeric: tabular-nums; font-weight: 600; } -.distribution-note { margin: 0.5rem 0 0; color: #475569; font-size: 0.85rem; } +.distribution-note { margin: 0.5rem 0 0; color: var(--muted); font-size: 0.85rem; } diff --git a/src/sims/LeastSquares.css b/src/sims/LeastSquares.css index f540a90..16e1f59 100644 --- a/src/sims/LeastSquares.css +++ b/src/sims/LeastSquares.css @@ -6,10 +6,10 @@ .leastsquares-slider input[type="range"] { width: 10rem; } .leastsquares-slider span { font-variant-numeric: tabular-nums; min-width: 3rem; } .leastsquares-readout { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-top: 0.5rem; } -.leastsquares-readout th { text-align: left; font-weight: 500; color: #475569; padding: 0.2rem 0.5rem 0.2rem 0; } +.leastsquares-readout th { text-align: left; font-weight: 500; color: var(--muted); padding: 0.2rem 0.5rem 0.2rem 0; } .leastsquares-readout td { padding: 0.2rem 1.25rem 0.2rem 0; font-variant-numeric: tabular-nums; font-weight: 600; } .leastsquares-equation { margin: 0.5rem 0 0; font-size: 0.9rem; } .leastsquares-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 0.75rem; } .leastsquares-actions button { padding: 0.4rem 0.95rem; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface); color: var(--brand-ink); cursor: pointer; font-weight: 600; transition: background var(--fast), border-color var(--fast); } -.leastsquares-note { margin: 0.75rem 0 0; color: #475569; font-size: 0.85rem; } +.leastsquares-note { margin: 0.75rem 0 0; color: var(--muted); font-size: 0.85rem; } diff --git a/src/sims/PValue.css b/src/sims/PValue.css index cb11628..b75202b 100644 --- a/src/sims/PValue.css +++ b/src/sims/PValue.css @@ -4,12 +4,12 @@ .pvalue-slider input[type="range"] { width: 9rem; } .pvalue-slider span { font-variant-numeric: tabular-nums; min-width: 2.5rem; } .pvalue-controls button { padding: 0.4rem 0.95rem; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface); color: var(--brand-ink); cursor: pointer; font-weight: 600; transition: background var(--fast), border-color var(--fast); } -.pvalue-caption { margin: 0 0 0.5rem; color: #475569; font-size: 0.85rem; } +.pvalue-caption { margin: 0 0 0.5rem; color: var(--muted); font-size: 0.85rem; } .pvalue-svg { width: 100%; height: auto; background: #f8fafc; border-radius: 4px; } .pvalue-axis { font-size: 11px; fill: #475569; } .pvalue-readout { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-top: 0.5rem; } -.pvalue-readout th { text-align: left; font-weight: 500; color: #475569; padding: 0.2rem 0.5rem 0.2rem 0; } +.pvalue-readout th { text-align: left; font-weight: 500; color: var(--muted); padding: 0.2rem 0.5rem 0.2rem 0; } .pvalue-readout td { padding: 0.2rem 1.25rem 0.2rem 0; font-variant-numeric: tabular-nums; font-weight: 600; } .pvalue-verdict { margin: 0.5rem 0 0; font-weight: 600; } -.pvalue-caveat { margin: 0.35rem 0 0; padding: 0.5rem 0.75rem; border-left: 3px solid #b91c1c; background: #fef2f2; font-size: 0.88rem; } +.pvalue-caveat { margin: 0.35rem 0 0; padding: 0.5rem 0.75rem; border-left: 3px solid var(--danger-ink); background: var(--danger-tint); font-size: 0.88rem; } From e7e7921cd491fcc30563ae1d851ee9e053b7f8ab Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Thu, 8 Oct 2026 08:21:02 +0000 Subject: [PATCH 2/2] Follow the system theme live and colour the browser bar to match Until the student picks a theme, switching the system to dark or light now switches the page straight away instead of on the next load. The browser bar colour on phones follows the theme too. --- index.html | 6 ++++-- src/components/Sidebar.test.tsx | 16 ++++++++++++++++ src/components/Sidebar.tsx | 26 ++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/index.html b/index.html index 1e7e90b..c827370 100644 --- a/index.html +++ b/index.html @@ -20,10 +20,12 @@
    diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 1dd1f6a..2e9d232 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -99,3 +99,19 @@ test('the dark mode button switches the theme and remembers it', () => { act(() => button.click()); expect(document.documentElement.dataset.theme).toBe('light'); }); + +test('until a theme is picked, the page follows the system setting as it changes', () => { + localStorage.removeItem('statlab.theme'); + document.documentElement.dataset.theme = 'light'; + let listener: ((event: { matches: boolean }) => void) | undefined; + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: (_: string, fn: typeof listener) => { listener = fn; }, removeEventListener: () => {} })); + renderSidebar(); + act(() => listener?.({ matches: true })); + expect(document.documentElement.dataset.theme).toBe('dark'); + expect(screen.getByRole('button', { name: 'Dark mode' }).getAttribute('aria-pressed')).toBe('true'); + // A choice the student made wins over the system. + localStorage.setItem('statlab.theme', 'dark'); + act(() => listener?.({ matches: false })); + expect(document.documentElement.dataset.theme).toBe('dark'); + vi.unstubAllGlobals(); +}); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b54c860..3c39639 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -11,6 +11,9 @@ import Logo from './Logo'; /** Read by the script in index.html, which applies the theme before React loads. */ const THEME_KEY = 'statlab.theme'; +/** The browser bar colour for each theme, the same as in index.html. */ +const LIGHT_BAR = '#f8fafc'; +const DARK_BAR = '#0b1120'; type Props = { /** Whether the drawer is showing, on screens narrow enough to have one. */ @@ -26,12 +29,27 @@ export default function Sidebar({ open = false }: Props) { useEffect(() => subscribeProgress(() => setTick((tick) => tick + 1)), []); const progress = getProgress(); const [dark, setDark] = useState(() => document.documentElement.dataset.theme === 'dark'); + function applyTheme(isDark: boolean) { + document.documentElement.dataset.theme = isDark ? 'dark' : 'light'; + document.querySelector('meta[name="theme-color"]')?.setAttribute('content', isDark ? DARK_BAR : LIGHT_BAR); + setDark(isDark); + } function toggleTheme() { - const next = dark ? 'light' : 'dark'; - document.documentElement.dataset.theme = next; - try { localStorage.setItem(THEME_KEY, next); } catch { /* Private mode: it still switches for this visit. */ } - setDark(!dark); + try { localStorage.setItem(THEME_KEY, dark ? 'light' : 'dark'); } catch { /* Private mode: it still switches for this visit. */ } + applyTheme(!dark); } + // Until the student picks a theme, follow the system setting as it changes. + useEffect(() => { + const query = window.matchMedia?.('(prefers-color-scheme: dark)'); + if (!query) return; + function onChange(event: MediaQueryListEvent) { + let saved = null; + try { saved = localStorage.getItem(THEME_KEY); } catch { /* Treated as no choice made. */ } + if (!saved) applyTheme(event.matches); + } + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + }, []); const stats = courseStats(progress); // One module open at a time by default: the one holding the current lesson.