feat: section links in prose, a picker that writes them, and cards that render
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 36s

**Cross-references can name a section.** `[[264#workup|the workup]]` opens the
reader at that heading, which is what a sentence about one part of a long
article actually means. Whole-article `[[264|label]]` is unchanged, and a
section renamed since is not a broken link — it lands at the top of the right
article, which is a mild disappointment rather than a dead end.

**A picker that writes the marker for you.** 🔗 Link an article, in the editor:
type a few words, click the article — or one of its sections — and the marker
is on the clipboard with the right title as its label. Getting an id used to
mean opening the library in another tab, finding the article and reading the
number out of the address bar, which is four steps and a chance to mistype,
every time. Its own small endpoint, because the listing deliberately does not
carry sections and this needs nothing else.

**Three things about cards that were built but never drawn:**

- A card can carry an image. The column is there, the API returns it, the
  editor accepts one — and no view in the app rendered it, so every picture
  anybody attached to a card was stored and never seen. Both card views show it
  now, small until clicked like every other figure.
- The deck browser printed `[[331|Epiglottitis]]` as brackets and a number. The
  study view has rendered them as links for a while; now both do.
- There was no way to make a deck by hand. Every deck came out of a model —
  generated from a document section or an article — so an educator who wanted
  to write six cards had nowhere to put them, and the add-a-card route could
  only add to a deck that did not exist yet. `+ New deck` on the cards page.

**Generate cards ran in silence.** It starts a real job, and the only place its
progress was drawn was inside the refine panel — which lives in the editor and
is shut. Pressing it on the reading page did nothing visible for ninety
seconds. It now says what it is doing where it was pressed.

**Overlays were invisible to learners.** A stored width is a fraction of the
image, and the stroke is drawn with `non-scaling-stroke`, which makes
`stroke-width` a count of screen pixels — so 0.006 meant six thousandths of a
pixel. The editor has always multiplied by its rendered width; the viewer now
does the same sum. Every region an educator has ever marked was invisible to
everyone who was not editing it.

Also: the figure viewer no longer scrolls, at any width, and the page behind it
is pinned properly (`overflow: hidden` on the body does nothing on iOS, so a
figure opened half-way down an article drifted while it was read). Options are
full width on a phone. The question toolbar's seven glyphs are four, with the
rest folded into the ⋯ that was already there, spelled out in words. The jobs
popover closes on a click anywhere outside it. And the editor has a way back to
Editorial — "back to the article", from an article you opened to edit, is a
loop.

The contract snapshot caught both new routes on the way through, which is what
it is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 02:05:02 +02:00
parent 2d55805471
commit 158930d532
20 changed files with 675 additions and 45 deletions

View file

@ -292,6 +292,46 @@ def list_articles(
return [_article_card_json(a) for a in articles]
@router.get("/link-targets")
def link_targets(
q: str | None = Query(None, description="What the writer typed"),
limit: int = Query(8, le=25),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Articles and their sections, for the picker that writes a cross-reference.
Its own endpoint rather than the listing, because the two want opposite
things: the listing is a page of cards and deliberately does not carry
sections, and this needs the sections and almost nothing else. Eight
results, because a picker is for choosing rather than for browsing.
"""
query = db.query(Article).filter(Article.deleted_at.is_(None))
ranked: list[int] = []
if q and q.strip():
ranked, _ = article_ids_with_sections(db, q.strip(), limit=60)
if not ranked:
return []
query = query.filter(Article.id.in_(ranked))
articles = query.order_by(Article.updated_at.desc()).all()
if ranked:
rank_of = {article_id: position for position, article_id in enumerate(ranked)}
articles.sort(key=lambda article: rank_of.get(article.id, len(rank_of)))
return [{
"id": article.id,
"title": article.title,
"slug": article.slug,
"status": article.status,
# Only the sections that have somewhere to land: a section with no id
# cannot be addressed, and offering it would produce a dead marker.
"sections": [
{"id": section.get("id"), "title": section.get("title") or section.get("slug") or "Untitled",
"variant": section.get("variant")}
for section in (article.sections or []) if section.get("id")
],
} for article in articles[:limit]]
@router.post("/")
def create_article(
data: ArticleWrite,

View file

@ -140,6 +140,37 @@ def create_flashcard_deck(
return {"job_id": job_id, "status": "pending"}
class ManualDeckCreate(BaseModel):
title: str
category_id: int | None = None
@router.post("/manual")
def create_deck_manually(
data: ManualDeckCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""An empty deck, to write cards into by hand.
Every deck until now came out of a model generated from a document
section or from an article so an educator who simply wanted to write six
cards had nowhere to put them. The single-card route already existed and
could only add to a deck that did not exist yet.
Unshared, like every other new deck: sharing is a second, deliberate act.
"""
title = (data.title or "").strip()
if not title:
raise HTTPException(status_code=400, detail="A deck needs a title")
deck = FlashcardDeck(title=title[:200], user_id=current_user.id,
category_id=data.category_id, card_count=0, is_shared=0)
db.add(deck)
db.commit()
db.refresh(deck)
return {"id": deck.id, "title": deck.title, "card_count": 0, "is_shared": 0}
@router.get("/", response_model=list[FlashcardDeckResponse])
def list_flashcard_decks(
include_deleted: bool = Query(False),

View file

@ -38,8 +38,11 @@ VARIANTS = ("short", "long", "clinical")
DEFAULT_VARIANT = "long"
# [[7|Febrile seizures]] — id first, because the id is the part that must not
# change. [[febrile-seizures]] is the older slug form and still resolves.
MARKER_RE = re.compile(r"\[\[(?:(\d+)\|([^\]]+)|([a-z0-9][a-z0-9-]*))\]\]")
# change. [[7#workup|the workup]] lands on one section of it, so a sentence can
# point at the paragraph it is actually about rather than at the top of a long
# article. [[febrile-seizures]] is the older slug form and still resolves.
MARKER_RE = re.compile(
r"\[\[(?:(\d+)(?:#([A-Za-z0-9_-]+))?\|([^\]]+)|([a-z0-9][a-z0-9-]*))\]\]")
STATUSES = ("draft", "in_review", "published")
@ -133,13 +136,19 @@ def snapshot(db: Session, article: Article, user_id: int | None, note: str | Non
def marker_targets(text: str) -> tuple[set[int], set[str]]:
"""The article ids and legacy slugs a piece of prose points at."""
"""The article ids and legacy slugs a piece of prose points at.
The section part of `[[7#workup|…]]` is not returned: what makes a marker
broken is the article being gone. A section that has been renamed since
leaves the reader at the top of the right article, which is a mild
disappointment rather than a dead link.
"""
ids, slugs = set(), set()
for match in MARKER_RE.finditer(text or ""):
if match.group(1):
ids.add(int(match.group(1)))
elif match.group(3):
slugs.add(match.group(3))
elif match.group(4):
slugs.add(match.group(4))
return ids, slugs
@ -175,7 +184,7 @@ def upgrade_markers(db: Session, text: str) -> str:
def replace(match: re.Match) -> str:
if match.group(1):
return match.group(0)
slug = match.group(3)
slug = match.group(4)
article = resolve_slug(db, slug)
return f"[[{article.id}|{article.title}]]" if article else match.group(0)

View file

@ -633,6 +633,17 @@
"422"
]
},
"GET /api/v1/articles/link-targets": {
"body": false,
"params": [
"query:limit?",
"query:q?"
],
"responses": [
"200",
"422"
]
},
"GET /api/v1/articles/preview/{slug}": {
"body": false,
"params": [
@ -2399,6 +2410,14 @@
"422"
]
},
"POST /api/v1/flashcards/manual": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/flashcards/{deck_id}/rate": {
"body": true,
"params": [

View file

@ -6,11 +6,12 @@ an AI draft, and a database import equally well.
## Cross-references — `[[…]]`
A link from one article to another. Two forms:
A link from one article to another. Three forms:
| You write | It renders as | Use when |
|---|---|---|
| `[[264\|respiratory failure]]` | a link labelled *respiratory failure* | **always, by preference** |
| `[[264#workup\|the workup]]` | the same, landing on that section | the sentence is about one part of a long article |
| `[[respiratory-failure]]` | a link labelled *respiratory-failure* | quick drafting |
The first number is the article's **id**, and the id is the part that cannot
@ -20,8 +21,19 @@ is the one that cannot rot. The label after the bar is whatever reads naturally
in the sentence: *"…severe viral `[[3|bronchiolitis]]`…"* reads as
"…severe viral bronchiolitis…", with the two words linked.
Where the id comes from: the article's URL. `/articles/264` is id 264. The
editor's Linked-questions panel and the library listing both show it too.
Where the id comes from: **🔗 Link an article**, in the editor's toolbar. Type
a few words, click the article, and the marker is on your clipboard with the
article's own title as the label — click a *section* instead and you get
`[[264#workup|Workup]]`, which opens the reader at that heading. Drafts are
marked as such in the list, because a link to one is a link to nothing yet.
The id is also in the article's URL — `/articles/264` is id 264 — and on the
editor's ID/Slug/marker buttons, which copy the whole thing ready to paste.
The section part is the section's own id, and it is optional in both
directions: a section renamed after the link was written leaves the reader at
the top of the right article rather than nowhere, which is why a missing
section is not reported as a broken link.
What happens at the far end: the link opens the article **in a side pane** next
to what you are reading, rather than navigating away — following a reference
@ -128,17 +140,17 @@ the model wrote.
| From | To | Where you do it | What the reader gets |
|---|---|---|---|
| Article prose | Article | `[[264\|label]]` in the text | A link with a hover card: excerpt, new tab, or a pane beside what they are reading |
| Article prose | Article | `[[264\|label]]`, or 🔗 Link an article | A link with a hover card: excerpt, new tab, or a pane beside what they are reading |
| Article prose | One section | `[[264#workup\|label]]`, or 🔗 → the section | The same card, opening the reader at that heading |
| Article | Question | Editor → Linked questions | Practice under the article, and the article under the question's answer |
| Question | Article | Question editor → the same list | ▤ chips under the correct answer, landing on the article or one of its sections |
| Card | Question | Cards → deck → ⛓ | ▦ chip under the correct answer, opening the deck to study |
| Card | Article | Cards → deck → ⛓ | The card listed under *Related cards* on the article |
| Explanation prose | Article | `[[264\|label]]`, same as anywhere | The same hover card |
A question→article link can land on **one section** rather than the whole
article — choose the section when you make the link, and the reader opens at
that heading. A cross-reference written in prose cannot: `[[264|label]]` always
means the whole article.
Both directions can land on **one section** rather than the whole article: a
question→article link has a section picker beside it, and prose says it with
`#``[[264#workup|the workup]]`.
## Deleting

View file

@ -20,6 +20,10 @@
position: fixed; inset: 0; z-index: 1200;
display: flex; flex-direction: column;
background: #0b1220; color: #e2e8f0;
/* The viewer never scrolls as a whole, at any width. It is one screen: look
at the picture, close it, carry on from where you were. A scrollbar down
the side of it invites a scroll that moves nothing anybody wanted moved. */
overflow: hidden;
}
.imgfig-bar {
display: flex; align-items: center; gap: 10px;
@ -39,7 +43,10 @@
}
.imgfig-close:hover { background: rgba(255, 255, 255, .12); }
.imgfig-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 320px minmax(0, 1fr); }
.imgfig-body {
flex: 1; min-height: 0; overflow: hidden;
display: grid; grid-template-columns: 320px minmax(0, 1fr);
}
.imgfig-desc {
display: flex; flex-direction: column; gap: 14px;
padding: 26px 24px; overflow-y: auto;
@ -52,32 +59,44 @@
.imgfig-stage {
display: flex; align-items: center; justify-content: center;
padding: 20px; overflow: auto; cursor: zoom-out;
/* `hidden`, not `auto`: the picture is capped to the space it has, so there
is nothing to scroll to. */
padding: 20px; overflow: hidden; cursor: zoom-out;
min-height: 0;
}
/* The frame is what the overlay is positioned against, so it must be exactly
the size of the drawn image and no larger hence a block that shrinks to
its content rather than a flex child that stretches. */
.imgfig-frame { position: relative; display: block; line-height: 0; cursor: default; }
.imgfig-frame img { display: block; max-width: 100%; max-height: calc(100dvh - 110px); width: auto; height: auto; }
.imgfig-frame { max-width: 100%; max-height: 100%; }
.imgfig-frame img { display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; }
/* A drawing has no size of its own, only a shape, so it is given the width it
is allowed and takes its height from that. Line art scales; a photograph
would be blown up by the same rule, which is why this is not the default. */
.imgfig-frame.is-vector { width: min(100%, 900px); }
.imgfig-frame.is-vector img { width: 100%; height: auto; max-height: calc(100dvh - 160px); object-fit: contain; }
.imgfig-frame.is-vector img { width: 100%; height: auto; max-height: 100%; object-fit: contain; }
.imgov { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
@media (max-width: 820px) {
/* Stacked, description first: it can be read before scrolling to the
picture rather than after it, and on a phone the picture wants the width
more than the text does. */
.imgfig-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); overflow-y: auto; }
.imgfig-desc { border-right: 0; border-bottom: 1px solid rgba(255, 255, 255, .1); padding: 16px 16px 14px; gap: 10px; }
/* The viewer fills the screen and does not scroll as a whole. What the
picture is stays at the top and scrolls inside its own box if it is long;
the picture keeps everything under it. A viewer you have to scroll to see
the picture in is a viewer that looks broken. */
.imgfig-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
.imgfig-desc {
border-right: 0; border-bottom: 1px solid rgba(255, 255, 255, .1);
padding: 16px 16px 14px; gap: 10px;
max-height: 38dvh; overflow-y: auto;
}
.imgfig-stage { padding: 12px; }
.imgfig-frame img { max-height: none; }
.imgfig-frame img { max-height: 100%; }
/* Stacked, so the picture has the width of the screen and whatever height
its shape asks for the row it sits in scrolls with the description. */
.imgfig-frame.is-vector { width: 100%; }
.imgfig-frame.is-vector img { max-height: none; }
.imgfig-frame.is-vector img { max-height: 100%; }
}
@media (max-width: 560px) {

View file

@ -37,12 +37,29 @@ export function ImageViewer({ src, alt = '', attemptId, onClose }) {
useEffect(() => {
const onKey = event => { if (event.key === 'Escape') onClose?.() }
document.addEventListener('keydown', onKey)
// The page behind must not scroll while the viewer has the screen.
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
// The page behind must not move while the viewer has the screen.
//
// `overflow: hidden` on the body is enough for a desktop browser and does
// nothing on iOS, where the document keeps scrolling under a fixed
// overlay so a figure opened half-way down an article drifted while it
// was being read, and closing it landed somewhere else. Pinning the body
// and putting the scroll position back on the way out is the only thing
// that holds on both.
const y = window.scrollY
const body = document.body
const previous = {
overflow: body.style.overflow, position: body.style.position,
top: body.style.top, width: body.style.width,
}
body.style.overflow = 'hidden'
body.style.position = 'fixed'
body.style.top = `-${y}px`
body.style.width = '100%'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = previous
Object.assign(body.style, previous)
window.scrollTo(0, y)
}
}, [onClose])

View file

@ -1,3 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import smoothPath from '../utils/smoothPath'
/**
@ -16,12 +18,41 @@ const DEFAULT_WIDTH = 0.006
const points = (shape) => (Array.isArray(shape.points) ? shape.points : [])
.filter(point => Array.isArray(point) && point.length === 2)
/**
* How wide the overlay is drawn, in real pixels.
*
* A stored width is a fraction of the image, so that a "medium" line carries
* the same weight in a thumbnail and on a projector. But the stroke is drawn
* with `non-scaling-stroke` otherwise a unit square stretched over a 3:2
* picture would make horizontal lines half again as heavy as vertical ones
* and that makes `stroke-width` a count of *screen* pixels. So 0.006 was
* six thousandths of a pixel, and every overlay an educator drew was invisible
* to every learner. The editor has always multiplied by its rendered width;
* this is the same sum, on the other side of the app.
*/
function useRenderedWidth() {
const ref = useRef(null)
const [width, setWidth] = useState(0)
useEffect(() => {
const node = ref.current
if (!node) return undefined
const measure = () => setWidth(node.getBoundingClientRect().width)
measure()
if (typeof ResizeObserver === 'undefined') return undefined
const observer = new ResizeObserver(measure)
observer.observe(node)
return () => observer.disconnect()
}, [])
return [ref, width]
}
export default function ImageOverlay({ overlay }) {
const [ref, box] = useRenderedWidth()
const shapes = (overlay && Array.isArray(overlay.shapes)) ? overlay.shapes : []
if (!shapes.length) return null
return (
<svg className="imgov" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
<svg ref={ref} className="imgov" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
<defs>
<marker id="imgov-head" viewBox="0 0 10 10" refX="8" refY="5"
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
@ -30,7 +61,10 @@ export default function ImageOverlay({ overlay }) {
</defs>
{shapes.map((shape, index) => {
const stroke = shape.color || DEFAULT_COLOR
const width = Number(shape.width) > 0 ? Number(shape.width) : DEFAULT_WIDTH
const fraction = Number(shape.width) > 0 ? Number(shape.width) : DEFAULT_WIDTH
// Two pixels until it has been measured, so the first paint shows
// something rather than nothing.
const width = box ? Math.max(1, fraction * box) : 2
const common = {
key: index, stroke, strokeWidth: width, fill: 'none',
strokeLinecap: 'round', strokeLinejoin: 'round',

View file

@ -0,0 +1,65 @@
/* The find-an-article-to-link-to panel. Floats over the editor, because it is
used beside the writing rather than instead of it. */
.lp {
position: fixed; right: 18px; bottom: 18px; z-index: 1300;
width: min(420px, calc(100vw - 24px));
max-height: min(560px, calc(100dvh - 90px));
display: flex; flex-direction: column;
padding: 12px; gap: 8px;
background: var(--card-bg); color: var(--text);
border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 18px 48px rgba(15, 23, 42, .22);
}
.lp-head { display: flex; align-items: center; gap: 8px; }
.lp-find {
flex: 1; min-width: 0; padding: 8px 10px;
border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text); font: inherit; font-size: .9rem;
}
.lp-find:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
.lp-close {
flex: none; width: 30px; height: 30px; cursor: pointer;
border: 0; border-radius: 8px; background: none; color: var(--text-muted);
}
.lp-close:hover { background: var(--bg); color: var(--text); }
.lp-said { margin: 0; font-size: .8rem; color: var(--correct-fg, #327b64); }
.lp-said code { font-size: .78rem; }
.lp-empty { margin: 4px 2px; font-size: .84rem; color: var(--text-muted); }
.lp-list { list-style: none; margin: 0; padding: 0; overflow-y: auto; flex: 1; min-height: 0; }
.lp-row { display: flex; align-items: stretch; gap: 4px; }
.lp-take {
flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px;
padding: 8px 9px; text-align: left; cursor: pointer;
border: 0; border-radius: 8px; background: none; color: var(--text); font: inherit;
}
.lp-take:hover { background: var(--option-hover, #eef4fb); }
.lp-take.is-section { padding-left: 22px; font-size: .86rem; color: var(--text-muted); }
.lp-take.is-section:hover { color: var(--text); }
.lp-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lp-marker { flex: none; font-size: .74rem; color: var(--text-subtle); }
.lp-draft {
flex: none; font-size: .68rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em; color: var(--text-subtle);
}
.lp-more {
flex: none; padding: 0 9px; cursor: pointer; font: inherit; font-size: .76rem;
border: 1px solid var(--border); border-radius: 8px;
background: none; color: var(--text-muted);
}
.lp-more:hover { border-color: var(--primary); color: var(--primary); }
.lp-sections { list-style: none; margin: 0 0 4px; padding: 0; }
.lp-hint { margin: 0; font-size: .74rem; line-height: 1.5; color: var(--text-subtle); }
.lp-hint code { font-size: .72rem; }
/* On a phone it takes the width and sits above the keyboard rather than under
it a picker you cannot see while typing into is not a picker. */
@media (max-width: 640px) {
.lp { right: 8px; left: 8px; bottom: 8px; width: auto; max-height: 70dvh; }
}

View file

@ -0,0 +1,130 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import api from '../api/client'
import './LinkPicker.css'
/**
* Find an article, take its marker.
*
* Writing a cross-reference means knowing a number: `[[264|respiratory
* failure]]` points at article 264, and the id is the part that cannot rot.
* Getting it meant opening the library in another tab, finding the article,
* reading the number out of the address bar and typing the marker by hand
* four steps and a chance to mistype, every time, which is how a library ends
* up under-linked.
*
* So: search, click, paste. The marker is on the clipboard, spelled correctly,
* with the article's own title as the label.
*
* A section can be picked too, where the sentence is about one part of a long
* article rather than the whole of it `[[264#workup|the workup]]` opens the
* reader at that heading.
*
* Floating, and closes on Escape or a click outside, because it is a tool used
* beside the writing rather than a page you go to.
*/
const MARKER = (id, title, section) => `[[${id}${section ? `#${section}` : ''}|${title}]]`
export default function LinkPicker({ onClose, onInsert }) {
const [query, setQuery] = useState('')
const [rows, setRows] = useState([])
const [busy, setBusy] = useState(false)
const [open, setOpen] = useState(null) // which article's sections are showing
const [copied, setCopied] = useState('')
const box = useRef(null)
const field = useRef(null)
useEffect(() => { field.current?.focus() }, [])
useEffect(() => {
const onKey = event => { if (event.key === 'Escape') onClose?.() }
const away = event => { if (!box.current?.contains(event.target)) onClose?.() }
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', away)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', away)
}
}, [onClose])
// Searched as you type, but not on every keystroke: the query goes through
// hybrid retrieval, and a request per letter would queue behind itself.
useEffect(() => {
let live = true
const timer = setTimeout(() => {
setBusy(true)
api.get('/articles/link-targets', { params: { q: query.trim() || undefined } })
.then(res => { if (live) setRows(res.data || []) })
.catch(() => { if (live) setRows([]) })
.finally(() => { if (live) setBusy(false) })
}, query.trim() ? 220 : 0)
return () => { live = false; clearTimeout(timer) }
}, [query])
const take = useCallback(async (article, section) => {
const marker = MARKER(article.id, section ? section.title : article.title, section?.id)
// Inserted where the writer is, if the caller knows where that is;
// otherwise the clipboard, which is what a paste needs.
if (onInsert) onInsert(marker)
try { await navigator.clipboard?.writeText(marker) } catch { /* clipboard refused */ }
setCopied(marker)
setTimeout(() => setCopied(''), 1600)
}, [onInsert])
return (
<div className="lp" ref={box} role="dialog" aria-label="Find an article to link to">
<div className="lp-head">
<input ref={field} className="lp-find" value={query} placeholder="Find an article…"
aria-label="Find an article to link to"
onChange={event => setQuery(event.target.value)} />
<button type="button" className="lp-close" aria-label="Close" onClick={() => onClose?.()}></button>
</div>
{copied && <p className="lp-said" role="status">Copied <code>{copied}</code></p>}
{busy && rows.length === 0 && <p className="lp-empty">Searching</p>}
{!busy && rows.length === 0 && (
<p className="lp-empty">{query.trim() ? 'Nothing matches that.' : 'No articles yet.'}</p>
)}
<ul className="lp-list">
{rows.map(article => (
<li key={article.id}>
<div className="lp-row">
<button type="button" className="lp-take" onClick={() => take(article)}>
<span className="lp-title">{article.title}</span>
<code className="lp-marker">[[{article.id}]]</code>
{article.status !== 'published' && <span className="lp-draft">draft</span>}
</button>
{article.sections.length > 0 && (
<button type="button" className="lp-more"
aria-expanded={open === article.id}
aria-label={`Sections of ${article.title}`}
onClick={() => setOpen(current => (current === article.id ? null : article.id))}>
{open === article.id ? '▴' : '▾'} {article.sections.length}
</button>
)}
</div>
{open === article.id && (
<ul className="lp-sections">
{article.sections.map(section => (
<li key={section.id}>
<button type="button" className="lp-take is-section"
onClick={() => take(article, section)}>
<span className="lp-title">{section.title}</span>
<code className="lp-marker">#{section.id}</code>
</button>
</li>
))}
</ul>
)}
</li>
))}
</ul>
<p className="lp-hint">
Click an article for <code>[[id|title]]</code>, or a section to land the
reader on that heading. It goes to the clipboard, ready to paste.
</p>
</div>
)
}

View file

@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LinkPicker from './LinkPicker'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
const rows = [
{
id: 264, title: 'Pediatric Respiratory Failure', slug: 'respiratory-failure', status: 'published',
sections: [
{ id: 'workup', title: 'Workup', variant: 'long' },
{ id: 'ladder', title: 'At the bedside: the escalation ladder', variant: 'clinical' },
],
},
{ id: 3, title: 'Bronchiolitis', slug: 'bronchiolitis', status: 'draft', sections: [] },
]
describe('finding an article to link to', () => {
let clipboard
beforeEach(() => {
vi.clearAllMocks()
api.get.mockResolvedValue({ data: rows })
clipboard = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: clipboard }, configurable: true,
})
})
it('copies the marker for an article, spelled correctly', async () => {
render(<LinkPicker onClose={() => {}} />)
await screen.findByText('Pediatric Respiratory Failure')
await userEvent.click(screen.getByText('Pediatric Respiratory Failure'))
// The id, not the slug: a slug can be renamed and an id cannot.
expect(clipboard).toHaveBeenCalledWith('[[264|Pediatric Respiratory Failure]]')
expect(await screen.findByRole('status')).toHaveTextContent('[[264|Pediatric Respiratory Failure]]')
})
it('offers the sections, and lands the reader on the one chosen', async () => {
render(<LinkPicker onClose={() => {}} />)
await screen.findByText('Pediatric Respiratory Failure')
await userEvent.click(screen.getByRole('button', { name: /Sections of Pediatric Respiratory Failure/ }))
await userEvent.click(screen.getByText('Workup'))
expect(clipboard).toHaveBeenCalledWith('[[264#workup|Workup]]')
})
it('hands the marker to the editor when there is one to hand it to', async () => {
const insert = vi.fn()
render(<LinkPicker onClose={() => {}} onInsert={insert} />)
await screen.findByText('Bronchiolitis')
await userEvent.click(screen.getByText('Bronchiolitis'))
expect(insert).toHaveBeenCalledWith('[[3|Bronchiolitis]]')
})
it('says which are still drafts, because a link to one is a link to nothing yet', async () => {
render(<LinkPicker onClose={() => {}} />)
await screen.findByText('Bronchiolitis')
expect(screen.getByText('draft')).toBeInTheDocument()
})
it('searches what was typed, once, rather than once per letter', async () => {
render(<LinkPicker onClose={() => {}} />)
await waitFor(() => expect(api.get).toHaveBeenCalledTimes(1))
await userEvent.type(screen.getByRole('textbox'), 'croup')
await waitFor(() => expect(api.get).toHaveBeenLastCalledWith(
'/articles/link-targets', { params: { q: 'croup' } }))
// Five letters, and not five searches: each one goes through hybrid
// retrieval and they would queue behind each other.
expect(api.get.mock.calls.length).toBeLessThan(4)
})
it('closes on Escape, because it floats over what you were writing', async () => {
const onClose = vi.fn()
render(<LinkPicker onClose={onClose} />)
await screen.findByText('Bronchiolitis')
await userEvent.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
})

View file

@ -95,15 +95,31 @@ function FeedbackBadge() {
function JobsBadge({ jobs }) {
const [open, setOpen] = useState(false)
const wrap = useRef(null)
const allJobs = jobs
const activeJobs = jobs.filter(j => j.status === 'running' || j.status === 'pending')
// Anywhere outside closes it, and so does Escape. It only closed when a job
// in it was clicked, so a panel opened to check on something then sat over
// the page until you found the badge again.
useEffect(() => {
if (!open) return undefined
const away = event => { if (!wrap.current?.contains(event.target)) setOpen(false) }
const onKey = event => { if (event.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', away)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', away)
document.removeEventListener('keydown', onKey)
}
}, [open])
if (allJobs.length === 0) return null
const running = activeJobs.length
return (
<div style={{ position: 'relative' }}>
<button onClick={() => setOpen(v => !v)} style={{
<div style={{ position: 'relative' }} ref={wrap}>
<button aria-expanded={open} onClick={() => setOpen(v => !v)} style={{
background: running > 0 ? '#f59e0b' : 'rgba(255,255,255,0.12)',
color: 'white', border: 'none', borderRadius: 20,
padding: '3px 10px', fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',

View file

@ -28,12 +28,15 @@ import './RichText.css'
// [[febrile-seizures]] and [[Febrile seizures|febrile-seizures]] are how an
// educator writes a cross-reference without needing an article's numeric id.
const WIKI_LINK = /\[\[(?:(\d+)\|([^\]]+)|([^\]|]+?)(?:\|([a-z0-9-]+))?)\]\]/g
// [[264#workup|label]] lands on one section of it: a long article opened at
// the paragraph the sentence was about, rather than at the top with the reader
// left to find it.
const WIKI_LINK = /\[\[(?:(\d+)(?:#([A-Za-z0-9_-]+))?\|([^\]]+)|([^\]|]+?)(?:\|([a-z0-9-]+))?)\]\]/g
const expandWikiLinks = (text) => (text || '').replace(
WIKI_LINK,
(_m, id, idLabel, label, slug) => (id
? `[${idLabel.trim()}](/articles/${id})`
(_m, id, section, idLabel, label, slug) => (id
? `[${idLabel.trim()}](/articles/${id}${section ? `?section=${section}` : ''})`
: `[${label.trim()}](/articles/${(slug || label).trim().toLowerCase()})`),
)
@ -51,12 +54,12 @@ const expandWikiLinks = (text) => (text || '').replace(
*/
export const plainWikiText = (text) => (text || '').replace(
WIKI_LINK,
(_m, _id, idLabel, label) => (idLabel || label || '').trim(),
(_m, _id, _section, idLabel, label) => (idLabel || label || '').trim(),
)
const internalArticle = (href) => {
const match = /^\/articles\/(?:s\/)?([a-z0-9-]+|\d+)\/?$/.exec(href || '')
return match ? match[1] : null
const match = /^\/articles\/(?:s\/)?([a-z0-9-]+|\d+)\/?(?:\?section=([A-Za-z0-9_-]+))?$/.exec(href || '')
return match ? { slug: match[1], sectionId: match[2] || null } : null
}
export default function RichText({
@ -95,7 +98,11 @@ export default function RichText({
),
a: ({ node, href, children, ...props }) => {
const target = linkArticles && internalArticle(href)
if (target) return <ArticleLink slug={target}>{children}</ArticleLink>
if (target) {
return (
<ArticleLink slug={target.slug} sectionId={target.sectionId}>{children}</ArticleLink>
)
}
return <a {...props} href={href} target="_blank" rel="noopener noreferrer">{children}</a>
},
// `==key point==`, highlighted. Passed through with its class rather than

View file

@ -67,6 +67,19 @@ describe('rendered prose', () => {
expect(screen.getByRole('link', { name: 'Febrile seizures' }))
.toHaveAttribute('href', '/articles/7')
})
it('lands on one section when the marker names one', () => {
// A long article opened at the paragraph the sentence was about, rather
// than at the top with the reader left to find it.
mount({ value: 'See [[7#workup|the workup]] for more.', linkArticles: true })
expect(screen.getByRole('link', { name: 'the workup' }))
.toHaveAttribute('href', '/articles/7?section=workup')
})
it('still flattens a section marker to its label where links are off', () => {
mount({ value: 'See [[7#workup|the workup]] for more.' })
expect(screen.getByText(/\[\[7#workup\|the workup\]\]/)).toBeInTheDocument()
})
})
describe('highlights across rendered markdown', () => {

View file

@ -481,3 +481,13 @@
font-size: 0.78rem;
}
.article-broken-hint { font-size: 0.8rem; color: var(--text-muted); }
/* A job's progress, above the article it is working on. */
.article-ai-note {
display: flex; align-items: center; gap: 10px;
margin-bottom: 12px; padding: 10px 12px;
font-size: .86rem; color: var(--text);
background: var(--card-bg); border: 1px solid var(--border);
border-left: 3px solid var(--primary); border-radius: 8px;
}
.article-ai-note .spinner { width: 14px; height: 14px; border-width: 2px; flex: none; }

View file

@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext'
import { SplitViewProvider } from '../context/SplitViewContext'
import RichEditor from '../components/RichEditor'
import ArticleEditor from '../components/ArticleEditor'
import LinkPicker from '../components/LinkPicker'
import ArticleQuestions from '../components/ArticleQuestions'
import ArticleRevisions from '../components/ArticleRevisions'
import CategoryColumns from '../components/CategoryColumns'
@ -256,6 +257,7 @@ export function ArticlePage() {
const [aiJob, setAiJob] = useState(null)
const [aiMessage, setAiMessage] = useState('')
const [showRefine, setShowRefine] = useState(false)
const [showLinks, setShowLinks] = useState(false)
const [refineText, setRefineText] = useState('')
const navigate = useNavigate()
// Reading claims the window; editing hands it back. Width only the
@ -386,7 +388,7 @@ export function ArticlePage() {
const runAi = async (kind, payload) => {
setError('')
setAiMessage('')
setAiMessage(kind === 'cards' ? 'Reading the article and writing cards…' : 'Starting…')
try {
const res = await api.post(kind === 'cards' ? `/articles/${id}/ai-cards` : `/articles/${id}/ai-refine`, payload)
setAiJob(res.data.job_id)
@ -430,10 +432,24 @@ export function ArticlePage() {
// Moderator business, and rare: kept above the reading rather than folded
// into it, with the page's gutters back so an alert is not flush to the
// window edge.
const hasNotices = !!error || broken.length > 0 || (editing && showRefine && user?.is_moderator)
// An AI job says so where it was started. "Generate cards" used to run in
// silence on the reading page: the only place its progress was drawn was
// inside the refine panel, which is in the editor and closed.
const hasNotices = !!error || !!aiMessage || broken.length > 0
|| (editing && showRefine && user?.is_moderator)
const notices = (
<>
{error && <div className="form-error" role="alert">{error}</div>}
{/* An AI job says so where it was started. "Generate cards" is pressed on
the reading page and its only progress line lived inside the refine
panel, which is in the editor and shut so the button ran a job and
looked broken. */}
{aiMessage && !(editing && showRefine) && (
<div className="article-ai-note" role="status">
{aiJob && <span className="spinner" aria-hidden="true" />}
<span>{aiMessage}</span>
</div>
)}
{broken.length > 0 && (
<div className="article-broken" role="alert">
<strong>
@ -468,6 +484,15 @@ export function ArticlePage() {
{breadcrumbs}
<div className="article-header">
<div>
{/* Two ways out, because there are two places you came from. The
first goes back to the article you are editing; the second
leaves the article altogether, which is what an educator who
arrived from the editorial queue wants and could not find
"back to the article" from an article you opened to edit is a
loop. */}
{user?.is_moderator && (
<Link to="/editorial" className="articles-back" style={{ marginRight: 12 }}> Editorial</Link>
)}
<button type="button" className="article-back" onClick={() => (dirty ? setConfirmDiscard(true) : leaveEditor())}>
Back to the article
</button>
@ -512,6 +537,10 @@ export function ArticlePage() {
{dirty ? 'Discard' : 'Close'}
</button>
)}
{/* Writing a cross-reference means knowing an article's id. This
finds it: search, click, paste. */}
<button className="btn btn-secondary btn-sm" aria-pressed={showLinks}
onClick={() => setShowLinks(v => !v)}>🔗 Link an article</button>
{user?.is_moderator && (
/* Refining is drafting, so it is here rather than on the page a
learner reads: you are already editing, and the result is
@ -524,6 +553,7 @@ export function ArticlePage() {
</div>
{notices}
<ArticleEditor form={form} setForm={setForm} />
{showLinks && <LinkPicker onClose={() => setShowLinks(false)} />}
<div className="card" style={{ marginTop: 16 }}>
<h4>Linked questions</h4>

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../api/client'
import RichText from '../components/RichText'
import ImageFigure from '../components/ImageFigure'
export default function FlashcardStudyPage() {
const { deckId } = useParams()
@ -182,6 +183,16 @@ export default function FlashcardStudyPage() {
most of what "link cards to things" turns out to mean. */}
<RichText value={flipped ? currentCard.back : currentCard.front} linkArticles />
</p>
{/* The picture on the card. A card could carry one the column is
there, the editor accepts one, the API returns it and no view
in the app drew it, so every image anybody attached to a card
was stored and never seen. Small until clicked, like every
other figure. */}
{currentCard.image_path && (
<div style={{ marginTop: 12 }}>
<ImageFigure src={currentCard.image_path} alt={currentCard.front} />
</div>
)}
{!flipped && (
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 16 }}>Tap to reveal answer</div>
)}

View file

@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import RichText from '../components/RichText'
import ImageFigure from '../components/ImageFigure'
import api from '../api/client'
function StarRating({ rating, onRate, readonly = false }) {
@ -26,6 +28,8 @@ function StarRating({ rating, onRate, readonly = false }) {
export default function FlashcardsPage() {
const [tab, setTab] = useState('decks')
const [making, setMaking] = useState(false)
const [newDeckTitle, setNewDeckTitle] = useState('')
const [decks, setDecks] = useState([])
const [categories, setCategories] = useState([])
const [trashedDecks, setTrashedDecks] = useState([])
@ -77,6 +81,17 @@ export default function FlashcardsPage() {
}).catch(() => {})
}
const makeDeck = async () => {
const title = newDeckTitle.trim()
if (!title) return
try {
await api.post('/flashcards/manual', { title })
setNewDeckTitle('')
setMaking(false)
loadDecks()
} catch { /* the list is unchanged, which is the honest signal */ }
}
useEffect(() => { loadDecks() }, [])
const loadCards = async (q = searchQuery, off = 0, deckId = filterDeckId) => {
@ -225,7 +240,13 @@ export default function FlashcardsPage() {
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<h2 style={{ marginBottom: 0 }}>Cards</h2>
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{/* Every deck used to come out of a model generated from a
document or an article so an educator who wanted to write six
cards by hand had nowhere to put them. */}
{user?.is_moderator && (
<button className="btn btn-sm btn-secondary" onClick={() => setMaking(true)}>+ New deck</button>
)}
<button className={`btn btn-sm ${tab === 'decks' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('decks')}>My Decks</button>
<button className={`btn btn-sm ${tab === 'shared' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('shared')}>Shared</button>
<button className={`btn btn-sm ${tab === 'browse' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('browse')}>Browse Cards</button>
@ -234,6 +255,24 @@ export default function FlashcardsPage() {
</div>
</div>
{making && (
<div className="card" style={{ marginBottom: 16 }}>
<h4 style={{ marginTop: 0 }}>A new deck</h4>
<p className="articles-subtitle">
Empty, and yours until you share it. Write the cards into it from the
deck itself.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input className="input" style={{ flex: 1, minWidth: 200 }} value={newDeckTitle}
placeholder="What is the deck about?" aria-label="Deck title"
onChange={e => setNewDeckTitle(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') makeDeck() }} />
<button className="btn btn-primary btn-sm" disabled={!newDeckTitle.trim()} onClick={makeDeck}>Create</button>
<button className="btn btn-secondary btn-sm" onClick={() => setMaking(false)}>Cancel</button>
</div>
</div>
)}
{/* Study card modal */}
{studyCard && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
@ -243,9 +282,17 @@ export default function FlashcardsPage() {
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 12, textTransform: 'uppercase', fontWeight: 700 }}>
{flipped ? 'Answer' : 'Question'} tap to flip
</div>
<p style={{ fontSize: '1.1rem', lineHeight: 1.7 }}>
{flipped ? studyCard.back : studyCard.front}
</p>
<div style={{ fontSize: '1.1rem', lineHeight: 1.7 }}>
{/* Rendered, not printed: a card's faces are prose, so a
cross-reference on one is a link here too rather than a pair
of brackets and a number. */}
<RichText value={flipped ? studyCard.back : studyCard.front} linkArticles />
{studyCard.image_path && (
<div style={{ marginTop: 10 }}>
<ImageFigure src={studyCard.image_path} alt={studyCard.front} />
</div>
)}
</div>
<div style={{ marginTop: 20, display: 'flex', gap: 8, justifyContent: 'center' }} onClick={e => e.stopPropagation()}>
<button className="btn btn-secondary btn-sm" onClick={() => { setStudyCard(null); setFlipped(false) }}>Close</button>
</div>

View file

@ -337,6 +337,11 @@ export default function QuizPage() {
// 1150px breakpoint in QuizPlayer.css that hides the rail.
const sessionDrawer = useSessionDrawer()
const hasRail = useMediaQuery('(min-width: 1151px)')
// On a phone the words under these controls are hidden and only the glyph is
// left, so seven of them in a row is seven puzzles. The three that are
// pressed on a question stay out; the rest go behind the that is already
// there for the occasional ones.
const phone = useMediaQuery('(max-width: 640px)')
// Only once a session is actually being sat. The screen that asks
// whether to start one is an ordinary card and wants the ordinary page.
useClaimSessionChrome(!!quizMode)
@ -1767,7 +1772,7 @@ const timerStarted = timeLeft !== null
are hidden and only the glyph is left, so a button whose name
lived in that hidden span had no name at all to a screen
reader and no tooltip to a long press. */}
{!examChrome && (
{!examChrome && !phone && (
<button type="button" className={labsOpen ? 'is-on' : ''}
aria-pressed={labsOpen} aria-label="Labs" title="Lab values"
onClick={() => setLabsOpen(v => !v)}>
@ -1795,6 +1800,27 @@ const timerStarted = timeLeft !== null
away behind one control rather than each taking a slot in a
bar that is read on every question. */}
<MoreActions>
{/* Folded away on a phone, spelled out in words: reading
"Listen through" once beats guessing at every time. */}
{phone && voices.length > 0 && !examChrome && (
<button type="button" className="quiz-more-item"
onClick={() => setReadThrough(v => !v)}>
{readThrough ? 'Stop reading through' : 'Read each question aloud'}
</button>
)}
{phone && (
<button type="button" className="quiz-more-item"
disabled={!manualHighlights[current.id]}
onMouseDown={e => e.preventDefault()} onClick={clearCurrentHighlights}>
Clear highlights on this question
</button>
)}
{phone && !examChrome && (
<button type="button" className="quiz-more-item"
onClick={() => setLabsOpen(v => !v)}>
Lab values
</button>
)}
<button type="button" className="quiz-more-item"
onClick={() => setPanel(p => (p === 'save' ? null : 'save'))}>
Save to a folder
@ -1836,7 +1862,7 @@ const timerStarted = timeLeft !== null
onSegmentChange={segment => setActiveReadSegment(segment === null ? null : { questionId: current.id, ...segment })}
/>
)}
{voices.length > 0 && !examChrome && (
{voices.length > 0 && !examChrome && !phone && (
<button type="button" className={readThrough ? 'is-on' : ''}
onClick={() => setReadThrough(v => !v)}
aria-label={readThrough ? 'Stop reading through' : 'Read each question aloud and move on'}
@ -1844,6 +1870,7 @@ const timerStarted = timeLeft !== null
<span>{readThrough ? 'Stop' : 'Listen through'}</span>
</button>
)}
{!phone && (
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
{/* Not a pencil. The pencil beside it opens the note, and
two identical glyphs an inch apart doing different things
@ -1855,6 +1882,7 @@ const timerStarted = timeLeft !== null
<span>Clear</span>
</button>
</div>
)}
</div>
{/* The panels sit under the toolbar, where the eye already is, and

View file

@ -1004,3 +1004,12 @@ body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; }
player, with nothing between the two. A scroller that ends flush against a
sticky bar reads as content cut off rather than content finished. */
.quiz-layout { padding-bottom: 14px; }
/* The options, on a phone
Full width, all of them. Shrink-to-fit is right on a wide screen, where the
eliminate control needs to sit against the words it belongs to but at
390px it made the one answered option a short box in a column of long ones,
which reads as a rendering fault rather than as a design. */
@media (max-width: 640px) {
.option-row > .option { flex: 1 1 auto; width: 100%; }
}