From 514edbd8643ec3eff2921591084e0037c95f3290 Mon Sep 17 00:00:00 2001 From: nschimme <5505185+nschimme@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:38:05 +0000 Subject: [PATCH 01/13] feat(tutorial): implement step rewind replaying and reactive pager - Clear log and cleanly replay history up to the target step on back navigation - Add reactive pager for multiline MUD outputs with desktop space/enter shortcuts and mobile tap bar - Reposition Exit button to top-right header across viewports --- .../theme/components/TutorialPlayer.vue | 298 +++++++++++++----- 1 file changed, 227 insertions(+), 71 deletions(-) diff --git a/docs/.vitepress/theme/components/TutorialPlayer.vue b/docs/.vitepress/theme/components/TutorialPlayer.vue index 2d50cb5..86507dc 100644 --- a/docs/.vitepress/theme/components/TutorialPlayer.vue +++ b/docs/.vitepress/theme/components/TutorialPlayer.vue @@ -97,6 +97,12 @@ const isSheetOpen = ref(false) const isExpanded = ref(false) const isModalOpen = ref(false) +// Reactive Pager State +const pendingPagerChunks = ref([]) // Remaining unrevealed chunks +const currentPagerTotal = ref(1) +const currentPagerIndex = ref(1) +const isPagerActive = computed(() => pendingPagerChunks.value.length > 0) + function openModal() { isModalOpen.value = true } @@ -135,14 +141,76 @@ function handleFullscreenChange() { } } +function advancePager() { + if (!isPagerActive.value) return + const nextChunk = pendingPagerChunks.value.shift() + if (nextChunk) { + log.value.push(nextChunk) + currentPagerIndex.value++ + scrollLog() + } +} + +function flushPager() { + while (pendingPagerChunks.value.length > 0) { + const chunk = pendingPagerChunks.value.shift() + if (chunk) log.value.push(chunk) + } + scrollLog() +} + function handleKeydown(e) { if (e.key === 'Escape' && isExpanded.value) { if (!document.fullscreenElement) { toggleExpand() } + } else if (isPagerActive.value) { + // Only intercept Space key for pager if target is body or input field is empty + if (e.key === ' ' || e.key === 'Spacebar' || e.key === 'PageDown') { + if (e.target === document.body || (e.target === inputEl.value && !entry.value)) { + e.preventDefault() + advancePager() + } + } else if (e.key === 'Enter' && e.target !== inputEl.value) { + e.preventDefault() + advancePager() + } } } +function pushLogItem(item) { + // If item is an example or story with multiline body, check if it needs paging + if ((item.kind === 'example' || item.kind === 'story') && item.body) { + const lines = item.body.split('\n') + // Chunk threshold: if lines > 8 + if (lines.length > 8) { + const chunks = [] + const chunkSize = 6 + for (let i = 0; i < lines.length; i += chunkSize) { + chunks.push(lines.slice(i, i + chunkSize).join('\n')) + } + + // First chunk is pushed immediately + item.body = chunks[0] + log.value.push(item) + + // Remaining chunks placed in queue + const remaining = chunks.slice(1).map(c => ({ + kind: item.kind, + body: c + })) + pendingPagerChunks.value = remaining + currentPagerIndex.value = 1 + currentPagerTotal.value = chunks.length + scrollLog() + return + } + } + + log.value.push(item) + scrollLog() +} + const logEl = ref(null) const inputEl = ref(null) @@ -192,46 +260,101 @@ function scrollLog() { } } -function renderStepLog() { - subStepIdx.value = 0 +function goToStep(targetIdx) { + pendingPagerChunks.value = [] + const steps = stepsList.value + if (!steps || steps.length === 0) { + subStepIdx.value = 0 + finished.value = false + log.value = [{ + kind: 'lesson', + chapterNum: chapterNum.value, + title: chapterTitle.value, + teach: teachList.value, + note: null, + ask: null + }] + scrollLog() + focusInput() + return + } + + const clampedIdx = Math.max(0, Math.min(targetIdx, steps.length - 1)) + subStepIdx.value = clampedIdx finished.value = false const newLog = [] - newLog.push({ - kind: 'lesson', - chapterNum: chapterNum.value, - title: chapterTitle.value, - teach: teachList.value, - note: currentSubStep.value ? currentSubStep.value.note : null, - ask: currentSubStep.value ? currentSubStep.value.ask : null - }) + + for (let i = 0; i <= clampedIdx; i++) { + const st = steps[i] + if (!st) continue + + if (i === 0) { + newLog.push({ + kind: 'lesson', + chapterNum: chapterNum.value, + title: chapterTitle.value, + teach: teachList.value, + note: st.note || null, + ask: st.ask || null + }) + } else { + newLog.push({ + kind: 'prompt_next', + stepIndex: i, + totalSteps: steps.length, + note: st.note || null, + ask: st.ask || null + }) + } + + if (i < clampedIdx) { + // Replay completed step response + if (st.ask) { + newLog.push({ kind: 'echo', text: st.ask }) + const rawRes = st.response || mumeResponses.value[st.ask.toLowerCase()] || '' + const body = rawRes ? rawRes.replace(/^>[^\n]*\n?/, '') : '' + if (body) { + newLog.push({ kind: 'example', body }) + } + } else if (st.text || st.response) { + newLog.push({ + kind: 'story', + body: st.text || st.response + }) + } + } + } log.value = newLog - scrollLog() - if (currentSubStep.value && !currentSubStep.value.ask && (currentSubStep.value.text || currentSubStep.value.response)) { - log.value.push({ + const activeStep = steps[clampedIdx] + if (activeStep && !activeStep.ask && (activeStep.text || activeStep.response)) { + pushLogItem({ kind: 'story', - body: currentSubStep.value.text || currentSubStep.value.response + body: activeStep.text || activeStep.response }) - scrollLog() setTimeout(() => { advanceSubStep() }, 250) } else { + scrollLog() focusInput() } } +function renderStepLog() { + goToStep(0) +} + function completeChapter() { finished.value = true - log.value.push({ + pushLogItem({ kind: 'chapter_complete', chapterNum: chapterNum.value, title: chapterTitle.value, nextUrl: nextChapterUrl.value }) - scrollLog() } function advanceNext() { @@ -244,29 +367,7 @@ function advanceNext() { function advanceSubStep() { if (subStepIdx.value < stepsList.value.length - 1) { - subStepIdx.value++ - const nextSub = currentSubStep.value - - if (nextSub.ask) { - log.value.push({ - kind: 'prompt_next', - stepIndex: subStepIdx.value, - totalSteps: stepsList.value.length, - note: nextSub.note || null, - ask: nextSub.ask - }) - scrollLog() - focusInput() - } else if (nextSub.text || nextSub.response) { - log.value.push({ - kind: 'story', - body: nextSub.text || nextSub.response - }) - scrollLog() - setTimeout(() => { - advanceSubStep() - }, 250) - } + goToStep(subStepIdx.value + 1) } else { completeChapter() } @@ -274,20 +375,22 @@ function advanceSubStep() { function prevSubStep() { if (subStepIdx.value > 0) { - subStepIdx.value-- - const prevSub = currentSubStep.value - log.value.push({ - kind: 'prompt_prev', - stepIndex: subStepIdx.value, - totalSteps: stepsList.value.length, - note: prevSub.note || null, - ask: prevSub.ask - }) - scrollLog() + goToStep(subStepIdx.value - 1) } } function submit() { + // If pager is active when submitting, advance or flush pager first if input is empty + if (isPagerActive.value) { + if (!entry.value.trim()) { + advancePager() + focusInput() + return + } else { + flushPager() + } + } + const raw = entry.value.trim() const cmd = raw.toLowerCase() entry.value = '' @@ -302,7 +405,7 @@ function submit() { return } - if (raw) { log.value.push({ kind: 'echo', text: raw }) } + if (raw) { pushLogItem({ kind: 'echo', text: raw }) } if (cmd === 'skip') { advanceNext(); focusInput(); return } if (cmd === 'tutorial') { navigateToUrl(allChapters[0]?.url || '/play/tutorial/1-orientation'); focusInput(); return } @@ -311,8 +414,7 @@ function submit() { if (!curStep) { if (cmd && mumeResponses.value[cmd]) { - log.value.push({ kind: 'example', body: mumeResponses.value[cmd] }) - scrollLog() + pushLogItem({ kind: 'example', body: mumeResponses.value[cmd] }) } else { advanceNext() } @@ -321,8 +423,7 @@ function submit() { } if (!cmd) { - log.value.push({ kind: 'error', text: curStep.hint || (`Type: ${curStep.ask}`) }) - scrollLog() + pushLogItem({ kind: 'error', text: curStep.hint || (`Type: ${curStep.ask}`) }) focusInput() return } @@ -334,16 +435,34 @@ function submit() { const rawRes = curStep.response || curStep.example || mumeResponses.value[cmd] || '' const body = rawRes ? rawRes.replace(/^>[^\n]*\n?/, '') : '' if (body) { - log.value.push({ kind: 'example', body }) + pushLogItem({ kind: 'example', body }) + } + if (subStepIdx.value < stepsList.value.length - 1) { + subStepIdx.value++ + const nextSub = currentSubStep.value + if (nextSub) { + log.value.push({ + kind: 'prompt_next', + stepIndex: subStepIdx.value, + totalSteps: stepsList.value.length, + note: nextSub.note || null, + ask: nextSub.ask || null + }) + if (!nextSub.ask && (nextSub.text || nextSub.response)) { + pushLogItem({ + kind: 'story', + body: nextSub.text || nextSub.response + }) + } + } + } else { + completeChapter() } - advanceSubStep() } else if (mumeResponses.value[cmd]) { - log.value.push({ kind: 'example', body: mumeResponses.value[cmd] }) - log.value.push({ kind: 'error', text: 'Good try! To proceed in this step, ' + (curStep.hint || (`try: ${curStep.ask}`)) }) - scrollLog() + pushLogItem({ kind: 'example', body: mumeResponses.value[cmd] }) + pushLogItem({ kind: 'error', text: 'Good try! To proceed in this step, ' + (curStep.hint || (`try: ${curStep.ask}`)) }) } else { - log.value.push({ kind: 'error', text: 'MUME does not know that one here. ' + (curStep.hint || (`Try: ${curStep.ask}`)) }) - scrollLog() + pushLogItem({ kind: 'error', text: 'MUME does not know that one here. ' + (curStep.hint || (`Try: ${curStep.ask}`)) }) } focusInput() } @@ -515,14 +634,25 @@ onUnmounted(() => { + +
+$1')
+
+ safe = safe.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
+ return `$1')
+ return safe.replace(/\n/g, '
-
-$1')
@@ -402,6 +404,8 @@ function advanceSubStep() {
function prevSubStep() {
if (subStepIdx.value > 0) {
goToStep(subStepIdx.value - 1)
+ } else if (prevChapterUrl.value) {
+ navigateToUrl(prevChapterUrl.value)
}
}
diff --git a/docs/play/TUTORIAL.md b/docs/play/TUTORIAL.md
index ed70274..3c784ad 100644
--- a/docs/play/TUTORIAL.md
+++ b/docs/play/TUTORIAL.md
@@ -59,7 +59,7 @@ Always use the YAML multiline literal pipe character `|` for multi-line story be
```yaml
steps:
- story: |
- 
+ 
When connected via the MUME Web Client, you'll enjoy integrated live mapping and stats tracking.
responses:
score: |
@@ -116,7 +116,7 @@ steps:
## Embedded Images & Step-by-Step Paging
For chapters introducing visual concepts or UI previews (such as client interface screenshots):
-1. **Stream Images via Story Beats or Responses:** Instead of dumping large images into the Markdown body at the bottom of the file, attach images to specific `story` beats using Markdown image syntax: ``.
+1. **Stream Images via Story Beats or Responses:** Instead of dumping large images into the Markdown body at the bottom of the file, attach images to specific `story` beats using Markdown image syntax: ``.
2. **Automated Terminal Pager:** When images or large text outputs stream into the log, the terminal's built-in MUD pager activates automatically if content overflows the viewport. Users can press `Space` or `Enter` (or tap the prompt on touch devices) to page through content line-by-line without needing to scroll manually with a mouse.
3. **Compact Initial Card:** Keep the Markdown lesson text below frontmatter concise so that when a chapter opens, the initial intro card and first quest prompt fit within the terminal log without initial overflow.
diff --git a/docs/play/tutorial/15-journey.md b/docs/play/tutorial/15-journey.md
index 9961554..bd49d42 100644
--- a/docs/play/tutorial/15-journey.md
+++ b/docs/play/tutorial/15-journey.md
@@ -17,7 +17,7 @@ steps:
- "l"
hint: "Type: look"
- story: |
- 
+ 
When connected via the MUME Web Client, you'll enjoy integrated live mapping and stats tracking.
- ask: "map"
note: "Type `map` to examine the live MMapper radar view."
@@ -26,7 +26,7 @@ steps:
- "m"
hint: "Type: map"
- story: |
- 
+ 
MMapper tracks your position in real-time as you journey through Middle-earth.
- ask: "who ranger"
note: "MUME Rangers exist specifically to assist new players! Check active Rangers with `who ranger`."
diff --git a/scripts/test-link-and-asset-paths.js b/scripts/test-link-and-asset-paths.js
index 401251b..c07d76d 100644
--- a/scripts/test-link-and-asset-paths.js
+++ b/scripts/test-link-and-asset-paths.js
@@ -149,7 +149,59 @@ for (const htmlFile of htmlFiles) {
}
}
-console.log(`[TEST SUMMARY] Checked ${htmlFiles.length} HTML files, ${totalLinksChecked} internal links, and ${totalAssetsChecked} asset references.`)
+// 4. Verify Markdown image asset links in docs/*.md
+const docsDir = path.resolve(__dirname, '../docs')
+function getAllMdFiles(dir, fileList = []) {
+ if (!fs.existsSync(dir)) return fileList
+ const files = fs.readdirSync(dir)
+ for (const file of files) {
+ const filePath = path.join(dir, file)
+ if (fs.statSync(filePath).isDirectory()) {
+ getAllMdFiles(filePath, fileList)
+ } else if (file.endsWith('.md')) {
+ fileList.push(filePath)
+ }
+ }
+ return fileList
+}
+
+const mdFiles = getAllMdFiles(docsDir)
+let totalMdImagesChecked = 0
+
+for (const mdFile of mdFiles) {
+ const content = fs.readFileSync(mdFile, 'utf8')
+ const relMdPath = path.relative(docsDir, mdFile)
+ const matches = [...content.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)]
+
+ for (const match of matches) {
+ const imgUrl = match[1].trim().split(/\s+/)[0]
+ if (
+ imgUrl.startsWith('http://') ||
+ imgUrl.startsWith('https://') ||
+ imgUrl.startsWith('data:') ||
+ imgUrl === 'url' ||
+ imgUrl.includes('filename.png')
+ ) {
+ continue
+ }
+
+ totalMdImagesChecked++
+ let resolvedImgPath = ''
+ if (imgUrl.startsWith('/assets/')) {
+ resolvedImgPath = path.join(docsDir, 'public', imgUrl)
+ } else if (imgUrl.startsWith('/')) {
+ resolvedImgPath = path.join(docsDir, 'public', imgUrl)
+ } else {
+ resolvedImgPath = path.resolve(path.dirname(mdFile), imgUrl)
+ }
+
+ if (!fs.existsSync(resolvedImgPath)) {
+ errors.push(`docs/${relMdPath}: Markdown image "${imgUrl}" not found at "${resolvedImgPath}"`)
+ }
+ }
+}
+
+console.log(`[TEST SUMMARY] Checked ${htmlFiles.length} HTML files, ${totalLinksChecked} internal links, ${totalAssetsChecked} dist asset references, and ${totalMdImagesChecked} Markdown image links.`)
if (errors.length > 0) {
console.error(`\n[TEST FAILURES] Found ${errors.length} link/asset path issues:\n`)
From aa6ad00d8bda3ec2d65e256b6b9735199b8be7bc Mon Sep 17 00:00:00 2001
From: nschimme <5505185+nschimme@users.noreply.github.com>
Date: Sun, 20 Sep 2026 22:54:09 +0000
Subject: [PATCH 11/13] fix(tutorial): clear inputs on step rewind, add
terminal pager, and relative image paths
---
.../theme/components/TutorialPlayer.vue | 21 +++++++++++++------
1 file changed, 15 insertions(+), 6 deletions(-)
diff --git a/docs/.vitepress/theme/components/TutorialPlayer.vue b/docs/.vitepress/theme/components/TutorialPlayer.vue
index 5c89b55..5a42712 100644
--- a/docs/.vitepress/theme/components/TutorialPlayer.vue
+++ b/docs/.vitepress/theme/components/TutorialPlayer.vue
@@ -26,7 +26,16 @@ function formatInlineMarkdown(text) {
safe = safe.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
const cleanAlt = alt.replace(/"/g, '"')
- const cleanSrc = src.replace(/"/g, '"')
+ let cleanSrc = src.trim().replace(/"/g, '"')
+
+ if (cleanSrc.includes('assets/')) {
+ cleanSrc = '/assets/' + cleanSrc.split('assets/')[1]
+ }
+
+ if (cleanSrc.startsWith('/')) {
+ cleanSrc = withBase(cleanSrc)
+ }
+
return `