diff --git a/alembic/versions/0039_library_audit_resume.py b/alembic/versions/0039_library_audit_resume.py new file mode 100644 index 0000000..6cfb8f9 --- /dev/null +++ b/alembic/versions/0039_library_audit_resume.py @@ -0,0 +1,40 @@ +"""library_audit_run: resume cursor + progress timestamp for chunked scans + +Revision ID: 0039 +Revises: 0038 +Create Date: 2026-06-07 + +scan_library_for_rule used to run one 2h pass that timed out on large libraries +and monopolized the concurrency-1 maintenance queue (operator-flagged). It now +runs short time-boxed chunks that re-enqueue: `resume_after_id` persists the +keyset cursor so the next chunk continues where it left off, and +`last_progress_at` lets the recovery sweep tell a progressing multi-chunk audit +from a genuinely stuck one. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0039" +down_revision: Union[str, None] = "0038" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "library_audit_run", + sa.Column( + "resume_after_id", sa.Integer, nullable=False, server_default="0" + ), + ) + op.add_column( + "library_audit_run", + sa.Column("last_progress_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("library_audit_run", "last_progress_at") + op.drop_column("library_audit_run", "resume_after_id") diff --git a/backend/app/api/system_activity.py b/backend/app/api/system_activity.py index 0d0600c..0371415 100644 --- a/backend/app/api/system_activity.py +++ b/backend/app/api/system_activity.py @@ -31,7 +31,7 @@ system_activity_bp = Blueprint( # absent. _QUEUE_NAMES = ( "default", "import", "thumbnail", "ml", - "download", "scan", "maintenance", + "download", "scan", "maintenance", "maintenance_long", ) # Cache module-level so all requests share the cache between polls. diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index 6620a6f..2d39f8f 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -43,10 +43,16 @@ def make_celery() -> Celery: "backend.app.tasks.thumbnail.*": {"queue": "thumbnail"}, "backend.app.tasks.download.*": {"queue": "download"}, "backend.app.tasks.scan.*": {"queue": "scan"}, + # `maintenance` is the QUICK lane — recovery sweeps, vacuum, cleanup + # (concurrency-1 on the scheduler). The long one-shots (DB backups, + # library audits, admin maintenance: normalize/re-extract/cascade- + # delete) run on a SEPARATE `maintenance_long` lane + worker so they + # can never starve the quick self-healing sweeps (operator-flagged + # 2026-06-07: a 2h audit blocked vacuum/backup/normalize for hours). "backend.app.tasks.maintenance.*": {"queue": "maintenance"}, - "backend.app.tasks.backup.*": {"queue": "maintenance"}, - "backend.app.tasks.admin.*": {"queue": "maintenance"}, - "backend.app.tasks.library_audit.*": {"queue": "maintenance"}, + "backend.app.tasks.backup.*": {"queue": "maintenance_long"}, + "backend.app.tasks.admin.*": {"queue": "maintenance_long"}, + "backend.app.tasks.library_audit.*": {"queue": "maintenance_long"}, }, # Heavy ML tasks need fair dispatch — see ImageRepo's precedent. task_acks_late=True, diff --git a/backend/app/models/library_audit_run.py b/backend/app/models/library_audit_run.py index ca5aa82..a2d4bc2 100644 --- a/backend/app/models/library_audit_run.py +++ b/backend/app/models/library_audit_run.py @@ -35,3 +35,10 @@ class LibraryAuditRun(Base): matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) matched_ids: Mapped[list[int]] = mapped_column(JSONB, nullable=False, default=list) error: Mapped[str | None] = mapped_column(Text, nullable=True) + # Chunked-scan state (alembic 0039): keyset cursor the next chunk resumes + # from, and the last time a chunk made progress (so the recovery sweep can + # tell a progressing multi-chunk audit from a stuck one). + resume_after_id: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + last_progress_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, + ) diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index bfdef07..255311e 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -1,6 +1,7 @@ """Tag CRUD + autocomplete + image-tag association.""" import logging +import time from collections.abc import Sequence from dataclasses import dataclass @@ -637,7 +638,10 @@ def _best_connected(tag_ids: list[int], counts: dict[int, int]) -> int: async def normalize_existing_tags( - session: AsyncSession, *, dry_run: bool = False + session: AsyncSession, + *, + dry_run: bool = False, + time_budget_seconds: float | None = None, ) -> dict: """Convert the back-catalog to the #701 canonical tag form. @@ -647,6 +651,14 @@ async def normalize_existing_tags( survivor to the canonical form. Idempotent: a group that is already a lone canonical tag is a no-op, so re-running is safe. + A first run over a fresh back-catalog can touch tens of thousands of tags + (the whole booru-derived vocabulary needs recasing) and won't finish inside + one Celery time limit — it timed out at 40 min (operator-flagged 2026-06-07). + `time_budget_seconds` time-boxes the live run: it stops cleanly at the budget + and reports `partial`/`remaining` so the caller can re-enqueue and continue. + Because it commits per group and is idempotent, the next run just picks up + the groups still needing change. + dry_run=True returns a projection (counts + a sample of the changes) with no mutations. Live runs commit per group and isolate failures per group so one bad group can't strand the rest. @@ -656,7 +668,7 @@ async def normalize_existing_tags( "total_changes": T, "sample": [{"to", "from": [...], "kind", "merge"}]} Returns (live): {"groups_processed", "merged", "renamed", "aliases_created", "errors", - "sample": [...]} + "total_changes", "remaining", "partial", "sample": [...]} """ rows = ( await session.execute( @@ -715,9 +727,23 @@ async def normalize_existing_tags( "renamed": 0, "aliases_created": 0, "errors": 0, + "total_changes": len(touched), + "remaining": len(touched), + "partial": False, "sample": sample, } - for key, members in touched: + start = time.monotonic() + for done, (key, members) in enumerate(touched): + # Time-box: stop cleanly before the Celery limit kills us mid-group and + # strands the run as a timeout. The caller re-enqueues to finish the + # rest (idempotent — already-canonical groups are skipped next pass). + if ( + time_budget_seconds is not None + and time.monotonic() - start >= time_budget_seconds + ): + summary["partial"] = True + summary["remaining"] = len(touched) - done + break canonical = key[2] names_by_id = dict(members) # Survivor: prefer a member already named canonically (no rename, no @@ -752,4 +778,7 @@ async def normalize_existing_tags( log.warning( "tag normalize failed for group %r: %s", canonical, exc ) + else: + # Loop finished without hitting the time budget — nothing left to do. + summary["remaining"] = 0 return summary diff --git a/backend/app/tasks/admin.py b/backend/app/tasks/admin.py index f115387..9a65f1e 100644 --- a/backend/app/tasks/admin.py +++ b/backend/app/tasks/admin.py @@ -75,6 +75,15 @@ def reextract_archive_attachments_task(self) -> dict: ) +# Time-box one chunk well under the soft limit so a large back-catalog (the +# first run recases the whole booru vocabulary) can't run the task into the +# Celery time limit — it timed out at 40 min, operator-flagged 2026-06-07. The +# task re-enqueues itself until nothing remains (idempotent — already-canonical +# groups are skipped). 600s keeps each chunk short enough that the recovery +# sweep and other maintenance tasks interleave on the concurrency-1 queue. +_NORMALIZE_CHUNK_SECONDS = 600 + + @celery.task( name="backend.app.tasks.admin.normalize_tags_task", bind=True, @@ -85,8 +94,9 @@ def reextract_archive_attachments_task(self) -> dict: def normalize_tags_task(self) -> dict: """Wraps tag_service.normalize_existing_tags (#714): Title-Case the back-catalog and merge case/whitespace-variant duplicate tags via the - tested async merge path. Runs under its own asyncio loop + per-task async - engine (NullPool, disposed when the loop ends), mirroring download_source.""" + tested async merge path. Time-boxed + self-resuming so a huge first run + finishes across chunks instead of timing out. Runs under its own asyncio + loop + per-task async engine (NullPool), mirroring download_source.""" import asyncio from ..services.tag_service import normalize_existing_tags @@ -97,8 +107,20 @@ def normalize_tags_task(self) -> dict: try: async with async_factory() as session: # normalize_existing_tags commits per group internally. - return await normalize_existing_tags(session, dry_run=False) + return await normalize_existing_tags( + session, dry_run=False, + time_budget_seconds=_NORMALIZE_CHUNK_SECONDS, + ) finally: await async_engine.dispose() - return asyncio.run(_run()) + summary = asyncio.run(_run()) + # More groups to canonicalize than fit this chunk — continue in the next. + if summary.get("partial") and summary.get("remaining", 0) > 0: + log.info( + "normalize_tags_task chunk done (%d processed, %d remaining) — " + "re-enqueuing to continue", + summary.get("groups_processed", 0), summary["remaining"], + ) + normalize_tags_task.delay() + return summary diff --git a/backend/app/tasks/backup.py b/backend/app/tasks/backup.py index 57d8157..77ad1f0 100644 --- a/backend/app/tasks/backup.py +++ b/backend/app/tasks/backup.py @@ -41,7 +41,11 @@ def _mark_failed(session, row: BackupRun, exc: BaseException) -> None: bind=True, autoretry_for=(OperationalError, DBAPIError), retry_backoff=10, retry_backoff_max=120, max_retries=2, - soft_time_limit=600, time_limit=720, + # A pg_dump can't be chunked; the 12-min limit timed out once the DB grew + # (operator-flagged 2026-06-07). 30/35 min gives real headroom. (A long + # backup still briefly holds the concurrency-1 maintenance lane — the + # structural fix is a dedicated lane for the long one-shots.) + soft_time_limit=1800, time_limit=2100, ) def backup_db_task(self, *, tag: str | None = None, triggered_by: str = "manual") -> dict: diff --git a/backend/app/tasks/library_audit.py b/backend/app/tasks/library_audit.py index dd66a23..6f2b26c 100644 --- a/backend/app/tasks/library_audit.py +++ b/backend/app/tasks/library_audit.py @@ -13,6 +13,7 @@ State machine: """ import logging +import time import traceback from datetime import UTC, datetime @@ -31,6 +32,12 @@ log = logging.getLogger(__name__) _BATCH = 500 _PROGRESS_TICK = 100 _MAX_MATCHED = 50_000 +# One chunk's wall-clock budget. Was a single 2h pass that timed out on large +# libraries and held the concurrency-1 maintenance queue the whole time +# (operator-flagged 2026-06-07). Now: scan ~10 min, persist the keyset cursor + +# matches, re-enqueue to continue — so backups/vacuum/normalize chunks can +# interleave. soft/hard limits sit just above so the budget fires first. +_CHUNK_SECONDS = 600 _RULES = { "transparency": transparency.evaluate, @@ -46,13 +53,16 @@ _RULES = { retry_backoff_max=60, retry_jitter=True, max_retries=3, - soft_time_limit=7200, - time_limit=7500, + soft_time_limit=900, + time_limit=1000, ) def scan_library_for_rule(self, audit_id: int) -> dict: - """See module docstring. Returns a small summary dict for eager-mode + """See module docstring. Time-boxed + self-resuming: one call scans a + ~10-min chunk, persists the resume cursor + matches, and re-enqueues itself + until the library is exhausted. Returns a small summary dict for eager-mode test assertions (real workers ignore the return value).""" SessionLocal = _sync_session_factory() + start = time.monotonic() try: with SessionLocal() as session: audit = session.get(LibraryAuditRun, audit_id) @@ -63,9 +73,10 @@ def scan_library_for_rule(self, audit_id: int) -> dict: _mark_error(session, audit_id, f"unknown rule {audit.rule!r}") return {"audit_id": audit_id, "status": "error"} params = dict(audit.params or {}) - matched: list[int] = [] - scanned = 0 - last_id = 0 + # Resume from the previous chunk's persisted state. + matched: list[int] = list(audit.matched_ids or []) + scanned = audit.scanned_count or 0 + last_id = audit.resume_after_id or 0 while True: # Cancellation check between batches. current_status = session.execute( @@ -74,6 +85,15 @@ def scan_library_for_rule(self, audit_id: int) -> dict: ).scalar_one() if current_status == "cancelled": return {"audit_id": audit_id, "status": "cancelled"} + # Time-box: persist the cursor + matches and re-enqueue so the + # queue is freed between chunks. The next call resumes here. + if time.monotonic() - start >= _CHUNK_SECONDS: + _persist_chunk(session, audit_id, scanned, matched, last_id) + scan_library_for_rule.delay(audit_id) + return { + "audit_id": audit_id, "status": "running", + "partial": True, "scanned": scanned, + } rows = session.execute( select(ImageRecord.id, ImageRecord.path) .where(ImageRecord.id > last_id) @@ -114,10 +134,16 @@ def scan_library_for_rule(self, audit_id: int) -> dict: ) return {"audit_id": audit_id, "status": "error"} if scanned % _PROGRESS_TICK == 0: + # Cheap heartbeat: scanned_count + last_progress_at so the + # recovery sweep sees the multi-chunk audit is alive. The + # cursor + matches are persisted at chunk boundaries. session.execute( update(LibraryAuditRun) .where(LibraryAuditRun.id == audit_id) - .values(scanned_count=scanned) + .values( + scanned_count=scanned, + last_progress_at=datetime.now(UTC), + ) ) session.commit() # Final state. @@ -128,8 +154,10 @@ def scan_library_for_rule(self, audit_id: int) -> dict: scanned_count=scanned, matched_count=len(matched), matched_ids=matched, + resume_after_id=last_id, status="ready", finished_at=datetime.now(UTC), + last_progress_at=datetime.now(UTC), ) ) session.commit() @@ -140,9 +168,14 @@ def scan_library_for_rule(self, audit_id: int) -> dict: "matched": len(matched), } except SoftTimeLimitExceeded: - with SessionLocal() as session: - _mark_error(session, audit_id, "soft_time_limit exceeded (>7200s)") - raise + # Backstop (the in-chunk budget should fire first): the audit stays + # 'running' with its last committed cursor; re-enqueue to continue from + # there rather than marking the whole run an error. + log.warning( + "audit %s: soft time limit hit — re-enqueuing to resume", audit_id, + ) + scan_library_for_rule.delay(audit_id) + return {"audit_id": audit_id, "status": "running", "partial": True} except (OperationalError, DBAPIError): # Retryable per the decorator; leave row in 'running' and let # autoretry try again. Recovery sweep catches if all retries fail. @@ -154,6 +187,23 @@ def scan_library_for_rule(self, audit_id: int) -> dict: raise +def _persist_chunk(session, audit_id, scanned, matched, last_id) -> None: + """Persist a chunk boundary: scanned count, matches so far, and the keyset + cursor the next chunk resumes from. Keeps status='running'.""" + session.execute( + update(LibraryAuditRun) + .where(LibraryAuditRun.id == audit_id) + .values( + scanned_count=scanned, + matched_count=len(matched), + matched_ids=list(matched), + resume_after_id=last_id, + last_progress_at=datetime.now(UTC), + ) + ) + session.commit() + + def _mark_error(session, audit_id: int, error_msg: str) -> None: session.execute( update(LibraryAuditRun) diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 2be4a26..807d0e6 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -647,19 +647,30 @@ def recover_stalled_library_audit_runs() -> int: guard in start_audit_run — a SIGKILL'd run would block all future audits until manual DB surgery. (The guard is now age-aware, but this sweep is what makes that work in practice.) + + Measures staleness from last_progress_at (alembic 0039), NOT started_at: + a chunked scan stays 'running' across many re-enqueued chunks and can + legitimately run for hours on a big library — only flag one that hasn't + made progress in the threshold window (a dead chunk that never re-enqueued). + Falls back to started_at for pre-0039 / never-ticked rows. """ SessionLocal = _sync_session_factory() now = datetime.now(UTC) cutoff = now - timedelta(minutes=LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES) msg = ( - f"stranded by recovery sweep (no terminal status after " + f"stranded by recovery sweep (no progress for " f"{LIBRARY_AUDIT_STALL_THRESHOLD_MINUTES} min)" ) with SessionLocal() as session: result = session.execute( update(LibraryAuditRun) .where(LibraryAuditRun.status == "running") - .where(LibraryAuditRun.started_at < cutoff) + .where( + func.coalesce( + LibraryAuditRun.last_progress_at, + LibraryAuditRun.started_at, + ) < cutoff + ) .values(status="error", finished_at=now, error=msg) ) session.commit() diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 5baecb7..aefd81f 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -35,6 +35,15 @@ services: volumes: - ./backend:/app/backend + maintenance-long: + build: + context: . + dockerfile: Dockerfile + environment: + LOG_LEVEL: DEBUG + volumes: + - ./backend:/app/backend + ml-worker: build: context: . diff --git a/docker-compose.yml b/docker-compose.yml index b8da8cd..f1088ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,11 @@ services: postgres: image: pgvector/pgvector:pg16 + # Docker's default /dev/shm is 64MB; VACUUM (ANALYZE) and parallel queries + # allocate larger shared-memory segments and fail with + # "could not resize shared memory segment ... No space left on device" + # (operator-flagged 2026-06-07, vacuum_analyze on import_task needed 67MB). + shm_size: 512m environment: POSTGRES_USER: ${DB_USER:-fabledcurator} POSTGRES_PASSWORD: ${DB_PASSWORD:-fabledcurator_dev} @@ -52,7 +57,6 @@ services: volumes: - ./images:/images - ./import:/import - - ./downloads:/downloads # FC-5 legacy migration: bind-mount the host's ImageRepo images dir # under /import (FC's existing filesystem scan picks them up). Read-only # is sufficient — FC copies into /images during the scan. The worker + @@ -71,10 +75,11 @@ services: <<: *app_env CELERY_QUEUES: default,import,thumbnail,download CELERY_CONCURRENCY: "2" + # /downloads dropped — nothing in the app references it (operator-flagged + # 2026-06-07: it wasn't mapped in prod and everything worked). volumes: - ./images:/images - ./import:/import - - ./downloads:/downloads depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } @@ -88,7 +93,25 @@ services: volumes: - ./images:/images - ./import:/import - - ./downloads:/downloads + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_healthy } + + # Dedicated lane for long one-shot maintenance (DB backups, library audits, + # admin maintenance). Kept off the scheduler's quick `maintenance` lane so a + # 30-min backup or a multi-chunk audit can never starve the 5-min recovery + # sweeps / vacuum (operator-flagged 2026-06-07). One slot — these are heavy. + maintenance-long: + image: git.fabledsword.com/bvandeusen/fabledcurator:dev + command: ["worker"] + environment: + <<: *app_env + CELERY_QUEUES: maintenance_long + CELERY_CONCURRENCY: "1" + # Only /images: backups write to /images/_backups, audits read /images, and + # the admin tasks (re-extract/cascade-delete/normalize) operate on /images. + volumes: + - ./images:/images depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } 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 53e17be..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,21 +116,27 @@ 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 — that's - // the universal "get me out of here" expectation, and the - // autofocused tag-entry field would otherwise trap focus with no - // visible escape (operator-flagged 2026-06-01). EXCEPTION: when a - // nested Vuetify overlay is open (v-menu autocomplete dropdown, - // FandomPicker v-dialog, per-suggestion 3-dot menu), let that - // overlay's own Esc handling fire instead of closing the whole - // modal mid-interaction. Vuetify marks open overlays with - // `.v-overlay--active`. - // EXCLUDE tooltips (`.v-tooltip`): they're also `.v-overlay--active` while - // shown, so one lingering after a hover/click (e.g. just after accepting a - // suggested tag) wrongly suppressed the close (#700). Only real interactive - // overlays (menus/dialogs) should keep ESC from closing the modal. - if (document.querySelector('.v-overlay--active:not(.v-tooltip)')) return + // Escape closes the modal even from inside a text input — the universal + // "get me out of here" expectation; the autofocused tag field would + // otherwise trap focus (operator-flagged 2026-06-01). EXCEPTION: when the + // keystroke originates INSIDE an open Vuetify overlay's content (a rename/ + // fandom/alias dialog, or a kebab menu), let that overlay handle its own + // Esc and don't close the whole modal. + // + // #700 re-fix (2026-06-07): the prior guard queried for ANY active overlay + // anywhere in the DOM and suppressed the close — so a lingering overlay + // after accepting a suggestion (focus drops to , not into any + // overlay) wrongly blocked Esc. Keying off the event's origin instead means + // a stray overlay no longer traps the modal: only an Esc pressed from + // within overlay content defers to that overlay. + if (ev.target?.closest?.('.v-overlay__content')) return ev.preventDefault() emit('close') } else if (ev.key === 'ArrowLeft') { @@ -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 ec6a258..efc8768 100644 --- a/frontend/src/components/modal/TagAutocomplete.vue +++ b/frontend/src/components/modal/TagAutocomplete.vue @@ -8,10 +8,12 @@ @keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)" @keydown.enter.prevent="onEnter" + @keydown.tab="onTab" @keydown.esc="$emit('cancel')" /> { const query = ref('') const hits = ref([]) const highlight = ref(0) +const listRef = ref(null) const fandomDialog = ref(false) let pendingNewName = null @@ -137,6 +140,14 @@ function moveHighlight (delta) { const total = hits.value.length + (allowCreate.value ? 1 : 0) if (total === 0) return highlight.value = (highlight.value + delta + total) % total + // Keep the highlighted row visible — the list is capped at 240px and arrowing + // past the fold otherwise left the active item off-screen (operator-flagged + // 2026-06-07). block:'nearest' scrolls the minimum needed. + nextTick(() => { + listRef.value?.$el + ?.querySelector('.v-list-item--active') + ?.scrollIntoView({ block: 'nearest' }) + }) } function onPick (hit) { emit('pick-existing', hit); reset() } @@ -177,6 +188,16 @@ function onEnter () { } } +// B5 (2026-06-07): when the suggestion list is open, Tab accepts the +// highlighted row (standard autocomplete convention) instead of leaving the +// field. With the list closed it falls through to normal focus traversal. +function onTab (e) { + if (hits.value.length || allowCreate.value) { + e.preventDefault() + onEnter() + } +} + function reset () { query.value = ''; hits.value = []; highlight.value = 0 } diff --git a/frontend/src/components/modal/TagChip.vue b/frontend/src/components/modal/TagChip.vue new file mode 100644 index 0000000..f06c18a --- /dev/null +++ b/frontend/src/components/modal/TagChip.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/frontend/src/components/modal/TagPanel.vue b/frontend/src/components/modal/TagPanel.vue index 4123c18..1d913f4 100644 --- a/frontend/src/components/modal/TagPanel.vue +++ b/frontend/src/components/modal/TagPanel.vue @@ -2,43 +2,11 @@