The app uses a plain sticky TopNav (no v-main), and the nav's height was
hardcoded as 64px in ~6 places: the Explore + Subscriptions full-height
workspaces (height: calc(100vh - 64px)) and every sticky sub-header pinned
beneath the nav (top: 64px — Gallery filter bar, Browse/Series/Settings tabs).
Vuetify 4's MD3 sizing changed the real nav height, so 64px was wrong: the
Explore workspace was sized taller than the space below the nav, overflowed the
viewport, and its breadcrumb tucked under the (taller) nav on 1080p.
TopNav now measures its own height via ResizeObserver and publishes it as
--fc-nav-h on documentElement (default 64px in app.css). Every consumer uses
var(--fc-nav-h) instead of the magic number, so the layout self-corrects to the
nav's real height and stays correct as it reflows (per-view teleported actions,
mobile breakpoint). Also tightens the new chrome-gradient seam — sub-headers now
pin at the nav's exact bottom.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TopNav and each sticky sub-header pinned beneath it (Gallery's filter bar,
the Browse/Series/Settings/Subscriptions tabs bars) each painted their OWN
dark-to-transparent gradient (Gallery) or a solid surface band (the rest), so
the fade read as happening twice — dark, fade out, then dark again — instead of
one gradient flowing from the nav down through the sub-nav.
Operator asked to treat the sub-nav as part of the nav with a single gradient.
New shared .fc-chrome-continues primitive (app.css): the nav fades from opaque
to a shared --fc-chrome-seam alpha (on views flagged meta.stickyChrome), and the
sub-header continues from that exact seam alpha to transparent over its own
height. Both reference the same var so the alphas meet at the 64px boundary — no
re-darkening, no doubling. Percentage stops keep it spanning the filter bar's
expanding refine panel; the primitive's blur keeps tabs/controls legible where
the old solid bars had none. --fc-chrome-seam is the single tuning knob.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vuetify 4's MD3 v-tab slider underline rendered wider than the tab and floated
below it (operator-flagged in the v4 review). The active tab's text is already
accent-coloured, so drop the slider and mark the active tab with a subtle accent
fill + rounded top — a clean highlight, app-wide across all tabbed views.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every view showed a large empty band at the top (operator-flagged during the
Vuetify-4 review; pre-existing). Cause: .fc-content had padding-top:64px to clear
the navbar, but TopNav is position:sticky and already reserves its own space in
the v-app flex column — the 64px was a fixed-navbar leftover that double-counted
the offset. Removed it; content now flows directly below the sticky nav (and the
full-height calc(100vh-64px) views no longer overflow).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vuetify 4 is a Material-Design-3 styling refresh with revert snippets, not a
component-API overhaul. FC's exposure was small:
- Bump vuetify ^4, vite-plugin-vuetify ^2.1.0, vue ^3.5, engines node>=24.
- Restore the dropped global CSS reset (minimal reset from the upgrade guide) in
Vuetify's own low-precedence reset layer, so FC's margin-zeroing assumptions hold.
- v-row prop→utility: 'dense' → density=compact (×3), align=center → class=align-center.
- v-snackbar: multi-line removed → min-height=68.
- v-autocomplete #item slot: item→internalItem (item now aliases raw) in TagPicker
+ GalleryFilterBar (item.raw.* → internalItem.raw.*).
ACCEPTED (cosmetic, operator reviews live per plan #158): MD3 typography
(text-body-2 ×73), non-uppercase buttons (v4 dropped the uppercase default),
MD3 elevation. CI verifies BUILD only — the LOOK is the live-review pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both are Vue-3-compatible majors with no code impact for FC:
- vue-router 5: no breaking changes when not using file-based routing (FC uses a
plain createRouter in router.js).
- pinia 3: drops Vue 2 + deprecated APIs; FC uses string-first setup-syntax
defineStore + no custom pinia plugins, so nothing to change.
Phase A of the Vuetify-4 UI framework migration (milestone #158); Vuetify 4 lands
in phase B.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Explore walk got stuck in dense signatures — neighbours all too similar, so
forward-arrow couldn't escape and Random was the only exit. Root cause: MMR only
diversifies WITHIN the nearest ~400 pool; in a dense cluster that whole pool is
near-identical, so there's no escape route in it.
- gallery_service.similar(reach=0.0, exclude_ids=None): reach>0 widens the pool
(cap 400→1000) and _reach_sample strides across an outward-growing distance span
so the set handed to MMR spans near→mid-far (guaranteed escape routes), not just
the tight cluster. exclude_ids drops already-walked images. Gallery 'more like
this' (reach=0) is unchanged.
- api/gallery similar: parse reach + exclude_ids.
- explore store: default reach 0.4 (auto-diversifies without touching the dial),
pass the breadcrumb as exclude_ids, setReach action.
- ExploreView: a Near↔Far reach slider in the trail.
- tests: _reach_sample math (deeper ranks with higher reach, near kept); similar
exclude_ids drops walked + reach path runs clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends WIP title-tagging to lower-precision cues (sketch/doodle/scribble) safely.
- wip_title.py: soft matcher (word-anchored; sketchbook/kadoodle don't trip it);
WIP_TITLE_SOFT_SOURCE + soft SQL prefilter; apply_wip_image_tags takes a source arg.
- training_data._AUTO_SOURCES += 'wip_title_soft' → the soft tier is PROVISIONAL and
never trains the wip head (a finished "sketch" can't pollute it). Only the hard
tier (wip_title) + manual train.
- ImportSettings.wip_soft_title_tagging_enabled (OFF by default, opt-in). Migration 0087.
- importer: hard tier wins, soft is the fallback (source wip_title_soft).
- backfill: refactored into a shared _backfill_wip_tier; hard always, soft when enabled.
- heads.soft_wip_conflict_audit + daily beat: score soft-tagged images against content
heads, flag ring-loud ones (PresentationReview mode=process) for the review strip —
the operator's "measure if they got falsely tagged" safety.
- api settings toggle; ImportFiltersForm soft toggle.
- tests: soft matcher pos/neg; soft source not a training positive; audit flags
ring-loud + spares quiet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Frontend for the system-tag refactor (milestone #157 step 6).
- HeadsCard: new 'Auto-tag work-in-progress' section (enable + tag-confidence +
conflict knobs) for wip/editor process auto-apply, mirroring the chrome card;
copy notes they stay VISIBLE and the head only learns from titles/manual (no
runaway). Presentation copy narrowed to banner-only.
- HiddenReviewStrip: mode-aware — chrome flags read 'hidden as X / Keep hidden /
Un-hide'; process flags read 'auto-tagged X / Keep tag / Remove tag'. Same
endpoints (the backend returns mode), different words.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend for the system-tag behavior refactor (milestone #157). editor screenshot
moves from chrome (hidden) to the PROCESS group (shown, like wip); wip+editor gain
provisional auto-apply so they stop needing endless manual identification —
without a runaway loop.
- tag.py: split PRESENTATION_SYSTEM_TAGS → CHROME_SYSTEM_TAGS (banner) +
PROCESS_SYSTEM_TAGS (wip, editor screenshot).
- heads.py: generalize presentation_auto_apply_sweep → system_tag_auto_apply_sweep
(mode chrome|process). Same Guard 1 (skip human/confirmed) + Guard 2 (ring-loud
conflict → PresentationReview). process mode uses source 'process_auto' and does
NOT hide (hide is a gallery-query effect of group membership).
- training_data._AUTO_SOURCES += 'process_auto' → the head never trains on its own
auto-applied output; only wip_title/manual train it (the runaway break).
- ml_settings: process_auto_apply_enabled (OFF, opt-in) + threshold + conflict
threshold. presentation_review.mode ('chrome'|'process'). Migration 0086.
- gallery_service: default-hide reads CHROME only (editor now shows); Explore
neighbors exclude the whole PROCESS group.
- tasks/ml + celery beat: scheduled_process_auto_apply (daily, opt-in); prune
covers both modes.
- api: ml_admin process_* CRUD+validation; hidden-review returns mode.
- tests: rename chrome sweep calls; new test_process_auto_apply (apply, guards,
mode flag, no-self-train); gallery test asserts editor now visible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
apply_wip_image_tags relied on result.rowcount, but psycopg reports -1 for a
multi-row INSERT ... ON CONFLICT DO NOTHING (executemany path), so the return
count (and the backfill's reported total) was wrong. Compute the count from a
pre-SELECT of already-tagged ids within the same transaction; keep ON CONFLICT
DO NOTHING as a race-safety belt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ruff I001 fixup for 5719387 — the new .wip_title import belongs after
.thumbnailer (alphabetical), not after .archive_extractor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Auto-apply the `wip` system tag to posts whose TITLE explicitly declares
work-in-progress ("WIP" / "work in progress") — a deterministic, high-precision
complement to the image-based ML `wip` head. WIP images are excluded from the
Explore/gallery browse, so honouring the artist's own label keeps unfinished
pieces out of the main browse.
- services/wip_title.py: precision-first token-anchored matcher (swipe/wiped
never trip it) + sync apply helpers (source='wip_title', ON CONFLICT DO
NOTHING, chunked under the psycopg param ceiling).
- importer: live hook on FRESH import only (never on deep-scan/supersede), so a
manually-removed WIP tag is never re-applied by a routine re-scan.
- maintenance.backfill_wip_title_tags: operator-triggered back-catalogue sweep
(coarse SQL prefilter + regex confirm, keyset-paginated). Deliberately NOT a
beat — a periodic re-run would silently undo manual removals.
- ImportSettings.wip_title_tagging_enabled (default ON, migration 0085) gating
the live hook; GET/PATCH + POST /settings/wip-title/scan.
- Settings UI: toggle + "Scan existing posts" button.
- Tests: pure matcher unit tests + integration (apply idempotency, backfill
precision).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Frontend is pure JS (no .ts/JSDoc); CI never ran vue-tsc. Removed the devDep and its orphaned `check` script instead of bumping to v3, and updated the ci.yml comment to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ci.yml: add pull_request trigger (base dev) so renovate/* PRs get validated before merge; dev→main flow unchanged (base main), no duplicate runs. renovate.json: group vite/vitest/plugin-vue/happy-dom/@vue-test-utils into one PR (version-coupled).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gate at a fixed 0.80 couldn't catch the real pain: Interpreter (fresh ==
cached, verified by probe) confidently mis-detects short ASCII English like
"... WIP Part 1" as German at 0.86 — above the floor — so it was accepted and a
re-translate reproduced it. Confidence alone can't separate the 0.86 collision
(genuine German lands there too), and single-word mis-flags sit at a confident
1.0 no floor catches.
Two operator-approved levers:
- Acceptance floor is now a live Settings value (ImportSettings.
translation_min_confidence, default 0.90; surfaced in the Translation card), so
it's tunable without a redeploy. _accept takes the threshold as a parameter.
- Per-post sticky override (Post.translation_override: auto/force/original).
'force' stores a translation even below the floor (rescue a skipped
legit-foreign title); 'original' keeps the original and clears any stored
translation (kill a confident mis-flag no floor catches). The sweep honors it
on every run and _reset_translations skips 'original', so the choice survives a
Re-translate-all. POST /api/posts/<id>/translation-override applies it
immediately (translate now when the service is up, else queue for the sweep).
UI: PostTranslationControl on the posts-feed card.
Migration 0084 (both columns + a CHECK on the override). The feed + provenance
serializers expose translation_override.
With a stricter floor the rollback finally works: raise it -> Re-translate all ->
the 0.86 mis-flags are rejected and restored to the original; force /
keep-original handle the residual either way.
Tests: gate thresholds against the param (0.86 rejected at 0.90, explicit-floor
cases); sweep force/original + re-translate-skips-original; override endpoint
(validation, original clears, force queues when disabled, feed exposes it);
settings min_confidence default/save/validate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy
Every tag suggestion is a canonical DB tag now (tagging-v2 #114: heads + CCIP
score EXISTING concept tags). The pre-heads apparatus for model-predicted tags
that didn't exist in the DB — creates_new_tag / raw_name / via_alias, the
/suggestions/alias endpoint + add_alias_and_accept, AliasPickerDialog, and the
store's aliasAccept/removeAlias — was dead and is removed.
The type-to-add dropdown was TWO row sources (server autocomplete + the image's
ML suggestions) merged with a dedup that dropped the %-bearing suggestion row
when the debounced server hit landed — the operator's "confidence % flickers
then vanishes". Now it's ONE list of DB-tag matches, each annotated with the
model's confidence (join by canonical_tag_id) when the tag was scored for this
image. No dedup, no flicker; picking a suggested tag still records acceptance
via TagPanel.findPending.
Single per-image fetch: score_image now reports above_threshold per row
(computed vs the head's own suggest cut, separate from the inclusion floor), so
the rail makes ONE min=0 request and derives the panel (above_threshold) and the
dropdown (all, text-filtered) client-side — the two /suggestions calls collapse
to one. Manual "Create 'X' as <kind>" (novel typed names) is unchanged; the
alias table + tag-side alias admin + auto-apply alias matching are untouched.
Tests: gate/serializer assertions updated (above_threshold; dropped dead-field
+ alias-endpoint checks); frontend spec seeds via the single load and covers the
byCategory/aboveByCategory split.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy
Calibrated against fresh probes once Interpreter returned real langdetect
confidence: genuine German detected at 1.0, a correctly-detected but ambiguous
latin string at 0.86. Set _MIN_LATIN_CONFIDENCE to 0.80 (below that band) so
legitimate ambiguous non-English still translates while genuinely-unsure guesses
are rejected. Real langdetect also fixed the original mis-flag at the source, so
this floor is a safety net, not the primary fix. Pin 0.86-accepted in the gate
test to guard against bumping the floor back up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy
Interpreter now returns a real per-detection confidence (source stays "auto"),
so curator can reject the mis-detections it was blindly storing — e.g. a short
English title mis-labelled as German and rewritten into the archive.
The gate consumes ONLY Interpreter's own reported detection — curator does no
language detection of its own (Scribe rule 133): a field is stored when the
engine actually translated it AND either the detected language is CJK
(script-detected, reliably high — ja/ko/zh trusted outright, incl. pure-kanji
Japanese that lands as zh ~0.75) or the reported confidence clears a
latin-script floor (_MIN_LATIN_CONFIDENCE = 0.90). A latin detection below the
floor keeps the original and marks the post handled; a missing confidence fails
open. The client already sent source="auto" and parsed confidence, so this is
purely the gate + tests.
Tests: pinned interpreter-client test now asserts source stays "auto"; new
pure-unit gate tests (CJK trusted / latin floor / case-insensitive / fail-open)
in the fast lane; end-to-end reject-low-latin, accept-high-latin,
accept-low-cjk sweeps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy
Throughput: translate_posts now runs every 8h (was daily) as the
steady-state cadence for newly-imported posts, and the Settings
"Translate now" button runs it in drain mode (run-until-done, no reset)
so one press clears the whole untranslated backlog instead of a single
300-post chunk. The interrupt/backoff re-enqueue now preserves the drain
flag so a bulk drain resumes cleanly after an Interpreter restart.
Misdetection groundwork: surface the detector's confidence from the
Interpreter client (it was in the detectedLanguage payload but discarded)
and add a read-only "Test translation" box — POST /settings/translation/
probe + TranslationCard UI — that shows detected language + confidence +
engine + result for pasted text, without saving. Lets the operator see
why a short/abbreviation-heavy English title gets mis-detected so the
detection guard (min-length + confidence floor) can be tuned from real
numbers. The guard itself follows once the mis-detected cases are probed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgZP9v2otxVJymiYsnVuMy
The tonal fill (esp. character = info) is intentionally faint and blends into
the dark tag rail. Add a thin border in each chip's own kind colour via
color-mix on currentColor (the tonal chip's themed foreground), defining the
edge without changing the fill. Theme-aware in both light and dark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The larger size=default chip widened Vuetify's negative start-margin on the
leading kind-icon, placing it left of the .v-chip__content box whose
overflow:hidden (the name-truncation guard) then clipped its left edge.
Zero the icon's negative inline-start margin so it sits inside the clip box;
the chip's 12px padding keeps a comfortable left inset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_translate_one translated [title, description] in ONE Interpreter call and keyed
the whole-post passthrough on the aggregate detected_lang (the FIRST item). So an
English title + non-English description detected "en" and marked the post handled,
leaving the description untranslated. Now each field is translated independently
(its own detected_lang / passthrough) and the non-target field is stored on its
own; translated_source_lang reflects the translated field's language.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-pill ✓/✗ on unconfirmed auto-tags read muted — a faint colored icon on a
tonal chip (worst on character tags, kind=info) that only lit up on hover. Make
them solid green/red circles with a white glyph (22px, icon 15), mirroring the
Suggestions rail's verdict buttons so accept/reject read identically. Also bump
the applied-tag chips from size=small to default and the leading kind icon to
match — bigger, clearer tags throughout the rail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
translation/status now reports `active` (a translate/retranslate sweep is
running, from the TaskRun table) and `last_run` (the most recent finished run's
task + status). The Settings card polls live while a sweep runs, showing a
spinner + "Translating… N remaining" that ticks down, and flags a last run that
ended in error/timeout. No migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
web gets a /api/health liveness check; workers a lenient celery-ping check. A
shared deploy policy (update_config order=start-first, failure_action=rollback,
monitor 90s; rollback_config; restart_policy) means a bad image that never goes
healthy is rolled back automatically instead of taking the service down. Ignored
by plain `docker compose up` (deploy: is swarm-only), so the dev override is
unaffected. Assumes prod deploys from this file via docker stack deploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Downloads/imports stage into <name>.part / <name>.partial then os.replace() into
place, so a kill mid-write leaves a discardable temp — never a corrupt final.
cleanup_orphaned_temp_files sweeps ones left behind under the images root, only
older than 6h so an in-flight download's staging file is never removed. Daily beat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- interpreter_client: shared requests.Session with a connect-only retry
(connect=2, no status retries — we map 429/5xx ourselves) so a proxy reload
is smoothed and the keep-alive connection is pooled across the sweep.
- translate_posts: on an interrupt (drain), re-enqueue after the Retry-After
hint / default backoff instead of waiting for the daily beat; self-terminates
via the health gate. Steady-state one-chunk-per-run on success is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Explore's neighbour grid (/api/gallery/similar → gallery_service.similar) now
takes an Explore-only exclude_wip flag that drops `wip` system-tagged images
from the candidates, alongside the banner/editor presentation tags. The
gallery's own "similar" button is unchanged (keeps wip, #1274) — only the
Explore store passes exclude_wip=1. The anchor itself may still be a WIP; only
neighbours are filtered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploys (docker SIGTERM→SIGKILL, default 10s) were killing Celery jobs
mid-flight. Give in-flight work room to drain and make interrupted work
resume cleanly instead of stalling.
- docker-compose.yml: stop_grace_period per lane (web 30s / worker 90s /
scheduler 60s / maintenance-long 180s / ml-worker 120s) so warm shutdown
can actually drain before SIGKILL.
- celery_app.py: task_reject_on_worker_lost=True — a task killed past the
grace window is re-queued (safe: idempotent + chunked, recovery sweeps
re-drive stragglers).
- interpreter_client.py: map 429/5xx (502/503/504) → InterpreterUnavailable
and parse Retry-After (delta-seconds or HTTP-date); a draining Interpreter
behind a reverse proxy no longer raises an opaque HTTPError.
- translation.py: thread retry_after out of _translate_batch; retranslate_posts
resumes after the Retry-After hint (or 60s default, capped 900s) on an
interrupt with _reset_done=True, self-terminating via the health gate.
- tests: 429/5xx mapping + Retry-After parse; interrupt-resume + default backoff.
No migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
retranslate_posts resets the 5 translation columns to NULL for a scoped set of posts (all, or WHERE artist_id IN ids) then reuses the untranslated sweep to re-run them, chasing the tail until drained (run-until-done). Interpreter cache keys on engine_version so a changed model re-translates, an unchanged one is cache-fast. Reset only happens when the service is configured+healthy so translations are never wiped when they can't be rebuilt. New POST /settings/translation/retranslate (artist_id | all=true). UI: per-artist 'Re-translate posts' on the Artist Management tab + 'Re-translate all' in the Settings Translation card, both with confirm dialogs. No migration (reuses m143 columns).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>