diff --git a/frontend/src/components/modal/FandomPicker.vue b/frontend/src/components/modal/FandomPicker.vue index d412b6c..e1ac0bf 100644 --- a/frontend/src/components/modal/FandomPicker.vue +++ b/frontend/src/components/modal/FandomPicker.vue @@ -2,12 +2,17 @@ Pick a fandom +

Or create a new fandom:

@@ -50,6 +55,12 @@ function onConfirm() { const f = store.fandomCache.find(x => x.id === selectedId.value) if (f) emit('confirm', f) } +// Picking a fandom (keyboard Enter or click) confirms immediately. Ignore the +// clear action (null) so clearing the field doesn't fire a confirm. +function onSelect(id) { + if (id == null) return + onConfirm() +} // Create the character with no fandom. Emits null so the caller knows this // was a deliberate "unassigned", not a cancel. function onNoFandom() { diff --git a/frontend/src/components/modal/FandomSetDialog.vue b/frontend/src/components/modal/FandomSetDialog.vue index 07f38da..d60a362 100644 --- a/frontend/src/components/modal/FandomSetDialog.vue +++ b/frontend/src/components/modal/FandomSetDialog.vue @@ -8,6 +8,7 @@ :items="store.fandomCache" :item-title="(f) => f.name" :item-value="(f) => f.id" label="Fandom" clearable density="compact" + autofocus :hint="selectedId == null ? 'No fandom — the character will be unassigned.' : ''" persistent-hint diff --git a/frontend/src/components/modal/ImageViewer.vue b/frontend/src/components/modal/ImageViewer.vue index d052bbd..89aff9e 100644 --- a/frontend/src/components/modal/ImageViewer.vue +++ b/frontend/src/components/modal/ImageViewer.vue @@ -8,6 +8,25 @@ mdi-close + + +
+ +
+ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useModalStore } from '../../stores/modal.js' -import { arrowNavAllowed } from '../../utils/textEntry.js' +import { arrowNavAllowed, isTextEntry } from '../../utils/textEntry.js' import ImageCanvas from './ImageCanvas.vue' import VideoCanvas from './VideoCanvas.vue' import TagPanel from './TagPanel.vue' @@ -74,6 +93,7 @@ const emit = defineEmits(['close']) const modal = useModalStore() const rootEl = ref(null) +const showHelp = ref(false) const isVideo = computed(() => modal.current?.mime && modal.current.mime.startsWith('video/') @@ -96,6 +116,12 @@ let prevBodyOverflow = null // that. Filtered via isTextEntry so tag/comment inputs still get their // own keystrokes. function onKeyDown(ev) { + if (ev.key === 'Escape' && showHelp.value) { + // Close the cheatsheet first; a second Esc closes the modal. + ev.preventDefault() + showHelp.value = false + return + } if (ev.key === 'Escape') { // Escape closes the modal even from inside a text input — the universal // "get me out of here" expectation; the autofocused tag field would @@ -123,6 +149,14 @@ function onKeyDown(ev) { if (!arrowNavAllowed(ev.target)) return ev.preventDefault() modal.goNext() + } else if ((ev.key === '/' || ev.key === 't') && !isTextEntry(ev.target)) { + // C9 (2026-06-07): jump focus to the tag input from anywhere in the modal. + const input = document.querySelector('.fc-tag-autocomplete input') + if (input) { ev.preventDefault(); input.focus() } + } else if (ev.key === '?' && !isTextEntry(ev.target)) { + // C8: toggle the keyboard cheatsheet. + ev.preventDefault() + showHelp.value = !showHelp.value } } @@ -178,6 +212,43 @@ function nextFrame() { } .fc-viewer__nav:disabled { opacity: 0.3; cursor: not-allowed; } .fc-viewer__close { top: 16px; right: 16px; transform: none; } +.fc-viewer__help-hint { + position: absolute; top: 16px; right: 64px; z-index: 2; + width: 32px; height: 32px; border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + color: rgb(var(--v-theme-on-surface)); + font-weight: 700; cursor: pointer; opacity: 0.6; +} +.fc-viewer__help-hint:hover { opacity: 1; } +.fc-viewer__help { + position: absolute; inset: 0; z-index: 4; + display: flex; align-items: center; justify-content: center; + background: rgba(0, 0, 0, 0.4); +} +.fc-viewer__help-card { + background: rgb(var(--v-theme-surface)); + border: 1px solid rgb(var(--v-theme-surface-light)); + border-radius: 10px; padding: 20px 24px; min-width: 320px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.fc-viewer__help-card h3 { + font-family: 'Fraunces', Georgia, serif; + font-size: 18px; margin-bottom: 12px; + color: rgb(var(--v-theme-on-surface)); +} +.fc-viewer__help-card dl { display: flex; flex-direction: column; gap: 8px; } +.fc-viewer__help-card dl > div { + display: flex; gap: 16px; align-items: baseline; +} +.fc-viewer__help-card dt { + flex: 0 0 96px; text-align: right; + font-family: 'JetBrains Mono', monospace; font-size: 12px; + color: rgb(var(--v-theme-accent)); +} +.fc-viewer__help-card dd { + flex: 1; font-size: 14px; + color: rgb(var(--v-theme-on-surface)); +} .fc-viewer__integrity { position: absolute; top: 72px; right: 16px; z-index: 3; } diff --git a/frontend/src/components/modal/TagAutocomplete.vue b/frontend/src/components/modal/TagAutocomplete.vue index 4e671f3..efc8768 100644 --- a/frontend/src/components/modal/TagAutocomplete.vue +++ b/frontend/src/components/modal/TagAutocomplete.vue @@ -8,6 +8,7 @@ @keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)" @keydown.enter.prevent="onEnter" + @keydown.tab="onTab" @keydown.esc="$emit('cancel')" />