Files
shopdb-flask/plugins/guides/frontend/views/GuideEditor.vue
cproudlock a52437b40b Make writing a guide less like operating an editor
Seven changes, all aimed at the same thing: think about the procedure, not
about blocks.

Live preview beside the editor, rendered with the SAME component readers get,
so what an author sees is what will be published - a separate preview
implementation would drift within a month.

Formatting buttons wrap the selection, so nobody needs to know the markup is
Markdown. New blocks and pasted images land AFTER the block being worked on
rather than at the bottom of the document, which removes the scroll-back that
made adding a screenshot mid-guide annoying.

Enter in a list makes the next item and focuses it, the way every other list in
every other tool behaves; backspace on an already-empty item removes it and
steps back up. Typing a ten-item checklist no longer needs the mouse.

Templates, because the blank page is the real barrier to somebody writing a
procedure down at all. The shapes are lifted from the KB articles the team
already writes, so guides written here read like the ones written before them.

Autosave on a three-second debounce with an honest status line, safe precisely
because the server dedups on content - an autosave that changed nothing writes
no version. Leaving with unsaved work now asks first.

CLIPBOARD: the code block no longer writes to the clipboard. A page that
programmatically copies a shell command is the exact shape of a ClickFix attack,
where malware plants a command in your clipboard and talks you into pasting it
into Run - and uBlock Origin blocked ours, correctly, because it was writing
PowerShell on click. The button now SELECTS the code and says to press Ctrl+C:
same convenience, decision back with the person, and nothing for security
tooling to object to.

AUTOCORRECT is on for prose and off for code and table cells. A code block that
autocorrects produces a guide telling somebody to run a command that does not
work - smart quotes replacing a straight one, an autocapitalised cmdlet - and it
still LOOKS right to the author. The person at the bay gets the syntax error.
2026-08-26 11:16:19 -04:00

621 lines
25 KiB
Vue

<template>
<div class="ge" @paste="onPaste" @dragover.prevent @drop.prevent="onDrop">
<div class="page-header">
<h2>{{ isNew ? 'New guide' : 'Edit guide' }}</h2>
<div class="ge-actions">
<span class="ge-status">{{ status }}</span>
<button type="button" class="ge-btn" @click="showPreview = !showPreview">
{{ showPreview ? 'Hide preview' : 'Preview' }}
</button>
<button type="button" class="ge-btn" @click="save()" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<button v-if="!isNew && canpublish" type="button" class="ge-btn ge-primary"
@click="publish" :disabled="saving">
{{ guide.ispublished ? 'Publish changes' : 'Publish' }}
</button>
<button v-if="!isNew && canpublish && guide.ispublished" type="button"
class="ge-btn" @click="unpublish">Withdraw</button>
<router-link v-if="!isNew && guide.slug" class="ge-btn"
:to="{ name: 'guide-view', params: { slug: guide.slug } }">View</router-link>
</div>
</div>
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="ge-fields">
<input v-model="guide.title" type="text" class="ge-title"
placeholder="Guide title">
<input v-model="guide.summary" type="text"
placeholder="One line: what this covers and when somebody needs it">
<input v-model="guide.tags" type="text"
placeholder="Tags, comma separated - e.g. partmarker, backups">
</div>
<!-- The blank page is the barrier, so offer a shape rather than an empty
canvas. Only on a guide that has nothing in it yet. -->
<div v-if="!blocks.length" class="ge-templates">
<span>Start from:</span>
<button v-for="tpl in templates" :key="tpl.key" type="button"
class="ge-tpl" @click="useTemplate(tpl)">
<strong>{{ tpl.name }}</strong>
<small>{{ tpl.description }}</small>
</button>
</div>
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
<div class="ge-editor">
<div v-for="(block, index) in blocks" :key="block.id"
class="ge-block" :class="{ 'ge-focused': focused === block.id }"
@focusin="focused = block.id">
<div class="ge-blockbar">
<span class="ge-type">{{ block.type }}</span>
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
<option value="info">info</option>
<option value="warning">warning</option>
<option value="success">success</option>
</select>
<input v-if="block.type === 'code'" v-model="block.language"
class="ge-mini" placeholder="language">
<input v-if="block.type === 'code'" v-model="block.caption"
class="ge-mini" placeholder="caption">
<!-- Formatting acts on the selection in this block's textarea, so
nobody has to know the markup exists. -->
<div v-if="hasText(block) && block.type !== 'code'" class="ge-format">
<button type="button" title="Bold" @click="wrap(block, '**')"><b>B</b></button>
<button type="button" title="Italic" @click="wrap(block, '*')"><i>I</i></button>
<button type="button" title="Code" @click="wrap(block, '`')">&lt;&gt;</button>
<button type="button" title="Link" @click="makeLink(block)">link</button>
</div>
<div class="ge-blockbtns">
<button type="button" title="Move up" @click="move(index, -1)"
:disabled="index === 0">^</button>
<button type="button" title="Move down" @click="move(index, 1)"
:disabled="index === blocks.length - 1">v</button>
<button type="button" title="Duplicate" @click="duplicate(index)">copy</button>
<button type="button" class="ge-del" title="Remove"
@click="remove(index)">x</button>
</div>
</div>
<input v-if="block.type === 'step'" v-model="block.title" class="ge-steptitle"
placeholder="Step title">
<!--
Spellcheck and autocorrect are ON for prose and OFF for code.
A code block that autocorrects is a guide that tells somebody to run
a command which does not work: smart quotes replacing ", an
autocapitalised cmdlet, a "corrected" flag. The author will not
notice, because it still LOOKS right - and the person at the bay
gets a syntax error with no idea why.
-->
<textarea v-if="hasText(block)" v-model="block.text"
:ref="el => registerArea(block.id, el)"
class="ge-text" :class="{ 'ge-mono': block.type === 'code' }"
:rows="block.type === 'code' ? 6 : 3"
:spellcheck="block.type !== 'code'"
:autocorrect="block.type === 'code' ? 'off' : 'on'"
:autocapitalize="block.type === 'code' ? 'off' : 'sentences'"
:autocomplete="block.type === 'code' ? 'off' : undefined"
:placeholder="placeholderFor(block)"></textarea>
<div v-if="hasItems(block)" class="ge-items">
<div v-for="(item, i) in block.items" :key="item.id" class="ge-item">
<span class="ge-bullet">{{ block.type === 'numbers' ? (i + 1) + '.' : '-' }}</span>
<input v-model="item.text" :placeholder="'Item ' + (i + 1)"
:ref="el => registerItem(item.id, el)"
@keydown.enter.prevent="addItemAfter(block, i)"
@keydown.backspace="maybeRemoveItem(block, i, $event)">
<button type="button" @click="removeItem(block, i)">x</button>
</div>
<button type="button" class="ge-additem" @click="addItemAfter(block, block.items.length - 1)">
Add item
</button>
</div>
<div v-if="block.type === 'image'" class="ge-image">
<img v-if="block.url" :src="block.url" alt="">
<input v-model="block.caption" placeholder="Caption (optional)">
<input v-model="block.alt" placeholder="Alt text, for anyone who cannot see it">
</div>
<div v-if="block.type === 'table'" class="ge-tableedit">
<textarea v-model="block.raw" rows="4" @input="parseTable(block)"
spellcheck="false" autocorrect="off" autocapitalize="off"
placeholder="One row per line, cells separated by |&#10;Header | Header&#10;Cell | Cell"></textarea>
</div>
</div>
<div class="ge-add">
<span>Add:</span>
<button v-for="type in blocktypes" :key="type" type="button"
@click="add(type)">{{ type }}</button>
<label class="ge-upload">
image
<input type="file" accept="image/*" hidden @change="onFilePick">
</label>
</div>
<p class="ge-hint">
Paste or drag an image anywhere to drop it in below the block you are working on.
Text supports **bold**, *italic*, `code` and [links](https://example.com) -
or use the buttons.
</p>
</div>
<!-- The same renderer readers get, so what you see here is what they see.
A separate preview implementation would drift within a month. -->
<div v-if="showPreview" class="ge-preview">
<div class="ge-previewhead">Preview</div>
<h1 class="ge-previewtitle">{{ guide.title || 'Untitled guide' }}</h1>
<p v-if="guide.summary" class="ge-previewsummary">{{ guide.summary }}</p>
<GuideBlocks :blocks="blocks" :interactive="false" :ticked="{}" />
</div>
</div>
<div v-if="!isNew && revisions.length" class="ge-revisions">
<h3>Version history</h3>
<div v-for="rev in revisions" :key="rev.guiderevisionid" class="ge-rev">
<span class="ge-revnum">v{{ rev.revision }}</span>
<span class="ge-revwhen">{{ formatDate(rev.createdat) }}</span>
<span v-if="rev.ispublished" class="ge-revpub">published</span>
<span class="ge-revnote">{{ rev.note }}</span>
<button type="button" @click="restore(rev)">Restore</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import api from '@/api'
import { apiError } from '@/utils/apiError'
import { useAuthStore } from '@/stores/auth'
import GuideBlocks from '../components/GuideBlocks.vue'
import { templates } from '../utils/templates.js'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets',
'numbers', 'callout', 'code', 'table']
const guide = reactive({ guideid: null, title: '', summary: '', tags: '',
slug: '', ispublished: false })
const blocks = ref([])
const revisions = ref([])
const saving = ref(false)
const message = ref('')
const error = ref('')
const status = ref('')
const showPreview = ref(true)
const focused = ref('')
const dirty = ref(false)
const isNew = computed(() => !guide.guideid)
const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false)
const textareas = {}
const itemInputs = {}
function registerArea (id, el) { if (el) textareas[id] = el }
function registerItem (id, el) { if (el) itemInputs[id] = el }
function newid () {
if (window.crypto?.randomUUID) return window.crypto.randomUUID().slice(0, 8)
return Math.random().toString(36).slice(2, 10)
}
function hasText (block) {
return ['heading', 'paragraph', 'step', 'callout', 'code'].includes(block.type)
}
function hasItems (block) {
return ['checklist', 'bullets', 'numbers'].includes(block.type)
}
function placeholderFor (block) {
if (block.type === 'code') return 'The command, exactly as it should be run'
if (block.type === 'callout') return 'What somebody needs to know before carrying on'
if (block.type === 'heading') return 'Section heading'
return 'Text'
}
function useTemplate (tpl) {
blocks.value = tpl.blocks()
dirty.value = true
}
// New blocks land AFTER the block being worked on, not at the bottom. Appending
// to the end means scrolling back every time, which is the difference between
// writing a guide and fighting an editor.
function insertIndex () {
const index = blocks.value.findIndex(b => b.id === focused.value)
return index === -1 ? blocks.value.length : index + 1
}
function add (type) {
const block = { id: newid(), type }
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
if (type === 'callout') block.variant = 'info'
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
const at = insertIndex()
blocks.value.splice(at, 0, block)
focused.value = block.id
nextTick(() => textareas[block.id]?.focus())
}
function duplicate (index) {
const source = blocks.value[index]
const copy = JSON.parse(JSON.stringify(source))
copy.id = newid()
if (copy.items) copy.items = copy.items.map(item => ({ ...item, id: newid() }))
blocks.value.splice(index + 1, 0, copy)
}
function remove (index) { blocks.value.splice(index, 1) }
function move (index, delta) {
const target = index + delta
if (target < 0 || target >= blocks.value.length) return
const [block] = blocks.value.splice(index, 1)
blocks.value.splice(target, 0, block)
}
// Enter makes the next item and focuses it - the way every list in every other
// tool behaves. Typing a ten-item checklist should not need the mouse.
function addItemAfter (block, index) {
const item = { id: newid(), text: '' }
block.items.splice(index + 1, 0, item)
nextTick(() => itemInputs[item.id]?.focus())
}
// Backspace on an ALREADY EMPTY item removes it and moves up, which is how a
// list stops without reaching for the mouse. Never on a non-empty one.
function maybeRemoveItem (block, index, event) {
if (event.target.value !== '' || block.items.length <= 1) return
event.preventDefault()
const previous = block.items[index - 1]
block.items.splice(index, 1)
if (previous) nextTick(() => itemInputs[previous.id]?.focus())
}
function removeItem (block, index) {
if (block.items.length > 1) block.items.splice(index, 1)
else block.items[0].text = ''
}
function parseTable (block) {
const lines = (block.raw || '').split('\n').filter(l => l.trim())
const rows = lines.map(line => line.split('|').map(cell => cell.trim()))
block.header = rows.length ? rows[0] : []
block.rows = rows.slice(1)
}
// Formatting wraps the SELECTION, so the markup stays an implementation detail.
function wrap (block, marker) {
const area = textareas[block.id]
if (!area) return
const start = area.selectionStart
const end = area.selectionEnd
const text = block.text || ''
const selected = text.slice(start, end) || 'text'
block.text = text.slice(0, start) + marker + selected + marker + text.slice(end)
nextTick(() => {
area.focus()
area.setSelectionRange(start + marker.length, start + marker.length + selected.length)
})
}
function makeLink (block) {
const area = textareas[block.id]
if (!area) return
const start = area.selectionStart
const end = area.selectionEnd
const text = block.text || ''
const selected = text.slice(start, end) || 'link text'
const url = window.prompt('Link to:', 'https://')
if (!url) return
block.text = text.slice(0, start) + '[' + selected + '](' + url + ')' + text.slice(end)
nextTick(() => area.focus())
}
async function uploadImage (file) {
const form = new FormData()
form.append('file', file)
if (guide.guideid) form.append('guideid', guide.guideid)
const response = await api.post('/guides/images', form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
return response.data?.data || response.data
}
async function insertImage (file) {
error.value = ''
status.value = 'Uploading image...'
try {
const image = await uploadImage(file)
const block = { id: newid(), type: 'image', url: image.url, caption: '', alt: '' }
blocks.value.splice(insertIndex(), 0, block)
focused.value = block.id
status.value = ''
} catch (err) {
status.value = ''
error.value = apiError(err, 'Could not upload that image')
}
}
function onPaste (event) {
const items = Array.from(event.clipboardData?.items || [])
const imageItem = items.find(item => item.type.startsWith('image/'))
if (!imageItem) return
event.preventDefault()
const file = imageItem.getAsFile()
if (file) insertImage(file)
}
function onDrop (event) {
const file = Array.from(event.dataTransfer?.files || [])
.find(f => f.type.startsWith('image/'))
if (file) insertImage(file)
}
function onFilePick (event) {
const file = event.target.files?.[0]
if (file) insertImage(file)
event.target.value = ''
}
function payload () {
return { title: guide.title, summary: guide.summary || '',
tags: guide.tags || '', blocks: blocks.value }
}
async function save (quiet) {
if (!guide.title.trim()) {
if (!quiet) error.value = 'A guide needs a title before it can be saved'
return
}
saving.value = true
error.value = ''
try {
if (isNew.value) {
const response = await api.post('/guides', payload())
const created = response.data?.data || response.data
Object.assign(guide, { guideid: created.guideid, slug: created.slug })
router.replace({ name: 'guide-edit', params: { guideid: created.guideid } })
} else {
const response = await api.put('/guides/' + guide.guideid, payload())
const saved = response.data?.data || response.data
guide.slug = saved.slug
}
dirty.value = false
status.value = 'Saved ' + new Date().toLocaleTimeString()
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not save this guide')
status.value = 'Not saved'
} finally {
saving.value = false
}
}
// Autosave, debounced. The server dedups on content, so an autosave that
// changed nothing writes no version - which is what makes this safe to run on a
// timer rather than a deliberate act.
let autosaveTimer = null
watch([blocks, () => guide.title, () => guide.summary, () => guide.tags],
() => {
dirty.value = true
status.value = 'Unsaved changes'
if (isNew.value) return // a new guide is saved deliberately, once
clearTimeout(autosaveTimer)
autosaveTimer = setTimeout(() => save(true), 3000)
},
{ deep: true })
onBeforeUnmount(() => clearTimeout(autosaveTimer))
// Leaving with unsaved work should require saying so out loud.
onBeforeRouteLeave(() => {
if (!dirty.value) return true
return window.confirm('This guide has unsaved changes. Leave anyway?')
})
async function publish () {
await save(true)
try {
const response = await api.post('/guides/' + guide.guideid + '/publish')
guide.ispublished = true
message.value = response.data?.message || 'Published'
setTimeout(() => { message.value = '' }, 3000)
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not publish this guide')
}
}
async function unpublish () {
try {
await api.post('/guides/' + guide.guideid + '/unpublish')
guide.ispublished = false
message.value = 'Withdrawn from readers'
setTimeout(() => { message.value = '' }, 3000)
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not withdraw this guide')
}
}
async function restore (rev) {
if (!window.confirm('Bring version ' + rev.revision + ' back as a new draft version?')) return
try {
const response = await api.post(
'/guides/' + guide.guideid + '/restore/' + rev.guiderevisionid)
const restored = response.data?.data || response.data
blocks.value = restored.blocks || []
Object.assign(guide, { title: restored.title, summary: restored.summary || '',
tags: (restored.tags || []).join(', ') })
dirty.value = false
message.value = response.data?.message || 'Restored'
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not restore that version')
}
}
async function loadRevisions () {
if (!guide.guideid) return
try {
const response = await api.get('/guides/' + guide.guideid + '/revisions')
revisions.value = response.data?.data || response.data || []
} catch (err) {
revisions.value = []
}
}
function formatDate (value) {
return value ? new Date(value).toLocaleString() : ''
}
onMounted(async () => {
const guideid = route.params.guideid
if (!guideid) return
try {
const response = await api.get('/guides/' + guideid + '/working')
const data = response.data?.data || response.data
Object.assign(guide, {
guideid: data.guideid, title: data.title, summary: data.summary || '',
tags: (data.tags || []).join(', '), slug: data.slug,
ispublished: data.ispublished,
})
blocks.value = (data.blocks || []).map(block => {
if (block.type === 'table' && !block.raw) {
const rows = [block.header || [], ...(block.rows || [])]
block.raw = rows.filter(r => r.length).map(r => r.join(' | ')).join('\n')
}
return block
})
await nextTick()
dirty.value = false
status.value = ''
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not load this guide')
}
})
</script>
<style scoped>
.page-header { display: flex; align-items: center; justify-content: space-between; }
.ge-actions { display: flex; gap: 0.4rem; align-items: center; }
.ge-status { font-size: 0.75rem; color: var(--text-light); margin-right: 0.3rem; }
.ge-btn {
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
color: var(--primary); background: transparent; border-radius: 4px;
cursor: pointer; text-decoration: none;
}
.ge-primary { background: var(--primary); color: #fff; }
.ge-btn:disabled { opacity: 0.5; cursor: default; }
.ge-fields { display: grid; gap: 0.4rem; margin: 0.8rem 0 1rem; max-width: 45rem; }
.ge-fields input {
padding: 0.4rem 0.55rem; border: 1px solid var(--border); border-radius: 4px;
background: var(--surface, transparent); color: var(--text);
}
.ge-title { font-size: 1.1rem; font-weight: 600; }
.ge-templates { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
margin-bottom: 1rem; }
.ge-templates > span { font-size: 0.8rem; color: var(--text-light); }
.ge-tpl {
display: grid; text-align: left; gap: 0.1rem; padding: 0.45rem 0.7rem;
border: 1px solid var(--border); border-radius: 5px; background: transparent;
color: var(--text); cursor: pointer; max-width: 15rem;
}
.ge-tpl:hover { border-color: var(--primary); }
.ge-tpl small { color: var(--text-light); font-size: 0.72rem; }
.ge-split { display: grid; gap: 1.2rem; }
.ge-haspreview { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
@media (max-width: 70rem) { .ge-haspreview { grid-template-columns: 1fr; } }
.ge-block {
border: 1px solid var(--border); border-radius: 5px; padding: 0.5rem;
margin-bottom: 0.5rem;
}
.ge-focused { border-color: var(--primary); }
.ge-blockbar { display: flex; gap: 0.35rem; align-items: center; margin-bottom: 0.35rem;
flex-wrap: wrap; }
.ge-type { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--text-light); }
.ge-mini {
font-size: 0.72rem; padding: 0.12rem 0.3rem; border: 1px solid var(--border);
border-radius: 3px; background: var(--surface, transparent); color: var(--text);
max-width: 8rem;
}
.ge-format { display: flex; gap: 0.15rem; }
.ge-format button, .ge-blockbtns button {
font-size: 0.7rem; padding: 0.1rem 0.35rem; border: 1px solid var(--border);
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
min-width: 1.5rem;
}
.ge-format button:hover, .ge-blockbtns button:hover { color: var(--primary); border-color: var(--primary); }
.ge-blockbtns { margin-left: auto; display: flex; gap: 0.15rem; }
.ge-del:hover { border-color: var(--danger, #c0392b) !important; color: var(--danger, #c0392b) !important; }
.ge-text, .ge-steptitle, .ge-tableedit textarea {
width: 100%; padding: 0.4rem 0.5rem; border: 1px solid var(--border);
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
font-family: inherit; font-size: 0.88rem; resize: vertical;
}
.ge-mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.82rem; }
.ge-steptitle { margin-bottom: 0.35rem; }
.ge-items { display: grid; gap: 0.25rem; }
.ge-item { display: flex; gap: 0.3rem; align-items: center; }
.ge-bullet { font-size: 0.8rem; color: var(--text-light); width: 1.2rem; }
.ge-item input {
flex: 1; padding: 0.3rem 0.45rem; border: 1px solid var(--border);
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
}
.ge-item button, .ge-additem, .ge-add button, .ge-upload {
font-size: 0.72rem; padding: 0.15rem 0.45rem; border: 1px solid var(--border);
background: transparent; color: var(--text-light); border-radius: 3px;
cursor: pointer; justify-self: start;
}
.ge-image img { max-width: 18rem; display: block; margin-bottom: 0.35rem;
border: 1px solid var(--border); border-radius: 4px; }
.ge-image input { width: 100%; margin-bottom: 0.25rem; padding: 0.3rem 0.45rem;
border: 1px solid var(--border); border-radius: 4px;
background: var(--surface, transparent); color: var(--text); }
.ge-add { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin: 0.8rem 0 0.4rem; }
.ge-add > span { font-size: 0.75rem; color: var(--text-light); }
.ge-hint { font-size: 0.75rem; color: var(--text-light); }
.ge-preview {
border-left: 1px solid var(--border); padding-left: 1.2rem;
position: sticky; top: 1rem; align-self: start; max-height: calc(100vh - 3rem);
overflow-y: auto;
}
.ge-previewhead {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--text-light); margin-bottom: 0.5rem;
}
.ge-previewtitle { margin: 0 0 0.2rem; font-size: 1.4rem; }
.ge-previewsummary { color: var(--text-light); margin: 0 0 1rem; }
.ge-revisions { margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 0.8rem; }
.ge-rev { display: flex; gap: 0.6rem; align-items: center; font-size: 0.8rem; padding: 0.25rem 0; }
.ge-revnum { font-weight: 600; }
.ge-revwhen, .ge-revnote { color: var(--text-light); }
.ge-revpub { font-size: 0.68rem; padding: 0.05rem 0.35rem; border-radius: 3px;
border: 1px solid var(--primary); color: var(--primary); }
.ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem;
border: 1px solid var(--border); background: transparent;
color: var(--text-light); border-radius: 3px; cursor: pointer; }
</style>