fix: edit an image over the grid, drop the role from figure titles, no iOS zoom

Editing expanded the card in place, which stretched its column to the height of
a form and shoved every neighbour out of line. It opens over the grid now, with
the image beside the fields so you can see what you are describing.

Figure titles read "Stem figure — Occult Fracture". The role is already obvious
from where the figure sits, and the link is the `question_media` row rather than
the words in the title, so the title is now just the subject. All 440 are named:
346 lost the prefix, and 94 that were still filenames took the subject of the
question they came from — including detached ones, whose caption records which
question they came off. No link was touched; the id is the link, and all 346
remain.

iOS Safari zooms the page when a focused field's text is under 16px and does not
zoom back out, which leaves a reader stuck at 1.4x with no way back. Every
control is 16px on a coarse pointer — one rule, applied once, rather than
remembered per component.

249 frontend tests green.

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-11 03:28:09 +02:00
parent f723a2fea4
commit 6abe46d1ea
5 changed files with 169 additions and 24 deletions

View file

@ -0,0 +1,86 @@
"""Drop the role from figure titles; the badge and the link already say it.
Titles read "Stem figure — Occult Fracture". The role is already visible from
where the figure sits on the question, and the link itself is the
`question_media` row, not the words in the title. What is useful in a bank of
440 is the subject, so that is all the title should be.
The id stays the identifier it always was, and no link is touched.
docker compose exec backend python -m scripts.retitle_figures
docker compose exec backend python -m scripts.retitle_figures --apply
"""
import re
import sys
from sqlalchemy import text as sa_text
from app.database import SessionLocal
from app.models.media import MediaAsset
PREFIX = re.compile(r"^(stem|explanation) figure\s*—\s*", re.I)
def main():
apply_changes = "--apply" in sys.argv
db = SessionLocal()
try:
# Where a figure has no subject of its own, the question's category is
# the best name available; the filename is not a name at all.
subjects = {}
for media_id, name in db.execute(sa_text("""
SELECT qm.media_id, c.name
FROM question_media qm
JOIN questions q ON q.id = qm.question_id
LEFT JOIN question_categories c ON c.id = q.question_category_id
""")).fetchall():
if name and media_id not in subjects:
subjects[media_id] = name
# A detached figure has no link to take a subject from, but its caption
# records which question it came off. That is still the best name.
detached = {}
for asset in db.query(MediaAsset).all():
match = re.search(r"question #(\d+)", asset.caption or "")
if match and asset.id not in subjects:
detached[asset.id] = int(match.group(1))
if detached:
names = dict(db.execute(sa_text("""
SELECT q.id, c.name FROM questions q
LEFT JOIN question_categories c ON c.id = q.question_category_id
WHERE q.id = ANY(:ids)
"""), {"ids": list(detached.values())}).fetchall())
for media_id, question_id in detached.items():
if names.get(question_id):
subjects[media_id] = names[question_id]
changed = []
for asset in db.query(MediaAsset).all():
title = asset.title or ""
if PREFIX.search(title):
new = PREFIX.sub("", title).strip()
elif re.match(r"^page_\d+_img", title):
new = subjects.get(asset.id) or title
else:
continue
if new and new != title:
changed.append((asset, title, new))
print(f" titles to change: {len(changed)}")
for _asset, old, new in changed[:6]:
print(f" {old:<44} -> {new}")
if not apply_changes:
print("\n Re-run with --apply.")
return 0
for asset, _old, new in changed:
asset.title = new[:300]
db.commit()
print(f"\n retitled: {len(changed)} (no link changed; the id is the link)")
finally:
db.close()
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -829,3 +829,15 @@ body {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
/* iOS Safari zooms the page when a focused field's text is under 16px, and it
does not zoom back out. Every control is at least 16px on a touch pointer
the visual size is what the design wanted, but not at the cost of trapping a
reader at 1.4x with no way back. */
@media (pointer: coarse) {
input, select, textarea,
input[type='text'], input[type='search'], input[type='email'],
input[type='password'], input[type='number'], input[type='url'] {
font-size: 16px;
}
}

View file

@ -59,7 +59,7 @@
.media-tag { font-size: 0.68rem; padding: 1px 8px; border-radius: 10px; background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
.media-actions { display: flex; gap: 6px; margin-top: auto; padding-top: 6px; }
.media-edit { padding: 0 12px 12px; display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); padding-top: 12px; }
.media-edit { display: flex; flex-direction: column; gap: 10px; }
.media-edit label { display: flex; flex-direction: column; gap: 4px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-subtle); }
.media-edit input, .media-edit textarea, .media-edit select {
padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;
@ -77,3 +77,28 @@
.media-header-actions { width: 100%; }
.media-header-actions .btn { flex: 1; }
}
/* Editing happens over the grid, not inside one cell of it. */
.media-modal {
position: fixed; inset: 0; z-index: 1100; padding: 20px;
background: rgba(15, 23, 42, 0.45);
display: flex; align-items: center; justify-content: center;
}
.media-modal-panel {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 14px;
width: min(760px, 100%); max-height: 88vh; display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); overflow: hidden;
}
.media-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
.media-modal-head h2 { margin: 0; font-size: 1rem; }
.media-modal-head button { background: none; border: 0; font-size: 1.05rem; color: var(--text-muted); cursor: pointer; padding: 6px 8px; }
.media-modal-body { display: grid; grid-template-columns: 240px 1fr; gap: 18px; padding: 18px; overflow-y: auto; }
.media-modal-body > img { width: 100%; border-radius: 10px; border: 1px solid var(--border); background: var(--bg); object-fit: contain; max-height: 260px; }
.media-modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--border); }
@media (max-width: 640px) {
.media-modal { padding: 0; align-items: flex-end; }
.media-modal-panel { max-height: 92vh; border-radius: 14px 14px 0 0; }
.media-modal-body { grid-template-columns: 1fr; }
.media-modal-foot .btn { flex: 1; }
}

View file

@ -207,29 +207,6 @@ export default function MediaPage() {
</div>
</div>
{editing === image.id && draft && (
<div className="media-edit">
<label>Title<input value={draft.title} aria-label={`Title for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, title: e.target.value }))} /></label>
<label>Caption<textarea rows={2} value={draft.caption} aria-label={`Caption for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, caption: e.target.value }))} /></label>
<label>Alt text<input value={draft.alt_text} aria-label={`Alt text for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, alt_text: e.target.value }))} /></label>
<label>Library<select value={draft.library_id} aria-label={`Library for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, library_id: e.target.value }))}>
<option value="">No library</option>
{libraries.map(lib => <option key={lib.id} value={lib.id}>{lib.name}</option>)}
</select></label>
<label>Tags<input value={draft.tags} placeholder="comma, separated"
aria-label={`Tags for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, tags: e.target.value }))} /></label>
<div className="media-edit-actions">
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => save(image)}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
)}
{confirmDelete === image.id && (
<div className="media-confirm" role="alert">
<span>Delete image #{image.id}? Questions using it will lose their picture.</span>
@ -243,6 +220,48 @@ export default function MediaPage() {
)}
</div>
</div>
{/* A form the size of a card does not belong inside one: expanding in
place stretched its column and shoved every neighbour out of line. */}
{editing != null && draft && (() => {
const image = images.find(row => row.id === editing)
if (!image) return null
return (
<div className="media-modal" role="dialog" aria-modal="true"
aria-label={`Edit image ${image.id}`}
onMouseDown={e => e.target === e.currentTarget && setEditing(null)}>
<div className="media-modal-panel">
<div className="media-modal-head">
<h2>Image #{image.id}</h2>
<button type="button" onClick={() => setEditing(null)} aria-label="Close"></button>
</div>
<div className="media-modal-body">
<img src={uploadUrl(image.path)} alt={image.alt_text || image.title || ''} />
<div className="media-edit">
<label>Title<input value={draft.title} aria-label={`Title for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, title: e.target.value }))} /></label>
<label>Caption<textarea rows={3} value={draft.caption} aria-label={`Caption for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, caption: e.target.value }))} /></label>
<label>Alt text<input value={draft.alt_text} aria-label={`Alt text for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, alt_text: e.target.value }))} /></label>
<label>Library<select value={draft.library_id} aria-label={`Library for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, library_id: e.target.value }))}>
<option value="">No library</option>
{libraries.map(lib => <option key={lib.id} value={lib.id}>{lib.name}</option>)}
</select></label>
<label>Tags<input value={draft.tags} placeholder="comma, separated"
aria-label={`Tags for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, tags: e.target.value }))} /></label>
</div>
</div>
<div className="media-modal-foot">
<button className="btn btn-secondary" onClick={() => setEditing(null)}>Cancel</button>
<button className="btn btn-primary" disabled={busy} onClick={() => save(image)}>Save</button>
</div>
</div>
</div>
)
})()}
</div>
)
}

View file

@ -52,6 +52,9 @@ describe('image bank', () => {
api.patch.mockResolvedValue({ data: {} })
await userEvent.click(screen.getByRole('button', { name: 'Edit image 11' }))
// Over the grid, not inside one cell of it: expanding a card in place
// stretched its column and shoved every neighbour out of line.
expect(screen.getByRole('dialog', { name: 'Edit image 11' })).toBeInTheDocument()
await userEvent.clear(screen.getByLabelText('Caption for image 11'))
await userEvent.type(screen.getByLabelText('Caption for image 11'), 'Right lower lobe')
await userEvent.clear(screen.getByLabelText('Tags for image 11'))