fix(ui): polish terminal options and assets
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-10 07:00:22 -05:00
parent 33933b00d7
commit daa5e9d655
13 changed files with 387 additions and 33 deletions
+145 -10
View File
@@ -26,6 +26,28 @@ body {
--m-divider-style: double;
}
:root[data-mystery-cursor='bar'],
:root:not([data-mystery-cursor]) {
--m-cursor-preview: '|';
--m-cursor-preview-size: 1em;
--m-cursor-preview-weight: normal;
--m-cursor-preview-offset: 0;
}
:root[data-mystery-cursor='block'] {
--m-cursor-preview: '█';
--m-cursor-preview-size: 0.9em;
--m-cursor-preview-weight: normal;
--m-cursor-preview-offset: 0;
}
:root[data-mystery-cursor='underscore'] {
--m-cursor-preview: '_';
--m-cursor-preview-size: 1.15em;
--m-cursor-preview-weight: bold;
--m-cursor-preview-offset: 0.18em;
}
.mystery-root {
position: fixed;
inset: 0;
@@ -93,17 +115,73 @@ body {
z-index: 1;
}
.mystery-theme-toggle {
.mystery-options {
position: absolute;
top: 8px;
right: 12px;
right: 32px;
z-index: 3;
display: flex;
gap: 6px;
font-size: 11px;
}
.mystery-theme-toggle button {
.mystery-options-toggle {
width: 34px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
position: relative;
padding: 0;
}
.mystery-options-icon {
width: 23px;
height: 23px;
background: currentColor;
display: block;
mask: var(--gear-icon) center / contain no-repeat;
-webkit-mask: var(--gear-icon) center / contain no-repeat;
}
.mystery-options-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 180px;
padding: 8px;
background: var(--m-bg);
border: 1px solid var(--m-dim);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.45);
}
.mystery-options-menu::after {
content: 'Cursor ' var(--m-cursor-preview);
display: block;
margin-top: 8px;
padding-top: 7px;
border-top: 1px solid var(--m-dim);
color: var(--m-fg);
font-size: 12px;
font-weight: var(--m-cursor-preview-weight);
line-height: 1;
}
.mystery-options-group {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.mystery-options-group + .mystery-options-group {
margin-top: 8px;
}
.mystery-options-label {
flex: 0 0 100%;
color: var(--m-dim);
}
.mystery-options button {
background: transparent;
color: var(--m-dim);
border: 1px solid var(--m-dim);
@@ -113,7 +191,8 @@ body {
cursor: pointer;
}
.mystery-theme-toggle button[aria-pressed='true'] {
.mystery-options button[aria-pressed='true'],
.mystery-options-toggle[aria-expanded='true'] {
color: var(--m-fg);
border-color: var(--m-fg);
}
@@ -123,6 +202,8 @@ body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-color: var(--m-dim) transparent;
scrollbar-width: thin;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
@@ -131,6 +212,18 @@ body {
padding-right: 6px;
}
.mystery-transcript::-webkit-scrollbar {
width: 6px;
}
.mystery-transcript::-webkit-scrollbar-track {
background: transparent;
}
.mystery-transcript::-webkit-scrollbar-thumb {
background: var(--m-dim);
}
.mystery-transcript .system {
color: var(--m-accent-1);
font-weight: bold;
@@ -172,22 +265,64 @@ body {
margin-top: 10px;
position: relative;
z-index: 2;
min-height: 1.45em;
cursor: text;
}
.mystery-input-row::before {
content: '>';
color: var(--m-accent-2);
flex: 0 0 auto;
}
.mystery-input {
flex: 1;
position: absolute;
inset: 0;
width: 100%;
min-width: 0;
opacity: 0;
color: transparent;
caret-color: transparent;
background: transparent;
color: var(--m-fg);
border: none;
outline: none;
font: inherit;
text-shadow: inherit;
caret-color: var(--m-fg);
}
.mystery-input-display {
flex: 1;
min-width: 0;
color: var(--m-fg);
white-space: pre-wrap;
overflow-wrap: anywhere;
pointer-events: none;
}
.mystery-input-display[data-placeholder='true'] {
color: var(--m-dim);
font-style: italic;
}
.mystery-input-display::after {
content: var(--m-cursor-preview);
display: inline-block;
color: var(--m-fg);
font-size: var(--m-cursor-preview-size);
font-style: normal;
font-weight: var(--m-cursor-preview-weight);
line-height: 1;
transform: translateY(var(--m-cursor-preview-offset));
margin-left: 0.1ch;
opacity: 0;
}
.mystery-input:focus + .mystery-input-display::after {
animation: mystery-cursor-blink 1.05s steps(1, end) infinite;
}
@keyframes mystery-cursor-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
.mystery-chips {
+95 -14
View File
@@ -10,6 +10,7 @@ import { renderChips } from './chip-render'
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
const inputDisplayEl = document.querySelector<HTMLSpanElement>('[data-mystery-input-display]')
const HELP_TEXT = `You arrive at the address, but you do not remember what has happened. The road behind you is gone...
@@ -30,13 +31,17 @@ theme change the terminal colors
Most commands are verb first, then the thing: examine gate, take lamp, use key on door.`
if (!transcriptEl || !inputEl) {
if (!transcriptEl || !inputEl || !inputDisplayEl) {
console.error('[halfstreet] terminal mount points missing')
} else {
const restored = loadState()
let state: GameState = restored ?? initialStateFor(world)
let lastState: GameState | null = null // for one-step undo
let transientHelpEl: HTMLDivElement | null = null
let commandHistory: string[] = []
let historyIndex: number | null = null
let historyDraft = ''
let idleHintTimer: number | null = null
if (!restored) {
// Fresh state already includes the opening narration in its transcript.
@@ -48,7 +53,9 @@ if (!transcriptEl || !inputEl) {
function refreshChips(): void {
renderChips(computeChips(state, world), (command) => {
clearIdleHint()
inputEl!.value = command
syncCommandLine()
if (command.endsWith(' ')) {
inputEl!.focus()
inputEl!.setSelectionRange(command.length, command.length)
@@ -66,6 +73,12 @@ if (!transcriptEl || !inputEl) {
inputEl!.classList.toggle('ended', state.endedWith !== null)
}
const syncCommandLine = (): void => {
const visibleText = inputEl.value || inputEl.placeholder
inputDisplayEl.textContent = visibleText
inputDisplayEl.dataset['placeholder'] = inputEl.value ? 'false' : inputEl.placeholder ? 'true' : 'false'
}
const buildParserContext = (s: GameState): ParserContext => {
const room = world.rooms[s.location]
const visibleNouns: { id: string; aliases: string[] }[] = []
@@ -110,6 +123,23 @@ if (!transcriptEl || !inputEl) {
transcriptEl.scrollTop = transcriptEl.scrollHeight
}
const clearIdleHint = (): void => {
if (idleHintTimer !== null) {
window.clearTimeout(idleHintTimer)
idleHintTimer = null
}
inputEl.placeholder = ''
syncCommandLine()
}
const scheduleIdleHint = (): void => {
clearIdleHint()
idleHintTimer = window.setTimeout(() => {
inputEl.placeholder = 'type here...'
syncCommandLine()
}, 30000)
}
const clearTransientHelp = (): void => {
transientHelpEl?.remove()
transientHelpEl = null
@@ -136,18 +166,58 @@ if (!transcriptEl || !inputEl) {
renderAll(lines)
}
const restart = (): void => {
const confirmed = confirm('Restart? Your progress will be lost.')
if (!confirmed) {
appendLines([{ kind: 'system', text: '(restart cancelled)' }])
return
}
clearSave()
state = initialStateFor(world)
transcriptEl.innerHTML = ''
inputEl.value = ''
syncCommandLine()
renderAll(state.transcript)
saveState(state)
refreshChips()
syncEndedUI()
}
renderAll(state.transcript)
refreshChips()
syncEndedUI()
inputEl.focus()
syncCommandLine()
scheduleIdleHint()
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (commandHistory.length === 0) return
e.preventDefault()
if (historyIndex === null) {
historyDraft = inputEl.value
historyIndex = commandHistory.length
}
if (e.key === 'ArrowUp') {
historyIndex = Math.max(0, historyIndex - 1)
} else {
historyIndex = Math.min(commandHistory.length, historyIndex + 1)
}
inputEl.value = historyIndex === commandHistory.length ? historyDraft : commandHistory[historyIndex]!
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length)
syncCommandLine()
return
}
if (e.key !== 'Enter') return
e.preventDefault()
const raw = inputEl.value
inputEl.value = ''
syncCommandLine()
if (!raw.trim()) return
clearTransientHelp()
clearIdleHint()
commandHistory = [...commandHistory, raw].slice(-50)
historyIndex = null
historyDraft = ''
appendLines([{ kind: 'player', text: raw }])
// Once the game has ended, only restart and undo are allowed.
@@ -162,18 +232,7 @@ if (!transcriptEl || !inputEl) {
// Engine-level meta-commands handled here so the engine stays pure.
const trimmed = raw.trim().toLowerCase()
if (trimmed === 'restart') {
const confirmed = confirm('Restart? Your progress will be lost.')
if (!confirmed) {
appendLines([{ kind: 'system', text: '(restart cancelled)' }])
return
}
clearSave()
state = initialStateFor(world)
transcriptEl.innerHTML = ''
renderAll(state.transcript)
saveState(state)
refreshChips()
syncEndedUI()
restart()
return
}
if (trimmed === 'help') {
@@ -220,10 +279,32 @@ if (!transcriptEl || !inputEl) {
}
})
inputEl.addEventListener('input', syncCommandLine)
inputEl.addEventListener('focus', clearIdleHint)
inputEl.addEventListener('pointerdown', clearIdleHint)
inputEl.parentElement?.addEventListener('pointerdown', () => {
inputEl.focus()
})
document.addEventListener('keydown', (e) => {
const target = e.target as HTMLElement | null
const isEditable =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target?.isContentEditable === true
if (e.key === '/' && !isEditable) {
e.preventDefault()
clearIdleHint()
inputEl.focus()
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length)
return
}
if (e.key === 'Escape') {
saveState(state)
window.location.href = '/'
}
})
document.addEventListener('halfstreet-restart', restart)
}
+61
View File
@@ -1,6 +1,8 @@
const STORAGE_KEY = 'halfstreet:theme:v1'
const CURSOR_STORAGE_KEY = 'halfstreet:cursor:v1'
type Theme = 'amber' | 'ansi'
type Cursor = 'bar' | 'block' | 'underscore'
function getStored(): Theme {
try {
@@ -10,6 +12,15 @@ function getStored(): Theme {
}
}
function getStoredCursor(): Cursor {
try {
const stored = localStorage.getItem(CURSOR_STORAGE_KEY)
return stored === 'block' || stored === 'underscore' ? stored : 'bar'
} catch {
return 'bar'
}
}
function setTheme(theme: Theme): void {
document.documentElement.setAttribute('data-mystery-theme', theme)
try {
@@ -22,8 +33,46 @@ function setTheme(theme: Theme): void {
}
}
function setCursor(cursor: Cursor): void {
document.documentElement.setAttribute('data-mystery-cursor', cursor)
try {
localStorage.setItem(CURSOR_STORAGE_KEY, cursor)
} catch {
// ignore
}
for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-cursor-choice]')) {
btn.setAttribute('aria-pressed', btn.dataset['cursorChoice'] === cursor ? 'true' : 'false')
}
}
const initial = getStored()
setTheme(initial)
setCursor(getStoredCursor())
const optionsRoot = document.querySelector<HTMLElement>('[data-mystery-options]')
const optionsToggle = document.querySelector<HTMLButtonElement>('[data-options-toggle]')
const optionsMenu = document.querySelector<HTMLElement>('[data-options-menu]')
function setOptionsOpen(open: boolean): void {
if (!optionsToggle || !optionsMenu) return
optionsToggle.setAttribute('aria-expanded', open ? 'true' : 'false')
optionsMenu.hidden = !open
document.documentElement.toggleAttribute('data-mystery-options-open', open)
}
optionsToggle?.addEventListener('click', () => {
setOptionsOpen(optionsToggle.getAttribute('aria-expanded') !== 'true')
})
document.addEventListener('click', (event) => {
if (!optionsRoot || !optionsRoot.contains(event.target as Node)) {
setOptionsOpen(false)
}
})
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') setOptionsOpen(false)
})
document.querySelectorAll<HTMLButtonElement>('[data-theme-choice]').forEach((btn) => {
btn.addEventListener('click', () => {
@@ -32,6 +81,18 @@ document.querySelectorAll<HTMLButtonElement>('[data-theme-choice]').forEach((btn
})
})
document.querySelectorAll<HTMLButtonElement>('[data-cursor-choice]').forEach((btn) => {
btn.addEventListener('click', () => {
const next = (btn.dataset['cursorChoice'] as Cursor | undefined) ?? 'bar'
setCursor(next)
})
})
document.querySelector<HTMLButtonElement>('[data-restart-choice]')?.addEventListener('click', () => {
setOptionsOpen(false)
document.dispatchEvent(new CustomEvent('halfstreet-restart'))
})
// Allow the engine's `theme` meta-command (handled in terminal.ts) to flip
// without going through the button by listening for a custom event.
document.addEventListener('halfstreet-toggle-theme', () => {