diff --git a/.gitignore b/.gitignore
index b2d6de3..d0c0b74 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,5 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+
+/static/docs-manifest.json
diff --git a/docs/start-here.md b/docs/start-here.md
index 4ff4d9e..379abae 100644
--- a/docs/start-here.md
+++ b/docs/start-here.md
@@ -1,10 +1,15 @@
-# Start Here
+import StartHereProgress from '@site/src/components/StartHereProgress/StartHereProgress';
+
+# Your Progress
The Borr Project aims to empower learners to master college curricula through free resources. Choose a major and start today!
-We offer the following fore curricula right now:
+We offer the following four curricula right now:
+
+
+
+Happy learning!
-- [Computer Science](../computer-science/)
-- [Pre-College Math](../precollege-math/)
-- [Data Science](../data-science/)
-- [Math](../math/)
+:::tip
+The progress trackers above reflect your progress through each curriculum and are stored locally in your browser. This data is private and only accessible to you. To back up your progress more permanently, use the export button.
+:::
diff --git a/docusaurus.config.js b/docusaurus.config.js
index 24548e9..4483776 100644
--- a/docusaurus.config.js
+++ b/docusaurus.config.js
@@ -191,6 +191,7 @@ const config = {
},
{to: '/getting-help', label: 'Getting Help'},
{to: '/blog', label: 'Blog'},
+ {to: '/start-here', label: 'Your Progress', position: 'right'},
{
href: 'https://github.com/BorrProject/',
position: 'right',
diff --git a/package.json b/package.json
index 5fb8312..ada3f35 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
+ "postbuild": "node ./scripts/generate-docs-manifest.mjs",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
diff --git a/scripts/generate-docs-manifest.mjs b/scripts/generate-docs-manifest.mjs
new file mode 100644
index 0000000..e98fcdf
--- /dev/null
+++ b/scripts/generate-docs-manifest.mjs
@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+
+import fs from 'fs/promises';
+import path from 'path';
+
+const BUILD_DIR = path.resolve(process.cwd(), 'build');
+const OUT_FILE = path.join(BUILD_DIR, 'docs-manifest.json');
+
+async function walk(dir) {
+ const entries = await fs.readdir(dir, { withFileTypes: true });
+ const files = [];
+
+ for (const ent of entries) {
+ const full = path.join(dir, ent.name);
+ if (ent.isDirectory()) {
+ files.push(...await walk(full));
+ } else if (ent.isFile() && ent.name === 'index.html') {
+ files.push(full);
+ }
+ }
+
+ return files;
+}
+
+function toRoute(filePath) {
+ const rel = path.relative(BUILD_DIR, path.dirname(filePath)).split(path.sep).join('/');
+ return rel === '' ? '/' : `/${rel}/`;
+}
+
+async function main() {
+ try {
+ await fs.access(BUILD_DIR);
+ } catch (e) {
+ console.error('build/ directory not found, skipping manifest generation.');
+ process.exit(0);
+ }
+
+ const files = await walk(BUILD_DIR);
+ const routes = files.map(toRoute);
+ const unique = Array.from(new Set(routes)).sort();
+
+ try {
+ await fs.writeFile(OUT_FILE, JSON.stringify(unique, null, 2), 'utf8');
+ console.log(`Wrote docs manifest with ${unique.length} entries to ${path.relative(process.cwd(), OUT_FILE)}`);
+ } catch (e) {
+ console.error('Failed to write manifest:', e);
+ process.exit(1);
+ }
+}
+
+main();
\ No newline at end of file
diff --git a/src/components/ReadingProgress/ReadingProgress.js b/src/components/ReadingProgress/ReadingProgress.js
new file mode 100644
index 0000000..9ae87e2
--- /dev/null
+++ b/src/components/ReadingProgress/ReadingProgress.js
@@ -0,0 +1,269 @@
+import React, {useEffect, useState, useRef} from 'react';
+import styles from './styles.module.css';
+
+const STORAGE_PREFIX = 'borr:read:';
+
+function normalizePath(path) {
+ if (!path) return '/';
+ try {
+ const url = new URL(path, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
+ path = url.pathname;
+ } catch (e) {
+ }
+ if (path !== '/' && path.endsWith('/')) path = path.slice(0, -1);
+ return path || '/';
+}
+
+function getSidebarDocLinks() {
+ const anchors = Array.from(document.querySelectorAll('.menu__list a'));
+ const internal = anchors
+ .map((a) => a.getAttribute('href'))
+ .filter(Boolean)
+ .filter((h) => h.startsWith('/'))
+ .map((h) => normalizePath(h));
+ return Array.from(new Set(internal));
+}
+
+const TRACK_SECTIONS = {
+ '/computer-science': 'Computer Science',
+ '/precollege-math': 'Pre-College Math',
+ '/data-science': 'Data Science',
+ '/math': 'Math',
+};
+
+export default function ReadingProgress() {
+ const [totalPages, setTotalPages] = useState(0);
+ const [readCount, setReadCount] = useState(0);
+ const [isRead, setIsRead] = useState(false);
+ const [pagesList, setPagesList] = useState(null);
+ const [currentSection, setCurrentSection] = useState(null);
+ const fileInputRef = useRef(null);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+
+ const currentPath = normalizePath(window.location.pathname + window.location.search);
+
+ const normalizedSections = Object.keys(TRACK_SECTIONS).map((p) => normalizePath(p));
+ const found = normalizedSections.find((s) => currentPath === s || currentPath.startsWith(s + '/')) || null;
+ setCurrentSection(found);
+
+ if (!found) {
+ setTotalPages(0);
+ setReadCount(0);
+ setIsRead(false);
+ setPagesList([]);
+ return;
+ }
+ async function loadManifest() {
+ try {
+ const res = await fetch('/docs-manifest.json', {cache: 'no-store'});
+ if (res.ok) {
+ const arr = await res.json();
+ const pages = arr.map((p) => normalizePath(p));
+ const sectionPages = pages.filter((p) => p === found || p.startsWith(found + '/'));
+ setPagesList(sectionPages);
+ setTotalPages(sectionPages.length);
+
+ const counts = sectionPages.reduce((acc, p) => acc + (localStorage.getItem(STORAGE_PREFIX + p) === '1' ? 1 : 0), 0);
+ setReadCount(counts);
+
+ const currentKey = STORAGE_PREFIX + currentPath;
+ setIsRead(localStorage.getItem(currentKey) === '1');
+ return;
+ }
+ } catch (e) {
+ }
+
+ const links = getSidebarDocLinks();
+ const sectionLinks = (links.length > 0 ? links : [currentPath]).filter((p) => p === found || p.startsWith(found + '/'));
+
+ setPagesList(sectionLinks);
+ setTotalPages(sectionLinks.length);
+
+ const counts = sectionLinks.reduce((acc, p) => {
+ const key = STORAGE_PREFIX + p;
+ if (localStorage.getItem(key) === '1') acc++;
+ return acc;
+ }, 0);
+ setReadCount(counts);
+
+ const currentKey = STORAGE_PREFIX + currentPath;
+ setIsRead(localStorage.getItem(currentKey) === '1');
+ }
+
+ loadManifest();
+
+ function onStorage(ev) {
+ if (!ev.key) return;
+ if (!ev.key.startsWith(STORAGE_PREFIX)) return;
+ let newCounts = 0;
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
+ const p = k.slice(STORAGE_PREFIX.length);
+ if (p === found || p.startsWith(found + '/')) newCounts++;
+ }
+ setReadCount(newCounts);
+ setIsRead(localStorage.getItem(STORAGE_PREFIX + currentPath) === '1');
+ }
+
+ window.addEventListener('storage', onStorage);
+ return () => window.removeEventListener('storage', onStorage);
+ }, []);
+
+ if (typeof window === 'undefined') return null;
+
+ if (!currentSection) return null;
+
+ const percent = totalPages > 0 ? Math.round((readCount / totalPages) * 100) : 0;
+ function toggleRead() {
+ const currentPath = normalizePath(window.location.pathname + window.location.search);
+ const key = STORAGE_PREFIX + currentPath;
+ if (localStorage.getItem(key) === '1') {
+ localStorage.removeItem(key);
+ setIsRead(false);
+ setReadCount((c) => Math.max(0, c - 1));
+ } else {
+ localStorage.setItem(key, '1');
+ setIsRead(true);
+ setReadCount((c) => c + 1);
+ }
+ try {
+ window.dispatchEvent(new Event('storage'));
+ } catch (e) {}
+ }
+
+ function exportProgress() {
+ if (!currentSection) return;
+ const entries = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
+ const p = k.slice(STORAGE_PREFIX.length);
+ if (p === currentSection || p.startsWith(currentSection + '/')) entries.push(p);
+ }
+ if (entries.length === 0) {
+ alert('No progress saved for this section.');
+ return;
+ }
+ const data = {version: 1, section: currentSection, entries};
+ const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'});
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ const safeSection = currentSection.replace(/[^a-z0-9]/gi, '_').replace(/^_+|_+$/g, '');
+ a.download = `borr-progress-${safeSection}.json`;
+ a.href = url;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ }
+
+ function openImportDialog() {
+ if (fileInputRef.current) fileInputRef.current.click();
+ }
+
+ async function handleImportFile(ev) {
+ const f = ev.target.files && ev.target.files[0];
+ if (!f) return;
+ try {
+ const text = await f.text();
+ const json = JSON.parse(text);
+ let entries = [];
+ if (Array.isArray(json)) entries = json;
+ else if (json && Array.isArray(json.entries)) entries = json.entries;
+ else if (json && typeof json === 'object') {
+ if (json.entries && typeof json.entries === 'object') entries = Object.keys(json.entries);
+ }
+ if (entries.length === 0) {
+ alert('No entries found in import file. Expected an array of doc paths or {entries: [...]}.');
+ return;
+ }
+
+ if (json.section && json.section !== currentSection) {
+ const ok = window.confirm(`Import file is for "${json.section}" but you are on "${currentSection}". Import anyway (will merge)?`);
+ if (!ok) return;
+ }
+
+ let added = 0;
+ for (const p of entries) {
+ const norm = normalizePath(p);
+ if (norm === currentSection || norm.startsWith(currentSection + '/')) {
+ const key = STORAGE_PREFIX + norm;
+ if (localStorage.getItem(key) !== '1') {
+ localStorage.setItem(key, '1');
+ added++;
+ }
+ }
+ }
+ const newCounts = pagesList ? pagesList.reduce((acc, p) => acc + (localStorage.getItem(STORAGE_PREFIX + p) === '1' ? 1 : 0), 0) : 0;
+ setReadCount(newCounts);
+ setIsRead(localStorage.getItem(STORAGE_PREFIX + normalizePath(window.location.pathname)) === '1');
+ alert(`Imported ${added} entries for this section.`);
+ } catch (e) {
+ alert('Failed to import file: ' + (e && e.message ? e.message : String(e)));
+ } finally {
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ }
+ }
+
+ function resetProgress() {
+ if (!currentSection) return;
+ const ok = window.confirm('Reset progress for this section? This will remove all saved "done" marks for this section.');
+ if (!ok) return;
+ let removed = 0;
+ const toRemove = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
+ const p = k.slice(STORAGE_PREFIX.length);
+ if (p === currentSection || p.startsWith(currentSection + '/')) toRemove.push(k);
+ }
+ for (const k of toRemove) {
+ localStorage.removeItem(k);
+ removed++;
+ }
+ setReadCount(0);
+ setIsRead(false);
+ alert(`Removed ${removed} entries for this section.`);
+ }
+
+ return (
+
+
+
+ {TRACK_SECTIONS[currentSection] ?? ''}
+ {readCount}
+ /
+ {totalPages}
+
+
+
+
+
+
+
+
+
{percent}%
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ReadingProgress/styles.module.css b/src/components/ReadingProgress/styles.module.css
new file mode 100644
index 0000000..7e06715
--- /dev/null
+++ b/src/components/ReadingProgress/styles.module.css
@@ -0,0 +1,161 @@
+.container {
+ border: 1px solid var(--ifm-color-emphasis-200, #e6eef6);
+ padding: 0.5rem 0.75rem;
+ border-radius: 6px;
+ background: var(--ifm-background-color, #fff);
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ width: 100%;
+ max-width: none;
+}
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.summary {
+ font-size: 0.9rem;
+ color: var(--ifm-color-emphasis-700, #333);
+}
+.sep {
+ margin: 0 0.25rem;
+ color: var(--ifm-color-emphasis-300, #888);
+}
+.label {
+ margin-left: 0.5rem;
+ color: var(--ifm-color-emphasis-400, #666);
+ font-size: 0.8rem;
+}
+.tick {
+ border: 1px solid var(--ifm-color-emphasis-200, #ccc);
+ background: transparent;
+ color: var(--ifm-color-emphasis-700, #222);
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.85rem;
+}
+.ticked {
+ background: var(--ifm-color-success, #25c29a);
+ color: white;
+ border-color: transparent;
+}
+.progressBar {
+ height: 8px;
+ background: var(--ifm-color-emphasis-100, #f1f6fb);
+ width: 100%;
+ border-radius: 6px;
+ overflow: hidden;
+}
+.filler {
+ height: 100%;
+ background: linear-gradient(90deg, var(--ifm-color-primary, #3b82f6), var(--ifm-color-success, #25c29a));
+ width: 0%;
+ transition: width 300ms ease;
+}
+.percent {
+ font-size: 0.8rem;
+ text-align: right;
+ color: var(--ifm-color-emphasis-400, #666);
+}
+
+.controls {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ align-items: center;
+ margin-top: 0.25rem;
+}
+.fileInput {
+ display: none;
+}
+
+.sectionName {
+ font-size: 0.9rem;
+ color: var(--ifm-color-emphasis-700, #222);
+ margin-right: 0.5rem;
+}
+
+.controls .button {
+ padding: 0.3rem 0.6rem;
+ font-size: 0.85rem;
+ border-radius: 4px;
+}
+
+.controls .button--secondary {
+ background: transparent;
+ color: var(--ifm-color-emphasis-700, #222);
+ border: 1px solid var(--ifm-color-emphasis-200, #d0d7df);
+}
+.controls .button--secondary:hover {
+ background: var(--ifm-color-emphasis-100, #f1f6fb);
+}
+
+.controls .button--outline {
+ background: transparent;
+ color: var(--ifm-color-emphasis-700, #222);
+ border: 1px solid var(--ifm-color-emphasis-200, #d0d7df);
+}
+.controls .button--outline:hover {
+ background: var(--ifm-color-emphasis-100, #f1f6fb);
+}
+
+.button.button--primary.tick {
+ background: var(--ifm-color-primary, #2e8555) !important;
+ color: white !important;
+ border-color: transparent !important;
+}
+
+[data-theme='dark'] .controls .button--secondary,
+[data-theme='dark'] .controls .button--outline,
+[data-theme='dark'] .controls .button {
+ color: var(--ifm-color-emphasis-100, #eee);
+ border-color: rgba(255,255,255,0.06);
+}
+[data-theme='dark'] .controls .button--secondary:hover,
+[data-theme='dark'] .controls .button--outline:hover,
+[data-theme='dark'] .controls .button:hover {
+ background: rgba(255,255,255,0.03);
+}
+
+.controls .button--outline:hover {
+ color: var(--ifm-color-emphasis-700, #222);
+}
+
+.container .borr-tick {
+ background: var(--ifm-color-primary, #2e8555) !important;
+ color: #fff !important;
+ border-color: transparent !important;
+}
+.container .borr-export,
+.container .borr-import {
+ background: transparent !important;
+ color: var(--ifm-color-emphasis-700, #222) !important;
+ border: 1px solid var(--ifm-color-emphasis-200, #d0d7df) !important;
+}
+.container .borr-export:hover,
+.container .borr-import:hover {
+ background: var(--ifm-color-emphasis-100, #f1f6fb) !important;
+}
+.container .borr-reset {
+ background: transparent !important;
+ color: var(--ifm-color-emphasis-700, #222) !important;
+ border: 1px solid var(--ifm-color-emphasis-200, #d0d7df) !important;
+}
+.container .borr-reset:hover {
+ background: var(--ifm-color-emphasis-100, #f1f6fb) !important;
+ color: var(--ifm-color-emphasis-700, #222) !important;
+}
+
+[data-theme='dark'] .container .borr-export,
+[data-theme='dark'] .container .borr-import,
+[data-theme='dark'] .container .borr-reset {
+ color: var(--ifm-color-emphasis-100, #eee) !important;
+ border-color: rgba(255,255,255,0.06) !important;
+}
+[data-theme='dark'] .container .borr-export:hover,
+[data-theme='dark'] .container .borr-import:hover,
+[data-theme='dark'] .container .borr-reset:hover {
+ background: rgba(255,255,255,0.03) !important;
+}
diff --git a/src/components/StartHereProgress/StartHereProgress.jsx b/src/components/StartHereProgress/StartHereProgress.jsx
new file mode 100644
index 0000000..94d1f8c
--- /dev/null
+++ b/src/components/StartHereProgress/StartHereProgress.jsx
@@ -0,0 +1,242 @@
+import React, {useEffect, useState, useRef} from 'react';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import styles from './styles.module.css';
+
+const STORAGE_PREFIX = 'borr:read:';
+const SECTIONS = [
+ ['/computer-science', 'Computer Science'],
+ ['/precollege-math', 'Pre-College Math'],
+ ['/data-science', 'Data Science'],
+ ['/math', 'Math'],
+];
+
+function norm(p){
+ if(!p) return '/';
+ try{ p = new URL(p, typeof window !== 'undefined' ? window.location.origin : 'http://localhost').pathname }catch(e){}
+ if(p !== '/' && p.endsWith('/')) p = p.slice(0,-1);
+ return p || '/';
+}
+
+async function loadManifest(){
+ try{
+ const r = await fetch('/docs-manifest.json', {cache:'no-store'});
+ if(r.ok){
+ const arr = await r.json();
+ return arr.map(norm);
+ }
+ }catch(e){}
+ try{
+ const anchors = Array.from(document.querySelectorAll('.menu__list a'));
+ return Array.from(new Set(anchors.map(a=>a.getAttribute('href')).filter(Boolean).filter(h=>h.startsWith('/')).map(norm)));
+ }catch(e){}
+ return [];
+}
+
+function Bar({label, done, total, onExport, onImport, onReset, onGo}){
+ const pct = total > 0 ? Math.round((done/total)*100) : 0;
+ return (
+
+
+
+
${label} ${done} / ${total}`}} />
+
+
+
{pct}%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ProgressContainer(){
+ function initialRowsFromStorage(){
+ try{
+ const out = SECTIONS.map(([_,label]) => ({label, done: 0, total: 0}));
+ for(let i=0;i
({label,done:0,total:0}));
+ }
+ }
+
+ const [rows, setRows] = useState(initialRowsFromStorage);
+ const fileInputRef = useRef(null);
+ const [pendingImportSection, setPendingImportSection] = useState(null);
+ async function handleImportFile(ev){
+ const f = ev.target && ev.target.files && ev.target.files[0];
+ if(!f) return;
+ try{
+ const text = await f.text();
+ const json = JSON.parse(text);
+ let entries = [];
+ if (Array.isArray(json)) entries = json;
+ else if (json && Array.isArray(json.entries)) entries = json.entries;
+ else if (json && json.entries && typeof json.entries === 'object') entries = Object.keys(json.entries);
+
+ if(entries.length === 0){
+ alert('No entries found in import file. Expected array or {entries:[...]}');
+ return;
+ }
+
+ if(!pendingImportSection){
+ alert('No section selected for import.');
+ return;
+ }
+
+ let added = 0;
+ for(const p of entries){
+ const normp = norm(p);
+ if(normp === pendingImportSection || normp.startsWith(pendingImportSection + '/')){
+ const key = STORAGE_PREFIX + normp;
+ if(localStorage.getItem(key) !== '1'){
+ localStorage.setItem(key, '1');
+ added++;
+ }
+ }
+ }
+ alert(`Imported ${added} entries for this section.`);
+
+ setRows((prev)=>{
+ const copy = prev.slice();
+ const idx = SECTIONS.findIndex(s=>norm(s[0])===pendingImportSection);
+ if(idx !== -1){
+ const section = pendingImportSection;
+ const manifest = [];
+
+ let done = 0;
+ for(let i=0;i{
+ const copy = prev.slice();
+ const idx = SECTIONS.findIndex(s => norm(s[0]) === section);
+ if(idx !== -1){
+ copy[idx] = {...copy[idx], done: 0};
+ }
+ return copy;
+ });
+ }
+
+
+ useEffect(()=>{
+ let mounted = true;
+ (async ()=>{
+ const manifest = await loadManifest();
+ const out = [];
+ for(const [path,label] of SECTIONS){
+ const section = norm(path);
+ const pages = manifest.length ? manifest.filter(p => p === section || p.startsWith(section + '/')) : [];
+ let done = 0;
+ for(const p of pages){ if(localStorage.getItem(STORAGE_PREFIX + p) === '1') done++; }
+ out.push({label,done,total:pages.length});
+ }
+ if(mounted) setRows(out);
+ })();
+ return ()=>{ mounted = false };
+ }, []);
+
+ return (
+
+
+
+ {rows.map((r, i) => {
+ const path = norm(SECTIONS[i][0]);
+ return (
+
+ exportSection(path)}
+ onImport={() => triggerImportForSection(path)}
+ onReset={() => resetSection(path)}
+ onGo={() => { window.location.href = path + '/'; }}
+ />
+
+ );
+ })}
+
+
+ );
+}
+
+export default function StartHereProgress(){
+ return (
+
+ {() => }
+
+ );
+}
diff --git a/src/components/StartHereProgress/styles.module.css b/src/components/StartHereProgress/styles.module.css
new file mode 100644
index 0000000..d279f45
--- /dev/null
+++ b/src/components/StartHereProgress/styles.module.css
@@ -0,0 +1,81 @@
+.borrMultiProgress {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ padding: 0.5rem;
+}
+
+.barContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ padding: 0.5rem;
+ border: 1px solid var(--ifm-color-emphasis-200, #e6eef6);
+ border-radius: 6px;
+ background: var(--ifm-background-color, #fff);
+}
+
+.barHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.barLabel {
+ display: flex;
+ align-items: center;
+ flex: 1 1 auto;
+}
+
+.barActions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ flex: 1 1 auto;
+}
+
+.barPercentage {
+ color: var(--ifm-color-emphasis-400, #666);
+ min-width: 3ch;
+}
+
+.barProgressTrack {
+ height: 8px;
+ background: var(--ifm-color-emphasis-100, #f1f6fb);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.barProgressFill {
+ height: 100%;
+ transition: width 300ms ease;
+ background: linear-gradient(
+ 90deg,
+ var(--ifm-color-primary, #3b82f6),
+ var(--ifm-color-success, #25c29a)
+ );
+}
+
+/* Responsive tweaks */
+@media (max-width: 600px) {
+ .barHeader {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .barActions {
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ width: 100%;
+ }
+
+ .barActions button {
+ flex: 0 1 auto;
+ min-width: 100px;
+ }
+}
\ No newline at end of file
diff --git a/src/css/custom.css b/src/css/custom.css
index f92b183..632d02b 100644
--- a/src/css/custom.css
+++ b/src/css/custom.css
@@ -8,6 +8,101 @@
:root {
--ifm-color-primary: #2e8555;
--ifm-color-primary-dark: #29784c;
+
+.borr-tick {
+ background: var(--ifm-color-primary) !important;
+ color: #fff !important;
+ border-color: transparent !important;
+}
+.borr-export,
+.borr-import,
+.borr-reset {
+ background: transparent !important;
+ color: var(--ifm-color-emphasis-700) !important;
+ border: 1px solid var(--ifm-color-emphasis-200) !important;
+}
+.borr-export:hover,
+.borr-import:hover,
+.borr-reset:hover {
+ background: var(--ifm-color-emphasis-100) !important;
+}
+
+[data-theme='dark'] .borr-export,
+[data-theme='dark'] .borr-import,
+[data-theme='dark'] .borr-reset,
+[data-theme='dark'] .borr-tick {
+ color: var(--ifm-color-emphasis-100) !important;
+ border-color: rgba(255,255,255,0.06) !important;
+}
+[data-theme='dark'] .borr-export:hover,
+[data-theme='dark'] .borr-import:hover,
+[data-theme='dark'] .borr-reset:hover,
+[data-theme='dark'] .borr-tick:hover {
+ background: rgba(255,255,255,0.03) !important;
+}
+
+.borr-multi-progress {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-top: 0.5rem;
+}
+
+.borr-export,
+.borr-import,
+.borr-reset,
+.borr-go {
+ background: transparent !important;
+ color: var(--ifm-color-emphasis-700) !important;
+ border: 1px solid var(--ifm-color-emphasis-200) !important;
+}
+.borr-export:hover,
+.borr-import:hover,
+.borr-reset:hover,
+.borr-go:hover {
+ background: var(--ifm-color-emphasis-100) !important;
+}
+
+.borr-go {
+ background: var(--ifm-color-primary) !important;
+ color: #fff !important;
+ border-color: transparent !important;
+}
+.borr-go:hover {
+ background: var(--ifm-color-primary-dark) !important;
+}
+
+.borr-reset {
+ background: transparent !important;
+ color: var(--ifm-color-emphasis-700) !important;
+ border: 1px solid var(--ifm-color-emphasis-200) !important;
+}
+.borr-reset:hover {
+ background: var(--ifm-color-emphasis-100) !important;
+ color: var(--ifm-color-emphasis-700) !important;
+}
+
+[data-theme='dark'] .borr-export,
+[data-theme='dark'] .borr-import,
+[data-theme='dark'] .borr-reset,
+[data-theme='dark'] .borr-go {
+ color: var(--ifm-color-emphasis-100) !important;
+ border-color: rgba(255,255,255,0.06) !important;
+ background: rgba(255,255,255,0.02) !important;
+}
+[data-theme='dark'] .borr-export:hover,
+[data-theme='dark'] .borr-import:hover,
+[data-theme='dark'] .borr-reset:hover {
+ background: rgba(255,255,255,0.03) !important;
+}
+[data-theme='dark'] .borr-go {
+ background: var(--ifm-color-primary) !important;
+ color: #fff !important;
+}
+[data-theme='dark'] .borr-go:hover {
+ background: var(--ifm-color-primary-dark) !important;
+}
+
--ifm-color-primary-darker: #277148;
--ifm-color-primary-darkest: #205d3b;
--ifm-color-primary-light: #33925d;
diff --git a/src/theme/DocItem/index.js b/src/theme/DocItem/index.js
new file mode 100644
index 0000000..fed94cd
--- /dev/null
+++ b/src/theme/DocItem/index.js
@@ -0,0 +1,49 @@
+import React, {useEffect, useState} from 'react';
+import * as OriginalModule from '@theme-original/DocItem';
+import ReadingProgress from '@site/src/components/ReadingProgress/ReadingProgress';
+import {createPortal} from 'react-dom';
+
+export default function DocItem(props) {
+ const [portalContainer, setPortalContainer] = useState(null);
+
+ useEffect(() => {
+ // Find the main article for the current doc page
+ const article = document.querySelector('article');
+ if (!article) return;
+
+ // Create a container and insert it before the doc footer (if any)
+ const container = document.createElement('div');
+ container.className = 'borr-reading-progress-portal';
+ container.style.width = '100%';
+ container.style.marginTop = '1rem';
+
+ const footer = article.querySelector('.theme-doc-footer');
+ if (footer) article.insertBefore(container, footer);
+ else article.appendChild(container);
+
+ setPortalContainer(container);
+
+ return () => {
+ try {
+ if (container && container.parentNode) container.parentNode.removeChild(container);
+ } catch (e) {}
+ };
+ }, []);
+
+ // Support both default and named module shapes that docusaurus might provide
+ const OriginalDocItem = OriginalModule && (OriginalModule.default ?? OriginalModule);
+ if (!OriginalDocItem) {
+ // Provide a helpful diagnostic if the theme original couldn't be resolved
+ // Render only the reading progress so the site remains usable.
+ // eslint-disable-next-line no-console
+ console.warn('[DocItem swizzle] @theme-original/DocItem resolved to', OriginalModule);
+ return portalContainer ? createPortal(, portalContainer) : ;
+ }
+
+ return (
+ <>
+
+ {portalContainer ? createPortal(, portalContainer) : null}
+ >
+ );
+}