Commit Graph

418 Commits

Author SHA1 Message Date
byrongamatos
53fa620a70 test(stats): prove seconds-only recency on a fresh row
CodeRabbit on #947: the prior lastPlayPosition POST already stamped
last_played_at, so the assertion passed even if the seconds-only path
left it unchanged — a guard that cannot fail. Assert on a fresh row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:31:49 +02:00
Byron Gamatos
3e57ba0345
fix(career): hours polish — recency stamp + non-2xx POST is a failure (#947)
CodeRabbit follow-up on #942:

- add_play_seconds() now stamps last_played_at (like touch_position):
  an unscored play that ran to the natural end WAS played — recent /
  Continue ordering must see it. Resume position stays untouched.
- stats-recorder post() treats non-2xx as failure: a 4xx/5xx JSON error
  body parsed as an object read as success, silently dropping the
  accrued seconds instead of re-queuing them.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:31:12 +02:00
Byron Gamatos
b85496fe58
feat(career): passport visuals pack — tilt, emerging ink, gold foil (#944)
* feat(career): passport visuals pack — tilt, emerging ink, gold foil

Career v2, WS4. All CSS + a rAF-throttled pointer handler, no deps:

- Trading-card tilt on EARNED artifacts only (shelf covers + the badge
  page stamp): pointer-tracked perspective rotateX/Y with a glint sweep
  following the pointer. The cover's jitter rotation moves into a CSS
  var so the tilt transform composes with it; the blanket cover-hover
  translate is scoped :not(.pp-tilt) so it can't fight the tilt.
  Hover-capable pointers only; off under prefers-reduced-motion.
- Emerging-stamp ink: the ghost stamp fills by qualifying/required via
  a conic-gradient — the stamp visibly carves in, no numbers added.
- Gold foil preview: the "coming" note gains a dashed foil chip with a
  periodic shimmer — honest, never earnable-looking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): tilt rAF race + freshly-slammed stamp becomes a card

CodeRabbit on #944: a queued tilt frame closed over the departed card
and re-applied vars after pointerleave; and the just-slammed stamp
never regained pp-tilt until the next open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:22:41 +02:00
Byron Gamatos
4027c31a61
feat(career): curated genre drills — per-instrument, achievably cleared (#943)
Career v2, WS3. Bronze in blues/rock/metal/funk/jazz now also asks for
the genre's signature Virtuoso drill, data-driven in passports.json:
blues_shuffle, rock_power_backbeat, melodic_metal_gallop,
sixteenth_pocket, vl_shells — with career-side display labels the
passport page renders instead of raw node ids.

- virtuoso_nodes becomes {instrument: [node_ids]} so a keys passport
  never demands a guitar drill; a flat list keeps meaning guitar
  (virtuoso's content is guitar-first).
- _node_cleared also accepts keysCleared (a top-tier clean pass in one
  key — virtuoso's FIRST gained-only artifact). The depth rungs
  additionally require a maxed speed tier, too high a bar for Bronze.
- Genres without a curated entry stay songs-only.

Note: pre-release behavior change — v1 passports aren't in any shipped
build, so no earned badge can demote in the wild.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:14:39 +02:00
Byron Gamatos
0fc6a4beed
feat(career): hours-per-genre odometer — honest wall-clock play time (#942)
Career v2, WS2. Nothing measured play time before (the achievements
plugin's final-position shortcut double-counts loops and mis-reads
seeks). Now:

- stats-recorder.js accrues WALL-CLOCK seconds across song:play/resume ↔
  pause/stop/ended spans (single spans clamp at 2h against suspend
  inflation) and piggybacks them as `seconds` on the POSTs it already
  sends; failed POSTs restore the accumulator; a session reset flushes
  first so time can't re-attribute to the next song/arrangement.
- POST /api/stats accepts optional `seconds` (finite, 0 < s ≤ 6h) on the
  scored and position branches, plus a new seconds-only branch for
  unscored plays that ran to the natural end — banks time WITHOUT
  touching the resume position (song:ended must not overwrite Continue)
  and still counts as playing today for the streak.
- song_stats gains additive idempotent `seconds_total`; record_session/
  touch_position accrue, new add_play_seconds() for the seconds-only
  path; the legacy-encoding stats merge sums seconds across duplicates.
- Passports surface it: "14.2 h in Blues" under the badge stamp and on
  the shelf cover sub-line — a true fact that only grows, never a
  target or a meter (Stage 5 post-cap, per the career design).

Tests: seconds accrual/validation/seconds-only branch (stats API),
per-instrument-and-genre summing (career), fmtHours formatting (vm).
Full suites: pytest 2480, JS 1165.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:12:23 +02:00
Byron Gamatos
d26347981c
feat(career): badge ceremony — the crowd erupts, the stamp drops (#941)
Earning a genre badge now stages the full moment (career v2, WS1):

- venue-crowd.js gains a public celebrate(): machine.force('ecstatic')
  commits instantly (bypassing STABLE_MS/DWELL_MS; the stamped
  lastSwitchAt makes the dwell window HOLD the forced state before the
  real perf machine reasserts) + a cheer stinger via the same
  _lastStingerAt=-Infinity bypass the end-of-song reaction uses. If a
  stinger/intro owns the idle layer, the ecstatic loop is queued via
  _pendingLoop exactly like onPerformanceState. No-op without a
  manifest/active venue.
- career detectNewBadges() calls badgeCeremony(): crowd first, then a
  body-appended full-screen overlay 300ms later (it cannot live in
  #pp-overlay — #plugin-career is display:none during playback): dimmed
  backdrop, the bronze stamp slamming in with a shine sweep, a 42-piece
  canvas confetti burst, click-or-4s dismiss.
- prefers-reduced-motion: chime + fbNotify only, no overlay.

Tests: machine.force commit/dwell-hold/bogus-state, celebrate export +
no-manifest no-op, celebrate-called-once-per-badge, crowd-absent and
crowd-throwing degradation.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:07:28 +02:00
Byron Gamatos
45caa86ab8
Bar pack v4 refresh + crowd sound reactions (#940)
* chore(career): refresh bar pack to v4 + restore crowd-SFX setting

Pack v4: per-character desynced animation starts, flyover intro,
per-venue reaction sounds (sfx-up/sfx-down in manifest).
Settings: re-add the crowd sound reactions toggle that was dropped
when settings.html became the passports data panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(v3): crowd mood-change sound reactions (cheer up / boo down)

Port the venue-crowd SFX runtime that the settings toggle and the
pack's sfx-up/sfx-down files were built for: on a committed mood
transition, play the venue's own cheer (up) or boo (down) one-shot,
gated by the feedBack-venue-crowd-sfx setting (default off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: rebuild tailwind.min.css for settings toggle classes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:29:34 +02:00
Byron Gamatos
8f1906a0c1
Merge pull request #926 from got-feedBack/feat/tuning-midis-followups
tuningMidis follow-ups: NaN/Inf guard in freqs_to_midis + v3 badge adopts exact midis
2026-07-13 14:20:58 +02:00
Byron Gamatos
8b6829a946
Merge pull request #938 from gionnibgud/feat/v3-start-fullscreen
feat(v3): add desktop-only "Start in fullscreen" system option
2026-07-13 14:17:05 +02:00
gionnibgud
3050c7b1d3 feat(v3): reword the fullscreen setting + note the macOS launch caveat
Retitle the toggle "Fullscreen" (from "Start in fullscreen") and reword
the description to "Run fee[dB]ack in fullscreen mode. On macOS, changes
take effect on the next launch."

The macOS note is honest about a native-fullscreen limitation: AppKit
drops the first programmatic fullscreen-enter on a window created
windowed, so on macOS the desktop side applies the pref at next launch
rather than live. Windows/Linux apply it live on the first toggle. The
note is self-scoping text (no platform-detection code needed).

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
2026-07-13 13:43:44 +02:00
Byron Gamatos
ba796b0f27
Merge pull request #934 from got-feedBack/chore/feedpak-spec-gate
ci: gate core against the feedpak spec
2026-07-13 13:31:18 +02:00
byrongamatos
ddc06ff1e7 Merge branch 'main' into chore/feedpak-spec-gate
Signed-off-by: byrongamatos <xasiklas@gmail.com>
2026-07-13 13:27:25 +02:00
byrongamatos
ac5c5ad20d ci: cover gap-fill manifest key scans
Signed-off-by: byrongamatos <xasiklas@gmail.com>
2026-07-13 13:25:44 +02:00
gionnibgud
f8012a8ce4 feat(v3): add desktop-only "Start in fullscreen" system option
Adds a "Start in fullscreen" toggle to the Settings → System panel,
addressing the desktop request in feedBack-desktop#97: users want the
app to launch fullscreen without hitting the OS hotkey every time.

The block ships hidden and is gated exactly like the App-updates block:
setupWindowOptions() only unhides + wires it when the feedBack-desktop
bridge exposes window.feedBackDesktop.window.{getStartFullscreen,
setStartFullscreen}. Web/Docker builds have no such bridge, so the
section never appears there. Persistence lives desktop-side because
only the Electron main process can read the pref at window-creation
time — core just proxies through the bridge.

The desktop bridge + launch behaviour land in a follow-up
feedBack-desktop PR.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
2026-07-13 12:38:24 +02:00
Byron Gamatos
99b974a5a1
fix(career): passport review polish — a11y semantics + seen-state guard (#937)
CodeRabbit follow-up on #936 (the one Major — overlay outside the click
root — was verified false: the host mounts every screen.html root inside
#plugin-career, ✕-close confirmed working live):

- Tabs: aria-selected/aria-controls + role=tabpanel/aria-labelledby.
- Book overlay: role=dialog + aria-modal + aria-label; focus moves to
  the close button on open and returns to the opener on close.
- seenBadges(): guard non-object JSON so a corrupt stored value cannot
  throw on every passport refresh (covered by a new corruption test).
- Fresh-session suppression test (badge seen → no re-notification).
- Stylelint declaration-empty-line-before nit in .pp-stamp.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:14:24 +02:00
Byron Gamatos
7ffa6e2c51
feat(career): passport UI — the book, the stamp, the rack (#936)
The Passports tab beside Venues renders the badge journey physically:

- Per-instrument passport book: embossed CSS-leather cover, 3D page-turn
  spread (badge page left, ticket stubs right), Escape/backdrop close.
- Wax-seal commitment ceremony (Stage 0) — pressing the seal commits the
  instrument; opening a first passport runs the ceremony implicitly.
- Rubber-stamp badge slam: earned badges chime + notify immediately, the
  slam (with ink bleed, page shake, deterministic sin-hash jitter) plays
  when the passport is next opened, then the badge is marked seen.
- Ticket-stub repertoire: qualifying songs as collected stubs.
- Brochure rack: unopened genres as "Explore next" invitations — no
  greyed slots, no completion meters (the anti-list as layout).
- Drill relay: on virtuoso:progress bus events the career screen posts
  the full virtuoso.progress localStorage snapshot to the drill-state
  intake (debounced; one-time bootstrap when the server has none).
- Four synthesized sfx (stamp/seal/page/chime, 13 KB total) as plugin
  assets; prefers-reduced-motion disables the theatrics.

Pure logic (ppKey, ppJitter, badge diff/seen) is covered by a bare-vm
node --test suite via a window.__careerPassportTest seam.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:56:32 +02:00
Byron Gamatos
3832a5762b
feat(career): passport backend — genre badges computed from stars (#935)
The badge-journey layer on top of career stars (Christian's career-mode
v2 design, composed with the shipped venue system). Badges are computed
on read from song_stats × the library's effective genre — never stored:
Bronze = N genre songs at min_stars (data-driven in passports.json,
default 5 songs at 2★) plus any configured virtuoso drill nodes.

New endpoints under /api/plugins/career/:
- GET  /passports        passport walls per instrument: badges, ticket
                         stubs (qualifying songs), library genres, drills
- POST /passports/commit instrument commitment (idempotent wax seal)
- POST /passports/open   open a genre passport (implies commitment)
- POST /drill-state      intake for the relayed virtuoso.progress
                         snapshot (career's frontend listens on the bus)

Instrument attribution reuses progression.instrument_for_arrangement via
the song_stats arrangement index; the genre column goes through the
host's override-aware effective-genre SQL. Non-graded instruments (bass,
drums) render shown-not-judged — repertoire, never a false badge denial.

Persisted state (commitments, opened passports, drill snapshot) lives
under CONFIG_DIR/career/ and rides the settings export bundle via
settings.server_files; a minimal settings.html documents it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:47:59 +02:00
topkoa
a60dcd10c2 ci: legible errors for a missing baseline and an unparseable reader
Two review nits: check_allowlist_closed() raised a traceback when
--baseline-exceptions pointed at a missing file (the error now says CI
derives it from the base branch and local runs should omit the flag), and
check_key_coverage() would traceback on a reader with a SyntaxError (now a
::error:: naming the module — belt-and-braces, since such a module can't
pass pytest either, but this job may run first).

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 01:23:59 -04:00
topkoa
5dcf39cd62 test: drop unused sys import (review)
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 01:14:02 -04:00
topkoa
c485f02211 ci: track the spec's HEAD — the app conforms to the living spec
Design change, at the maintainer's direction: the gate now checks out
feedpak-spec at HEAD instead of a pinned SHA. .feedpak-spec-ref, its
40-char validation step, and the pin-bump machinery are gone.

Rationale: it is vital that the app conforms to the spec — the current
spec, not a snapshot. The pin bought determinism at the cost of a
maintenance loop (bump PRs, a PAT, weekly latency) and a window where the
gate verified against a stale spec. Tracking HEAD makes the dev flow fully
self-serve with zero upkeep: gated PR -> FEP -> spec merge -> re-run
checks -> green. Nothing to bump.

The trade-off is accepted with eyes open, and the docs state it: the
normal FEP is additive and can only loosen the gate, so it cannot redden
anyone's PR. Only a breaking spec change (rare, deliberate, MAJOR per the
spec's compatibility policy) turns PRs red repo-wide — which is the
correct org-wide signal that the app is out of conformance. The CI job
logs the spec SHA each run verified against, so any red run is
reproducible.

Failure messages now also say why it matters beyond the one PR (also at
the maintainer's direction): non-conformance that lands shows up as red CI
on every teammate's PR until it is resolved, and only its author can clear
it — the FEP route keeps everyone else unblocked. Tone softened throughout
(the exceptions-file header now explains rather than shouts).

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 01:07:12 -04:00
topkoa
203f82b6fe ci: legible failures for malformed exceptions file; docs catch up
_parse_exceptions() now validates the document shape — top level must be a
mapping, 'exceptions' must be a list, each entry a mapping, and YAML parse
errors are caught — each failing with a ::error:: instead of an
AttributeError traceback. CI output must say what to fix. Parametrised
tests cover all four malformed shapes.

docs/feedpak-spec-gate.md: the Limitations section still described the
pre-flow-aware scanner (KEY_OPS, name-list-only receivers). Now states the
actual residual gaps: function-parameter manifests are recognised by name
only, and helper-mediated literal keys (song.py's
_gap_fill_manifest_absent(manifest, "album")) are unseen by both the scan
and the readers-complete guard, since they share one detector.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 00:53:09 -04:00
topkoa
0158286d06 ci: flow-aware manifest discovery — a name list missed real readers
Review found lib/routers/chart.py binding `m = load_manifest(p) or {}` and
reading eight manifest keys through it. `m` was not in MANIFEST_VARS and
`m.get` did not match the readers-complete regex, so the module was
invisible to BOTH halves of the gate — unlisted and unscanned. Same for
lib/routers/song.py (binds `manifest` from load_manifest for enrichment
gap-fill). Both are now in READERS.

The structural fix, not the name-list patch: keys_touched() now discovers
receivers flow-aware — any local assigned from load_manifest(...) is a
manifest dict, whatever it is called. MANIFEST_VARS remains only as the
fallback for manifests that arrive as function parameters (ws_highway).
A plain `m = {}` is not a receiver; test pins that.

readers-complete now reuses keys_touched() itself instead of a parallel
KEY_OPS regex — the two detectors diverged once already (that is exactly
how chart.py slipped through), so now there is one detector and one truth.

check_reverse() gets a 300s subprocess timeout: the validator executes at a
pinned SHA, but a pathological pack or validator bug should fail the job,
not hang the runner to the Actions-level timeout.

Tests: flow-aware receiver under an arbitrary name (read + write), and the
negative — a plain dict named `m` stays out of the scan. 17 pass.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 00:45:38 -04:00
topkoa
1e2ce29cf6 docs: make FEP-first impossible to miss before the gate fires
The CI gate catches spec drift at merge time; these two additions catch it
at write time, which is where "developer didn't read the spec first"
actually happens.

CLAUDE.md (Song Formats): a spec-is-sacrosanct paragraph next to the spec
pointer — the spec defines the format, the app implements it, any new
manifest key/file/directory lands in the spec first via the FEP process,
and the gate has no in-repo bypass. AI agents and contributors both hit
this while writing feedpak-touching code, not after CI reddens.

.github/pull_request_template.md (new — the repo had only issue templates):
a feedpak-surface section requiring either "doesn't touch pack I/O" or a
link to the landed FEP + the .feedpak-spec-ref bump, plus the standing
changelog/tests/DCO checklist.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 00:41:44 -04:00
topkoa
b54b65d35c test: give the spec gate its own regression suite
Self-review finding: the gate is what keeps the app from drifting off the
feedpak spec, but the gate itself had zero pytest coverage — a refactor
could quietly weaken keys_touched or the allowlist logic and nothing would
notice. The protector needs protecting.

tests/test_spec_gate.py pins the load-bearing behaviours:

- read/write classification: get() reads; subscript Store and setdefault()
  write (the two forms that were blind spots in review); the
  load_manifest-wrapped get; unrelated dicts and non-literal keys ignored.
- exceptions file: duplicate keys and issue-less entries rejected.
- the closed allowlist: growth fails, shrink and steady state pass,
  bootstrap skips.
- live-tree checks, same as CI: READERS matches the codebase, and the only
  non-spec key core touches is the grandfathered original_audio.

Also fixes stale "Layer 2/3" docstrings on check_forward/check_reverse
(they are layers 3/4 since allowlist-closed landed) — the same
docs-lag-the-code class this PR's review kept catching; now the numbering
is asserted by the printed [n/4] headers next to them.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 00:39:36 -04:00
topkoa
ab2e68a638 ci: resolve the allowlist baseline against the real base branch
The allowlist-closed diff hardcoded `origin main`, but ship-ci.yml also runs
this workflow for PRs into release/** and for pushes to release/**, where a
main baseline diffs against the wrong branch and can fail changes that have
nothing to do with the allowlist. It now resolves the base:

  PR   -> github.event.pull_request.base.ref (the branch it merges into)
  push -> github.ref_name (the branch itself; its tip already contains the
          change, so the diff is a no-op — enforcement happens at PR time)

Also from review, all documentation drift introduced by my own earlier
commits:

- The layer count said "three" in the module docstring, the workflow comment,
  the docs, and the changelog. There are four (allowlist-closed was added).
- The changelog listed three scanned modules; there are five.
- The docs and changelog stated the rule for keys core *reads*, omitting
  writes — which are equally gated, and land in every pack we emit.
- The CI summary line labelled grandfathered keys "pending spec", implying
  adoption is the only resolution. For original_audio it is not: the fix is
  removal. Relabelled "grandfathered (tracked debt)".
- feedpak-spec-exceptions.yml said an entry clears when core "stops reading"
  the key; the rule is "no longer reads or writes".
- Replaced a bitwise `&` over two bools with two named results and an
  explicit `and` — both checks must run (a stale READERS list and an
  undeclared key are separate failures; short-circuiting would hide one), and
  `&` reads like a typo.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 00:32:24 -04:00
topkoa
d806d12c22 ci: close two blind spots in the key scan
Review found the gate was scanning less than it claimed.

READERS missed two modules that genuinely touch feedpak manifests:
lib/routers/ws_highway.py (reads `authors`) and lib/gp2notation.py (loads
manifest.yaml, stamps feedpak_version, writes the file back). Keys touched
there were going entirely unchecked.

The scan also only recognised writes done via subscript, so
`manifest.setdefault("k", v)` — exactly how gp2notation.py stamps
feedpak_version — was invisible. setdefault with a literal key now counts as
a write.

The deeper problem is that READERS is hand-maintained, and a hand-maintained
list rots; that is how both modules went unnoticed. check_readers_complete()
now re-derives the set: any module under lib/ (or server.py) that both
touches manifest keys and shows a feedpak signal must be listed, or the build
fails. It is a guard on the gate itself.

The list stays explicit rather than becoming a glob, because `manifest` is
overloaded here: lib/loosefolder.py (the loose-folder manifest.json) and
lib/diagnostics_bundle.py (the diagnostics bundle manifest) have their own
unrelated manifests, and scanning those would flag *their* keys as feedpak
drift. Both score zero on the feedpak signals, which is what keeps them out.

Now scanning 5 modules: 20 reads, 2 writes, all spec-declared.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 00:23:36 -04:00
topkoa
32d723b774 ci: close the escape hatches — the FEP process is the only route
The gate's purpose is to make a non-conforming change *not merge*, so the
person merging must stop and decide whether to take it through the format
process. The escape hatches defeated exactly that: a developer who did not
want to write a FEP could name their key `x-whatever`, or append an entry to
feedpak-spec-exceptions.yml with any issue link, and merge. Both were
self-serve and in-repo. That is a speed bump with a signed excuse note, not a
gate.

The relief valve is the FEP process itself, not something in this repo. The
spec's governance already says so: "A change is not part of the format until
it lands here."

Removed the `x-` prefix bypass. It was invented here, not in the spec — the
spec reserves no experimental namespace. Its "unknown keys are reserved for
forward-compatibility" rule is about *tolerating* other implementations'
keys, not a licence for core to mint its own.

feedpak-spec-exceptions.yml is now a CLOSED grandfather list. A new check
(allowlist-closed) diffs it against the base branch and fails any PR that
ADDS an entry; removal stays allowed, so the list can only shrink. Deleting
an entry does not by itself pass the gate — key-coverage still fails while
core reads the key, so the entry goes when the code goes.

Every failure message now points at the FEP process and at bumping
.feedpak-spec-ref to the merged spec SHA, which is the one supported way a
new manifest key reaches core.

CI fetches the base branch to diff the allowlist; the bootstrap flag covers
the one case with no baseline — the PR introducing the gate.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-13 00:14:57 -04:00
topkoa
ceb1e143cd ci: second review pass — recurse examples, reject duplicate exceptions
Six more findings from CodeRabbit and Copilot on #934. All valid; four were
my own docs lagging the write-checking change in d0626f5.

check_forward() now discovers example packs recursively, so a pack nested
under examples/<group>/ can't slip past the "every example pack" contract.
Taken WITHOUT the suggested is_file() filter, which would have broken it: a
feedpak is dual-form — a zip (foo.feedpak) or a directory (foo.feedpak/) —
and the spec's own examples ship as directories, so is_file() would have
matched zero packs. Suffix matching covers both forms.

load_exceptions() rejects duplicate keys instead of silently keeping the
last one, which would quietly retarget the tracking issue for a piece of
debt this file exists to track.

The sloppak import is wrapped so a missing dependency produces a CI-legible
::error:: rather than a bare traceback.

Docs caught up with the code: the exceptions file header, its stale-entry
rule, and the changelog all said "reads" when the gate checks reads AND
writes.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 23:54:07 -04:00
topkoa
d0626f5618 ci: address review — check writes too, pin deps, harden the spec pin
Review feedback from CodeRabbit and Copilot on #934. All six findings were
valid; one is fixed the other way round from how it was suggested.

Key-coverage now checks manifest WRITES as well as reads. Copilot correctly
spotted that `ast.walk` ignored subscript context, so
`manifest["year"] = ...` (lib/songmeta.py) scored as a read — but the fix is
not to drop writes. A key core *writes* is spec surface pointed outward: it
lands in every pack we emit, so an undeclared one seeds the ecosystem with
non-spec data. Subscripts are now classified by ctx (Store = write, Load =
read) and both sets are checked, with distinct error messages. Today: 19
reads, 2 writes, all declared.

Workflow:
- persist-credentials: false on the repo checkout — the job runs repository
  code and never pushes (CodeRabbit / zizmor artipacked).
- .feedpak-spec-ref must be a full 40-char SHA. actions/checkout resolves
  branches and tags in `ref` too, so a non-SHA there would silently un-pin
  the spec — precisely what the file exists to prevent.
- Pin jsonschema==4.26.0, for the same reason the spec SHA is pinned: an
  upstream release must not redden this job on a PR that changed neither
  this repo nor the spec.

Script:
- check_forward() guards a missing examples/ dir instead of raising an
  unhandled FileNotFoundError.
- TemporaryDirectory() instead of mkdtemp(), so a local run doesn't leak.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 23:44:15 -04:00
topkoa
0dc9fd7ba8 docs: the fix for original_audio is removal, not adoption
The spec already carries the pre-separation mixdown as a stem
({id: full, file: stems/full.ogg}), so the key added a second, redundant
location for audio to a format that already had one. Adopting it into the
spec would make that permanent; the resolution in #933 is to remove it.

No behaviour change — the gate is agnostic about which way a violation
resolves, and only insists that one of the two happens deliberately and in
the open before the code merges. This just stops the exception entry, the
changelog, and the docs from presupposing adoption.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 23:35:49 -04:00
topkoa
22332bef22 ci: gate core against the feedpak spec
feedpak is published as an open format with its own repo, normative spec,
JSON Schemas, and reference validator. That makes the spec a contract with
everyone outside this repo: third-party packers, converters, and players
build against it, and it is meant to be the complete description of a pack.

Nothing enforced that. #583 added a manifest key (`original_audio`) that
core, lib/enrichment.py, and the stems plugin all now depend on, but which
was never added to the spec — so a spec-compliant pack stopped being a
fully-working pack, the reference validator could not warn authors about a
key it had never heard of, and third-party tooling began emitting an
`original/` directory reverse-engineered from an example in a code comment.
See #933.

We cannot mechanically prove core interprets a key the way the spec means.
We can prove three surface properties, and they cover the drift that
actually happens:

  1. key-coverage — every manifest key core reads is declared in the spec's
     manifest.schema.json (AST scan of lib/sloppak.py, lib/enrichment.py,
     lib/songmeta.py).
  2. forward — core's load_song() ingests every example pack the spec ships.
  3. reverse — every pack committed here passes the spec's own
     tools/validate.py (7/7 pass today).

The spec is pinned by SHA in .feedpak-spec-ref rather than tracked from its
default branch, so a change over there cannot redden an unrelated PR here;
bump it in its own PR, where a red result is precisely the signal that core
does not satisfy the new spec.

A gate with no legitimate way to say "yes, deliberately, not yet" gets
switched off the first time it blocks a release, so there are two escape
hatches: the reserved `x-` key prefix (always permitted, and it tells every
third-party packer the key is not stable surface), and
feedpak-spec-exceptions.yml, which requires a tracking issue per entry. An
exception that goes stale — the spec caught up, or core stopped reading the
key — fails the build, so the allowlist cannot become somewhere drift
quietly accumulates. `original_audio` is seeded there against #933 so the
gate lands green and starts blocking the next instance immediately, rather
than requiring #933 to be resolved first.

Dev/CI tooling only; never on the serve or Docker path (constitution
Principle I). jsonschema is installed in the CI job, not added to
requirements.txt.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 23:30:45 -04:00
K. O. A.
342def3851
Merge pull request #931 from got-feedBack/docs/pane-best-practices
Some checks are pending
ship-ci / ci (push) Waiting to run
docs(panes): best practices for plugin authors
2026-07-12 22:59:05 -04:00
topkoa
a0278bd3a7 docs(panes): the lifecycle traps — isConnected lies, and re-injection duplicates
Three more rules, all learned by shipping the bug first. Every one of them
produced a symptom that pointed nowhere near its cause.

RULE 5 REWRITTEN — `isConnected` lies about a panel that is a pane, in BOTH
directions:

  - true when the panel is not here (it is in a pane window)
  - FALSE when the panel is perfectly fine — the host detaches the element the
    moment a pop-out starts, before the new window has loaded

Code that rebuilds on that `false` builds a SECOND panel while the host still
holds the first. Docking brings both home. The one the user can see is the
original, which the module no longer points at — so its close button closes the
other, invisible panel ("the X doesn't work"), and the chip gets re-attached to
the impostor ("the pop-out icon vanished"). Two baffling symptoms, one duplicate,
nothing in the stack trace.

Ask the pane system where the element is (`panes.isOpen(id)`), not the DOM.

RULE 6 (new) — a plugin that can be re-injected must be able to remove itself.
Without a teardown the second run duplicates every observer, timer and listener —
and leaves a stale pane registration, which is worse than untidy: `element` is
resolved LAZILY at open time, so the host gets a node from a dead instance. Pop
out, and it moves a panel nobody owns. Includes the teardown people forget:
panes.unregister().

RULE 1 EXTENDED — panel-internal id lookups. document.getElementById returns null
once the panel has moved, so every update it guards silently stops happening
while the user is looking at the panel. Search from the panel instead. Elements
outside the panel never move and are fine as they are — audit which is which.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 22:58:10 -04:00
topkoa
67e6b25c43 docs(panes): best practices for plugin authors
Every rule here is something that has already gone wrong on this feature —
mostly in core's own code, twice in the two plugins that adopted it first.
They are cheap to get right up front and miserable to diagnose later,
because a broken pane almost always LOOKS perfect.

The traps, and why each one is easy to walk into:

- Your code still runs in the main window. That is exactly why moving the
  element works at all — and exactly why `document.body.appendChild(tooltip)`
  inside a popped-out panel lands in the window the user is NOT looking at.

- Don't hide your own panel when it pops out. Core hides it and leaves a
  stub. A plugin that also hides it hides the node that just moved — which is
  precisely how core's own chip shipped a blank pop-out window.

- Use `hidden` or a class, not inline `display`, for show/hide. `.fb-paned`
  forces the panel visible while it is out; when it docks and that class is
  removed, an inline `display:none` reasserts itself and the panel returns
  invisible.

- `isConnected` does not mean "docked". A panel in a pane window IS connected,
  just not to this document. The test you meant is
  `el.ownerDocument === document`.

- `element` is a function so it can be resolved late: return the LIVE node, and
  re-attach the chip if you rebuild your panel (Camera Director rebuilds on
  every mode change).

- rAF is throttled while the main window is backgrounded — which it is, whenever
  the user is looking at your pane. Event-driven panels don't care; continuously
  animating ones will stutter exactly when they are the only thing on screen.

- Don't synchronise anything. One realm, one panel. Writing sync code means
  you have misunderstood the model.

Also states what core guarantees back, including the one that cost the most to
learn: the element is evacuated BEFORE the pane window's document is destroyed,
so it comes home alive rather than as a photograph of a panel with every
listener in its subtree silently gone.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 22:57:05 -04:00
K. O. A.
503716acbf
Merge pull request #928 from got-feedBack/feat/panes-core
feat(panes): detachable panes — pop a plugin's real panel out into its own window
2026-07-12 20:59:24 -04:00
topkoa
41bb4482fe docs(panes): say which repo the desktop half lives in
Two comments pointed at `main.ts` and `pane-hosts.ts` as though they were in
this repo. They are not — they are in got-feedback/feedBack-desktop, and a
contributor reading only this codebase would go looking for files that do not
exist.

Named the repo and the paths, and said the part that actually matters: nothing
here depends on that code. In a plain browser a pane window is simply a pop-up;
the desktop side only upgrades it. And the frame-name prefix is a contract
across two repos with no build-time link between them, so the comment IS the
link — worth saying out loud.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:52:37 -04:00
topkoa
0955f0b6f2 fix(panes): keep a pane window in step with theme + interface scale
The pane window got a ONE-TIME snapshot of the app's theme classes and the
interface-scale custom property. The app changes both at runtime — Interface
size emits `scale:changed`, the theme emits `theme:changed` /
`v3:cosmetics-applied` — so an already-open pane went on rendering at the old
scale, in the old palette, the moment the user touched either.

"Looks identical" has to keep being true, not merely start out true.

A pane window now follows those three events for as long as it is open, and
stops on unplace(). The inline style is assigned wholesale rather than merged:
unlike the class lists (where pane.html's own `fb-pane-window` must survive),
there is nothing in the pane document's inline style to preserve — and
concatenating on every change would grow the attribute without bound as the
user dragged the scale slider.

Also: the dock's focus() always smooth-scrolled, ignoring
prefers-reduced-motion — which panes.css already honours for the card's flash
animation. A smooth scroll is motion too, and someone who asked for less of it
meant this as well.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:44:16 -04:00
topkoa
3a50e593bf fix(panes): fail fast when the pane window is unreachable; validate opts.header
Two from review.

1. _whenReady's own comment said a SecurityError means the pop-out is not
   reachable from this realm and "no amount of waiting will fix it" — and then
   it waited the full 10s deadline anyway. Ten seconds of a detached panel and a
   half-popped-out UI, for a condition we had already diagnosed as fatal.

   It now gives up after a 1s grace instead. Not instantly, deliberately: a
   throw *during* the navigation from about:blank to /pane would otherwise take
   down a pop-out that was about to work perfectly. A second is far more than
   that transition needs and far less than a user should spend staring at a
   detached panel.

2. attachChip() took opts.header on trust. It's a public plugin API, and a
   truthy non-Element header (a selector string, a wrapper object, a ref) is an
   easy mistake — one that surfaced as a confusing DOM exception from deep
   inside core instead of a TypeError naming the offending pane.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:36:10 -04:00
topkoa
5049be0523 fix(panes): the dock is born empty, so say so
panes.css hides an empty dock (.fb-pane-dock.is-empty { display: none }), but
the element was created without the class — so between creation and the first
card it was a visible-to-CSS, announced-to-screen-readers role="region"
landmark containing nothing.

Harmless in practice today (the dock is created lazily, on the same tick as the
card that prompted it), but the CSS contract should hold from first paint rather
than from the first _syncEmpty(), and any future caller of dock() gets the right
thing for free.

Found by CodeRabbit on #928.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:28:33 -04:00
topkoa
de2a42bd35 fix(panes): coerce plugin-supplied pane sizes to numbers
`spec.width` / `spec.height` are plugin-controlled, and the window host builds
window.open()'s feature string by concatenation:

    'popup,width=' + spec.width + ',height=' + spec.height

`spec.width || 380` passed anything truthy straight through. So a width of
'300,menubar=1' would not merely be an invalid size — it would inject window
features. Less dramatically, any non-numeric value produced a malformed feature
string and a pane that failed to open for no visible reason.

They now go through _size(): Number, round, reject anything not finite and
positive, clamp to 120..4000. A hostile or careless value falls back to the
default instead of reaching window.open() at all.

Verified against the obvious inputs: '300,menubar=1' -> 380 (default), '300' ->
300, 0/-50/NaN/{}/'abc' -> 380, 1e9 -> 4000, 5 -> 120.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:20:15 -04:00
topkoa
671aba950c chore(panes): drop the leftover adoption diagnostics
A ~15-line console.info dumping computed styles, sizes, child counts and the
element's inline style on every single pop-out. It was instrumentation written
to chase the "panel comes home dead" bug, and it should have gone out with the
rest of the debugging — it survived the cleanup.

Removed rather than downgraded to console.debug: nothing here is worth keeping
even behind a flag. The failures it was built to diagnose are all handled and
commented now, and the paths that can still go wrong (window never loads, adopt
throws) already log a console.error that says what happened.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:08:48 -04:00
topkoa
95d6d8a46e fix(panes): keep panes.css last in the pane window's cascade
_copyStyles appended the app's stylesheets to the pane document — which
already links panes.css — so they landed AFTER it. In the app document
panes.css loads last, after tailwind/style/v3, and its rules win ties. In the
pane window that order was silently inverted, letting core styles override the
pane chrome and the .fb-paned placement rules.

Cascade order is not a detail here. "Looks identical" has to include the order
things are said in, or the same markup with the same sheets can still render
differently.

The clones now go in BEFORE pane.html's own link, preserving their relative
order among themselves and leaving panes.css last, exactly as in the app.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:01:17 -04:00
topkoa
f43779c99e fix(panes): detach the element when the pop-out starts, not when it lands
The window host's place() is asynchronous — it opens the window, waits for
/pane to load, and only then adopts the element in. But the manager emits
`panes:opened` as soon as place() returns, and the chip reacts by putting its
"popped out" stub where the element used to be.

So for that gap the user saw BOTH: the real panel still sitting in its
original spot, and a stub next to it claiming the panel had left. On a window
that never loads, that lasts the full 10s readiness timeout.

Detach the element as soon as we commit to moving it. That is not destructive:
the node keeps its owner document, its listeners and its closures — it is
simply out of the tree, waiting for a document to be adopted into. And if the
window never loads, closePane() puts it straight back at its home, which is
exactly what the failure path already does.

The dock host has no such gap; its place() moves the element synchronously.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:52:32 -04:00
topkoa
82aa8a757e fix(panes): harden the persisted host map against unsafe pane ids
A pane id is plugin-controlled, and it becomes a KEY in the persisted
{ paneId: hostId } map. `__proto__` and friends are not ids, they are booby
traps:

  - `map['__proto__'] = 'window'` on a plain object corrupts the map, and
    can reach Object.prototype.
  - `map[id]` on a polluted (or hand-edited) object can return a value straight
    off the prototype chain for a pane that was never remembered at all — so a
    pane could be "restored" to a host nobody ever put it in.

Three layers, because each is a one-liner:

  - Reject `__proto__` / `constructor` / `prototype` as pane ids at
    registration, so they never reach storage.
  - Re-key whatever comes out of localStorage onto a null-prototype object, so
    a corrupt or hand-edited value cannot smuggle a prototype in.
  - Read with an own-property check.

Also from the same review:

  - Removed `window.__fbPaneWindows`. It was exposed for pane-desktop.js back
    when that file needed to reach the window handles; the rewrite dropped that
    need and nothing has referenced it since. Dead global, and its comment
    described a collaborator that no longer exists.
  - Corrected the /pane cache comment. It claimed a stale page would leave the
    window blank, which stopped being true when the readiness check gained a
    `doc.body` fallback — it would still work, just without the pane window's
    own layout. A comment that describes a failure mode the code no longer has
    is worse than no comment.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:39:57 -04:00
topkoa
cb425ed48d fix(panes): don't hide a docked pane; don't force display; restore visibility
Six more findings from CodeRabbit on #928. Three are real bugs.

1. THE CHIP HID DOCKED PANES. `_onOpened` decided "did the pane take my
   element?" from `ownerDocument !== document`. That is true for a pane in a
   pop-out window — and false for a pane moved into the DOCK, which lives in
   this very document. So docking a pane stamped `.fb-pane-detached`
   (display:none !important) onto the panel the user was looking at, and put
   the stub next to it instead of at its home.

   The element cannot answer this question — `isConnected` is true in a pane
   window, `ownerDocument` is this one in the dock. Both were live bugs. Ask
   the manager, which knows exactly what it handed to the host:
   `panes.elementOf(id)`. That holds for every host, and for reconciling after
   the fact (detail == null), which is what a plugin rebuilding its panel
   mid-pop-out triggers.

2. `.fb-paned` FORCED `display: block !important`. A panel that is
   `display:flex` or `grid` would be silently re-laid-out while detached —
   the exact opposite of "placement only", and precisely the kind of surprise
   this feature exists to avoid. Removed.

   Making a hidden panel visible is a separate job, and it now belongs to the
   manager, which does it without touching the panel's display MODE: clear
   `hidden`, and clear an inline `display:none` if that is how the panel hides.

3. VISIBILITY IS NOW RESTORED. The hosts used to set `el.hidden = false` and
   never put it back, so the docs' "core only changes placement" was a lie and
   a panel's hidden state was quietly lost. The manager stashes both `hidden`
   and the inline `display` on open and restores them on dock: a panel that was
   closed when you opened its pane from the tray goes back to being closed; one
   that was open stays open.

Plus:

- The launcher rebuilt its whole list on every panes:opened/closed — including
  the one fired by clicking a button in that list — destroying the button under
  the user's finger and dropping focus to <body>. It now restores focus to the
  toggled pane's button.
- `_copyStyles` cloned every stylesheet link, including the panes.css that
  pane.html already loads. Skip sheets the pane document already has.
- Docs: the chip may route to the DOCK, not always a window (it goes through
  detach() → the host router). `header` precedence was documented backwards —
  an explicit `header` wins. And the visibility contract above is now written
  down rather than being a surprise.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:24:00 -04:00
topkoa
b74a364857 docs(panes): re-attach the chip when the panel is rebuilt
A plugin that rebuilds its panel (Camera Director does, on every mode
change) takes the chip with it. attachChip() returns a detach(); call it
before re-attaching, and again in teardown, or you leave a stub pointing at
DOM that no longer exists.

Found by CodeRabbit on #928.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:06:04 -04:00
topkoa
859b0036e5 fix(panes): review fixes — stranded elements, duplicate listener, class clobber
Three real findings from CodeRabbit on #928, all in current code.

1. closePane() adopted the element out of the pane window ONLY when its
   original home was still connected. If the panel never had a parent (a
   plugin that builds it lazily and hands it straight over) or its container
   was torn down while the pane was out (a screen change), the whole block was
   skipped — leaving the element inside a window we then close, which strips
   every listener in its subtree. That is exactly the "comes home dead" failure
   this ordering exists to prevent; the guard just moved it from the common
   path to the rare one, where it is far harder to spot.

   Adopting and re-homing are two different jobs and only one of them is
   allowed to fail. Adopt UNCONDITIONALLY — that is what rescues the element —
   and insert only when there is somewhere to insert it. With no home the
   element ends up owned by this document but not in it: detached, intact,
   listeners alive, ready for the plugin to re-insert.

2. The pane window's `beforeunload` handler was registered TWICE, comment block
   and all — a bad scripted edit on my part. Harmless (the handler is
   idempotent via panes.isOpen) but dead duplicate code. Also fixed the stale
   comment further down that still claimed there was no beforeunload listener
   at all.

3. _copyStyles ASSIGNED className on the pane document's <html> and <body>
   instead of merging. pane.html sets `class="fb-pane-window"` on <html>, and
   panes.css hangs the pane window's own chrome off exactly that — so copying
   the app's classes over it silently took the pane window's own layout with
   them. Merge both class lists, and append the interface-scale inline style
   rather than replacing the attribute.

Also guarded the docs' integration example behind a feedBack.panes check: the
doc says the API is optional, and then showed an example that would throw on a
host without it.

Not applicable (reviewed against e5cbea2, the branch's first commit, before the
rebuild): the prototype-pollution findings in pane-bridge.js and pane-mirror.js,
and the `panes[]` manifest validation in plugins/__init__.py. All three files are
gone — 188bdaa deleted the entire cross-realm bridge, mirrorGlobal, and manifest
layer when panes switched to moving the real DOM node.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:04:10 -04:00
topkoa
a7348052ae fix(panes): give the pop-out stub a focus ring
.fb-pane-stub is a <button>, and both of its sibling controls
(.fb-pane-chip, .fb-pane-card-btn) have an explicit :focus-visible outline.
It didn't, so keyboard focus fell back to the UA default and looked
inconsistent next to them.

Found by CodeRabbit on #928.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 19:01:08 -04:00
topkoa
1e5282e27e fix(panes): get the element out before the pane window's document dies
Docking a popped-out panel brought it home DEAD. It rendered perfectly —
right markup, right size, right place — and every control in it was inert:
the close button, the sliders, the presets, even the pop-out chip. A
photograph of a panel.

Closing a pane window tears down its document, and the panel was still
inside it. The node itself survives (the manager holds a reference), but
every event listener in its subtree goes with the document that hosted
them. Two paths did this:

  1. closePane() called the host's unplace() — which closes the window —
     BEFORE adopting the element back. Order is now reversed, and the
     comment says why so nobody helpfully "tidies" it back.

  2. The user closing the pane window themselves was only noticed by the
     `closed` poll, which by definition runs AFTER the document is gone.
     The window now gets a `beforeunload` listener that brings the element
     home while its document is still alive.

That listener has to be attached AFTER /pane loads: window.open() hands
back a throwaway about:blank document, and anything registered on it is
discarded when the real page replaces it. This is the same trap that made
the pane window blank in the first place — adopt into about:blank and the
panel is destroyed a moment later — and it is now handled in both places.

The `closed` poll stays, but only as a last-resort net for a CRASHED pane
window, where nothing can be saved.

Also fixed while chasing this:

  - The chip stamped `.fb-pane-detached` (display:none !important) onto the
    element to hide it in the main window — and that element is the one we
    move, so the class travelled with it and blanked the pane window. The
    chip now only hides an element the pane did NOT take, and marks the hole
    with its stub otherwise. "Did not take" is an ownerDocument test, not
    isConnected: a panel sitting in a pane window IS connected, just not
    here, and a plugin that rebuilds its panel (Camera Director does, on
    every mode change) re-runs attachChip while popped out.

  - The stub was inserted "before the element", which is nowhere — the
    element has left the document. The manager now hands over the element's
    recorded home, and the stub goes there.

  - GET /pane sent no cache headers. A stale copy is especially nasty here:
    the opener waits for an element inside that page before adopting, so an
    old cached version means the pane window just sits there blank.

Verified in the desktop app: pop out, use the controls in the pane window,
dock back, use them again. Panel comes home alive.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 18:47:38 -04:00
OmikronApex
d364529919
Merge pull request #930 from got-feedBack/fix/accuracy-floor-not-round
Some checks are pending
ship-ci / ci (push) Waiting to run
fix(v3): floor accuracy percentages so 100% means all notes hit
2026-07-13 00:41:03 +02:00