Guides: write a procedure in ShopDB, not beside it

The knowledgebase plugin is a link registry - a description, a URL and some
keywords pointing at a document held somewhere else. That leaves the documents
themselves as HTML files on a share: no search, no history, no way to know
whether the copy pinned to a noticeboard is current, and no way for a technician
to tick off where they are.

A guide is stored as an ordered list of typed blocks rather than a blob of
HTML, because three of the four things this feature exists for depend on it. A
tickable step needs a stable id to be remembered; dark mode needs semantic
markup rather than pasted colours; and print needs to know where a step begins
so it does not split one across a page. Free HTML gives none of that and lets
pasted styling fight the theme.

Inline text is a restricted Markdown subset - bold, italic, inline code, links,
line breaks - escaped BEFORE formatting, so nothing an author pasted can reach
the DOM as markup. No fonts, sizes or colours: emphasis is semantic and the
theme decides how it looks. That is what makes a guide written next year match
one written today, and dark mode work without an author thinking about it.

DRAFTS AND VERSIONS. The guide row is the working copy; every save that changes
something writes an immutable revision; and a separate pointer says which
revision READERS get. Without that pointer, editing a live procedure changes it
under a technician who is halfway through following it. Publishing promotes an
existing revision rather than snapshotting, so a publish with unsaved edits
cannot ship a version nobody reviewed. Restoring writes a new revision with old
content, so the record of what people were told in between survives.

Saves dedup on a content hash covering title, summary, tags and blocks, so an
autosave that changed nothing writes no version - otherwise the history fills
with identical entries and hides the three edits that mattered.

Progress lives in the browser, not the database: shopfloor PCs share a login, so
per-user ticks would show one technician's progress to the next person on that
bay, which reads as "already done". Keyed by guide and version, because step 4
of version 3 is not step 4 of version 2.

Five permissions, and the split between edit and publish is the point: a
technician can write a procedure up while somebody else decides it is fit for
the floor. create-without-edit works too - an author may edit their own guide
until it is published, after which other people are following it.
This commit is contained in:
cproudlock
2026-08-26 10:24:26 -04:00
parent 28af92aaec
commit 38d1e367fc
22 changed files with 2771 additions and 0 deletions

View File

@@ -0,0 +1,240 @@
<template>
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
<template v-for="(block, index) in blocks" :key="block.id || index">
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
v-html="inline(block.text)"></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>
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
<div v-if="block.text" v-html="inline(block.text)"></div>
</div>
<!--
The reason this feature exists. Interactive ONLY when viewing: 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".
-->
<ul v-else-if="block.type === 'checklist'" class="gb-checklist">
<li v-for="item in itemsOf(block)" :key="item.id" class="gb-check">
<label>
<input type="checkbox"
:checked="isTicked(item.id)"
:disabled="!interactive"
@change="toggle(item.id, $event.target.checked)">
<span :class="{ 'gb-done': interactive && isTicked(item.id) }"
v-html="inline(item.text)"></span>
</label>
</li>
</ul>
<ul v-else-if="block.type === 'bullets'" class="gb-list">
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
</ul>
<ol v-else-if="block.type === 'numbers'" class="gb-list">
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
</ol>
<div v-else-if="block.type === 'callout'"
class="gb-callout" :class="'gb-' + (block.variant || 'info')">
<strong v-if="block.title">{{ block.title }}</strong>
<div v-html="inline(block.text)"></div>
</div>
<div v-else-if="block.type === 'code'" class="gb-code">
<div class="gb-codehead">
<span class="gb-lang">{{ block.language || 'text' }}</span>
<span v-if="block.caption" class="gb-caption">{{ block.caption }}</span>
<!-- Copies the code ONLY: no caption, no prompt. A prompt glued to a
copied command is how somebody ends up pasting "PS C:\>" into a
terminal. -->
<button type="button" class="gb-copy" @click="copy(block)">
{{ copied === block.id ? 'Copied' : 'Copy' }}
</button>
</div>
<pre><code>{{ block.text }}</code></pre>
</div>
<figure v-else-if="block.type === 'image'" class="gb-figure">
<img :src="block.url" :alt="block.alt || ''" loading="lazy">
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
</figure>
<div v-else-if="block.type === 'table'" class="gb-tablewrap">
<table class="gb-table">
<thead v-if="block.header && block.header.length">
<tr><th v-for="(cell, c) in block.header" :key="c" v-html="inline(cell)"></th></tr>
</thead>
<tbody>
<tr v-for="(row, r) in (block.rows || [])" :key="r">
<td v-for="(cell, c) in row" :key="c" v-html="inline(cell)"></td>
</tr>
</tbody>
</table>
</div>
<!-- An unknown type renders as nothing rather than as an error. A guide
written against a newer version must stay readable on an older one. -->
</template>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { renderInline } from '../utils/inline.js'
const props = defineProps({
blocks: { type: Array, default: () => [] },
// Live checkboxes belong to the viewer only.
interactive: { type: Boolean, default: false },
ticked: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['toggle'])
const copied = ref('')
function inline (text) {
return renderInline(text)
}
// Items may be plain strings (bullets) or objects with ids (checklists). One
// shape here so the template does not branch on it.
function itemsOf (block) {
return (block.items || []).map((item, index) =>
typeof item === 'string'
? { id: (block.id || 'b') + '-' + index, text: item }
: item)
}
function isTicked (itemid) {
return Boolean(props.ticked[itemid])
}
function toggle (itemid, checked) {
if (!props.interactive) return
emit('toggle', itemid, checked)
}
// Steps number themselves by position among steps, so inserting a paragraph
// between two of them does not renumber anything by hand.
const stepIndex = computed(() => {
const map = {}
let n = 0
props.blocks.forEach((block, index) => {
if (block.type === 'step') { n += 1; map[index] = n }
})
return map
})
function stepNumber (index) {
return stepIndex.value[index] || 1
}
function anchorFor (block) {
return 'g-' + (block.id || '')
}
async function copy (block) {
try {
await navigator.clipboard.writeText(block.text || '')
copied.value = block.id
setTimeout(() => { copied.value = '' }, 2000)
} catch (err) {
// Clipboard needs a secure context. Select the text instead of failing
// silently, so the tech can copy it by hand.
const node = document.querySelector('.gb-code pre')
if (node && window.getSelection) {
const range = document.createRange()
range.selectNodeContents(node)
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
}
}
}
</script>
<style scoped>
/* Every colour is a theme token. Nothing here is a literal, which is what makes
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); }
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
.gb-step {
border-left: 3px solid var(--primary);
padding: 0.6rem 0 0.6rem 1rem;
margin: 0 0 1rem;
}
.gb-stepnum {
display: inline-block; font-size: 0.72rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--primary); font-weight: 600;
}
.gb-step h3 { margin: 0.2rem 0 0.4rem; font-size: 1.05rem; }
.gb-checklist { list-style: none; padding: 0; margin: 0 0 1rem; }
.gb-check { margin: 0.35rem 0; }
.gb-check label { display: flex; gap: 0.6rem; align-items: flex-start; cursor: pointer; }
.gb-interactive .gb-check label:hover { color: var(--primary); }
.gb-check input { margin-top: 0.28rem; flex: none; width: 1rem; height: 1rem; }
.gb-done { opacity: 0.55; text-decoration: line-through; }
.gb-list { margin: 0 0 1rem 1.2rem; }
.gb-list li { margin: 0.25rem 0; }
.gb-callout {
border-left: 4px solid var(--info, var(--primary));
background: var(--surface-2, rgba(127, 127, 127, 0.08));
padding: 0.75rem 1rem; margin: 0 0 1rem; border-radius: 4px;
}
.gb-callout strong { display: block; margin-bottom: 0.25rem; }
.gb-warning { border-left-color: var(--warning, #d9822b); }
.gb-success { border-left-color: var(--success, #2e9e5b); }
.gb-code { margin: 0 0 1rem; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
.gb-codehead {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.3rem 0.6rem; background: var(--surface-2, rgba(127,127,127,0.08));
border-bottom: 1px solid var(--border); font-size: 0.75rem;
}
.gb-lang { text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-light); }
.gb-caption { color: var(--text-light); }
.gb-copy {
margin-left: auto; font-size: 0.72rem; padding: 0.15rem 0.5rem;
border: 1px solid var(--border); background: transparent;
color: var(--text-light); border-radius: 3px; cursor: pointer;
}
.gb-copy:hover { border-color: var(--primary); color: var(--primary); }
.gb-code pre {
margin: 0; padding: 0.7rem 0.8rem; overflow-x: auto;
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.84rem;
/* WRAP rather than clip: these guides are full of long UNC paths, and a
line that scrolls out of sight is a command somebody copies half of. */
white-space: pre-wrap; word-break: break-word;
}
.gb-figure { margin: 0 0 1.2rem; }
.gb-figure img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
.gb-tablewrap { overflow-x: auto; margin: 0 0 1.2rem; }
.gb-table { border-collapse: collapse; width: 100%; }
.gb-table th, .gb-table td {
border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left;
vertical-align: top; font-size: 0.9rem;
}
.gb-table th { background: var(--surface-2, rgba(127,127,127,0.08)); }
@media print {
/* Paper has no hover, no clipboard and no theme. */
.gb-copy { display: none; }
.gb-step, .gb-callout, .gb-code, .gb-figure { break-inside: avoid; }
.gb-check input { /* keep the box, printed empty, so it can be ticked by hand */ }
.gb-done { opacity: 1; text-decoration: none; }
.gb-code pre { white-space: pre-wrap; }
}
</style>