From e078e53718ae9e95eaf00e9904c612448ede2858 Mon Sep 17 00:00:00 2001 From: Andrew SG Date: Tue, 11 Aug 2026 13:16:42 +0100 Subject: [PATCH 1/5] feat(loom): dual-screen keepalive, global recording bar, sidebar polish - loom_stimulus.py / loom_camera_module.py: the near-screen keepalive tick and test-flash button now also cover the stimulus monitor, gated to only fire while idle (batch.active is False) so a tick can never blank an in-progress loom animation or the photodiode marker mid-trial. Renamed the IPC command/status from test_near_screen to test_screens to match. - App.jsx (basic + loom): RecordingStatusWidget is now mounted once at the App level instead of only inside each Dashboard page, so it's visible on every route, not just Dashboard. Adjusted Dashboard/ LoomDashboard root height rules (100vh -> flex:1) to account for the bar now sitting above them in the same flex column. - Sidebar.jsx/css: footer's plain-text "GitHub Page" link is now an icon link (octocat mark), alongside a new "Docs" link (book icon) pointing at https://saviour.readthedocs.io. Both icons use currentColor to theme with light/dark, matching the existing power-button icon convention. Increased the title-to-nav-links gap (20px -> 32px, plus a 10px floor on .main-nav for when no hostname is shown). Co-Authored-By: Claude Sonnet 5 --- src/controller/frontend/src/basic/App.css | 4 ++ src/controller/frontend/src/basic/App.jsx | 2 + .../CameraConfigCard/CameraConfigCard.jsx | 15 +++-- .../src/basic/components/Sidebar/Sidebar.css | 37 ++++++++++- .../src/basic/components/Sidebar/Sidebar.jsx | 18 +++++- .../src/basic/pages/Dashboard/Dashboard.css | 3 +- .../src/basic/pages/Dashboard/Dashboard.jsx | 3 - .../frontend/src/basic/pages/Guide/Guide.css | 4 -- .../frontend/src/basic/pages/Guide/Guide.jsx | 1 - .../src/basic/pages/Settings/Settings.css | 7 --- .../src/basic/pages/Settings/Settings.jsx | 2 - .../src/basic/pages/System/System.css | 6 +- .../src/basic/pages/System/System.jsx | 1 - src/controller/frontend/src/loom/App.css | 6 ++ src/controller/frontend/src/loom/App.jsx | 2 + .../pages/LoomDashboard/LoomDashboard.css | 9 +-- .../pages/LoomDashboard/LoomDashboard.jsx | 2 - .../loom_camera/loom_camera_module.py | 10 +-- .../examples/loom_camera/loom_stimulus.py | 61 ++++++++++++------- 19 files changed, 125 insertions(+), 68 deletions(-) diff --git a/src/controller/frontend/src/basic/App.css b/src/controller/frontend/src/basic/App.css index 08b68aa9c..b61a30194 100644 --- a/src/controller/frontend/src/basic/App.css +++ b/src/controller/frontend/src/basic/App.css @@ -16,6 +16,10 @@ flex-direction: column; } + .content > .recording-status-widget { + flex-shrink: 0; + } + .nas-warning-banner { background: color-mix(in srgb, var(--error-color, #e74c3c) 15%, var(--card-bg-color)); color: var(--error-color, #e74c3c); diff --git a/src/controller/frontend/src/basic/App.jsx b/src/controller/frontend/src/basic/App.jsx index 4dd342bf7..4559a1ce3 100644 --- a/src/controller/frontend/src/basic/App.jsx +++ b/src/controller/frontend/src/basic/App.jsx @@ -14,6 +14,7 @@ import Guide from "./pages/Guide/Guide"; import ClockModal from "./components/ClockModal/ClockModal"; import FaultAlertModal from "./components/FaultAlertModal/FaultAlertModal"; import ConnectionOverlay from "./components/ConnectionOverlay/ConnectionOverlay"; +import RecordingStatusWidget from "./components/RecordingStatusWidget/RecordingStatusWidget"; import useClockOnce from "/src/hooks/useClockOnce"; import useSessions from "/src/hooks/useSessions"; import socket from "/src/socket"; @@ -90,6 +91,7 @@ function App() {
+ {nasHealth?.status === "error" && (
NAS unreachable — exports will queue locally until the share is restored. diff --git a/src/controller/frontend/src/basic/components/ConfigCard/CameraConfigCard/CameraConfigCard.jsx b/src/controller/frontend/src/basic/components/ConfigCard/CameraConfigCard/CameraConfigCard.jsx index 9ed71782b..14c01d0e5 100644 --- a/src/controller/frontend/src/basic/components/ConfigCard/CameraConfigCard/CameraConfigCard.jsx +++ b/src/controller/frontend/src/basic/components/ConfigCard/CameraConfigCard/CameraConfigCard.jsx @@ -626,9 +626,9 @@ function CameraConfigCard({ id, module, clipboard, onCopy, syncServerModule }) {
Opens a window on each detected physical monitor, picked by GLFW's raw detection order. There's no way to know in advance which is - Monitor 0 vs 1 — use the "Near screen" test flash button below to + Monitor 0 vs 1 — use the "Test screens" flash button below to check, and switch this if it's backwards. If a second monitor is - detected it automatically gets the keepalive/near-test display. + detected it automatically gets the near/keepalive-only display.
@@ -756,7 +756,7 @@ function CameraConfigCard({ id, module, clipboard, onCopy, syncServerModule }) { onChange={e => handleChange(["loom_stimulus", "photodiode_y_ndc"], e)} />
-
Near screen
+
Keep-alive (both screens)
-

System Health

diff --git a/src/controller/frontend/src/basic/components/ConfigCard/CopyActionsBar.jsx b/src/controller/frontend/src/basic/components/ConfigCard/CopyActionsBar.jsx index 17bb28650..00738cf7e 100644 --- a/src/controller/frontend/src/basic/components/ConfigCard/CopyActionsBar.jsx +++ b/src/controller/frontend/src/basic/components/ConfigCard/CopyActionsBar.jsx @@ -14,7 +14,7 @@ function CopyActionsBar({ activeTab, tabSectionMap, formData, moduleType, module {hasSection && ( <> {moduleType && ( @@ -32,7 +32,7 @@ function CopyActionsBar({ activeTab, tabSectionMap, formData, moduleType, module )}
diff --git a/src/controller/frontend/src/basic/components/ConfigCard/MicrophoneConfigCard/MicrophoneConfigCard.jsx b/src/controller/frontend/src/basic/components/ConfigCard/MicrophoneConfigCard/MicrophoneConfigCard.jsx index 43386f730..29ba8d347 100644 --- a/src/controller/frontend/src/basic/components/ConfigCard/MicrophoneConfigCard/MicrophoneConfigCard.jsx +++ b/src/controller/frontend/src/basic/components/ConfigCard/MicrophoneConfigCard/MicrophoneConfigCard.jsx @@ -383,7 +383,7 @@ function MicrophoneConfigCard({ id, module, clipboard, onCopy }) {
@@ -553,7 +553,7 @@ function MicrophoneConfigCard({ id, module, clipboard, onCopy }) { <> {allSerials.length === 0 ? (
- No AudioMoths discovered — connect devices and refresh + No AudioMoths discovered - connect devices and refresh
) : allSerials.map(serial => (
diff --git a/src/controller/frontend/src/basic/components/ConfigCard/TTLConfigCard/TTLConfigCard.jsx b/src/controller/frontend/src/basic/components/ConfigCard/TTLConfigCard/TTLConfigCard.jsx index e5baf1b36..a300f5ef8 100644 --- a/src/controller/frontend/src/basic/components/ConfigCard/TTLConfigCard/TTLConfigCard.jsx +++ b/src/controller/frontend/src/basic/components/ConfigCard/TTLConfigCard/TTLConfigCard.jsx @@ -405,7 +405,7 @@ function TTLConfigCard({ id, module, clipboard, onCopy }) {

This will overwrite the {applyAllConfirm.label.toLowerCase()} config on every{" "} - {applyAllConfirm.moduleType ?? "module"} and save immediately — unsaved changes on other modules will be lost. + {applyAllConfirm.moduleType ?? "module"} and save immediately - unsaved changes on other modules will be lost.

diff --git a/src/controller/frontend/src/basic/components/FaultAlertModal/FaultAlertModal.jsx b/src/controller/frontend/src/basic/components/FaultAlertModal/FaultAlertModal.jsx index 1e58723f8..45b78c15d 100644 --- a/src/controller/frontend/src/basic/components/FaultAlertModal/FaultAlertModal.jsx +++ b/src/controller/frontend/src/basic/components/FaultAlertModal/FaultAlertModal.jsx @@ -10,7 +10,7 @@ function parseTimestamp(str) { } function formatDateTime(date) { - if (!date) return "—"; + if (!date) return "-"; return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } @@ -40,7 +40,7 @@ export default function FaultAlertModal({ faultedSessions, onAcknowledge }) { const title = faultedSessions.length === 1 ? (faultedSessions[0].state === "error" ? "Recording Fault Detected" - : "Recording Fault — Session Recovered") + : "Recording Fault - Session Recovered") : "Recording Faults Detected"; return ( diff --git a/src/controller/frontend/src/basic/components/HealthSummaryWidget/HealthSummaryWidget.jsx b/src/controller/frontend/src/basic/components/HealthSummaryWidget/HealthSummaryWidget.jsx index 69300b10f..b0d1d50eb 100644 --- a/src/controller/frontend/src/basic/components/HealthSummaryWidget/HealthSummaryWidget.jsx +++ b/src/controller/frontend/src/basic/components/HealthSummaryWidget/HealthSummaryWidget.jsx @@ -197,7 +197,7 @@ export default function HealthSummaryWidget() {
Hottest device - {maxTemp}°C — {maxTempId} + {maxTemp}°C - {maxTempId}
)} @@ -206,7 +206,7 @@ export default function HealthSummaryWidget() {
Fullest disk - {maxDisk}% — {maxDiskId} + {maxDisk}% - {maxDiskId}
)} @@ -215,7 +215,7 @@ export default function HealthSummaryWidget() {
PTP sync (worst) - {ptpDisplay(worstPtp)} — {worstPtpId} + {ptpDisplay(worstPtp)} - {worstPtpId}
)} diff --git a/src/controller/frontend/src/basic/components/ModuleActionsMenu/ModuleActionsMenu.jsx b/src/controller/frontend/src/basic/components/ModuleActionsMenu/ModuleActionsMenu.jsx index af1dc36fd..ba3274178 100644 --- a/src/controller/frontend/src/basic/components/ModuleActionsMenu/ModuleActionsMenu.jsx +++ b/src/controller/frontend/src/basic/components/ModuleActionsMenu/ModuleActionsMenu.jsx @@ -118,18 +118,18 @@ function ModuleActionsMenu({ id, name, isOnline }) {
) : (
- -
+ {/* Admin/Guest badge — lives here (below the title) rather than in + .footer below; class names keep the "footer-" prefix since the + styling itself is unchanged, only its position moved. */}
+ + +
+ +
diff --git a/src/controller/frontend/src/basic/pages/Dashboard/Dashboard.jsx b/src/controller/frontend/src/basic/pages/Dashboard/Dashboard.jsx index 0e5589193..76ad06f32 100644 --- a/src/controller/frontend/src/basic/pages/Dashboard/Dashboard.jsx +++ b/src/controller/frontend/src/basic/pages/Dashboard/Dashboard.jsx @@ -141,12 +141,12 @@ function Dashboard() { })), ...micModules.map(m => ({ id: m.id, ip: m.ip, port: STREAM_PORTS.microphone, - label: `${m.name} — Audio`, isRecording: m.status === "RECORDING", + label: `${m.name} - Audio`, isRecording: m.status === "RECORDING", syncStatus: m.config_sync_status, })), ...ttlModules.map(m => ({ id: m.id, ip: m.ip, port: STREAM_PORTS.ttl, - label: `${m.name} — TTL`, isRecording: m.status === "RECORDING", + label: `${m.name} - TTL`, isRecording: m.status === "RECORDING", syncStatus: m.config_sync_status, })), ], [cameraModules, micModules, ttlModules]); @@ -235,7 +235,7 @@ function Dashboard() { key={m.id} ip={m.ip} port={STREAM_PORTS.microphone} - label={`${m.name} — Audio`} + label={`${m.name} - Audio`} isRecording={m.status === "RECORDING"} syncStatus={m.config_sync_status} /> @@ -245,7 +245,7 @@ function Dashboard() { key={m.id} ip={m.ip} port={STREAM_PORTS.ttl} - label={`${m.name} — TTL`} + label={`${m.name} - TTL`} isRecording={m.status === "RECORDING"} syncStatus={m.config_sync_status} /> diff --git a/src/controller/frontend/src/basic/pages/Guide/Guide.css b/src/controller/frontend/src/basic/pages/Guide/Guide.css index 860e75c4e..32e1d85a8 100644 --- a/src/controller/frontend/src/basic/pages/Guide/Guide.css +++ b/src/controller/frontend/src/basic/pages/Guide/Guide.css @@ -1,136 +1,195 @@ .guide-page { padding: 16px 24px; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 28px; } -.guide-header { - margin-bottom: 16px; +/* ── Contents panel — left-aligned, like readthedocs' own sidebar nav or a + Word/Google Docs outline pane, not a button bar. ─────────────────────── */ + +.guide-contents { + flex-shrink: 0; + width: 200px; + position: sticky; + top: 16px; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 14px 10px; + background: var(--card-bg-color); } -.guide-intro { - color: var(--secondary-text-color); - margin: 0; +.guide-contents-title { + font-size: 0.72em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.6; + padding: 0 8px; + margin-bottom: 8px; } -.guide-tabs { - position: sticky; - top: 0; - z-index: 1; +.guide-contents-list { + list-style: none; + margin: 0; + padding: 0; display: flex; - gap: 4px; - margin-bottom: 16px; - padding: 8px 0; - background: var(--bg-color); - border-bottom: 1px solid var(--border-color); + flex-direction: column; + gap: 1px; } -.guide-tab { - padding: 6px 14px; - border: 1px solid var(--border-color); - border-radius: 6px; - background: var(--card-bg-color); +.guide-contents-link { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + border-left: 2px solid transparent; + padding: 6px 8px; + border-radius: 0 6px 6px 0; color: var(--text-color); - cursor: pointer; font-size: 0.9em; + cursor: pointer; +} + +.guide-contents-link:hover { + background: color-mix(in srgb, var(--text-color) 8%, transparent); } -.guide-tab:hover { - border-color: var(--accent-color); +.guide-contents-link--active { + border-left-color: var(--accent-color); + background: color-mix(in srgb, var(--accent-color) 12%, transparent); color: var(--accent-color); + font-weight: 600; } -.guide-tab--active { - background: var(--accent-color); - border-color: var(--accent-color); - color: #fff; +/* ── Content column ──────────────────────────────────────────────────────── */ + +.guide-content { + flex: 1; + min-width: 0; } .guide-topics { display: flex; flex-direction: column; gap: 16px; + max-width: 720px; } .guide-topic { min-width: 0; - max-width: 720px; } -.guide-topic h3 { +@media (max-width: 720px) { + .guide-page { + flex-direction: column; + } + + .guide-contents { + width: 100%; + position: static; + } +} + +.guide-missing { + color: var(--secondary-text-color); + font-style: italic; +} + +/* ── Rendered markdown body ──────────────────────────────────────────────── */ +/* .card h2 (index.css) centers card-title-style headings; markdown body + subsection headings read better left-aligned. */ + +.guide-topic h1 { margin-top: 0; + font-size: 1.5em; } -.guide-video-wrap { - position: relative; - width: 100%; - padding-top: 56.25%; /* 16:9 */ - margin-bottom: 12px; - border-radius: 6px; - overflow: hidden; - background: var(--bg-color); +.guide-topic h2 { + text-align: left; + font-size: 1.2em; + margin: 1.2em 0 0.5em; } -.guide-video { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - border: 0; +.guide-topic h2:first-child { + margin-top: 0; } -.guide-video-placeholder { - color: var(--secondary-text-color); - font-style: italic; - margin: 0 0 12px; +.guide-topic h3 { + font-size: 1.05em; + margin: 1em 0 0.4em; } -.guide-steps { - margin: 0; - padding-left: 20px; +.guide-topic p { + line-height: 1.5; + margin: 0 0 0.8em; } -.guide-steps li { - margin-bottom: 6px; +.guide-topic ul, +.guide-topic ol { + margin: 0 0 0.8em; + padding-left: 22px; + line-height: 1.5; } -.guide-steps li:last-child { - margin-bottom: 0; +.guide-topic li { + margin-bottom: 4px; } -.guide-faq { - max-width: 720px; - padding: 4px 12px; +.guide-topic a { + color: var(--accent-color); } -.guide-faq-item { - border-bottom: 1px solid var(--border-color); +.guide-topic code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9em; + background: color-mix(in srgb, var(--text-color) 8%, transparent); + padding: 0.1em 0.35em; + border-radius: 4px; } -.guide-faq-item:last-child { - border-bottom: none; +.guide-topic pre { + background: color-mix(in srgb, var(--text-color) 8%, transparent); + padding: 10px 12px; + border-radius: 6px; + overflow-x: auto; } -.guide-faq-question { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - width: 100%; - padding: 12px 4px; - border: none; +.guide-topic pre code { background: none; - color: var(--text-color); + padding: 0; +} + +.guide-topic blockquote { + margin: 0 0 0.8em; + padding: 4px 12px; + border-left: 3px solid var(--border-color); + color: var(--secondary-text-color); +} + +.guide-topic table { + border-collapse: collapse; + width: 100%; + margin: 0 0 0.8em; + font-size: 0.92em; +} + +.guide-topic th, +.guide-topic td { + border: 1px solid var(--border-color); + padding: 6px 10px; text-align: left; - font-size: 0.95em; - font-weight: 600; - cursor: pointer; } -.guide-faq-caret { - flex-shrink: 0; - opacity: 0.6; +.guide-topic img { + max-width: 100%; + border-radius: 6px; } -.guide-faq-answer { - margin: 0 4px 12px; - color: var(--secondary-text-color); +.guide-topic hr { + border: none; + border-top: 1px solid var(--border-color); + margin: 1em 0; } diff --git a/src/controller/frontend/src/basic/pages/Guide/Guide.jsx b/src/controller/frontend/src/basic/pages/Guide/Guide.jsx index 9f88a82f2..0545dd041 100644 --- a/src/controller/frontend/src/basic/pages/Guide/Guide.jsx +++ b/src/controller/frontend/src/basic/pages/Guide/Guide.jsx @@ -1,205 +1,121 @@ -import React, { useState } from "react"; +import React, { useState, useMemo, useCallback } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeRaw from "rehype-raw"; +import rehypeSlug from "rehype-slug"; import "./Guide.css"; +import { docs, resolveDocLink, resolveDocImage } from "/src/docs/loadDocs.js"; -// Each topic gets a short written walkthrough plus an optional tutorial -// video. Fill in `youtubeId` (the id from a youtube.com/watch?v= URL, -// or a full youtube.com/embed/ share link's id) to embed a video — -// topics with no id yet just show the written steps. -const BASICS_TOPICS = [ - { - title: "Assigning a device role", - youtubeId: null, - steps: [ - "Flash the SD card and boot the Raspberry Pi 5 on the PoE network.", - "Run `sudo saviour-config` on the device.", - "Choose Controller (one per system) or Module, then pick the module type (camera, microphone, TTL, RFID, ...).", - "The device reboots into its assigned role and appears automatically once discovered.", - ], - }, - { - title: "Connecting modules to the controller", - youtubeId: null, - steps: [ - "Power on the controller first — it acts as the PTP grandmaster and service discovery hub.", - "Power on modules; they register over mDNS and appear on the Dashboard within a few seconds.", - "Check the System page to confirm every module shows a recent heartbeat and a locked PTP offset before recording.", - ], - }, - { - title: "Running a recording session", - youtubeId: null, - steps: [ - "Configure each module on the Settings page (resolution, sample rate, etc.) before starting.", - "On the Recording page, click \"Check Ready\" to confirm PTP sync is within threshold on every module.", - "Start the session, and stop it (or let a scheduled window end it) once you're done.", - "Recordings export automatically to the controller's share once each module finishes.", - ], - }, - { - title: "Exporting and retrieving data", - youtubeId: null, - steps: [ - "Recordings land on the controller's Samba share, organised by session name and date.", - "Connect to the share from a lab workstation to copy files off, or point analysis tools at it directly.", - "Use tools/analyse_framesync.py and tools/make_aligned_video.py to check multi-camera timing and build aligned review videos.", - ], - }, - { - title: "Troubleshooting a module", - youtubeId: null, - steps: [ - "A module marked offline usually means its heartbeat timed out — check power, PoE link and network cabling first.", - "The System page shows per-module CPU, disk, temperature and PTP offset — a drifting PTP offset points to a clock sync problem, not a recording bug.", - "Reboot or shut down an individual module from its actions menu on the System page if it needs a clean restart.", - ], - }, +// Mirrors mkdocs.yml's nav — the RTD site's structure is this page's +// structure too, so there's one source of truth for both. +const TABS = [ + { id: "index", label: "Home" }, + { id: "getting_started", label: "Getting Started" }, + { id: "faqs", label: "FAQs" }, + { id: "open_ephys", label: "Using with Ephys" }, + { id: "how_it_works", label: "How it Works" }, + { id: "hardware", label: "Hardware" }, + { id: "cad", label: "CAD/3D Prints" }, + { id: "about", label: "About" }, ]; -const EPHYS_TOPICS = [ - { - title: "Syncing SAVIOUR to an ephys acquisition system", - youtubeId: null, - steps: [ - "Wire a shared TTL line between the ephys acquisition system and a TTL module input pin so both systems see the same sync pulses.", - "Recording an input pin logs each pulse edge with a PTP-disciplined timestamp, in the same clock domain as every camera/microphone/TTL module on the network.", - "On the ephys side, log the same pulses against its own acquisition clock — the shared pulses are your alignment reference between the two clocks.", - ], - }, - { - title: "Sending sync pulses out to the ephys rig", - youtubeId: null, - steps: [ - "Configure a TTL module output pin (fixed-interval or pseudorandom pulse train) and wire it into a spare digital/sync input on the acquisition system.", - "Start the pulse train before recording begins so there are reference edges throughout the whole session, not just at the start.", - "A pseudorandom (non-periodic) pulse train is easier to align unambiguously than a fixed-rate one if a few pulses are missed on either side.", - ], - }, - { - title: "Aligning ephys data post-hoc", - youtubeId: null, - steps: [ - "Export the TTL module's per-pulse timestamp CSV alongside the ephys recording.", - "Match pulse edges between the two logs to fit a clock offset (and drift, if the ephys clock isn't disciplined) between ephys time and SAVIOUR/PTP time.", - "Apply that mapping to bring spike times, video frames and any other module's timestamps into one common timeline.", - ], - }, -]; - -const FAQS = [ - { - question: "How do I know if PTP sync is good enough to start recording?", - answer: "Click \"Check Ready\" on the Recording page — it checks ptp4l and phc2sys offset on every module against a threshold (50 µs by default) and reports which modules aren't ready. The System page also shows live per-module PTP offset if you want to watch it settle after a reboot.", - }, - { - question: "A camera was just rebooted — can I record straight away?", - answer: "Give it 5–10 minutes first. phc2sys needs that long to converge its frequency estimate for that crystal; recording immediately after a reboot can leave a larger-than-usual (but still bounded) inter-camera phase offset.", - }, - { - question: "What happens to a recording if the NAS/export share goes down?", - answer: "Files are staged locally on the module and export is retried with backoff once the share comes back — recordings aren't lost, but they won't appear on the share until the export queue catches up.", - }, - { - question: "Can I change which rig UI (basic / loom / apa / habitat / acoustic startle) a controller shows?", - answer: "The frontend variant is selected by which App is imported in src/controller/frontend/src/main.jsx. Switching rigs means editing that import and rebuilding the frontend — it isn't a runtime setting yet.", - }, - { - question: "Do all modules need to be the same type?", - answer: "No — a system is any mix of camera, microphone, TTL, RFID and rig-specific module types (loom camera, APA camera/arduino) all reporting to one controller. Add or remove modules freely; the controller discovers them automatically over mDNS.", - }, -]; - -const SECTIONS = [ - { id: "basics", label: "Basics" }, - { id: "ephys", label: "Using with Ephys" }, - { id: "faq", label: "FAQ" }, -]; +// mkdocs nests these three under "About" in its nav; combined onto one tab +// here rather than a two-level tab widget, since each file is short. +const ABOUT_DOC_IDS = ["about/license", "about/contributing", "about/acknowledgements"]; -function TopicCard({ topic }) { - return ( -
-

{topic.title}

+function makeMarkdownComponents(docId, onNavigate) { + return { + a({ href, children, ...props }) { + const resolved = resolveDocLink(href, docId); + if (resolved.type === "external") { + return ( + + {children} + + ); + } + if (resolved.type === "anchor") { + return {children}; + } + return ( + { + e.preventDefault(); + onNavigate(resolved.tabId); + }} + {...props} + > + {children} + + ); + }, + img({ src, alt, ...props }) { + return {alt}; + }, + }; +} - {topic.youtubeId ? ( -
-