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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ jobs:
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Python
run: uv python install 3.12
- name: Install dependencies
Expand All @@ -27,3 +30,5 @@ jobs:
run: uv run mypy src
- name: Test application and content contracts
run: uv run pytest
- name: Test browser state contracts
run: node --test tests/browser/*.test.mjs
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "study-reader-browser",
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/browser/*.test.mjs"
}
}
104 changes: 104 additions & 0 deletions src/study_reader/reader/static/reader-state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
export const DEFAULT_SETTINGS = Object.freeze({
theme: "sepia",
font: "serif",
textSize: 17,
lineHeight: 1.78,
pageMargin: 48,
contentWidth: 68,
});

const THEMES = new Set(["white", "sepia", "grey", "dark"]);
const FONTS = new Set(["serif", "sans", "system"]);

function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}

function numberOrDefault(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}

export function normalizeSettings(candidate = {}) {
return {
theme: THEMES.has(candidate.theme) ? candidate.theme : DEFAULT_SETTINGS.theme,
font: FONTS.has(candidate.font) ? candidate.font : DEFAULT_SETTINGS.font,
textSize: clamp(
numberOrDefault(candidate.textSize, DEFAULT_SETTINGS.textSize),
15,
24,
),
lineHeight: clamp(
numberOrDefault(candidate.lineHeight, DEFAULT_SETTINGS.lineHeight),
1.4,
2.1,
),
pageMargin: clamp(
numberOrDefault(candidate.pageMargin, DEFAULT_SETTINGS.pageMargin),
12,
96,
),
contentWidth: clamp(
numberOrDefault(candidate.contentWidth, DEFAULT_SETTINGS.contentWidth),
42,
82,
),
};
}

export function resolveReadingTarget(blockIds, position) {
if (!blockIds.length || !position) {
return null;
}

const stableIndex = blockIds.indexOf(position.blockId);
if (stableIndex >= 0) {
return {
blockId: blockIds[stableIndex],
blockIndex: stableIndex,
blockOffset: clamp(numberOrDefault(position.blockOffset, 0), 0, 1),
restoredBy: "block",
};
}

const percentage = clamp(numberOrDefault(position.percentage, 0), 0, 100);
const fallbackIndex = Math.min(
blockIds.length - 1,
Math.floor((percentage / 100) * blockIds.length),
);
return {
blockId: blockIds[fallbackIndex],
blockIndex: fallbackIndex,
blockOffset: 0,
restoredBy: "percentage",
};
}

export function calculateChapterProgress(blockIds, position) {
if (!blockIds.length || !position) {
return 0;
}

const blockIndex = blockIds.indexOf(position.blockId);
if (blockIndex < 0) {
return Math.round(
clamp(numberOrDefault(position.percentage, 0), 0, 100),
);
}

const blockOffset = clamp(numberOrDefault(position.blockOffset, 0), 0, 1);
return Math.round(((blockIndex + blockOffset) / blockIds.length) * 100);
}

export function calculateBookProgress(chapterIds, progressByChapter) {
if (!chapterIds.length) {
return 0;
}

const total = chapterIds.reduce(
(sum, chapterId) =>
sum + clamp(numberOrDefault(progressByChapter[chapterId], 0), 0, 100),
0,
);
return Math.round(total / chapterIds.length);
}
159 changes: 148 additions & 11 deletions src/study_reader/reader/static/reader.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,74 @@
--accent: #315b52;
--accent-soft: #e1ece6;
--warm: #a35c35;
--strong: #183c34;
--header-bg: rgba(255, 253, 248, .96);
--hover: rgba(255, 255, 255, .7);
--reader-font: Georgia, "Times New Roman", serif;
--text-size: 17px;
--line-height: 1.78;
--page-margin: 48px;
--content-width: 68ch;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
}

:root[data-theme="white"] {
--ink: #202320;
--muted: #626862;
--paper: #ffffff;
--canvas: #f1f4f2;
--line: #d8ded9;
--accent: #245c50;
--accent-soft: #e4f0eb;
--warm: #9a4f2d;
--strong: #17463b;
--header-bg: rgba(255, 255, 255, .96);
}

:root[data-theme="grey"] {
--ink: #222625;
--muted: #5f6764;
--paper: #e9eceb;
--canvas: #dde2e0;
--line: #c8cfcc;
--accent: #245c50;
--accent-soft: #d4e3dd;
--warm: #914d2d;
--strong: #17463b;
--header-bg: rgba(233, 236, 235, .96);
--hover: rgba(255, 255, 255, .45);
}

:root[data-theme="dark"] {
color-scheme: dark;
--ink: #eeeae1;
--muted: #bbb5aa;
--paper: #171a19;
--canvas: #202523;
--line: #3d4541;
--accent: #9bcabb;
--accent-soft: #263d36;
--warm: #e1a071;
--strong: #b9ddcf;
--header-bg: rgba(23, 26, 25, .96);
--hover: rgba(255, 255, 255, .08);
}

* { box-sizing: border-box; }

.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

html { scroll-behavior: smooth; }

body {
Expand All @@ -39,7 +101,7 @@ a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-off
.skip-link:focus { transform: translateY(0); }

.reading-progress { height: 3px; background: var(--line); }
.reading-progress span { display: block; width: var(--chapter-progress); height: 100%; background: var(--warm); }
.reading-progress span { display: block; width: 0; height: 100%; background: var(--warm); transition: width .2s ease-out; }

.site-header {
min-height: 4.5rem;
Expand All @@ -49,14 +111,30 @@ a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-off
gap: 1rem;
padding: .75rem clamp(1rem, 3vw, 2.5rem);
border-bottom: 1px solid var(--line);
background: rgba(255, 253, 248, .96);
background: var(--header-bg);
}

.brand { display: flex; align-items: center; gap: .75rem; color: var(--ink); text-decoration: none; }
.brand-mark { display: grid; place-items: center; width: 2.6rem; aspect-ratio: 1; border-radius: .7rem; background: var(--accent); color: white; font: 700 .85rem Georgia, serif; letter-spacing: .08em; }
.brand strong, .brand small { display: block; }
.brand strong { font-family: Georgia, "Times New Roman", serif; font-size: 1.05rem; }
.brand small, .chapter-count { margin-top: .12rem; color: var(--muted); font-size: .76rem; }
.header-actions { display: flex; align-items: center; gap: .8rem; }
.progress-summary { color: var(--muted); font-size: .72rem; white-space: nowrap; }
.progress-summary > span[id] { color: var(--ink); font-weight: 750; }
.appearance-button, .dialog-close {
display: grid;
place-items: center;
min-width: 2.75rem;
min-height: 2.75rem;
border: 1px solid var(--line);
border-radius: .65rem;
background: var(--paper);
color: var(--ink);
cursor: pointer;
font: 700 .95rem Georgia, serif;
}
.appearance-button:hover, .dialog-close:hover { border-color: var(--accent); }

.reader-layout { display: grid; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); min-height: calc(100vh - 4.7rem); }

Expand All @@ -72,24 +150,26 @@ a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-off
.toc-domain ol { margin: .75rem 0 0 2.25rem; padding: 0; list-style: none; }
.toc-domain li + li { margin-top: .2rem; }
.toc-domain a { position: relative; display: block; padding: .55rem .7rem; border-radius: .5rem; color: var(--muted); font-size: .78rem; line-height: 1.35; text-decoration: none; }
.toc-domain a:hover { background: rgba(255,255,255,.7); color: var(--ink); }
.toc-domain a:hover { background: var(--hover); color: var(--ink); }
.toc-domain a[aria-current="page"] { background: var(--paper); color: var(--accent); box-shadow: inset 3px 0 var(--warm); font-weight: 650; }
.status-dot { display: inline-block; width: .4rem; height: .4rem; margin-left: .3rem; border: 1px solid #9d988d; border-radius: 50%; vertical-align: middle; }
.completion-mark { margin-left: .25rem; color: var(--accent); font-weight: 800; }
.toc-domain a.is-complete:not([aria-current="page"]) { color: var(--accent); }

.reading-pane { width: min(100%, 58rem); min-width: 0; margin: 0 auto; padding: clamp(2rem, 6vw, 5.5rem) clamp(1.15rem, 6vw, 5rem) 5rem; }
.reading-pane { width: min(100%, calc(var(--content-width) + 2 * var(--page-margin))); min-width: 0; margin: 0 auto; padding: clamp(2rem, 6vw, 5.5rem) var(--page-margin) 5rem; }
.chapter-header { padding-bottom: 2rem; border-bottom: 1px solid var(--line); }
.eyebrow { margin: 0 0 .65rem; color: var(--warm); font-size: .72rem; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; }
.chapter-header h1 { max-width: 18ch; margin: 0; font: 600 clamp(2.25rem, 6vw, 4.25rem)/1.03 Georgia, "Times New Roman", serif; letter-spacing: -.035em; }
.chapter-deck { margin: 1rem 0 0; color: var(--muted); font: 1.05rem/1.5 Georgia, "Times New Roman", serif; }
.chapter-header h1 { max-width: 18ch; margin: 0; font: 600 clamp(2.25rem, 6vw, 4.25rem)/1.03 var(--reader-font); letter-spacing: -.035em; }
.chapter-deck { margin: 1rem 0 0; color: var(--muted); font: 1.05rem/1.5 var(--reader-font); }
.objective-strip { display: flex; flex-wrap: wrap; gap: .55rem 1.25rem; margin-top: 1.5rem; color: var(--muted); font-size: .78rem; }
.objective-strip span::before { content: ""; display: inline-block; width: .38rem; height: .38rem; margin: 0 .45rem .08rem 0; border-radius: 50%; background: var(--accent); }

.chapter-prose { padding-top: 2rem; font: 1.08rem/1.78 Georgia, "Times New Roman", serif; }
.chapter-prose { padding-top: 2rem; font-family: var(--reader-font); font-size: var(--text-size); line-height: var(--line-height); }
.chapter-prose > h1:first-child { display: none; }
.chapter-prose h2 { margin: 3rem 0 .9rem; font: 600 1.65rem/1.2 Georgia, "Times New Roman", serif; letter-spacing: -.018em; scroll-margin-top: 1rem; }
.chapter-prose h2 { margin: 3rem 0 .9rem; font: 600 1.65rem/1.2 var(--reader-font); letter-spacing: -.018em; scroll-margin-top: 1rem; }
.chapter-prose h3 { margin-top: 2rem; font-size: 1.2rem; }
.chapter-prose p { margin: 0 0 1.25rem; }
.chapter-prose strong { color: #183c34; }
.chapter-prose strong { color: var(--strong); }
.chapter-prose ul, .chapter-prose ol { margin: 0 0 1.5rem; padding-left: 1.4rem; }
.chapter-prose li { padding-left: .35rem; }
.chapter-prose li + li { margin-top: .45rem; }
Expand All @@ -104,6 +184,22 @@ a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-off
.source-card p:not(.eyebrow) { max-width: 46ch; margin: .4rem 0 0; color: var(--muted); font-size: .88rem; line-height: 1.5; }
.source-card > a { padding: .75rem 1rem; border: 1px solid var(--accent); border-radius: .5rem; font-size: .8rem; font-weight: 700; text-decoration: none; }

.complete-button {
display: flex;
align-items: center;
gap: .55rem;
margin: 2rem 0 0 auto;
padding: .75rem 1rem;
border: 1px solid var(--line);
border-radius: .6rem;
background: var(--paper);
color: var(--accent);
cursor: pointer;
font: 700 .78rem Inter, ui-sans-serif, sans-serif;
}
.complete-button span { display: grid; place-items: center; width: 1.25rem; aspect-ratio: 1; border: 1px solid currentColor; border-radius: 50%; }
.complete-button.is-complete { background: var(--accent); color: var(--paper); border-color: var(--accent); }

.chapter-navigation { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--line); }
.chapter-navigation a { min-width: 0; padding: 1rem; border: 1px solid var(--line); border-radius: .65rem; color: var(--ink); text-decoration: none; }
.chapter-navigation a:hover { border-color: var(--accent); }
Expand All @@ -112,17 +208,58 @@ a:focus-visible, summary:focus-visible { outline: 3px solid #d69c52; outline-off
.chapter-navigation span { overflow-wrap: anywhere; font: .9rem/1.35 Georgia, serif; }
.chapter-navigation .next { text-align: right; }

.appearance-dialog {
position: fixed;
inset: 0;
width: min(92vw, 31rem);
max-height: min(86vh, 48rem);
margin: auto;
padding: 0;
overflow: auto;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--paper);
color: var(--ink);
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, .24);
}
.appearance-heading { position: sticky; z-index: 2; top: 0; display: flex; align-items: center; justify-content: space-between; padding: 1.25rem 1.4rem; border-bottom: 1px solid var(--line); background: var(--paper); }
.appearance-heading h2 { margin: 0; font: 600 1.6rem var(--reader-font); }
.appearance-heading .eyebrow { margin-bottom: .25rem; }
.dialog-close { border: 0; font: 400 1.7rem/1 Inter, sans-serif; }
.appearance-controls { display: grid; gap: 1.35rem; padding: 1.4rem; }
.appearance-controls fieldset { margin: 0; padding: 0; border: 0; }
.appearance-controls legend, .control-row > span, .range-control > span { margin-bottom: .65rem; color: var(--muted); font-size: .76rem; font-weight: 700; }
.theme-options { display: grid; grid-template-columns: repeat(4, 1fr); gap: .5rem; }
.theme-choice { position: relative; display: block; min-width: 0; cursor: pointer; }
.theme-choice input { position: absolute; opacity: 0; }
.theme-choice span { display: grid; min-height: 3.2rem; place-items: center; border: 2px solid transparent; border-radius: .6rem; font-size: .7rem; font-weight: 700; }
.theme-choice input:checked + span { border-color: var(--warm); box-shadow: 0 0 0 2px var(--paper), 0 0 0 4px var(--warm); }
.theme-choice input:focus-visible + span { outline: 3px solid #d69c52; outline-offset: 3px; }
.theme-white span { background: #fff; color: #202320; }
.theme-sepia span { background: #fffdf8; color: #24231f; }
.theme-grey span { background: #e9eceb; color: #222625; }
.theme-dark span { background: #171a19; color: #eeeae1; }
.control-row { display: grid; }
.control-row select { min-height: 2.75rem; padding: .6rem .75rem; border: 1px solid var(--line); border-radius: .5rem; background: var(--canvas); color: var(--ink); font: inherit; }
.range-control { display: grid; gap: .2rem; }
.range-control > span { display: flex; justify-content: space-between; }
.range-control output { color: var(--ink); font-variant-numeric: tabular-nums; }
.range-control input { width: 100%; accent-color: var(--accent); }
.secondary-button { min-height: 2.75rem; border: 1px solid var(--line); border-radius: .5rem; background: var(--canvas); color: var(--ink); cursor: pointer; font-weight: 700; }

@media (max-width: 767px) {
.site-header { position: sticky; z-index: 10; top: 0; min-height: 4.1rem; padding: .65rem 1rem; }
.brand small { display: none; }
.chapter-count { font-size: .7rem; }
.chapter-count { display: none; }
.header-actions { gap: .55rem; }
.progress-label { display: none; }
.reader-layout { display: block; }
.toc-shell { border-right: 0; border-bottom: 1px solid var(--line); }
.toc-details { position: static; max-height: none; padding: 0; overflow: visible; }
.toc-details > summary { display: flex; min-height: 3rem; align-items: center; justify-content: space-between; padding: .75rem 1rem; cursor: pointer; color: var(--accent); font-size: .8rem; font-weight: 750; }
.toc-details[open] > summary { border-bottom: 1px solid var(--line); }
.toc-details nav { max-height: 65vh; padding: 1.1rem 1rem 2rem; overflow-y: auto; }
.reading-pane { padding: 2.5rem 1.15rem 4rem; }
.reading-pane { width: 100%; padding: 2.5rem clamp(.75rem, var(--page-margin), 4rem) 4rem; }
.chapter-header h1 { font-size: clamp(2.25rem, 12vw, 3.35rem); }
.chapter-prose { font-size: 1.04rem; line-height: 1.72; }
.chapter-prose h2 { margin-top: 2.5rem; }
Expand Down
Loading
Loading