Tick a guide's sections off as you work through it
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m38s

Only checklist items could be ticked, which answers "what have I done" but not
the question a long procedure actually raises: where was I. Somebody looks away
to do the thing the guide asked for and looks back at a wall of text.

Every step and every heading now carries a checkbox while reading. Ticking one
strikes it through and fades it rather than hiding it, because somebody
scanning back up the page has to still be able to read what they did. The
number is struck through with it, but the step's own "Step 3" label is not -
that is the address of the thing, not part of what was done.

Headings count, not just steps. A guide converted from an HTML article often
has fifty headings and no steps at all, and those are exactly the guides long
enough to lose your place in. That also means the card's "N steps to tick"
stops reading as a broken feature on a guide that is entirely tickable.

Reading only, as the checklists already were: in the editor a tick would be an
edit, and an author clicking around their own draft would quietly change what
the next reader sees as done. Progress stays per browser and per version -
shopfloor PCs share a login, so one technician's ticks must not greet the next
person on that bay, and step 4 of version 3 is not step 4 of version 2.
This commit is contained in:
cproudlock
2026-08-27 13:02:24 -04:00
parent 151d7c4f44
commit 2331c03664
6 changed files with 85 additions and 6 deletions

View File

@@ -12,6 +12,18 @@ ADR-007 and ADR-002.
### Added
- **A guide's sections can be ticked off while you read it.** Only checklist
items were tickable, which answered "what have I done" but not the question a
long procedure actually raises - where was I. Somebody looks away to do the
thing the guide asked for and looks back at a wall of text. Every step and
every heading now carries a checkbox in the reader; ticking one strikes it
through and fades it rather than hiding it, because scanning back up the page
has to still show what was done. Headings count as well as steps: a guide
converted from an HTML article often has fifty headings and no steps at all.
Progress is per browser and per version, as it already was for checklists -
shopfloor PCs share a login, so one technician's ticks must not greet the
next person on that bay.
- **A guest is a role now, not a switch per feature.** Whether somebody with no
account may see something was answered one feature at a time: a
`securesend_allow_guests` setting, an enable flag per kiosk, and very nearly a

View File

@@ -88,6 +88,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
## Size
- test functions defined: **1413** (parametrised cases collect higher)
- test functions defined: **1414** (parametrised cases collect higher)
- documented API paths: **305** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)

View File

@@ -2,14 +2,36 @@
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
<template v-for="(block, index) in blocks" :key="block.id || index">
<!--
A heading and a step can be ticked off while reading, not just a
checklist item. On a long procedure the question is not "what have I
done" so much as "where was I" - somebody looks away to do the thing
the guide asked for, and looks back at a wall of text. A struck-through
section is where they were.
Reading only: in the editor a tick would be an edit.
-->
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
v-html="inline(block.text)"></h2>
:class="{ 'gb-sectiondone': isTicked(block.id) }">
<input v-if="interactive" type="checkbox" class="gb-sectiontick"
:checked="isTicked(block.id)"
:aria-label="'Mark this section done'"
@change="toggle(block.id, $event.target.checked)">
<span v-html="inline(block.text)"></span>
</h2>
<p v-else-if="block.type === 'paragraph'" class="gb-para"
v-html="inline(block.text)"></p>
<div v-else-if="block.type === 'step'" class="gb-step">
<span class="gb-stepnum">Step {{ stepNumber(index) }}</span>
<div v-else-if="block.type === 'step'" class="gb-step"
:class="{ 'gb-sectiondone': isTicked(block.id) }">
<label class="gb-stepnum">
<input v-if="interactive" type="checkbox" class="gb-sectiontick"
:checked="isTicked(block.id)"
:aria-label="'Mark step ' + stepNumber(index) + ' done'"
@change="toggle(block.id, $event.target.checked)">
Step {{ stepNumber(index) }}
</label>
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
<div v-if="block.text" v-html="inline(block.text)"></div>
</div>
@@ -193,6 +215,20 @@ function select (block) {
dark mode work with no dark-mode code. */
.gb-blocks { line-height: 1.65; }
.gb-heading { margin: 1.6rem 0 0.6rem; font-size: 1.35rem; color: var(--text); }
/* A ticked section is struck through and faded, not hidden: somebody scanning
back up the page has to be able to read what they already did. */
.gb-sectiondone { text-decoration: line-through; opacity: 0.55; }
.gb-sectiondone .gb-stepnum { text-decoration: none; }
.gb-sectiontick {
/* Touch-sized: a guide is read on a shopfloor PC, often with gloves on. */
width: 1.1rem;
height: 1.1rem;
margin-right: 0.5rem;
vertical-align: middle;
cursor: pointer;
}
.gb-heading .gb-sectiontick { text-decoration: none; }
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
.gb-step {

View File

@@ -122,10 +122,14 @@ function resetProgress () {
saveProgress()
}
// Everything that can be ticked: checklist items, and the sections a reader
// crosses off to keep their place - a step, or a heading on a guide that has
// no steps at all (a converted article often has fifty headings and none).
const totalChecks = computed(() => {
let total = 0
for (const block of guide.value?.blocks || []) {
if (block.type === 'checklist') total += (block.items || []).length
else if (block.type === 'step' || block.type === 'heading') total += 1
}
return total
})

View File

@@ -124,11 +124,21 @@ def plaintext(blocks):
def checklistitemcount(blocks):
"""How many tickable items a guide has, for the list and the progress bar."""
"""How many tickable things a guide has, for the card and the progress bar.
Checklist items, plus the sections a reader crosses off to keep their place:
a step, or a heading. A guide converted from an article often has fifty
headings and no steps at all, and "0 steps to tick" on a card that is
entirely tickable reads as a broken feature.
"""
total = 0
for block in blocks or []:
if isinstance(block, dict) and block.get('type') == 'checklist':
if not isinstance(block, dict):
continue
if block.get('type') == 'checklist':
total += len(block.get('items') or [])
elif block.get('type') in ('step', 'heading'):
total += 1
return total

View File

@@ -559,3 +559,20 @@ def test_renaming_a_guide_does_not_move_its_url(client, db, author):
assert fixed.status_code == 200
assert fixed.get_json()['data']['slug'] == slug, 'the URL must not move'
assert fixed.get_json()['data']['title'] == 'Replacing a marker controller'
def test_a_section_counts_as_something_to_tick():
"""A reader crosses off a step or a heading to keep their place, so those
count towards progress too. A guide converted from an article often has
fifty headings and no steps at all, and "0 steps to tick" on a card that is
entirely tickable reads as a broken feature."""
from plugins.guides.services.blocks import checklistitemcount
blocks = [
_block('b1', 'heading', text='Before you start'),
_block('b2', 'paragraph', text='Not tickable.'),
_block('b3', 'step', title='Do the thing'),
_block('b4', 'checklist', items=[{'id': 'i1', 'text': 'One'},
{'id': 'i2', 'text': 'Two'}]),
_block('b5', 'code', text='echo hi'),
]
# heading + step + two checklist items
assert checklistitemcount(blocks) == 4