Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<p align="center">
<a href="https://peterslijkhuis.github.io/statlab/"><img alt="Open StatLab" src="https://img.shields.io/badge/Open_StatLab-Start_learning_now-4f46e5?style=for-the-badge&logo=r&logoColor=white"></a>
<a href="#see-it-in-action"><img alt="Watch the tour" src="https://img.shields.io/badge/Watch-the_tour-0ea5e9?style=for-the-badge"></a>
<a href="docs/readme/launch.mp4"><img alt="Play the 20 second video" src="https://img.shields.io/badge/Play-20_second_video-db2777?style=for-the-badge"></a>
</p>

<p align="center">
Expand All @@ -29,6 +30,11 @@

## See it in action

<p align="center">
<a href="docs/readme/launch.mp4"><img src="docs/readme/launch.jpg" alt="Poster of StatLab's 20-second launch video. Click to play it." width="100%"></a>
<br><sub>▶️ <a href="docs/readme/launch.mp4">Play the 20-second launch video</a> (with sound), or watch the longer tour below.</sub>
</p>

<p align="center">
<img src="docs/readme/demo.webp" alt="A one-minute tour of StatLab: the home page with a 14-day streak and 1300 points; a lesson where R runs in the page and draws a scree plot; an exercise where a wrong answer gets an explanation from the student's avatar and the right answer earns points and confetti; the least-squares simulation with a line being dragged; the model chooser recommending multiple regression in five clicks; and the avatar shop where a crown is bought with points." width="100%">
</p>
Expand Down Expand Up @@ -78,6 +84,20 @@
The layout folds down for small screens, and progress can be exported and picked up on another device.
</td>
</tr>
<tr>
<td valign="top">
<h3>🎯 "How many participants?"</h3>
A step-by-step sample size planner for two groups, repeated measures, 2 × 2 designs, regression and proportions, with the course model to fit, a power curve and R code that reproduces it.
</td>
<td valign="top">
<h3>🧑‍🏫 "Where to?"</h3>
Type what you are looking for, "compare two groups" or "how many people do I need", and the avatar points to the right lesson or tool.
</td>
<td valign="top">
<h3>♿ Built for everyone</h3>
Keyboard navigation, a skip link and screen reader labels, with every page scanned against WCAG 2.1 AA on each change.
</td>
</tr>
</table>

## A look inside
Expand Down Expand Up @@ -107,6 +127,14 @@
<td align="center"><sub>The R Workspace: RStudio's panes, with your own data.</sub></td>
<td align="center"><sub>From research question to model in a few clicks.</sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/screenshots/sample-size.png" alt="The sample size planner's result for a 2 × 2 pre-post design: recruit 144 participants to end with 128 complete cases, a table showing how many are needed if the true effect is smaller, and a power curve crossing 80% at 128 people."></td>
<td width="50%"><img src="docs/screenshots/helper.png" alt="The Where to? helper opened on a lesson page. The student typed how many people do I need, and it suggests the How many participants? planner and Lesson 8-4 on effect sizes and planning a study."></td>
</tr>
<tr>
<td align="center"><sub>Plan a study: how many people, and what if the effect is smaller.</sub></td>
<td align="center"><sub>Lost? Ask the avatar where to go.</sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/images/least-squares.png" alt="The least-squares simulation: a scatter of points, a line the student drags with intercept and slope sliders, and orange squares showing each squared residual."></td>
<td width="50%"><img src="docs/screenshots/simulation-clt.png" alt="The Central Limit Theorem simulation: a strongly skewed population above, and below it 2000 sample means with n = 30 forming a near-normal histogram."></td>
Expand Down Expand Up @@ -193,6 +221,8 @@ on wellbeing).
solution, alternate solution and known wrong answer through R and checks that
the right ones pass and the wrong ones fail for the right reason.
- **A real browser clicks through the site** before anything is published.
- **Accessible by default.** Every page is scanned with axe against WCAG 2.1 AA
on each change, and a failing scan blocks the release.
- **It keeps itself up to date.** A weekly workflow checks for new versions of
webR and the site's dependencies and only takes them after the full R check
passes.
Expand Down
Binary file added docs/readme/launch.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/readme/launch.mp4
Binary file not shown.
Binary file added docs/screenshots/helper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/sample-size.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 7 additions & 10 deletions src/components/Logo.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
import { useId } from 'react';

/** Three bars of a histogram under a fitted curve: the course in one mark. */
/** A bell curve on the brand gradient: the same mark as public/favicon.svg. */
export default function Logo({ size = 22 }: { size?: number }) {
// Each instance needs its own gradient id: the first copy of a shared id can
// sit in a hidden topbar, and a hidden gradient paints nothing.
const gradient = `logo-gradient-${useId().replace(/:/g, '')}`;
return (
<svg className="logo" viewBox="0 0 24 24" width={size} height={size} aria-hidden="true">
<rect x="1" y="1" width="22" height="22" rx="6" fill={`url(#${gradient})`} />
<rect x="5.5" y="12" width="3" height="6.5" rx="1" fill="#fff" opacity="0.85" />
<rect x="10.5" y="8" width="3" height="10.5" rx="1" fill="#fff" />
<rect x="15.5" y="10.5" width="3" height="8" rx="1" fill="#fff" opacity="0.85" />
<path d="M4.5 13.5C8 4 16 4 19.5 12" fill="none" stroke="#fde68a" strokeWidth="1.6" strokeLinecap="round" />
<svg className="logo" viewBox="0 0 64 64" width={size} height={size} aria-hidden="true">
<defs>
<linearGradient id={gradient} x1="0" y1="0" x2="24" y2="24">
<stop offset="0" stopColor="#6366f1" />
<stop offset="1" stopColor="#2563eb" />
<linearGradient id={gradient} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#4f46e5" />
<stop offset="1" stopColor="#0ea5e9" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill={`url(#${gradient})`} />
<path d="M8 48 C 20 48, 22 16, 32 16 S 44 48, 56 48" fill="none" stroke="#fff" strokeWidth="5" strokeLinecap="round" />
</svg>
);
}
2 changes: 1 addition & 1 deletion src/components/SiteGuide.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ test('the avatar helper suggests a lesson from a few words and goes there', asyn
const button = screen.getByRole('button', { name: /Where to\?/ });
await user.click(button);
expect(screen.getByRole('dialog', { name: 'Where do you want to go?' })).toBeTruthy();
expect(screen.getByRole('link', { name: /Start here: Lesson 00-1/ })).toBeTruthy();
expect(screen.getByRole('link', { name: /Start here: Lesson 0-1/ })).toBeTruthy();
await user.type(screen.getByLabelText('Type a topic or a question'), 'logistic regression');
await user.click(screen.getByRole('link', { name: /Lesson 15-2/ }));
expect(screen.getByTestId('where').textContent).toBe('/lesson/15-2');
Expand Down
2 changes: 1 addition & 1 deletion src/components/SiteGuide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default function SiteGuide() {
{text.trim() === '' ? (
<>
<Link className="site-guide-hit" to={`/lesson/${last.id}`}>
<strong>{lastVisitedLesson() ? 'Carry on' : 'Start here'}: Lesson {last.id}</strong>
<strong>{lastVisitedLesson() ? 'Carry on' : 'Start here'}: Lesson {last.id.replace(/^0/, '')}</strong>
<span>{last.title}</span>
</Link>
<p className="site-guide-note">Or try:</p>
Expand Down
4 changes: 2 additions & 2 deletions src/content/guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const ENTRIES: Entry[] = [
...MODULES.flatMap((module) =>
module.lessons.map((lesson) => ({
to: `/lesson/${lesson.id}`,
title: `Lesson ${lesson.id}: ${lesson.title}`,
title: `Lesson ${lesson.id.replace(/^0/, '')}: ${lesson.title}`,
detail: `Module ${module.number}: ${module.title}`,
titleWords: new Set(words(lesson.title)),
words: new Set(words(`${module.title} ${LESSON_KEYWORDS[lesson.id] ?? ''}`)),
Expand Down Expand Up @@ -66,7 +66,7 @@ export function guide(text: string): GuideHit[] {
const answer = best && allAnswers().find((entry) => entry.answer.id === best.id)?.answer;
if (answer) {
const lesson = answer.lessonId ? findLesson(answer.lessonId) : undefined;
const teaching: GuideHit[] = lesson ? [{ to: `/lesson/${lesson.id}`, title: `Lesson ${lesson.id}: ${lesson.title}`, detail: `Where the course teaches ${answer.model.toLowerCase()}` }] : [];
const teaching: GuideHit[] = lesson ? [{ to: `/lesson/${lesson.id}`, title: `Lesson ${lesson.id.replace(/^0/, '')}: ${lesson.title}`, detail: `Where the course teaches ${answer.model.toLowerCase()}` }] : [];
const rest = hits.filter((hit) => !teaching.some((t) => t.to === hit.to));
return [{ to: `/which-model?model=${answer.id}`, title: `Likely analysis: ${answer.model}`, detail: 'Suggested from the words in your question. The model chooser explains it.' }, ...teaching, ...rest].slice(0, 4);
}
Expand Down