Compare commits

...
Author SHA1 Message Date
byrongamatosandClaude Fable 5 36e91574e8 fix(career): passport review polish — a11y semantics + seen-state guard
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:01:17 +02:00
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
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
K. O. A.andGitHub 342def3851 Merge pull request #931 from got-feedBack/docs/pane-best-practices
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.andGitHub 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
OmikronApexandGitHub d364529919 Merge pull request #930 from got-feedBack/fix/accuracy-floor-not-round
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
OmikronApexandClaude Fable 5 81ef11d855 fix(v3): floor accuracy percentages so 100% means all notes hit
Math.round let 431/433 (99.54%) display as 100%. Floor at every
accuracy display site (HUD, library badges, dashboard, lessons,
profile, playlists, calibration overlay); stored fractions and
mastery thresholds unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 00:35:09 +02:00
topkoa 9e9f0fdac6 fix(panes): adopt into the real pane document, not about:blank
Both pop-outs opened blank.

window.open() returns immediately, and the window it hands back already has
a document — an `about:blank` one, whose readyState is 'complete'. So the
host cheerfully adopted the panel into THAT, it worked for a few
milliseconds, and then /pane finished loading, replaced the document, and
took the panel with it. Blank window, vanished element.

Waiting for 'load' is no better: it may already have fired for about:blank
before we could listen.

So don't trust readyState and don't trust 'load' — wait for the one thing
that exists only in the document we actually want: pane.html's
#fb-pane-root. Poll for it (guarding the cross-document window while it is
mid-swap), give up after 10s, and on failure bring the element home rather
than stranding it in a window that never loaded.

Also drop the popup's 'beforeunload' listener: it was registered on the
about:blank window and discarded along with it, so it never fired. The
`closed` poll is what notices a user shutting a pane window — as it must be
anyway, since a crashed renderer never says goodbye either.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 18:20:57 -04:00
topkoa 188bdaa837 feat(panes)!: move the real element, instead of rebuilding it
The first cut of this got the model wrong. A pane was a SECOND
implementation of the plugin's panel — its own sliders, its own styling,
driven over a cross-realm bridge (ctx, a state store, capability RPC,
mirrorGlobal, a stream sampler). Popping out gave you something that
resembled the panel you popped, and every feature it did not reimplement
(presets, tabs, EQ, language) was simply gone.

What a user wants from "pop this out" is the thing they popped out.

So: MOVE THE REAL ELEMENT. Same-origin windows can adopt each other's
nodes, and an adopted node keeps its event listeners and its closures.
The panel goes on running the plugin's own code, against the plugin's own
state, in the plugin's own realm — it is merely being DISPLAYED in another
window. Copy the app's stylesheets into that window and it looks identical
too, because it is identical.

The plugin's side collapses to two lines:

    feedBack.panes.register({ id, title, element: () => panelEl });
    feedBack.panes.attachChip(panelEl, id);

and everything comes along: the CSS, the listeners, the presets, the
state. Nothing to keep in step, because there is no second copy.

Deleted, all of it now pointless: pane-bridge (ctx + transports), pane-hub
(the cross-realm server), pane-runtime (the pane realm's boot), pane-streams
(the rAF sampler that existed because an AnalyserNode can't cross a window),
pane-mirror (mirrorGlobal), pane-plugins + the manifest `panes[]` key and its
server-side validation, panes.state(), and both built-in demo panes. ~1200
lines. None of it was wrong — it was all correct machinery for the wrong
problem.

Consequences worth knowing:

- The window MUST be opened by the renderer with window.open(), not by the
  desktop's main process: a window we did not open gives this realm no handle
  to its document, and without the handle there is nothing to adopt into.
  Electron turns the same-origin window.open() into a real BrowserWindow
  anyway (setWindowOpenHandler → 'allow'), so we get the OS window AND the
  live DOM link. The desktop side finds it by frame name.
- `.fb-paned` neutralises PLACEMENT only (position/inset/width/z-index/shadow).
  A plugin panel is nearly always a fixed overlay pinned to a corner of the
  app; alone in a 380px window that positioning is nonsense. Colours, borders,
  padding, fonts and the panel's own internal layout are untouched — the whole
  promise is that what you popped out is what you get.
- The element is returned to its EXACT home on dock: same parent, same position
  among its siblings.
- The plugin's code still runs in the main window. So a document.body
  .appendChild() inside a panel (a tooltip, a popover) lands in the main
  window, not the pane — anchor to the panel instead. And a continuously
  animating panel may run slowly while the main window is backgrounded, since
  its rAF lives there. Both documented.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 18:12:46 -04:00
topkoa 330995588c feat(panes): panes.state(id) — let a plugin apply its own pane's values
mirrorGlobal covers the case where a pane drives a plain global that some
renderer reads each frame. It does not cover the far more common one: a
plugin whose MAIN-realm code is the authority — it clamps, it persists, it
emits events, it owns the audio graph or the camera rig — and which must
therefore APPLY the pane's values itself rather than have core splat them
somewhere.

Camera Director is the case that forced this. Its brain is the sole writer
of the camera store, the sole broadcaster on splitscreen's channel, and the
only thing that clamps an axis to its legal range. A pane cannot write
window.__h3dCamCtl behind its back without desynchronising its presets, its
persistence, and the panel's own sliders — and running the brain inside the
pane realm would make it a SECOND store writer and a second broadcaster,
racing the real one.

So: `panes.state(id)` hands the main realm the open pane's store
(get/set/all/subscribe). A plugin seeds it on `panes:opened`, subscribes,
and applies what comes back through its own API. The pane stays
realm-agnostic — it only ever touches ctx.state — and the plugin stays the
single source of truth.

For that to work, the hub now broadcasts EVERY change to the store, not just
the ones a pane asked for: it subscribes to the store on connect rather than
echoing pane-originated writes by hand. A value the plugin clamps or corrects
therefore reaches the pane window immediately, and there is exactly one path
by which state arrives in a pane — so it cannot drift.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 17:58:51 -04:00
topkoa 254e26bb3a feat(panes): mirrorGlobal, manifest-declared panes, and the plugin docs
Three things a plugin needs before it can actually use panes.

## mirrorGlobal — the camera-director problem

The 3D highways read their free camera from a plain global,
`window.__h3dCamCtl` (highway_3d/FREECAM_BRIDGE.md), once per frame in
_resolveFreeCam(). A camera panel in the main window just writes that
object and the camera moves. A panel in a POP-OUT window cannot:
window.__h3dCamCtl there is a different object in a different realm, and
writing it moves nothing.

So a pane declares one field — `mirrorGlobal: '__h3dCamCtl'` — and
pane-mirror.js (main realm, where the renderers live) copies that pane's
state onto the global whenever it changes. highway_3d, keys_highway_3d
and drum_highway_3d are NOT modified and do not know panes exist.

The rule that makes it work: MUTATE THE OBJECT, NEVER REPLACE IT. A
renderer may be holding the reference, and swapping in a new object would
leave it reading an orphan. Keys the pane doesn't set are left alone
rather than deleted — the global may carry a renderer's own bookkeeping.
Closing the pane deliberately leaves the global as-is: closing the camera
panel should not snap the camera back to a default, which is exactly what
happens today (nobody clears __h3dCamCtl).

## Manifest-declared panes

    "panes": [{ "id": "camera_director", "title": "Camera Director",
                "script": "panes/camera.js", "mirrorGlobal": "__h3dCamCtl" }]

Declaring a pane beats calling panes.register() from screen.js because it
becomes openable FROM THE RAIL OR THE TRAY WITHOUT THE PLUGIN'S SCREEN
EVER HAVING BEEN VISITED — core registers a stub from the manifest and
fetches the script only when the user opens it. A pane you can only reach
by first navigating to the screen it was meant to replace is not much of a
pane.

The script sets `window.feedBackPane_<id> = { mount, unmount }`, mirroring
the existing window.feedBackViz_<id> convention, and the SAME file is what
a pop-out window loads in its own realm.

`script` is validated as a relpath under the plugin's src/ and served
through the sandboxed /api/plugins/<id>/src/ route — the containment rule
`styles` already has for assets/. Traversal, absolute paths, drive letters,
backslashes and non-.js are rejected; a bad entry is dropped with a warning
rather than failing the whole plugin, because one malformed pane should not
cost the user a working plugin.

Note the projection is written TWICE — _nav_entry() and the /api/plugins
route re-project independently — so panes had to be added to both, plus the
pending branch (a pane can be opened while its plugin is still installing
deps; the script is fetched on open, not at discovery).

## docs/plugin-panes.md

The contract, and the one rule it all hangs on: mount(root, ctx) runs in a
realm that may not have the app in it. Everything comes through ctx, or the
pane works docked and silently dies popped out.

Verified: manifest validation rejects ../.., C:\, non-.js, dupes and
missing fields while passing a good entry; /api/plugins projects panes[] for
all 20 plugins. mirrorGlobal mutates the global IN PLACE — a reference held
the way _resolveFreeCam holds it sees the change, and a renderer's own field
on that object survives — both for a local write and for a write arriving
over the channel from a pop-out realm.

pytest: 2401 passed, 8 failed — all 8 reproduce on a clean main (including
the one in tests/test_plugins.py) and are unrelated.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 17:38:05 -04:00
topkoa d508380532 feat(panes): desktop host — real windows and the system tray
Registers a `desktop` pane host at priority 20, above the browser pop-up
host (10) and the dock (0), whenever the Electron bridge exposes
feedBackDesktop.panes. A popped-out pane then gets a real BrowserWindow:
it remembers where you put it, can float above everything, minimizes to
the system tray, and appears in the tray's menu.

In a plain browser — or on an older desktop build that predates the
bridge — this file registers nothing and the browser pop-up host handles
detach exactly as before. Nothing else in the pane system changes. That
is what the host registry is for.

Two things only this realm can decide, so it owns them:

- The user closed a pane window (or it crashed). Close the pane, or the
  dialog its pop-out chip hid never comes back and the user is left with
  no way to reach their own UI.
- The tray asked to toggle a pane it has no window for. Main cannot know
  what opening one means — the pane might belong in the dock — so it asks.

Unlike a browser pop-up, this host needs no user gesture, so it sets
autoRestore: true — a pane you left popped out comes back popped out,
where you left it, on the next launch.

Pairs with got-feedback/feedBack-desktop#103.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 17:23:36 -04:00
topkoa fefb9051a4 feat(panes): pop-out windows — the pane realm, hub, and remote transport
A pane can now leave the main window entirely. Same `mount(root, ctx)`,
same file, different JS realm — which is what the ctx-only contract in the
previous commit was for.

## A purpose-built document, not the app shell with a flag on it

`GET /pane` serves static/panes/pane.html: the bridge, the runtime, and
the pane's own script. No highway, no library, no v3 shell, no <audio>,
no Tailwind.

The splitscreen follower takes the other road — it reloads the whole app
at `/?ssFollower=1` and hides what it doesn't want — and pays for it with
an anti-flash block that must run before any script parses (index.html),
bail-outs in app.js and shell.js, and ~40 lines of CSS hiding core
elements by id. It loads the entire app to throw it away. A pane window
has nothing to throw away, so it boots in milliseconds and there is
nothing to flash.

The cost is that `window.feedBack` in a pane realm is a deliberate,
documented SUBSET. The runtime installs exactly what a pane is promised —
`panes.register`, and the no-op chip/dock calls a shared script may make
at load — so a pane reaching for something it was never given fails
loudly at authoring time instead of subtly at runtime.

## The channel

BroadcastChannel('feedback-panes'), same origin. This works only because
Electron's setWindowOpenHandler returns `action: 'allow'` for same-origin
URLs: `deny` would push the window to the system browser, a different
Chromium instance, where BroadcastChannel cannot reach it and the pane
would silently never sync. That flag is load-bearing.

  hello -> snapshot   resync-on-open, always. The snapshot is the only way
                      the pane realm learns anything.
  state               main is authoritative. A pane's write is a REQUEST;
                      main applies it and echoes to every realm, so a
                      losing write self-corrects instead of splitting brain.
  rpc / rpc:reply     ctx.call() -> the capability bus, with a 10s deadline.
                      Without one, a main window that died mid-call leaves
                      the pane's promise pending forever.
  event               allowlisted bus events, JSON-safe. A CustomEvent
                      carrying a DOM node (highway:canvas-replaced does)
                      would throw on postMessage and take the channel down
                      for everyone, so detail is round-tripped through JSON.
  stream              one coalesced message per pane per frame, OVERWRITING
                      anything not yet flushed. Queueing would build a
                      backlog: Chromium throttles a backgrounded window, and
                      the main window is exactly what's backgrounded while
                      the user looks at the pane.
  sub / unsub         refcounts the main-realm sampler.
  bye                 both directions.

## The follower clock

The pane extrapolates between broadcasts: anchor + observedRate * elapsed,
capped at 2s. observedRate is learned from the broadcasts themselves
(dt/dwall) so it tracks the speed slider without being told about it, and
seeks/pauses are excluded from the fit — a jump is not a tempo. Capping it
means a dead main window decays into a frozen clock rather than one that
confidently runs away. This is splitscreen's hard-won trick, generalized:
panes just call ctx.playhead().

## Failure modes, all of them

- Main window closes -> `bye {main-closed}` and the pane says so plainly,
  rather than showing a frozen playhead that looks live. The host also
  closes its windows outright; a pane that cannot be fed should not be on
  screen.
- Pane window X'd or crashed -> a `closed` poll reaps it (a crashed
  renderer never sends `bye`), the pane closes, and the chip's dialog comes
  back. Without this the user's dialog stays hidden with no way back.
- Popup blocked -> a toast, and we bail BEFORE the manager records
  anything, so the caller's dialog stays exactly where it was.
- Nobody answers `hello` in 5s -> the window says so instead of spinning.
- A pane with no `script` is a closure in this realm and cannot honestly
  cross a window boundary. The window host declines it (canHost) and the
  router falls back to the dock.
- A browser blocks window.open() outside a user gesture, so a popped-out
  pane cannot be auto-restored on page load — it would only ever produce a
  "blocked" toast. Such a pane comes back in the DOCK, and the chip pops it
  out again on the next click. (autoRestore: false. The desktop host will
  set it true.)

Hosts may now declare `remote: true`, meaning the pane's mount() runs in
another realm: the manager then owns only the authoritative state store and
never calls mount() itself. That is the seam the Electron BrowserWindow +
tray host drops into next, with no change here.

Verified: popped Now Playing and Mixer into real windows. The pane realm has
no window.highway, no capability bus and no <audio>, yet the Mixer renders
its faders via ctx.call('audio-mix','list-faders') across the channel — and
dragging that fader IN THE PANE WINDOW moved the main window's song volume
to 55 and persisted it. Closing the pane window un-hid the mixer dialog,
removed the stub and restored the chip, while the other pane window stayed
open.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 17:03:21 -04:00
Byron GamatosandGitHub e2215df753 feat(career): bundle dive bar venue pack (#927) 2026-07-12 22:58:04 +02:00
topkoa e5cbea2e9f feat(panes): core detachable pane system + pop-out chip
The option-heavy player UIs (mixer, camera director, viz, audio routing)
all live in the rail popovers, which are exclusive: openPopFor() closes
the last one before opening the next. You cannot watch the mixer while
riding the camera, and both vanish the moment you look at the highway.

Add `window.feedBack.panes` — a core registry for live UI that is
authored once as `mount(root, ctx)` and hosted anywhere. Panes are
non-exclusive, and they survive song switches structurally: the dock is a
body child outside every .screen, so the per-song teardown never sees it.

The adoption cost for a plugin is two calls:

    feedBack.panes.register({ id, title, icon, mount, unmount });
    feedBack.panes.attachChip(myExistingDialogEl, id);

attachChip injects THE standard pop-out chip. Clicking it opens the pane
and hides the plugin's dialog, leaving a stub to bring it back. Core owns
the hide/restore, so every plugin's pop-out looks and behaves the same —
which is the point. It hides via a dedicated .fb-pane-detached class, not
.hidden/[hidden], because the dialogs we attach to already toggle those.

Everything a pane may touch arrives through `ctx` — never a global. That
is what will let the same mount() run inside a pop-out window, a separate
JS realm with no window.feedBack, no window.highway and no audio graph:

  ctx.call(domain, cmd, payload)  -> the capability bus
  ctx.on(event, fn)              -> the feedBack bus (allowlisted)
  ctx.subscribe(stream, fn)      -> playhead / meters
  ctx.state.get/set              -> persisted, main realm is the only writer
  ctx.playhead(), ctx.song(), ctx.toast(), ctx.close()

ctx tracks every subscription it hands out and drops them on unmount, so
a pane cannot leak listeners across a dock/undock cycle.

Streams exist because an AnalyserNode cannot cross a window boundary:
levels are reduced to numbers in the realm that owns the audio graph.
One shared rAF loop, refcounted against live subscriptions, dirty-checked
before fan-out, and stopped dead when the last pane closes.

Hosts register themselves with the manager rather than being imported by
it — the dock lands at priority 0 (the floor, always available), so the
OS pane window can drop in later without this code changing.

Ships two built-in panes: Now Playing (the reference pane — reads the bus,
a stream, and levels, and touches no globals) and Mixer (the same faders
as the rail, via ctx.call('audio-mix', ...), with the chip attached to the
real #mixer-control). Plus a "Panes" rail popover to open panes that have
no dialog of their own; the system tray will mirror that list.

Note the dock sits at z-index 110, not on the docs/plugin-v3-ui.md ladder
(transport 20, rail 30, popovers 40) — those live INSIDE #player's
stacking context, and #player is itself fixed at z-index 100. A dock below
100 is invisible on the one screen panes exist for. Body-level ladder:
#player 100 < dock 110 < toasts 120 < modals 200.

Pop-out windows, the system tray, manifest-declared panes and mirrorGlobal
(the window.__h3dCamCtl proxy the camera director needs) follow.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 16:54:15 -04:00
46 changed files with 5097 additions and 1771 deletions
+34
View File
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Career passports (backend)** — the badge-journey layer on top of career stars.
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
passport walls: genre badges computed on read from `song_stats` × the library's
effective genre — Bronze = N genre songs at K★, data-driven in
`plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song
"ticket stubs", the library genre list, and drill status), `POST
/passports/commit` (instrument commitment), `POST /passports/open` (open a genre
passport), and `POST /drill-state` (intake for the relayed Virtuoso
`virtuoso.progress` snapshot, so drill requirements can gate badges
server-side). Badges are never stored; the only persisted state (commitments,
opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the
settings export/import bundle via `settings.server_files`. Instruments are
attributed via the existing progression arrangement→instrument mapping;
non-graded instruments (bass, drums) render shown-not-judged — repertoire
without a pass bar, never a false badge denial.
- **Career passports (UI)** — the Career screen gains a Passports tab beside
Venues: a physical per-instrument passport book (embossed leather cover, 3D
page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge
slam with ink bleed and deterministic per-genre jitter, qualifying songs as
collected ticket stubs, and unopened genres as an "Explore next"
travel-brochure rack (invitations, never greyed-out slots or completion
meters). Badge earns chime + notify immediately; the stamp slam plays when
the passport is next opened. Four small synthesized sound effects ship as
plugin assets. The career screen also relays the Virtuoso `virtuoso.progress`
localStorage snapshot to the drill-state intake on `virtuoso:progress` bus
events (debounced, plus a one-time bootstrap), closing the
fires-into-a-void seam without touching the virtuoso plugin.
### Removed
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
@@ -27,6 +56,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
### Fixed
- **Career passports review polish** — the passport tabs and book overlay carry
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
`role="dialog"` + `aria-modal` with focus moved to the close button on open
and restored on close), and a corrupt stored seen-badges value (e.g. a stray
`"null"`) can no longer throw on every passport refresh.
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
+34
View File
@@ -465,6 +465,40 @@ window.feedBack.diagnostics.contribute('my_plugin', {
Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs. Available on the `window.feedBack.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers.
### Detachable panes — pop your panel out into its own window
If your plugin has a floating panel that sits over the player — a mixer, a camera rig, a settings board — you can let the user pop it out into its own OS window and leave it there: while they play, across song switches, on a second monitor, minimized to the tray. Two calls:
```js
feedBack.panes.register({
id: 'camera_director',
title: 'Camera Director',
icon: '🎥',
element: () => panelEl, // your existing panel, exactly as it is
});
feedBack.panes.attachChip(panelEl, 'camera_director');
```
**The host moves your real element.** Not a copy, not a re-render — the actual DOM node, adopted into the pop-out window, keeping its listeners and its closures. Your panel goes on running *your* code against *your* state. It looks and behaves like what was popped out because it **is** what was popped out. Nothing to mirror, nothing to keep in sync.
The rules below are all things that have already gone wrong. Full contract: **[docs/plugin-panes.md](docs/plugin-panes.md)**.
- **Your code still runs in the main window.** The element is *displayed* elsewhere; its closures, timers and `document` references still belong to the main realm. That is exactly why everything keeps working — and exactly why `document.body.appendChild(myPopover)` lands in the **main window, not the pane**. Anchor tooltips, popovers and menus to your panel, not to `document.body`. Measure with `el.ownerDocument.defaultView`, never a cached `window`.
- **Don't hide your panel yourself when it pops out.** Core hides it and leaves a "bring it back" stub. If you also hide it, you will hide the node that just moved — and blank the pane window.
- **Prefer `hidden` or a class over inline `display` for show/hide.** While popped out, core neutralises *placement* with `.fb-paned` (`position`, `inset`, `width`, `z-index`, `box-shadow`). An inline `display:none` on your panel reasserts itself the moment the pane docks back and the class is removed, so your panel returns invisible.
- **`element` is a function so it can be resolved late.** Return the *live* node. If you rebuild your panel (Camera Director rebuilds on every mode change), re-run `attachChip` — it returns a `detach()`; call it before re-attaching, and again in your teardown.
- **`isConnected` does not mean "docked".** A panel sitting in a pane window is very much connected — just not to *this* document. Test `el.ownerDocument === document`, or take the `onHost(hostId, el)` callback.
- **rAF is throttled while the main window is backgrounded** — and it will be, whenever the user is looking at your pane. Event-driven panels (sliders, buttons) are unaffected. A panel that *animates continuously* may run slowly while it is the only thing on screen.
- **Don't reach for BroadcastChannel, `postMessage`, or a second copy of your state.** There is one realm and one panel. If you find yourself synchronising, you have misunderstood the model.
- **Nothing is required.** No panes API on the host → skip both calls, and your panel behaves exactly as it does today.
### Keyboard Shortcuts
Plugins can register keyboard shortcuts via the global `window.registerShortcut()` function. Shortcuts appear in the `?` help panel.
+354
View File
@@ -0,0 +1,354 @@
# Detachable panes (`window.feedBack.panes`)
Pop a panel out of the app into its own OS window, and leave it there: while you
play, across song switches, on a second monitor, minimized to the system tray.
Panes exist because the player's rail popovers are **exclusive** — opening one
closes the last. You cannot watch the mixer while riding the camera, and both
vanish the moment you want to look at the highway.
---
## The whole idea, in one sentence
**We move the real element.**
Not a copy of your panel. Not a re-implementation of it in the pop-out window.
The actual DOM node. Same-origin windows can adopt each other's nodes, and an
adopted node keeps its event listeners and its closures — so your panel goes on
running *your* code, against *your* state, in *your* realm. The app's stylesheets
are copied into the pane window, so it looks identical too.
What you popped out is what you get. That is the promise, and it is the reason
there is no `ctx`, no state mirroring, no cross-window RPC and no second copy of
your UI to keep in step with the first. Those are all solutions to a problem we
simply do not have.
---
## Adding a pane to your plugin
Two lines.
```js
// Guard: the panes API is optional. On a host without it, skip both calls and
// your panel behaves exactly as it does today.
const panes = window.feedBack && window.feedBack.panes;
if (panes && typeof panes.register === 'function') {
panes.register({
id: 'camera_director',
title: 'Camera Director',
icon: '🎥',
element: () => panelEl, // your existing panel, as it is
});
panes.attachChip(panelEl, 'camera_director');
}
```
`attachChip()` injects **the** standard pop-out chip (`⇱`) — same glyph, same
place, same behaviour in every plugin. Clicking it moves your panel to whichever
**host** the router picks — usually a pop-out window, but the dock when a window
can't be had (a blocked pop-up, or `defaultHost: 'dock'`) — and leaves a
"⇲ … is popped out" stub in its place. Clicking the stub brings the panel back, to
exactly the spot it left. Core owns the chip, the hiding and the stub, so you write
no show/hide logic.
That's it. Your sliders, your presets, your tabs, your CSS, your event handlers,
your state — all of it comes along, because none of it moved anywhere except into
a different window's document.
### `element` is a function for a reason
It is resolved at open time, not at registration. Plugins commonly build their
panel lazily on first use, or rebuild it wholesale when something changes (Camera
Director rebuilds its panel on every mode change). Asking for it when we need it
means we always move the live one.
**If you rebuild your panel, re-attach the chip.** Rebuilding takes the chip with
it. `attachChip()` returns a `detach()`; call it before re-attaching, and again in
your teardown — otherwise you leave a stub pointing at DOM that no longer exists.
```js
if (chipDetach) chipDetach();
chipDetach = panes.attachChip(panel, PANE_ID, { header: toolsEl });
```
Re-attaching is safe while the pane is popped out: the chip reconciles against the
pane's real state, so a panel rebuilt mid-pop-out stays correctly stubbed.
### The two things core changes about your element
**1. Placement.** `.fb-paned` is added while the pane is out:
```css
position: static; inset: auto; margin: 0; width: 100%;
max-width: none; max-height: none; z-index: auto; box-shadow: none;
```
Your panel was almost certainly a fixed overlay pinned to a corner of the app
(`position:fixed; top:72px; right:18px; width:288px`). Alone in its own window,
every one of those is wrong — it would float 72px down from the top of a 380px
window, still 288px wide, still casting a shadow over nothing.
Note there is deliberately **no `display` override**: a panel that is
`display:flex` or `grid` stays that way. Colours, borders, radius, padding, fonts
and your panel's own internal layout are untouched.
**2. Visibility.** A panel is usually hidden until its launcher is clicked, and a
pane can be opened from the tray or the rail without that ever happening — so core
un-hides it, in the two ways a panel is actually hidden:
```js
el.hidden = false;
if (el.style.display === 'none') el.style.display = '';
```
**Both are restored exactly as they were when the pane docks**, along with the
`.fb-paned` class. A panel that was closed when you opened its pane from the tray
goes back to being closed; one that was open stays open.
---
## Spec
```js
feedBack.panes.register({
id, // required, unique
element, // required — an Element, or a function returning one
title, // shown in the pane window's title bar, the dock card, the tray
icon, // one glyph, for the dock/tray/launcher lists
width, height, // the pane window's initial size (it remembers yours after that)
defaultHost, // 'window' (default) or 'dock'
onHost, // optional (hostId | null, el) => void — re-measure/re-anchor
});
```
```js
feedBack.panes.attachChip(el, paneId, { header }) // → detach()
feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()
```
`attachChip` puts the chip in the `header` element you pass, else in
`el.querySelector('[data-pane-header]')` if it finds one, else at the top of `el`.
An explicit `header` always wins.
---
## Hosts
`detach(id)` puts a pane in the best host available:
| host | | |
|---|---|---|
| `window` | 10 | A real OS window. In the desktop app: remembered bounds, always-on-top, system tray. |
| `dock` | 0 | A card in the in-window stack. **The floor** — always available, so opening a pane can never fail. |
You don't pick; you declare `defaultHost` and the router does the rest.
In the **desktop app** a pane you left popped out comes back popped out on next
launch. In a **browser** it comes back **docked** — a browser blocks
`window.open()` without a user gesture, so restoring it would only ever produce a
"pop-up blocked" toast. The chip pops it out again on your next click.
---
## Best practices
Every item below is something that has already gone wrong, in this codebase, on
this feature. They are cheap to get right up front and confusing to diagnose later
— a broken pane usually *looks* perfect.
### 1. Your code still runs in the main window
The element is *displayed* in the pane window, but its closures, its timers and its
`document` references all still belong to the main realm. **That is precisely why
everything keeps working** — and it has one sharp consequence:
```js
// WRONG — lands in the MAIN window, not the pane the user is looking at.
document.body.appendChild(myTooltip);
// RIGHT — anchored to the panel, so it travels with it.
panelEl.appendChild(myTooltip);
```
**And every lookup for something inside your panel.** Once the panel has moved,
`document.getElementById('my-panel-thing')` returns `null` — so every update it
guards silently stops happening, precisely while the user is looking at the panel.
No error. Just a UI that quietly goes dead.
```js
// WRONG — null once the panel is popped out.
document.getElementById('my-panel-hint').textContent = msg;
// RIGHT — search FROM the panel; works in either document.
panelEl.querySelector('#my-panel-hint').textContent = msg;
```
Elements that live outside your panel (your plugin's *screen*, host chrome) never
move, and should keep using `document.getElementById`. Audit which is which — in
the stem mixer, four ids were inside the panel and a dozen were not.
Same for measuring and popovers. `window.innerWidth` is the *main* window's, and a
dismiss listener on `window` watches a window the user isn't clicking in. Use
`el.ownerDocument` / `el.ownerDocument.defaultView` when you need the window your
panel is actually in.
### 2. Don't hide your panel yourself
Core hides it and leaves a "bring it back" stub. If your plugin *also* hides it,
you are hiding the node that just moved — and the pane window renders nothing.
(This is not hypothetical: core's own chip did exactly this, and the first
pop-out shipped blank because of it.)
### 3. Prefer `hidden` or a class for show/hide
Core makes your panel visible while it's hosted — it clears `hidden`, and clears an
inline `display: none` if that's how you hide — and **restores both on dock**. So
either style works.
`hidden` is still the better choice: it composes with everything, and it leaves
your panel's `display` mode (`flex`, `grid`, whatever it is) entirely alone. Core
deliberately does not override `display` for exactly that reason.
```js
panel.hidden = true; // best
panel.style.display = 'none'; // works — core saves and restores it
```
### 4. `element` is a function — return the *live* node
It is resolved when the pane opens, not when you register. Plugins build panels
lazily, and rebuild them wholesale (Camera Director rebuilds on every mode
change). If you rebuild yours, **re-attach the chip**:
```js
if (chipDetach) chipDetach(); // attachChip returns a detach()
chipDetach = feedBack.panes.attachChip(panel, PANE_ID, { header: toolsEl });
```
Call `chipDetach()` in your teardown too, or you leave a stub pointing at DOM that
no longer exists.
### 5. `isConnected` lies about a panel that is a pane
This one has cost more debugging than everything else on this page combined, and
it lies in **both directions**.
**It says `true` when your panel is not here.** A panel sitting in a pane window is
`isConnected` — just not to *this* document. Code asking "am I still mounted?" gets
`true` and then acts on a panel that is somewhere else entirely.
**It says `false` when your panel is perfectly fine.** The host *detaches* the
element the moment a pop-out starts, before the new window has even loaded. In that
gap `isConnected` is `false` — and any code that rebuilds on that basis builds a
**second panel**, while the host is still holding the first.
That second panel is the one your module variables now point at. The one the user
can *see* is the original, owned by nobody. So:
- its close button closes the *other*, invisible panel — "the X doesn't work"
- your chip gets re-attached to the impostor — "the pop-out icon vanished"
Two baffling symptoms, one duplicate, and nothing in the stack trace to suggest it.
**Ask the pane system, not the DOM.** It knows where your element is:
```js
function paneOwnsPanel() {
const panes = window.feedBack && window.feedBack.panes;
return !!(panes && panes.isOpen && panes.isOpen(MY_PANE_ID));
}
// "Is my panel gone?" — not "is it in this document?"
if (panel && (panel.isConnected || paneOwnsPanel())) return panel; // alive; possibly elsewhere
```
Every `isConnected` check on a panel that can be a pane needs this. In the stem
mixer that was `ensureMixerPanel()` (which rebuilt) *and* the MutationObserver's
fast path (which decided the UI was unmounted and swept on every mutation).
For "which document is it in right now", use `el.ownerDocument === document`, or
take the optional `onHost(hostId, el)` callback, which fires on both moves.
### 6. If your plugin can be re-injected, it must be able to remove itself
The host may run your script more than once — a screen re-entry, a version change.
Without a teardown, the second run builds a second panel while the first one is
still on screen, and every module variable in the new instance points at the new,
invisible one. The user clicks the panel they can see; nothing happens.
Everything stateful duplicates: observers, timers, listeners. And one thing is
worse than duplicated — **your pane registration**:
```js
panes.register({ id, element: () => panel }); // resolved LAZILY, at open time
```
First registration wins, so a stale one hands the host `panel` from a **dead
instance**. Popping out then moves a panel nobody owns.
So publish a teardown handle and call it at the top of your script:
```js
if (window.__myPluginInstance?.destroy) {
try { window.__myPluginInstance.destroy(); } catch (e) { /* tear down what we can */ }
}
window.__myPluginInstance = {
destroy() {
observer?.disconnect();
clearTimeout(myTimer);
chipDetach?.(); // attachChip() returned this
panes?.unregister?.(MY_PANE_ID); // ← the one people forget
document.querySelectorAll('#my-panel').forEach((n) => n.remove());
},
};
```
Belt and braces: when you build your panel, remove any node carrying its id that
isn't yours. A zombie panel is worse than no panel — it looks alive and does
nothing.
### 7. Expect rAF to be throttled while your pane has focus
Chromium throttles a **backgrounded** window's `requestAnimationFrame` — and the
main window is exactly what's backgrounded while the user is looking at your pane.
Your rAF lives in the main window.
Event-driven panels (sliders, buttons, presets) don't care. A panel that
*animates continuously* may run slowly precisely when it's the only thing on
screen. Drive such animation from data you already have, or accept the stutter.
### 8. Don't synchronise anything
No `BroadcastChannel`, no `postMessage`, no second copy of your state, no mirrored
UI. There is **one** realm and **one** panel. If you find yourself writing sync
code, you have misunderstood the model — the whole point is that there is nothing
to sync.
### 9. Nothing here is required
On a host without the panes API, `feedBack.panes` is `undefined`. Skip both calls
and your panel behaves exactly as it does today. Guard, don't depend:
```js
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.register !== 'function') return;
```
---
## Things core guarantees
- **The element goes home exactly where it came from** — same parent, same position
among its siblings. Don't move it yourself while it's popped out.
- **It comes home alive.** Core evacuates the element *before* the pane window's
document is destroyed. (Get this wrong — dock after the window dies — and the
node returns looking perfect with every listener in its subtree silently gone.
That bug is why this section exists.)
- **A pane window the user closes, or that crashes, is reaped** and the element
docked back. Your panel is never stranded in a dead document.
- **The app's stylesheets are copied into the pane window**, so your panel looks
identical — including your plugin's own `styles` sheet.
+345
View File
@@ -64,3 +64,348 @@
.career-star-row .song .artist { color: #9ca3af; }
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
.career-star-row .hint.close { color: #22d3ee; }
/* ── Passports (badge journey) ─────────────────────────────────────────── */
.career-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(55, 65, 81, 0.6);
}
.career-tab {
padding: 0.375rem 0.875rem;
font-size: 0.85rem;
color: #9ca3af;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.career-tab:hover { color: #e5e7eb; }
.career-tab.active { color: #fff; border-bottom-color: #06b6d4; }
.pp-instruments { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pp-inst {
padding: 0.3rem 0.8rem;
border-radius: 999px;
font-size: 0.8rem;
color: #d1d5db;
background-color: rgba(31, 41, 55, 0.7);
border: 1px solid transparent;
}
.pp-inst:hover { background-color: rgba(55, 65, 81, 0.9); }
.pp-inst.active { border-color: #06b6d4; color: #fff; }
.pp-inst.uncommitted { color: #6b7280; border-style: dashed; border-color: rgba(107, 114, 128, 0.5); }
.pp-inst-badges { color: #fbbf24; font-size: 0.7rem; }
.pp-inst-plus { color: #6b7280; }
/* Leather covers — per-instrument hue, embossed with layered shadows and a
subtle grain gradient (no image assets). */
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
.pp-leather-drums { background: linear-gradient(160deg, #3f3f46, #26262b); }
.pp-shelf { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; }
.pp-cover, .pp-commit-cover {
position: relative;
width: 9.5rem;
height: 13rem;
border-radius: 0.5rem 0.75rem 0.75rem 0.5rem;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
inset 0.5rem 0 0.75rem -0.5rem rgba(0, 0, 0, 0.8),
0 6px 16px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.75rem;
text-align: center;
}
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
.pp-cover:hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); }
.pp-cover-title {
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.14em;
color: rgba(240, 226, 195, 0.92);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7), 0 -1px 0 rgba(255, 255, 255, 0.12);
overflow-wrap: anywhere;
}
.pp-cover-inst {
font-size: 0.6rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(240, 226, 195, 0.55);
}
.pp-cover-sub {
position: absolute;
bottom: 0.6rem;
font-size: 0.6rem;
color: rgba(240, 226, 195, 0.5);
}
.pp-commit-card {
display: flex;
gap: 1.25rem;
align-items: center;
padding: 1rem;
border-radius: 0.75rem;
border: 1px solid rgba(55, 65, 81, 0.6);
background-color: rgba(31, 41, 55, 0.35);
}
.pp-commit-card .pp-commit-cover { width: 7rem; height: 9.5rem; flex: none; }
.pp-rack { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); }
.pp-brochure {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
padding: 0.75rem 0.875rem;
border-radius: 0.5rem;
text-align: left;
background: linear-gradient(165deg, rgba(45, 55, 72, 0.55), rgba(31, 41, 55, 0.55));
border: 1px solid rgba(75, 85, 99, 0.5);
transition: transform 0.15s ease, border-color 0.15s ease;
}
.pp-brochure:hover { transform: translateY(-2px); border-color: #06b6d4; }
.pp-brochure-art { font-size: 1.4rem; }
.pp-brochure-name { color: #e5e7eb; font-size: 0.85rem; font-weight: 600; }
.pp-brochure-sub { color: #6b7280; font-size: 0.65rem; }
/* The open book */
.pp-overlay { position: fixed; inset: 0; z-index: 60; }
.pp-book-wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 7, 18, 0.72);
backdrop-filter: blur(2px);
}
.pp-book {
position: relative;
width: min(92vw, 720px);
height: min(72vh, 470px);
perspective: 1800px;
}
.pp-page {
position: absolute;
top: 0;
bottom: 0;
width: 50%;
background:
linear-gradient(105deg, rgba(0, 0, 0, 0.08), transparent 12%),
#efe6d0;
color: #3f3428;
padding: 1.1rem 1.2rem;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pp-page-left { left: 0; border-radius: 0.6rem 0 0 0.6rem; opacity: 0; transition: opacity 0.35s ease 0.3s; align-items: center; }
.pp-page-right { right: 0; border-radius: 0 0.6rem 0.6rem 0; box-shadow: inset 0.4rem 0 0.6rem -0.4rem rgba(0, 0, 0, 0.35); }
.pp-book.open .pp-page-left { opacity: 1; }
.pp-book-cover {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 50%;
border-radius: 0 0.6rem 0.6rem 0;
transform-origin: left center;
transform: rotateY(0deg);
backface-visibility: hidden;
transition: transform 0.8s cubic-bezier(0.4, 0.1, 0.2, 1);
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 20px rgba(0, 0, 0, 0.5);
}
.pp-book.open .pp-book-cover { transform: rotateY(-180deg); }
.pp-book-close {
position: absolute;
top: -0.75rem;
right: -0.75rem;
z-index: 8;
width: 2rem;
height: 2rem;
border-radius: 999px;
background: rgba(17, 24, 39, 0.95);
color: #d1d5db;
border: 1px solid rgba(107, 114, 128, 0.5);
}
.pp-book-close:hover { color: #fff; border-color: #06b6d4; }
.pp-page-head {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #8a7a5e;
border-bottom: 1px solid rgba(138, 122, 94, 0.35);
padding-bottom: 0.4rem;
width: 100%;
text-align: center;
}
/* The rubber stamp */
.pp-stamp {
--pp-rot: 0deg;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
width: 9rem;
height: 9rem;
border-radius: 999px;
border: 3px solid #9a5b16;
box-shadow: inset 0 0 0 3px #efe6d0, inset 0 0 0 4px #9a5b16;
color: #9a5b16;
transform: rotate(var(--pp-rot));
margin-top: 1.25rem;
text-align: center;
padding: 0.75rem;
opacity: 0.92;
}
.pp-stamp-genre { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.16em; overflow-wrap: anywhere; }
.pp-stamp-tier { font-size: 0.58rem; letter-spacing: 0.3em; }
.pp-stamp-ghost {
border-style: dashed;
box-shadow: none;
border-color: #b3a68b;
color: #b3a68b;
opacity: 0.8;
}
.pp-stamp-hidden { opacity: 0; }
.pp-stamp-mini {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: auto;
height: auto;
border-width: 2px;
box-shadow: none;
border-radius: 999px;
font-size: 0.5rem;
font-weight: 800;
letter-spacing: 0.2em;
color: #d9a253;
border-color: #d9a253;
padding: 0.2rem 0.4rem;
margin: 0;
display: inline-block;
transform: rotate(var(--pp-rot));
opacity: 0.95;
}
.pp-stamp-page::after {
content: '';
position: absolute;
inset: -10%;
border-radius: 999px;
background: radial-gradient(closest-side, rgba(154, 91, 22, 0.25), transparent 72%);
filter: blur(5px);
opacity: 0;
pointer-events: none;
}
.pp-slam { animation: pp-slam 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) forwards; }
.pp-slam::after { animation: pp-ink 0.45s ease-out 0.12s forwards; }
@keyframes pp-slam {
0% { transform: rotate(calc(var(--pp-rot) - 15deg)) scale(2.5); opacity: 0; }
55% { transform: rotate(var(--pp-rot)) scale(0.92); opacity: 1; }
75% { transform: rotate(var(--pp-rot)) scale(1.05); }
100% { transform: rotate(var(--pp-rot)) scale(1); opacity: 0.92; }
}
@keyframes pp-ink {
from { opacity: 0; transform: scale(0.6); }
to { opacity: 1; transform: scale(1); }
}
.pp-shake { animation: pp-shake 0.4s ease-out 0.28s; }
@keyframes pp-shake {
0%, 100% { transform: translate(0, 0) rotate(0); }
25% { transform: translate(2px, 1px) rotate(0.3deg); }
50% { transform: translate(-2px, 2px) rotate(-0.25deg); }
75% { transform: translate(1px, -1px) rotate(0.15deg); }
}
.pp-invite, .pp-snj, .pp-gold-note { font-size: 0.75rem; text-align: center; }
.pp-invite { color: #6d5d40; }
.pp-snj { color: #6d5d40; margin-top: 2rem; font-style: italic; max-width: 15rem; }
.pp-gold-note { color: #a8946d; font-size: 0.62rem; margin-top: 0.5rem; }
.pp-drills { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.7rem; color: #6d5d40; }
.pp-drill.cleared { color: #4d7c0f; }
/* Ticket stubs */
.pp-stubs { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; padding-right: 0.25rem; }
.pp-stub {
background: #f7f1e3;
border: 1px solid #d8cbaa;
border-left: 2px dashed #b6a98c;
border-radius: 0.25rem 0.4rem 0.4rem 0.25rem;
padding: 0.4rem 0.6rem 0.4rem 0.75rem;
display: grid;
grid-template-columns: auto 1fr;
column-gap: 0.6rem;
align-items: baseline;
box-shadow: 0 1px 2px rgba(63, 52, 40, 0.15);
}
.pp-stub-stars { color: #b8860b; font-size: 0.7rem; letter-spacing: 0.08em; grid-row: span 2; }
.pp-stub-title { font-size: 0.78rem; font-weight: 600; color: #3f3428; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pp-stub-artist { grid-column: 2; font-size: 0.65rem; color: #6d5d40; }
.pp-stub-meta { grid-column: 2; font-size: 0.6rem; color: #8a7a5e; }
.pp-stub-empty { font-size: 0.72rem; color: #8a7a5e; font-style: italic; padding: 0.75rem 0.25rem; }
/* Wax-seal commitment ceremony */
.pp-ceremony { width: 11rem; height: 15rem; }
.pp-wax {
position: absolute;
bottom: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
width: 3.4rem;
height: 3.4rem;
border-radius: 999px;
background:
radial-gradient(circle at 32% 30%, #d24545 0%, #a41f1f 42%, #7c1414 100%);
box-shadow:
inset 0 0 0 4px rgba(124, 20, 20, 0.9),
inset 0 2px 4px rgba(255, 255, 255, 0.25),
0 3px 8px rgba(0, 0, 0, 0.55);
color: rgba(255, 235, 235, 0.9);
font-weight: 700;
font-size: 1.15rem;
animation: pp-seal-drop 0.9s cubic-bezier(0.25, 0.9, 0.3, 1.15) 0.35s backwards;
}
@keyframes pp-seal-drop {
0% { transform: translateY(-120px) scale(2.1); opacity: 0; }
60% { transform: translateY(0) scale(0.9); opacity: 1; }
80% { transform: translateY(0) scale(1.05); }
100% { transform: translateY(0) scale(1); }
}
/* Small screens: the spread stacks; the flip cover would straddle both
pages, so the book simply opens. */
@media (max-width: 640px) {
.pp-book { height: min(80vh, 620px); }
.pp-page { position: static; width: 100%; height: 50%; border-radius: 0; }
.pp-page-left { border-radius: 0.6rem 0.6rem 0 0; opacity: 1; }
.pp-page-right { border-radius: 0 0 0.6rem 0.6rem; }
.pp-book-cover { display: none; }
.pp-book { display: flex; flex-direction: column; }
}
@media (prefers-reduced-motion: reduce) {
.pp-book-cover, .pp-page-left, .pp-cover { transition: none; }
.pp-slam, .pp-slam::after, .pp-shake, .pp-wax { animation: none; }
.pp-slam, .pp-stamp-page::after { opacity: 1; }
.pp-stamp-hidden { opacity: 0.92; }
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
{
"badge_requirement": {
"songs": 5,
"min_stars": 2
},
"genres": {},
"graded_instruments": [
"guitar",
"keys"
],
"instruments": [
"guitar",
"bass",
"keys",
"drums"
]
}
+8 -2
View File
@@ -1,12 +1,18 @@
{
"id": "career",
"name": "Career",
"version": "0.1.0",
"version": "0.2.0",
"bundled": true,
"private": false,
"description": "Career mode — gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.",
"description": "Career mode — gig your way from a local bar to the arena, and build a passport wall of genre badges per instrument. Earn stars per song; the crowd reacts to how you play.",
"screen": "screen.html",
"script": "screen.js",
"styles": "assets/career.css",
"settings": {
"html": "settings.html",
"server_files": [
"career/"
]
},
"routes": "routes.py"
}
+344 -12
View File
@@ -5,18 +5,27 @@ accuracy across arrangements crosses 0/1/2/3 of the thresholds in
``venues.json`` (data-driven so tuning never touches code). Cumulative
stars unlock venue tiers (bar → club → arena).
Venue packs (crowd-loop videos rendered offline in UE) are heavyweight and
never ship with the app: ``venues.json`` points at a release asset per
venue, downloaded on demand into ``CONFIG_DIR/plugin_uploads/career/venues/
<id>/`` on a background thread (constitution: nothing heavy inline on the
request path), sha256-verified, then served back with the same
FileResponse/no-cache recipe as highway_3d's custom-video route.
Venue packs (crowd-loop videos rendered offline in UE) may be bundled with
the plugin under ``venue-packs/<id>/`` or downloaded on demand into
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
bundled packs so release assets can replace a built-in starter venue.
Passports (badge journey per instrument × genre — the identity layer on top
of the same stars): badges are COMPUTED on read from ``song_stats`` × the
library's effective genre, never stored. The only persisted career state is
what cannot be derived — instrument commitment, opened passports, and the
relayed virtuoso drill snapshot — as JSON under ``CONFIG_DIR/career/``
(exported via ``settings.server_files``).
Endpoints (all under /api/plugins/career/):
GET /state stars + per-venue unlock/install/download status
POST /packs/{venue_id}/download start background pack download (409 if running)
DELETE /packs/{venue_id} remove an installed pack
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers)
GET /passports passport walls: badges, stubs, genres, drill status
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
POST /passports/open open a genre passport for an instrument
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
"""
import hashlib
@@ -28,11 +37,14 @@ import tempfile
import threading
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from fastapi import HTTPException
from fastapi import Body, HTTPException
from fastapi.responses import FileResponse
from progression import instrument_for_arrangement
PLUGIN_ID = "career"
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
@@ -42,6 +54,7 @@ DOWNLOAD_CHUNK = 1024 * 256
_lock = threading.Lock()
_state = {
"content": None, # parsed venues.json
"plugin_dir": None, # plugin root; bundled packs live below it
"venues_dir": None, # CONFIG_DIR/plugin_uploads/career/venues
"meta_db": None, # MetadataDB (song_stats reads are lock-free / WAL)
"log": logging.getLogger("feedBack.plugin.career"),
@@ -60,8 +73,27 @@ def _venue_dir(venue_id) -> Path:
return _state["venues_dir"] / venue_id
def _bundled_venue_dir(venue_id) -> Path:
return _state["plugin_dir"] / "venue-packs" / venue_id
def _pack_dir(venue_id):
"""Runtime pack location: downloaded override first, bundled fallback."""
local = _venue_dir(venue_id)
if (local / "manifest.json").is_file():
return local
bundled = _bundled_venue_dir(venue_id)
if (bundled / "manifest.json").is_file():
return bundled
return local
def _installed(venue_id):
return (_venue_dir(venue_id) / "manifest.json").is_file()
return (_pack_dir(venue_id) / "manifest.json").is_file()
def _bundled(venue_id):
return (_bundled_venue_dir(venue_id) / "manifest.json").is_file()
def _stars():
@@ -100,6 +132,235 @@ def _stars():
return sum(per_song.values()), per_song, detail
# ── Passports ─────────────────────────────────────────────────────────────────
GENRE_MAX_LEN = 64
DRILL_SNAPSHOT_MAX_BYTES = 256 * 1024
def _now_iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _genre_display(genre):
return " ".join(str(genre or "").strip().split())
def _genre_key(genre):
return _genre_display(genre).lower()
def _state_file() -> Path:
return _state["state_dir"] / "passports-state.json"
def _drill_file() -> Path:
return _state["state_dir"] / "drill-state.json"
def _load_json(path: Path, default):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return default
def _save_json(path: Path, obj):
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(obj, indent=2), encoding="utf-8")
tmp.replace(path)
def _career_state():
st = _load_json(_state_file(), {})
if not isinstance(st, dict):
st = {}
if not isinstance(st.get("instruments"), dict):
st["instruments"] = {}
if not isinstance(st.get("passports"), dict):
st["passports"] = {}
return st
def _genre_expr(db):
# Reuse the host's override-aware effective-genre SQL (Fix-metadata popup
# overrides); plain `genre` on stand-ins that don't implement it.
fn = getattr(db, "_effective_genre_expr", None)
return fn() if callable(fn) else "genre"
def _instrument_of(arrangements, arrangement):
"""Progression's arrangement→instrument mapping, via the song_stats
arrangement index into the song's arrangements JSON."""
entry = None
try:
idx = int(arrangement)
if isinstance(arrangements, list) and 0 <= idx < len(arrangements):
entry = arrangements[idx]
except (TypeError, ValueError):
entry = None
return instrument_for_arrangement(entry)
def _played_by_instrument_genre():
"""(instrument, genre_key) → {filename: stub dict}. Best accuracy per
(instrument, song); the JOIN keeps the same dead-song filter as _stars()."""
db = _state["meta_db"]
if db is None:
return {}
thresholds = _state["content"]["star_accuracy_thresholds"]
rows = db.conn.execute(
"SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, "
" songs.title, songs.artist, songs.arrangements, "
f" {_genre_expr(db)} "
"FROM song_stats s JOIN songs ON songs.filename = s.filename"
).fetchall()
arrs_cache = {}
out = {}
for filename, arrangement, acc, played_at, title, artist, arrs_json, genre in rows:
gkey = _genre_key(genre)
if not gkey:
continue
if filename not in arrs_cache:
try:
arrs_cache[filename] = json.loads(arrs_json) if arrs_json else None
except (TypeError, ValueError):
arrs_cache[filename] = None
instrument = _instrument_of(arrs_cache[filename], arrangement)
acc = acc or 0.0
stub = out.setdefault((instrument, gkey), {}).get(filename)
if stub is None:
out[(instrument, gkey)][filename] = {
"filename": filename,
"title": title or filename,
"artist": artist or "",
"best_accuracy": acc,
"last_played_at": played_at,
}
else:
stub["best_accuracy"] = max(stub["best_accuracy"], acc)
stub["last_played_at"] = max(stub["last_played_at"] or "", played_at or "") or None
for stubs in out.values():
for stub in stubs.values():
acc = stub["best_accuracy"]
stub["best_accuracy"] = round(acc, 4)
stub["stars"] = sum(1 for t in thresholds if acc >= t)
return out
def _library_genres():
"""Distinct effective genres across the live library (the brochure rack)."""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT {_genre_expr(db)} AS g, COUNT(*) FROM songs GROUP BY g").fetchall()
by_key = {}
for genre, count in rows:
display = _genre_display(genre)
key = display.lower()
if not key:
continue
cur = by_key.get(key)
if cur: # case-variant duplicates collapse onto the first-seen casing
cur["songs_in_library"] += count
else:
by_key[key] = {"genre_key": key, "genre": display,
"songs_in_library": count}
return sorted(by_key.values(),
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
def _badge_requirement(gkey):
cfg = _state["passports_content"]
req = dict(cfg.get("badge_requirement") or {})
req.setdefault("songs", 5)
req.setdefault("min_stars", 2)
override = (cfg.get("genres") or {}).get(gkey)
if isinstance(override, dict):
req.update(override)
req["virtuoso_nodes"] = [n for n in (req.get("virtuoso_nodes") or [])
if isinstance(n, str)]
return req
def _drill_by_node():
doc = _load_json(_drill_file(), {})
if not isinstance(doc, dict):
return None, {}
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
return doc.get("received_at"), by_node
def _node_cleared(by_node, node_id):
"""A drill counts as cleared on real completion evidence: mastered, or any
depth rung flipped true (virtuoso's gained-only false→true artifacts)."""
entry = by_node.get(node_id)
if not isinstance(entry, dict):
return False
depth = entry.get("depth") if isinstance(entry.get("depth"), dict) else {}
return bool(entry.get("masteredAt")) or any(bool(v) for v in depth.values())
def _passports_view():
cfg = _state["passports_content"]
graded = set(cfg.get("graded_instruments") or [])
st = _career_state()
played = _played_by_instrument_genre()
received_at, by_node = _drill_by_node()
instruments = {}
for inst in cfg.get("instruments") or []:
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
opened = st["passports"].get(inst)
opened = opened if isinstance(opened, dict) else {}
passports = []
for gkey, meta in sorted(opened.items(),
key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])):
meta = meta if isinstance(meta, dict) else {}
req = _badge_requirement(gkey)
songs = list(played.get((inst, gkey), {}).values())
for s in songs:
s["qualifies"] = s["stars"] >= req["min_stars"]
songs.sort(key=lambda s: (not s["qualifies"], -s["stars"],
s["title"].lower()))
qualifying = sum(1 for s in songs if s["qualifies"])
required = req["virtuoso_nodes"]
cleared = [n for n in required if _node_cleared(by_node, n)]
is_graded = inst in graded
if not is_graded:
# Where the engine can't fairly grade the instrument's job
# (bass pocket, feel) the passport shows repertoire, never a
# false badge denial — the doc's shown-not-judged rule.
badge = "shown_not_judged"
elif qualifying >= req["songs"] and len(cleared) == len(required):
badge = "earned"
else:
badge = "in_progress"
passports.append({
"genre_key": gkey,
"genre": meta.get("genre") or gkey,
"opened_at": meta.get("opened_at"),
"requirement": req,
"graded": is_graded,
"songs": songs,
"qualifying_count": qualifying,
"drills": {"required": required, "cleared": cleared},
"badge": badge,
})
instruments[inst] = {"committed_at": committed_at, "passports": passports}
return {
"config": {
"badge_requirement": cfg.get("badge_requirement") or {},
"graded_instruments": sorted(graded),
"instruments": list(cfg.get("instruments") or []),
},
"instruments": instruments,
"genres": _library_genres(),
"drill_state": {"received_at": received_at},
}
def _validate_pack_dir(pack_dir: Path):
"""Raise ValueError unless pack_dir holds a complete venue pack."""
manifest_path = pack_dir / "manifest.json"
@@ -174,12 +435,23 @@ def _download_pack(venue_id, pack, progress):
def setup(app, context):
plugin_dir = Path(__file__).resolve().parent
_state["plugin_dir"] = plugin_dir
_state["content"] = json.loads((plugin_dir / "venues.json").read_text(encoding="utf-8"))
_state["venues_dir"] = (
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
_state["passports_content"] = json.loads(
(plugin_dir / "passports.json").read_text(encoding="utf-8"))
# Persisted career state (commitment / opened passports / drill snapshot)
# lives under CONFIG_DIR/career/ — declared in settings.server_files so it
# rides the settings export/import bundle. Packs stay out (they're media).
_state["state_dir"] = Path(context["config_dir"]) / PLUGIN_ID
_state["state_dir"].mkdir(parents=True, exist_ok=True)
_state["meta_db"] = context.get("meta_db")
_state["log"] = context.get("log") or _state["log"]
for v in _state["content"]["venues"]:
if _bundled(v["id"]):
_validate_pack_dir(_bundled_venue_dir(v["id"]))
@app.get(f"/api/plugins/{PLUGIN_ID}/state")
def get_state():
@@ -195,7 +467,8 @@ def setup(app, context):
"star_threshold": v["star_threshold"],
"unlocked": stars_total >= v["star_threshold"],
"installed": _installed(v["id"]),
"has_pack": bool(v.get("pack")),
"bundled": _bundled(v["id"]),
"has_pack": _bundled(v["id"]) or bool(v.get("pack")),
"download": dl,
})
return {
@@ -206,6 +479,64 @@ def setup(app, context):
"venues": venues,
}
@app.get(f"/api/plugins/{PLUGIN_ID}/passports")
def get_passports():
with _lock:
return _passports_view()
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/commit")
def commit_instrument(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
with _lock:
st = _career_state()
entry = st["instruments"].setdefault(inst, {})
# Idempotent: the wax seal is pressed once; re-commits keep the
# original date (only-gained-never-lost).
if not entry.get("committed_at"):
entry["committed_at"] = _now_iso()
_save_json(_state_file(), st)
return {"ok": True, "instrument": inst,
"committed_at": entry["committed_at"]}
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/open")
def open_passport(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
with _lock:
st = _career_state()
# Opening a passport implies the instrument commitment (permissive
# server, ceremony ordering is the UI's job).
st["instruments"].setdefault(inst, {}).setdefault(
"committed_at", _now_iso())
genres = st["passports"].setdefault(inst, {})
if gkey not in genres:
genres[gkey] = {"genre": genre, "opened_at": _now_iso()}
_save_json(_state_file(), st)
return {"ok": True, "instrument": inst, "passport": genres[gkey]}
@app.post(f"/api/plugins/{PLUGIN_ID}/drill-state")
def post_drill_state(body: dict = Body(...)):
# The relayed virtuoso.progress snapshot (career's screen.js listens to
# the virtuoso:progress bus event and forwards the localStorage doc).
# Only the fields the badge check reads are kept.
if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict):
raise HTTPException(400, "Expected a progress snapshot with byNode.")
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
"byNode": body["byNode"]}
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
with _lock:
_save_json(_drill_file(), {"received_at": _now_iso(),
"snapshot": snapshot})
return {"ok": True}
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
def start_download(venue_id: str):
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
@@ -244,12 +575,13 @@ def setup(app, context):
async def get_pack_file(venue_id: str, filename: str):
if not VENUE_ID_RE.fullmatch(venue_id) or not PACK_FILENAME_RE.fullmatch(filename):
raise HTTPException(404, "Not found.")
path = _venue_dir(venue_id) / filename
pack_dir = _pack_dir(venue_id)
path = pack_dir / filename
# Defense-in-depth beyond the regexes (same recipe as highway_3d):
# the resolved path must stay inside the venues dir.
# the resolved path must stay inside the selected pack dir.
try:
resolved = path.resolve()
resolved.relative_to(_state["venues_dir"].resolve())
resolved.relative_to(pack_dir.resolve())
except (OSError, ValueError):
raise HTTPException(404, "Not found.")
if not resolved.is_file():
+33 -12
View File
@@ -3,19 +3,40 @@
<h1 class="text-2xl font-bold text-white">Career</h1>
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
</div>
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
<div id="career-progress-wrap" class="mb-6">
<div class="career-bar-track">
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
</div>
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
<div class="career-tabs" role="tablist">
<button class="career-tab" data-career-tab="venues" role="tab" id="career-tab-btn-venues" aria-controls="career-tab-venues">Venues</button>
<button class="career-tab" data-career-tab="passports" role="tab" id="career-tab-btn-passports" aria-controls="career-tab-passports">Passports</button>
</div>
<div id="career-venues" class="career-venues"></div>
<div class="mt-8">
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
<div id="career-star-summary" class="text-xs text-gray-400"></div>
<div id="career-tab-venues" role="tabpanel" aria-labelledby="career-tab-btn-venues">
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
<div id="career-progress-wrap" class="mb-6">
<div class="career-bar-track">
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
</div>
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
</div>
<div id="career-venues" class="career-venues"></div>
<div class="mt-8">
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
<div id="career-star-summary" class="text-xs text-gray-400"></div>
</div>
<div id="career-star-list" class="career-star-list"></div>
</div>
</div>
<div id="career-tab-passports" class="hidden" role="tabpanel" aria-labelledby="career-tab-btn-passports">
<p class="text-sm text-gray-400 mb-4">Commit to an instrument, pick a genre, and stamp your way to its badge — five ★★ songs mint a Bronze. Your passport wall is who you are as a musician.</p>
<div id="pp-instruments" class="pp-instruments"></div>
<div id="pp-shelf-wrap" class="mt-5">
<div id="pp-shelf" class="pp-shelf"></div>
</div>
<div id="pp-rack-wrap" class="mt-8">
<h2 class="text-lg font-semibold text-white mb-1">Explore next</h2>
<p class="text-xs text-gray-500 mb-3">More genres, whenever you want them — your wall is complete as it is.</p>
<div id="pp-rack" class="pp-rack"></div>
</div>
<div id="career-star-list" class="career-star-list"></div>
</div>
</div>
<div id="pp-overlay" class="pp-overlay hidden"></div>
+424 -1
View File
@@ -17,11 +17,25 @@
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
const POLL_MS = 2000;
// Passports (the badge-journey layer; see routes.py — badges are computed
// server-side, this file only renders and relays).
const PP_SEEN_KEY = 'feedBack-career-badges-seen';
const PP_INST_KEY = 'feedBack-career-instrument';
const PP_TAB_KEY = 'feedBack-career-tab';
const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' };
const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕'];
let _state = null;
let _pollTimer = 0;
let _appliedManifestVenue = null;
let _manifestReqGen = 0; // invalidates in-flight manifest fetches
let _prevUnlockedIds = null;
let _pp = null; // last /passports view
let _ppRelayTimer = 0;
let _ppBook = null; // {inst, gkey} of the open spread
let _ppReturnFocus = null; // element to refocus when the book closes
let _ppBootstrapped = false;
let _ppNotified = {}; // badges chimed this session (slam still pending)
function $(id) { return document.getElementById(id); }
@@ -89,9 +103,12 @@
const main = active
? `<button data-career-unselect="1" class="career-btn career-btn-ghost">Leave venue</button>`
: `<button data-career-play="${esc(v.id)}" class="career-btn career-btn-primary">Play here</button>`;
const remove = v.bundled
? ''
: `<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>`;
action = `<div class="flex items-center gap-2">
${main}
<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>
${remove}
</div>`;
} else if (v.has_pack) {
const err = dl.status === 'error'
@@ -216,9 +233,401 @@
render(state);
schedulePoll(state);
pushCrowdManifest(state);
refreshPassports(); // independent fetch; failures don't touch venues
}
// ── Passports ─────────────────────────────────────────────────────────
function lsGet(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* ok */ } }
function ppLabel(inst) {
return PP_LABELS[inst] || (inst.charAt(0).toUpperCase() + inst.slice(1));
}
function ppKey(genre) {
return String(genre || '').trim().replace(/\s+/g, ' ').toLowerCase();
}
function ppHash(seed) {
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
return h;
}
// Deterministic per-key jitter (sin-hash): stamps and stubs land slightly
// askew, the same way on every visit.
function ppJitter(seed, range) {
return (Math.abs(Math.sin(ppHash(seed))) * 2 - 1) * range;
}
function sfx(name) {
try {
const a = new Audio(`${API}/assets/sfx/${name}.mp3`);
a.volume = 0.45;
a.play().catch(() => { /* autoplay policy — silent is fine */ });
} catch (_) { /* no Audio — fine */ }
}
function showCareerTab(tab) {
lsSet(PP_TAB_KEY, tab);
const venues = $('career-tab-venues');
const pp = $('career-tab-passports');
if (!venues || !pp) return;
venues.classList.toggle('hidden', tab !== 'venues');
pp.classList.toggle('hidden', tab !== 'passports');
document.querySelectorAll('#plugin-career .career-tab').forEach((b) => {
const active = b.dataset.careerTab === tab;
b.classList.toggle('active', active);
b.setAttribute('aria-selected', active ? 'true' : 'false');
});
}
function activeInstrument() {
const list = (_pp && _pp.config && _pp.config.instruments) || [];
const saved = lsGet(PP_INST_KEY);
if (saved && list.includes(saved)) return saved;
const committed = list.find((i) => ((_pp.instruments || {})[i] || {}).committed_at);
return committed || list[0] || 'guitar';
}
function seenBadges() {
try {
const seen = JSON.parse(lsGet(PP_SEEN_KEY) || '{}');
// Guard non-object JSON (a stray "null" or array) — a broken
// stored value must not throw on every passport refresh.
return seen && typeof seen === 'object' && !Array.isArray(seen) ? seen : {};
} catch (_) { return {}; }
}
function badgeId(inst, gkey) { return inst + '/' + gkey; }
function markBadgeSeen(inst, gkey) {
const seen = seenBadges();
seen[badgeId(inst, gkey)] = 1;
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
}
// New badge → chime + notification once per session; the stamp SLAM plays
// when the passport is next opened (and only then is the badge marked
// seen, so a pending slam survives a reload).
function detectNewBadges(view) {
const seen = seenBadges();
for (const inst of Object.keys(view.instruments || {})) {
for (const p of (view.instruments[inst].passports || [])) {
const id = badgeId(inst, p.genre_key);
if (p.badge !== 'earned' || seen[id] || _ppNotified[id]) continue;
_ppNotified[id] = true;
sfx('chime');
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
window.fbNotify.show({
big: true, icon: '🛂', accent: '#b45309',
title: 'Badge earned!',
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
});
}
}
}
}
// Relay the Virtuoso drill snapshot (localStorage doc, not the thin bus
// payload) to the server intake, debounced across event bursts.
function relayDrillState() {
clearTimeout(_ppRelayTimer);
_ppRelayTimer = setTimeout(() => {
let snap = null;
try { snap = JSON.parse(lsGet('virtuoso.progress') || 'null'); } catch (_) { /* corrupt */ }
if (!snap || typeof snap !== 'object' || !snap.byNode || typeof snap.byNode !== 'object') return;
fetch(`${API}/drill-state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: snap.mode, xp: snap.xp, byNode: snap.byNode }),
}).then(() => refreshPassports()).catch(() => { /* next event retries */ });
}, 1500);
}
async function refreshPassports() {
let view;
try {
const res = await fetch(`${API}/passports`);
if (!res.ok) return;
view = await res.json();
} catch (_) { return; }
_pp = view;
detectNewBadges(view);
renderPassports();
if (!_ppBootstrapped) {
_ppBootstrapped = true;
// First run on this browser: seed the server with the local drill
// snapshot if it has never received one.
if (!(view.drill_state || {}).received_at) relayDrillState();
}
}
function ppCoverHTML(inst, p) {
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
const stamp = p.badge === 'earned'
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
: '';
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
return `<button class="pp-cover pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="transform:rotate(${rot}deg)">
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
${stamp}
<span class="pp-cover-sub">${stubs}</span>
</button>`;
}
function renderShelf(inst, data) {
const shelf = $('pp-shelf');
if (!shelf) return;
if (!data.committed_at) {
shelf.innerHTML = `<div class="pp-commit-card">
<div class="pp-commit-cover pp-leather-${esc(inst)}">
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
<span class="pp-cover-inst">passport</span>
</div>
<div>
<div class="text-sm text-gray-200 font-medium mb-1">Pick up the ${esc(ppLabel(inst).toLowerCase())}.</div>
<div class="text-xs text-gray-400 mb-2">Press your seal to commit — then choose a genre below and go deep.</div>
<button class="career-btn career-btn-primary" data-pp-commit="${esc(inst)}">Press the seal</button>
</div>
</div>`;
return;
}
const books = (data.passports || []).map((p) => ppCoverHTML(inst, p)).join('');
shelf.innerHTML = books ||
'<div class="text-xs text-gray-500">Your shelf is ready — open your first genre passport below.</div>';
}
function renderRack(inst, data) {
const rack = $('pp-rack');
if (!rack || !_pp) return;
const openedKeys = new Set((data.passports || []).map((p) => p.genre_key));
const genres = (_pp.genres || []).filter((g) => !openedKeys.has(g.genre_key));
if (!genres.length) {
rack.innerHTML = '<div class="text-xs text-gray-500">No further genres in your library yet — new songs bring new brochures.</div>';
return;
}
rack.innerHTML = genres.map((g) => {
const art = PP_BROCHURE_ART[Math.abs(ppHash(g.genre_key)) % PP_BROCHURE_ART.length];
return `<button class="pp-brochure" data-pp-genre="${esc(g.genre)}">
<span class="pp-brochure-art" aria-hidden="true">${art}</span>
<span class="pp-brochure-name">${esc(g.genre)}</span>
<span class="pp-brochure-sub">${g.songs_in_library === 1 ? '1 song' : `${g.songs_in_library} songs`} in your library</span>
</button>`;
}).join('');
}
function renderPassports() {
const host = $('pp-instruments');
if (!host || !_pp) return;
const inst = activeInstrument();
const data = (_pp.instruments || {})[inst] || { passports: [] };
host.innerHTML = ((_pp.config || {}).instruments || []).map((i) => {
const d = (_pp.instruments || {})[i] || {};
const earned = (d.passports || []).filter((p) => p.badge === 'earned').length;
const committed = !!d.committed_at;
return `<button class="pp-inst${i === inst ? ' active' : ''}${committed ? '' : ' uncommitted'}" data-pp-inst="${esc(i)}">
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
</button>`;
}).join('');
renderShelf(inst, data);
renderRack(inst, data);
}
function ppStubHTML(s) {
const date = (s.last_played_at || '').slice(0, 10);
return `<div class="pp-stub" style="transform:rotate(${ppJitter(s.filename, 1.2).toFixed(2)}deg)">
<span class="pp-stub-stars">${'★'.repeat(s.stars)}</span>
<span class="pp-stub-title">${esc(s.title)}</span>
${s.artist ? `<span class="pp-stub-artist">${esc(s.artist)}</span>` : ''}
<span class="pp-stub-meta">${date ? `${esc(date)} · ` : ''}best ${(s.best_accuracy * 100).toFixed(0)}%</span>
</div>`;
}
function ppBookHTML(inst, p, pendingSlam) {
const req = p.requirement || {};
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
const starGl = '★'.repeat(req.min_stars || 0);
let badgeArea = '';
if (p.badge === 'shown_not_judged') {
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
} else if (p.badge === 'earned') {
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
<span class="pp-stamp-tier">BRONZE</span>
</div>
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
} else {
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
<span class="pp-stamp-tier">BRONZE</span>
</div>
<div class="pp-invite">${need === 1 ? `One more ${starGl} song mints this stamp.` : `${need} more ${starGl} songs mint this stamp.`}</div>`;
}
let drills = '';
const reqNodes = (p.drills || {}).required || [];
if (reqNodes.length) {
const cleared = new Set((p.drills || {}).cleared || []);
drills = `<div class="pp-drills">${reqNodes.map((n) =>
`<div class="pp-drill${cleared.has(n) ? ' cleared' : ''}">${cleared.has(n) ? '✓' : '○'} ${esc(n)}</div>`).join('')}</div>`;
}
// Graded instruments collect stubs at the badge bar; shown-not-judged
// instruments have no bar — every played genre song is repertoire.
const stubs = p.badge === 'shown_not_judged'
? (p.songs || [])
: (p.songs || []).filter((s) => s.qualifies);
const emptyLine = p.badge === 'shown_not_judged'
? `Play ${esc(p.genre)} songs to fill this page.`
: `Play ${esc(p.genre)} songs at ${starGl} to collect ticket stubs.`;
const stubsHTML = stubs.length ? stubs.map(ppStubHTML).join('')
: `<div class="pp-stub-empty">${emptyLine}</div>`;
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="${esc(p.genre)} ${esc(ppLabel(inst))} passport">
<div class="pp-book">
<div class="pp-page pp-page-left">
<div class="pp-page-head">${esc(p.genre)}${esc(ppLabel(inst))}</div>
${badgeArea}${drills}
</div>
<div class="pp-page pp-page-right">
<div class="pp-page-head">Ticket stubs</div>
<div class="pp-stubs">${stubsHTML}</div>
</div>
<div class="pp-book-cover pp-leather-${esc(inst)}">
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
</div>
<button class="pp-book-close" data-pp-close="1" aria-label="Close">✕</button>
</div>
</div>`;
}
function openBook(inst, gkey) {
if (!_pp) return;
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
.find((x) => x.genre_key === gkey);
const overlay = $('pp-overlay');
if (!p || !overlay) return;
_ppBook = { inst, gkey };
_ppReturnFocus = document.activeElement;
const pending = p.badge === 'earned' && !seenBadges()[badgeId(inst, gkey)];
overlay.innerHTML = ppBookHTML(inst, p, pending);
overlay.classList.remove('hidden');
const close = overlay.querySelector('.pp-book-close');
if (close) close.focus();
sfx('page');
// Double rAF so the cover's closed state paints before the transition.
requestAnimationFrame(() => requestAnimationFrame(() => {
const book = overlay.querySelector('.pp-book');
if (book) book.classList.add('open');
}));
if (pending) {
setTimeout(() => {
if (!_ppBook || _ppBook.gkey !== gkey || _ppBook.inst !== inst) return;
const stamp = overlay.querySelector('.pp-stamp-page');
const book = overlay.querySelector('.pp-book');
if (!stamp) return;
stamp.classList.remove('pp-stamp-hidden');
stamp.classList.add('pp-slam');
if (book) book.classList.add('pp-shake');
sfx('stamp');
markBadgeSeen(inst, gkey);
renderPassports(); // the shelf cover gains its mini-stamp
}, 950);
}
}
function closeBook() {
_ppBook = null;
const overlay = $('pp-overlay');
if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; }
if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' &&
document.contains(_ppReturnFocus)) {
_ppReturnFocus.focus();
}
_ppReturnFocus = null;
}
function commitInstrument(inst, after) {
fetch(`${API}/passports/commit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instrument: inst }),
}).then(() => refreshPassports())
.then(() => { if (after) after(); })
.catch(() => { /* server restarting; user retries */ });
}
// Stage 0 — the wax seal. Purely theatrical: the overlay plays the press,
// the POST commits, the shelf re-renders committed.
function sealCeremony(inst, after) {
const overlay = $('pp-overlay');
if (!overlay) { commitInstrument(inst, after); return; }
overlay.innerHTML = `<div class="pp-book-wrap">
<div class="pp-commit-cover pp-ceremony pp-leather-${esc(inst)}">
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
<span class="pp-cover-inst">passport</span>
<span class="pp-wax"><span>${esc(ppLabel(inst).charAt(0))}</span></span>
</div>
</div>`;
overlay.classList.remove('hidden');
setTimeout(() => sfx('seal'), 450);
setTimeout(() => {
overlay.classList.add('hidden');
overlay.innerHTML = '';
commitInstrument(inst, after);
}, 1500);
}
function openGenre(inst, genre) {
fetch(`${API}/passports/open`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instrument: inst, genre }),
}).then((res) => { if (!res.ok) throw new Error('open ' + res.status); })
.then(() => refreshPassports())
.then(() => openBook(inst, ppKey(genre)))
.catch(() => { /* validation/restart; rack stays */ });
}
function onClick(e) {
const tabBtn = e.target.closest('[data-career-tab]');
const instBtn = e.target.closest('[data-pp-inst]');
const commitBtn = e.target.closest('[data-pp-commit]');
const coverBtn = e.target.closest('[data-pp-open]');
const brochureBtn = e.target.closest('[data-pp-genre]');
if (tabBtn) {
showCareerTab(tabBtn.dataset.careerTab);
return;
}
if (instBtn) {
lsSet(PP_INST_KEY, instBtn.dataset.ppInst);
renderPassports();
return;
}
if (commitBtn) {
sealCeremony(commitBtn.dataset.ppCommit);
return;
}
if (coverBtn) {
openBook(activeInstrument(), coverBtn.dataset.ppOpen);
return;
}
if (brochureBtn) {
const inst = activeInstrument();
const genre = brochureBtn.dataset.ppGenre;
const committed = _pp && ((_pp.instruments || {})[inst] || {}).committed_at;
// Opening your first passport on an instrument IS the commitment —
// the seal ceremony runs first, then the passport opens.
if (committed) openGenre(inst, genre);
else sealCeremony(inst, () => openGenre(inst, genre));
return;
}
if (e.target.closest('[data-pp-close]') ||
(e.target.dataset && e.target.dataset.ppCloseBg)) {
closeBook();
return;
}
const dlBtn = e.target.closest('[data-career-download]');
const delBtn = e.target.closest('[data-career-delete]');
const playBtn = e.target.closest('[data-career-play]');
@@ -265,10 +674,24 @@
if (sm && typeof sm.on === 'function') {
// New song stats can add stars → thresholds may cross mid-session.
sm.on('stats:recorded', () => refresh());
// Virtuoso's progress emits are the drill-state relay trigger; the
// payload is a thin delta, so the relay reads the full localStorage
// snapshot instead (see relayDrillState).
sm.on('virtuoso:progress', relayDrillState);
}
showCareerTab(lsGet(PP_TAB_KEY) === 'passports' ? 'passports' : 'venues');
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && _ppBook) closeBook();
});
refresh();
}
// Test seam (bare-vm harness, see plugins/career/tests/): pure helpers +
// the badge-diff logic; nothing here touches the DOM.
window.__careerPassportTest = {
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
+10
View File
@@ -0,0 +1,10 @@
<!-- Career plugin — data panel. Exists so the passport/drill state declared in
settings.server_files has a visible home in Settings; nothing to configure. -->
<div class="text-sm text-gray-300 space-y-2">
<p><strong>Career</strong> computes stars and genre badges from your play
stats — they are never stored, so there is nothing to back up or reset.</p>
<p class="text-gray-400">What <em>is</em> saved server-side: your instrument
commitments, opened genre passports, and the practice-drill snapshot the
Virtuoso plugin reports. These ride along in
<em>Settings → Export</em> automatically.</p>
</div>
+101
View File
@@ -0,0 +1,101 @@
// Passport UI pure-logic tests: load screen.js in a bare vm window and
// exercise the __careerPassportTest seam (no DOM beyond stubs, no network).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load(seed) {
const store = Object.assign({}, seed);
const window = {
console,
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
},
document: {
readyState: 'complete',
getElementById: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
},
notifications: [],
};
window.window = window;
window.globalThis = window;
window.fbNotify = { show: (n) => window.notifications.push(n) };
const context = vm.createContext(window);
// `document` and `localStorage` resolve as bare names inside the IIFE.
context.document = window.document;
context.localStorage = window.localStorage;
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'career/screen.js' });
return window;
}
test('module loads (and boots) in a bare vm window', () => {
const w = load();
assert.equal(typeof w.__careerPassportTest.ppKey, 'function');
});
test('ppKey normalizes case and whitespace', () => {
const { ppKey } = load().__careerPassportTest;
assert.equal(ppKey(' Blues Rock '), 'blues rock');
assert.equal(ppKey('FUNK'), 'funk');
assert.equal(ppKey(''), '');
assert.equal(ppKey(null), '');
});
test('ppJitter is deterministic and bounded', () => {
const { ppJitter } = load().__careerPassportTest;
assert.equal(ppJitter('blues', 8), ppJitter('blues', 8));
for (const seed of ['blues', 'funk', 'jazz', 'metal']) {
const j = ppJitter(seed, 8);
assert.ok(j >= -8 && j <= 8, `${seed}${j}`);
}
assert.notEqual(ppJitter('blues', 8), ppJitter('funk', 8));
});
test('detectNewBadges notifies once per badge, never after it is seen', () => {
const w = load();
const t = w.__careerPassportTest;
const view = {
instruments: {
guitar: {
passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' },
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress' },
],
},
},
};
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
assert.match(w.notifications[0].message, /Blues/);
// Same view again in the same session: no duplicate notification.
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// Seen (slam played) → a fresh session stays quiet too.
t.markBadgeSeen('guitar', 'blues');
// JSON-compare: vm objects carry a foreign Object prototype.
assert.equal(JSON.stringify(t.seenBadges()), '{"guitar/blues":1}');
// Fresh session (new vm, empty notify cache) with the badge already seen:
// detection must stay silent.
const w2 = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 0);
});
test('seenBadges tolerates corrupt stored values', () => {
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
const w = load({ 'feedBack-career-badges-seen': bad });
const t = w.__careerPassportTest;
assert.equal(JSON.stringify(t.seenBadges()), '{}', `stored ${bad}`);
// And detection still works on top of the recovered empty state.
t.detectNewBadges({ instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } });
assert.equal(w.notifications.length, 1, `stored ${bad}`);
}
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
{
"venue": "bar",
"version": 1,
"loops": {
"bored": "bored.mp4",
"neutral": "neutral.mp4",
"engaged": "engaged.mp4",
"ecstatic": "ecstatic.mp4"
},
"stingers": {
"clap": "clap.mp4",
"cheer": "cheer.mp4"
},
"intro": {
"video": "intro.mp4",
"audio": "bar-ambience.mp3"
}
}
Binary file not shown.
+1733 -1717
View File
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
/*
* fee[dB]ack — the pop-out chip.
*
* One affordance, core-owned, identical everywhere: the small ⇱ button a plugin
* drops into the panel it already has.
*
* feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl });
* feedBack.panes.attachChip(panelEl, 'camera_director');
*
* That is the entire adoption cost. Clicking the chip pops the panel out; a stub
* takes its place so the user can find it again; closing the pane brings the panel
* home and restores the chip. The plugin writes no show/hide logic — if it did,
* every plugin would invent a slightly different one, which is exactly the
* inconsistency this exists to prevent.
*
* The panel a chip is attached to is USUALLY the very element the pane moves into
* the pop-out window — so most of the time there is nothing here left to hide, and
* the job is simply to mark the hole it left. Hiding it would in fact be actively
* harmful: `.fb-pane-detached` is `display:none !important`, and it would travel
* with the node straight into the pane window and blank it.
*
* When the chip IS attached to something the pane didn't take (a wrapper, a
* launcher row), that element stays put and is hidden with `.fb-pane-detached` —
* a dedicated class, not `.hidden`/[hidden], because the panels we attach to
* already toggle those themselves.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.register !== 'function') {
console.error('[panes] pane-manager.js must load before pane-chip.js');
return;
}
// paneId -> { el, chip, stub, spec }
const attached = new Map();
function _makeChip(spec) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'fb-pane-chip';
b.title = 'Pop out';
b.setAttribute('aria-label', 'Pop out ' + spec.title);
b.textContent = '⇱';
b.addEventListener('click', (e) => {
// Rail popovers close on any document click that lands outside them
// (player-chrome.js). Without this the popover would close under the
// chip mid-click, which reads as the button not working.
e.stopPropagation();
e.preventDefault();
panes.detach(spec.id);
});
return b;
}
function _makeStub(spec) {
const s = document.createElement('button');
s.type = 'button';
s.className = 'fb-pane-stub';
s.setAttribute('aria-label', 'Bring ' + spec.title + ' back');
s.title = 'Bring it back';
const glyph = document.createElement('span');
glyph.className = 'fb-pane-stub-glyph';
glyph.textContent = '⇲';
const label = document.createElement('span');
label.textContent = spec.title + ' is popped out';
s.appendChild(glyph);
s.appendChild(label);
s.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
panes.close(spec.id);
});
return s;
}
// The pane is out. Leave a stub where its panel used to be.
//
// The subtlety: the panel a chip is attached to is USUALLY the very element the
// pane moved into the pop-out window. It is no longer in this document at all —
// so hiding it would be worse than pointless (the `display:none` travels with
// the node and blanks the pane window, which is exactly the bug this fixes), and
// the stub cannot be inserted "before it", because it is not here to be before.
//
// Hence `home`: the manager tells us where the element used to live, and the
// stub goes there. If the chip is attached to something the pane did NOT take —
// a wrapper, a launcher row — that element is still here, and we hide it as
// before.
function _onOpened(rec, detail) {
// Did the pane take MY element?
//
// Ask the manager, which knows exactly what it handed to the host. Do not
// try to infer it from the element:
//
// - `isConnected` says "still here" for a panel sitting in a pane window.
// It IS connected — to that window.
// - `ownerDocument` says "still here" for a panel moved into the DOCK,
// which is in this very document. Hiding it there would blank a pane the
// user is looking at.
//
// Both were live bugs. The manager's answer is the only one that holds for
// every host, and it works when reconciling after the fact (detail == null),
// which is what a plugin rebuilding its panel mid-pop-out triggers.
const takenEl = (detail && detail.el) || panes.elementOf(rec.spec.id);
const moved = takenEl === rec.el;
if (!moved && rec.el.isConnected) {
rec.el.classList.add('fb-pane-detached');
if (!rec.stub.isConnected && rec.el.parentNode) rec.el.parentNode.insertBefore(rec.stub, rec.el);
return;
}
// Mark the hole the element left. `home` comes with the event, or from the
// manager when we are reconciling after the fact.
const home = (detail && detail.home) || panes.homeOf(rec.spec.id);
if (!rec.stub.isConnected && home && home.parent && home.parent.isConnected) {
const next = (home.next && home.next.parentNode === home.parent) ? home.next : null;
home.parent.insertBefore(rec.stub, next);
}
}
function _onClosed(rec) {
// The element is back. Whatever we did to hide it, undo — including a class
// it might have carried out of the document and back.
rec.el.classList.remove('fb-pane-detached');
rec.stub.remove();
}
/**
* attachChip(el, paneId, opts)
*
* `el` — the dialog to hide when the pane pops out. The chip is injected
* into `el.querySelector('[data-pane-header]')` when present, else
* prepended to `el` itself.
* `opts` — { header: Element } to place the chip somewhere specific.
*
* Returns a detach function that removes the chip and stub and restores the
* dialog — call it if your plugin tears its dialog down.
*/
function attachChip(el, paneId, opts) {
opts = opts || {};
if (!(el instanceof Element)) throw new TypeError('panes.attachChip: el must be an Element');
// Validate here, not at the insertBefore below. This is a public plugin API,
// and a truthy non-Element `header` (a selector string, a jQuery-ish wrapper,
// a ref object) is an easy mistake to make — one that would otherwise surface
// as a confusing DOM exception from deep inside core.
if (opts.header != null && !(opts.header instanceof Element)) {
throw new TypeError('panes.attachChip(' + paneId + '): opts.header must be an Element');
}
const spec = panes.get(paneId);
if (!spec) { console.warn('[panes] attachChip: register the pane first:', paneId); return () => {}; }
if (attached.has(paneId)) { console.warn('[panes] attachChip: already attached:', paneId); return () => {}; }
const chip = _makeChip(spec);
const stub = _makeStub(spec);
const host = opts.header || el.querySelector('[data-pane-header]') || el;
if (host === el) host.insertBefore(chip, host.firstChild);
else host.appendChild(chip);
const rec = { el, chip, stub, spec };
attached.set(paneId, rec);
// Reconcile immediately: register() reopens a pane the user left open at
// last unload, and that can land before (or after) attachChip runs.
if (panes.isOpen(paneId)) _onOpened(rec, null);
return () => {
if (attached.get(paneId) !== rec) return;
attached.delete(paneId);
chip.remove();
_onClosed(rec);
};
}
// One pair of bus listeners for every chip, rather than one pair per chip.
const bus = window.feedBack;
if (bus && typeof bus.on === 'function') {
bus.on('panes:opened', (e) => {
const rec = attached.get(e.detail && e.detail.id);
if (rec) _onOpened(rec, e.detail);
});
bus.on('panes:closed', (e) => {
const rec = attached.get(e.detail && e.detail.id);
if (rec) _onClosed(rec);
});
}
window.feedBack.panes.attachChip = attachChip;
})();
+47
View File
@@ -0,0 +1,47 @@
/*
* fee[dB]ack — desktop upgrades for pane windows.
*
* In the desktop app a pane window is a real BrowserWindow: it remembers where you
* put it, it stays off the taskbar, it minimizes to the system tray, and the tray
* lists every pane you have.
*
* Note what this file does NOT do: it does not open the window, and it does not
* close it. That stays in pane-window-host.js, and it stays `window.open()` —
* because the pane's element is MOVED into that window's document, and a window
* the main process created for us would give this realm no handle to adopt into.
*
* Electron turns our same-origin `window.open()` into a real BrowserWindow anyway,
* and the main process recognises it by its frame name (`fbpane-<id>`) and takes
* over the OS-level behaviour from there. So the only thing left to say across IPC
* is "here are the panes that exist" — for the tray — and to listen for the tray
* saying "open that one".
*
* In a browser, or on an older desktop build, this file does nothing and pop-out
* works anyway. Everything here is an upgrade, not a dependency.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
const bus = window.feedBack;
const desktop = window.feedBackDesktop && window.feedBackDesktop.panes;
if (!panes || !bus || !desktop) return;
// The tray asked to toggle a pane. Only this realm knows what that means — the
// pane might belong in the dock, and its element lives here.
desktop.onToggle((paneId) => {
if (panes.isOpen(paneId)) panes.close(paneId);
else panes.detach(paneId);
});
// Keep the tray's menu in step with the registry. Cheap and rare — panes are
// registered at load and toggled by hand, never on a playback path.
function sync() {
desktop.sync(panes.list().map((p) => ({ id: p.id, title: p.title, icon: p.icon, open: p.open })));
}
bus.on('panes:registered', sync);
bus.on('panes:unregistered', sync);
bus.on('panes:opened', sync);
bus.on('panes:closed', sync);
sync();
})();
+121
View File
@@ -0,0 +1,121 @@
/*
* fee[dB]ack — pane dock (the in-window pane host).
*
* A right-edge stack of cards, one per open pane. Deliberately NOT a rail popover:
* the rail is exclusive (player-chrome.js's openPopFor closes the last one before
* opening the next), which is exactly why you cannot watch the mixer while riding
* the camera. Cards here coexist.
*
* As everywhere in this system, the card holds the plugin's REAL element — moved,
* not copied. The dock is a frame; the panel inside it is the panel.
*
* Song-switch survival is structural, not defended: #fb-pane-dock is a <body>
* child outside every .screen, so the per-song teardown never sees it.
*
* Registers as the `dock` host at priority 0 — the floor. Whatever else exists
* (an OS window), a pane can always land here, so opening one can never fail.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.registerHost !== 'function') {
console.error('[panes] pane-manager.js must load before pane-dock.js');
return;
}
let dockEl = null;
const cards = new Map(); // paneId -> card element
function dock() {
if (dockEl && dockEl.isConnected) return dockEl;
dockEl = document.getElementById('fb-pane-dock');
if (!dockEl) {
dockEl = document.createElement('div');
dockEl.id = 'fb-pane-dock';
// `is-empty` from the start: panes.css hides an empty dock, and a dock
// born without the class is a visible-to-CSS, announced-to-screen-readers
// `role="region"` landmark with nothing in it until the first card
// arrives. Born empty, because it is.
dockEl.className = 'fb-pane-dock is-empty';
dockEl.setAttribute('role', 'region');
dockEl.setAttribute('aria-label', 'Panes');
document.body.appendChild(dockEl);
}
return dockEl;
}
function _syncEmpty() {
dock().classList.toggle('is-empty', cards.size === 0);
}
function place(spec, el) {
const card = document.createElement('section');
card.className = 'fb-pane-card';
card.dataset.paneId = spec.id;
card.setAttribute('aria-label', spec.title);
const head = document.createElement('header');
head.className = 'fb-pane-card-head';
const title = document.createElement('span');
title.className = 'fb-pane-card-title';
// textContent, not innerHTML — a pane title comes from a plugin.
title.textContent = spec.icon + ' ' + spec.title;
const close = document.createElement('button');
close.type = 'button';
close.className = 'fb-pane-card-btn';
close.setAttribute('aria-label', 'Close ' + spec.title);
close.title = 'Close';
close.textContent = '✕';
close.addEventListener('click', () => panes.close(spec.id));
head.appendChild(title);
head.appendChild(close);
const body = document.createElement('div');
body.className = 'fb-pane-card-body';
// Same neutralisation as the window host: the panel was a fixed overlay
// pinned to a corner of the app, and inside a card that positioning is
// nonsense. .fb-paned unpins it and nothing else.
el.classList.add('fb-paned');
body.appendChild(el);
card.appendChild(head);
card.appendChild(body);
dock().appendChild(card);
cards.set(spec.id, card);
_syncEmpty();
}
function unplace(id, el) {
// Hand the element back unmarked. The manager returns it to its home right
// after this, and it must arrive as the plugin left it — a panel that
// stayed .fb-paned would come back with its own positioning stripped.
if (el) el.classList.remove('fb-paned');
const card = cards.get(id);
if (card) card.remove();
cards.delete(id);
_syncEmpty();
}
function focus(id) {
const card = cards.get(id);
if (!card) return;
// Honour prefers-reduced-motion, as the flash animation below already does
// in panes.css. A smooth scroll is motion too, and a user who asked for less
// of it meant this as well.
const calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
card.scrollIntoView({ block: 'nearest', behavior: calm ? 'auto' : 'smooth' });
// Re-trigger the flash even if the class is still there — repeat focus of
// the same card would otherwise be a no-op animation.
card.classList.remove('is-flash');
void card.offsetWidth;
card.classList.add('is-flash');
setTimeout(() => card.classList.remove('is-flash'), 700);
}
panes.registerHost({ id: 'dock', priority: 0, available: () => !!document.body, place, unplace, focus });
})();
+71
View File
@@ -0,0 +1,71 @@
/*
* fee[dB]ack — pane launcher (the "Panes" rail popover).
*
* A chip only works for a pane that already has a dialog to hide. Panes with no
* dialog — a readout, a plugin's optional extra — need somewhere to be opened
* from, so every registered pane gets one: a checkbox list in the rail.
*
* The rail popover is the right home for this precisely because it IS exclusive
* and transient. It's a menu, not a workspace; the panes it opens are the
* workspace, and they persist.
*
* Populated from the registry, so a plugin that calls panes.register() appears
* here with no further work. (The system tray will mirror this list.)
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
const bus = window.feedBack;
if (!panes || !bus || typeof bus.on !== 'function') return;
let listEl = null;
function render() {
if (!listEl || !listEl.isConnected) listEl = document.getElementById('v3-rail-panes-list');
if (!listEl) return;
const all = panes.list();
// Toggling a pane from this list fires panes:opened/closed, which re-renders
// the list — destroying the very button the user just pressed and dropping
// focus to <body>. Remember which one had it and give it back, so keyboard
// and screen-reader users can toggle several panes without losing their place.
const focusedId = (listEl.contains(document.activeElement) && document.activeElement.dataset)
? document.activeElement.dataset.paneId : null;
listEl.replaceChildren();
if (!all.length) {
const empty = document.createElement('div');
empty.className = 'v3-pop-empty';
empty.textContent = 'No panes available.';
listEl.appendChild(empty);
return;
}
all.forEach((p) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'v3-pop-btn';
b.dataset.paneId = p.id;
b.setAttribute('aria-pressed', p.open ? 'true' : 'false');
b.textContent = (p.open ? '● ' : '○ ') + p.icon + ' ' + p.title;
b.addEventListener('click', (e) => {
e.stopPropagation();
if (panes.isOpen(p.id)) panes.close(p.id); else panes.detach(p.id);
});
listEl.appendChild(b);
if (p.id === focusedId) b.focus();
});
}
// The registry changes when plugins load and when panes open/close. Render is
// cheap and rare (never on a playback path), so just re-run it.
bus.on('panes:registered', render);
bus.on('panes:unregistered', render);
bus.on('panes:opened', render);
bus.on('panes:closed', render);
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', render);
else render();
})();
+381
View File
@@ -0,0 +1,381 @@
/*
* fee[dB]ack — pane manager.
*
* The registry and host router behind `window.feedBack.panes`.
*
* A "pane" is a piece of UI a plugin already has — a mixer panel, a camera rig,
* a settings board — that the user can pop out into its own OS window and leave
* open: while they play, across song switches, on a second monitor, minimized to
* the tray.
*
* The whole design is one sentence: WE MOVE THE REAL ELEMENT.
*
* Not a copy of it, not a re-implementation of it in the pop-out window — the
* actual DOM node. Same-origin windows can adopt each other's nodes, and an
* adopted node keeps its event listeners and its closures. So the panel goes on
* running the plugin's own code, against the plugin's own state, in the plugin's
* own realm. It looks and behaves exactly like the thing that was popped out,
* because it IS the thing that was popped out.
*
* That is what makes the plugin's side of this two lines:
*
* feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl });
* feedBack.panes.attachChip(panelEl, 'camera_director');
*
* No state mirroring, no cross-window RPC, no second copy of the UI to keep in
* step with the first. Those were all workarounds for a problem we simply do not
* have once the node itself moves.
*
* The manager owns which pane is open and where, and — crucially — where each
* pane's element CAME FROM, so docking it puts it back exactly where it was.
*/
(function () {
'use strict';
const HOSTS_KEY = 'fbPaneHosts'; // { paneId: hostId } — panes open at last unload
// id -> normalized spec
const specs = new Map();
// id -> { spec, hostId, el, home: { parent, next } }
const open = new Map();
// hostId -> host provider
const hosts = new Map();
// ── Persistence ──────────────────────────────────────────────────────────
// Only which pane was open, and where. A pane's CONTENTS are the plugin's own
// DOM and the plugin's own state — none of our business.
// A pane id is plugin-controlled and is used as a key in the persisted
// host map. `__proto__` and friends are not ids, they are booby traps: writing
// `map['__proto__'] = 'window'` on a plain object corrupts the map (and can
// reach Object.prototype), and reading `map[id]` can pick a value straight off
// the prototype chain for a pane that was never remembered at all.
//
// Rejected at registration, so the id never reaches storage — and the reads
// below are own-property checks anyway, because defence in depth is cheap here.
const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
function _isUnsafeId(id) { return UNSAFE_KEYS.indexOf(id) >= 0; }
function _readJSON(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return fallback;
// Re-key onto a null-prototype object: whatever was in storage (hand
// edited, corrupt, polluted) can no longer smuggle in a prototype.
const safe = Object.create(null);
Object.keys(parsed).forEach((k) => { if (!_isUnsafeId(k)) safe[k] = parsed[k]; });
return safe;
} catch (e) { return fallback; } // private mode / corrupt value
}
function _writeJSON(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota / private mode: non-fatal */ }
}
function _rememberHost(id, hostId) {
if (_isUnsafeId(id)) return;
const map = _readJSON(HOSTS_KEY, Object.create(null));
if (hostId) map[id] = hostId; else delete map[id];
_writeJSON(HOSTS_KEY, map);
}
function _rememberedHost(id) {
const map = _readJSON(HOSTS_KEY, Object.create(null));
return Object.prototype.hasOwnProperty.call(map, id) ? map[id] : undefined;
}
// ── Spec ─────────────────────────────────────────────────────────────────
// A pane window's initial size. Plugin-controlled, and the window host builds
// window.open()'s feature string by concatenation — so this has to come out the
// other side as a number, not merely as something number-ish.
const MIN_PANE_PX = 120;
const MAX_PANE_PX = 4000; // wider than any real display; a guard, not a policy
function _size(v, fallback) {
const n = Math.round(Number(v));
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.min(MAX_PANE_PX, Math.max(MIN_PANE_PX, n));
}
function _normalize(spec) {
if (!spec || typeof spec !== 'object') throw new TypeError('panes.register: spec must be an object');
if (!spec.id || typeof spec.id !== 'string') throw new TypeError('panes.register: spec.id is required');
// See UNSAFE_KEYS: a pane id becomes a key in the persisted host map.
if (_isUnsafeId(spec.id)) throw new TypeError('panes.register: unsafe pane id: ' + spec.id);
if (typeof spec.element !== 'function' && !(spec.element instanceof Element)) {
throw new TypeError('panes.register(' + spec.id + '): spec.element must be an Element, or a function returning one');
}
return {
id: spec.id,
title: spec.title || spec.id,
icon: spec.icon || '▣',
// Resolved lazily: a plugin often builds its panel on first use, so the
// element may not exist at registration time — and it may be rebuilt
// later (Camera Director rebuilds its panel on every mode change).
// Asking for it at open time means we always move the live one.
element: typeof spec.element === 'function' ? spec.element : () => spec.element,
// Coerced to real numbers, because these are plugin-controlled and the
// window host concatenates them into window.open()'s feature string. A
// `width` of '300,menubar=1' would not merely be an invalid size — it
// would inject window features. Anything that isn't a finite positive
// number falls back to the default, and absurd sizes are clamped rather
// than honoured.
width: _size(spec.width, 380),
height: _size(spec.height, 560),
defaultHost: spec.defaultHost || 'window',
// Called after the element lands in (or returns from) a pane window,
// for a plugin that needs to re-measure or re-anchor something.
onHost: typeof spec.onHost === 'function' ? spec.onHost : null,
};
}
// ── Host routing ─────────────────────────────────────────────────────────
function _resolveHost(preferred) {
const wanted = hosts.get(preferred);
if (wanted && wanted.available()) return wanted;
// Fall back to the best available host. The dock registers at priority 0
// and is always available, so a pane can never fail to open.
let best = null;
hosts.forEach((h) => {
if (!h.available()) return;
if (!best || h.priority > best.priority) best = h;
});
return best;
}
function _emit(name, detail) {
const bus = window.feedBack;
if (bus && typeof bus.emit === 'function') bus.emit(name, detail);
}
// ── Open / close ─────────────────────────────────────────────────────────
function openPane(id, opts) {
opts = opts || {};
const spec = specs.get(id);
if (!spec) { console.warn('[panes] open: no such pane:', id); return false; }
if (open.has(id)) { focusPane(id); return true; }
let el;
try { el = spec.element(); } catch (e) { el = null; }
if (!(el instanceof Element)) {
console.warn('[panes] open: pane has no element yet:', id);
return false;
}
const host = _resolveHost(opts.host || spec.defaultHost);
if (!host) { console.error('[panes] open: no host available for', id); return false; }
// Where the element lives right now, so docking can put it back EXACTLY
// there — same parent, same position among its siblings. Anything less and
// a docked panel reappears at the bottom of its container, or not at all.
const home = { parent: el.parentNode, next: el.nextSibling };
// An element on its way OUT of this document must not carry a class whose
// whole job is to hide it IN this document. `.fb-pane-detached` is
// `display:none !important`, and it travels with the node — straight into
// the pane window, which then renders nothing at all.
el.classList.remove('fb-pane-detached');
// Make it visible, and remember exactly how it wasn't.
//
// A plugin's panel is usually hidden until its launcher is clicked, and a
// pane can be opened from the tray or the rail without that ever happening.
// So we un-hide it — but only in the two ways a panel is actually hidden
// (`hidden`, or an inline `display:none`), and we put both back on dock.
//
// Note what we do NOT do: force a `display`. A panel that is `display:flex`
// must stay flex. Neutralising placement is one thing; silently re-laying
// out someone's panel is another.
const vis = { hidden: el.hidden, display: el.style.display };
el.hidden = false;
if (el.style.display === 'none') el.style.display = '';
try {
host.place(spec, el);
} catch (e) {
console.error('[panes] host', host.id, 'failed to take', id, e);
el.hidden = vis.hidden;
el.style.display = vis.display;
return false;
}
open.set(id, { spec, hostId: host.id, el, home, vis });
if (opts.remember !== false) _rememberHost(id, host.id);
if (spec.onHost) { try { spec.onHost(host.id, el); } catch (e) { console.error('[panes]', id, 'onHost threw', e); } }
// `home` rides along because the element has LEFT this document — anything
// that wants to mark the hole it left (the chip's stub) needs to know where
// the hole is, and can no longer ask the element itself.
_emit('panes:opened', { id: id, host: host.id, el: el, home: home });
return true;
}
function closePane(id, opts) {
opts = opts || {};
const entry = open.get(id);
if (!entry) return false;
open.delete(id);
// ORDER IS LOAD-BEARING: bring the element home BEFORE the host lets go of
// it. The host's unplace() closes the pane window, and closing a window
// tears down its document — with the element still inside it. The node
// survives (we hold a reference) but comes back stripped of its event
// listeners, so the panel returns looking perfect and completely dead: no
// buttons, no sliders, nothing.
//
// Adopt first, while the pane window is still alive, and the node moves out
// of a living document into a living document, which is the only case the
// DOM actually guarantees.
// ADOPT UNCONDITIONALLY, INSERT CONDITIONALLY. The rescue and the
// re-homing are two different jobs, and only one of them is allowed to
// fail.
//
// Adopting is what saves the element: it transfers ownership away from the
// pane window's document, so that document can be destroyed without taking
// the listeners with it. Do that FIRST, and always — even when there is
// nowhere to put the element afterwards.
//
// Re-homing can legitimately be impossible: the panel may never have had a
// parent (a plugin that builds it lazily and hands it straight to us), or
// its container may have been torn down while the pane was out (a screen
// change). Gating the adopt on a reachable home would mean that in exactly
// those cases we leave the element inside a window we are about to close —
// which is the "comes home dead" failure this whole ordering exists to
// prevent. It just moves it from the common path to the rare one, where it
// is far harder to spot.
//
// With no home, the element ends up owned by this document but not in it:
// detached, intact, listeners alive, and ready for the plugin to re-insert
// whenever it rebuilds its UI.
try {
// adoptNode, not appendChild: the node's owner is currently the pane
// window's document, and adopting is what transfers ownership back.
const node = document.adoptNode(entry.el);
const home = entry.home;
if (home && home.parent && home.parent.isConnected) {
if (home.next && home.next.parentNode === home.parent) home.parent.insertBefore(node, home.next);
else home.parent.appendChild(node);
} else {
console.warn('[panes]', id, 'has no home to return to — the element is detached but intact');
}
} catch (e) {
console.error('[panes] could not bring', id, 'back out of its pane window', e);
}
const host = hosts.get(entry.hostId);
try { if (host) host.unplace(id, entry.el); } catch (e) { console.error('[panes] host', entry.hostId, 'threw releasing', id, e); }
// Put its visibility back exactly as we found it. A panel that was closed
// when the pane was opened from the tray goes back to being closed; one that
// was open stays open. We forced it visible; we un-force it.
if (entry.vis) {
entry.el.hidden = entry.vis.hidden;
entry.el.style.display = entry.vis.display;
}
if (opts.remember !== false) _rememberHost(id, null);
if (entry.spec.onHost) { try { entry.spec.onHost(null, entry.el); } catch (e) { /* non-fatal */ } }
_emit('panes:closed', { id: id, host: entry.hostId });
return true;
}
function focusPane(id) {
const entry = open.get(id);
if (!entry) return false;
const host = hosts.get(entry.hostId);
if (host && typeof host.focus === 'function') host.focus(id);
return true;
}
// What the pop-out chip calls: put this pane wherever a pane most wants to
// live. That is a window if one can be had, and the dock otherwise.
function detach(id) {
const spec = specs.get(id);
return openPane(id, { host: (spec && spec.defaultHost) || 'window' });
}
function dock(id) {
if (open.has(id)) closePane(id, { remember: false });
return openPane(id, { host: 'dock' });
}
// ── Registry ─────────────────────────────────────────────────────────────
function register(spec) {
const s = _normalize(spec);
if (specs.has(s.id)) {
// First registration wins, matching libraryCardActions.register. A
// silent overwrite would swap the element out from under an open pane.
console.warn('[panes] pane already registered, ignoring:', s.id);
return () => {};
}
specs.set(s.id, s);
_emit('panes:registered', { id: s.id, title: s.title });
// Reopen where the user left it. Deferred a tick so a plugin can call
// register() and attachChip() back to back — the chip must exist before
// the pane opens, or it has nothing to hide.
//
// A host may refuse to be auto-restored: a browser blocks window.open()
// without a user gesture, so restoring a popped-out pane on page load
// would only ever produce a "pop-up blocked" toast. Such a pane comes back
// in the dock, and the chip pops it out again on the user's next click.
let remembered = _rememberedHost(s.id);
if (remembered) {
const h = hosts.get(remembered);
if (h && h.autoRestore === false) remembered = 'dock';
setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0);
}
return () => unregister(s.id);
}
function unregister(id) {
if (open.has(id)) closePane(id, { remember: false });
specs.delete(id);
_emit('panes:unregistered', { id: id });
}
function registerHost(host) {
if (!host || !host.id) throw new TypeError('panes: host needs an id');
hosts.set(host.id, {
id: host.id,
priority: host.priority || 0,
autoRestore: host.autoRestore !== false,
available: typeof host.available === 'function' ? host.available : () => true,
place: host.place,
unplace: host.unplace,
focus: host.focus,
});
}
const api = {
version: 2,
register,
unregister,
open: openPane,
close: closePane,
detach,
dock,
focus: focusPane,
isOpen: (id) => open.has(id),
hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; },
// Where an open pane's element came from. The chip needs this to mark the
// hole the element left, since it can no longer ask the element itself.
homeOf: (id) => { const e = open.get(id); return e ? e.home : null; },
// The element a host actually took. The chip needs this to tell "the pane
// took MY element" from "the pane took something else" — and it cannot ask
// the element, which may now be in a dock card or another window entirely.
elementOf: (id) => { const e = open.get(id); return e ? e.el : null; },
get: (id) => specs.get(id) || null,
list: () => Array.from(specs.values()).map((s) => ({
id: s.id, title: s.title, icon: s.icon,
open: open.has(s.id), host: (open.get(s.id) || {}).hostId || null,
})),
registerHost,
};
window.feedBack = window.feedBack || {};
window.feedBack.panes = Object.assign(window.feedBack.panes || {}, api);
})();
+355
View File
@@ -0,0 +1,355 @@
/*
* fee[dB]ack — the pop-out window host.
*
* Opens a real OS window and MOVES THE PANE'S ELEMENT INTO IT.
*
* The move is the whole trick, and it works because the pane window is same-origin
* and opener-linked: `document.adoptNode()` re-parents a live node into another
* window's document, and an adopted node keeps its event listeners, its closures,
* and every reference anything else holds to it. So the plugin's panel goes on
* running the plugin's own code in the plugin's own realm — it is just being
* *displayed* somewhere else. It looks and behaves exactly like what was popped
* out, because it is exactly what was popped out.
*
* That is why this file must use `window.open()` and not ask the desktop's main
* process to make a BrowserWindow: a window we didn't open gives us no handle to
* its document, and without the handle there is nothing to adopt into.
*
* Electron turns this same-origin `window.open()` into a real BrowserWindow anyway
* — its setWindowOpenHandler answers same-origin URLs with `action: 'allow'` — and
* the main process then recognises the window by its frame name and gives it
* remembered bounds, skip-taskbar and a system-tray entry. So we get the OS window
* AND the DOM link. (That code lives in the separate desktop repo,
* got-feedback/feedBack-desktop: src/main/main.ts and src/main/pane-hosts.ts. It is
* not in this repo, and nothing here depends on it — in a plain browser this is
* simply a pop-up.)
*
* Styles come across too — the pane document starts empty, so we copy the app's
* stylesheets into it. Without that the panel would land unstyled, which is the
* one thing a "pop out exactly this" feature cannot do.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.registerHost !== 'function') {
console.error('[panes] pane-manager.js must load before pane-window-host.js');
return;
}
// The frame name every pane window is opened with. In the desktop app the main
// process matches on this prefix to recognise a pane window and give it its
// remembered bounds, skip-taskbar and tray entry — so changing it here without
// changing it there silently downgrades every pane to a plain pop-up.
//
// The other half lives in a DIFFERENT REPO (got-feedback/feedBack-desktop,
// src/main/pane-hosts.ts). There is no build-time link between them; this comment
// is the link.
const FRAME_PREFIX = 'fbpane-';
const wins = new Map(); // paneId -> Window
let reaper = null;
// A pane window the user closed with the OS X button gets no reliable
// beforeunload (a crashed renderer certainly gets none). Poll `closed` and
// reap — otherwise the pane stays "open" forever, its chip stays stubbed out,
// and the element it holds is stranded in a dead document with no way back.
function _startReaper() {
if (reaper != null) return;
reaper = setInterval(() => {
wins.forEach((w, id) => { if (w.closed) panes.close(id); });
if (!wins.size) { clearInterval(reaper); reaper = null; }
}, 400);
}
// Give the pane document the app's styles, so the panel looks identical.
// Cloned rather than shared: a <link> node can only live in one document, and
// we are not about to steal the app's own stylesheet out of its head.
function _copyStyles(doc) {
// pane.html already links panes.css, so don't clone a second copy of it —
// duplicate sheets cost a redundant fetch and an extra style recalc for no
// change in appearance.
const own = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'));
const have = new Set(own.map((l) => l.href));
// Insert the app's sheets BEFORE pane.html's own, not after.
//
// Cascade order is the whole game here. In the app document panes.css loads
// LAST, after tailwind/style/v3 — so its rules win ties. Appending the app's
// sheets into the pane document would put them after panes.css and silently
// invert that, letting core styles override the pane chrome and the .fb-paned
// placement rules. "Looks identical" has to include the order things are
// said in.
const anchor = own[0] || null;
document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
if (node.tagName === 'LINK' && have.has(node.href)) return;
try { doc.head.insertBefore(node.cloneNode(true), anchor); } catch (e) { /* skip a node we can't clone */ }
});
_syncChrome(doc);
}
// The theme/scale hooks the app hangs on <html> and <body>. v3 keys off these
// for its colour tokens and its interface scale, and a panel that lands without
// them renders in the wrong palette at the wrong size.
//
// MERGE, don't assign: pane.html sets `class="fb-pane-window"` on <html>, and
// panes.css hangs the pane window's own chrome off it. Overwriting the class
// list would take that with it and the window would lose its own layout — the
// app's classes and the pane document's are both wanted.
//
// Re-run on every theme/scale change for as long as the pane is open (see
// _followChrome). A one-time snapshot would leave an already-open pane rendering
// at the old scale the moment the user touched Interface size — "looks identical"
// has to keep being true, not merely start out true.
function _syncChrome(doc) {
try {
document.documentElement.classList.forEach((c) => doc.documentElement.classList.add(c));
document.body.classList.forEach((c) => doc.body.classList.add(c));
// The inline style on <html> carries the interface-scale custom property
// (--fb-scale). Assign it wholesale: unlike the class lists, pane.html
// sets no inline style of its own, so there is nothing here to preserve —
// and merging by concatenation would grow the attribute without bound as
// the user dragged the scale slider.
doc.documentElement.style.cssText = document.documentElement.style.cssText;
} catch (e) { /* the window may be closing under us */ }
}
// paneId -> stop following the app's theme/scale
const chromeFollowers = new Map();
function _followChrome(paneId, doc) {
const bus = window.feedBack;
if (!bus || typeof bus.on !== 'function') return;
const sync = () => _syncChrome(doc);
bus.on('scale:changed', sync);
bus.on('theme:changed', sync);
bus.on('v3:cosmetics-applied', sync);
chromeFollowers.set(paneId, () => {
bus.off('scale:changed', sync);
bus.off('theme:changed', sync);
bus.off('v3:cosmetics-applied', sync);
});
}
function _unfollowChrome(paneId) {
const off = chromeFollowers.get(paneId);
if (off) { off(); chromeFollowers.delete(paneId); }
}
// How long a "we cannot even see the pop-out's document" condition has to persist
// before we call it fatal. A SecurityError means the window is not reachable from
// this realm at all, and waiting cannot fix that — but we give it a moment anyway
// rather than bailing on the first tick, because 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 the 10s a user would otherwise stare at a detached panel for.
const UNREACHABLE_GRACE_MS = 1000;
// Wait for the REAL pane document.
//
// window.open() returns immediately, with an `about:blank` document that is
// already readyState 'complete'. Adopt into that and it works for a few
// milliseconds — and then /pane finishes loading, replaces the document, and
// takes the panel with it. The window is left blank and the element is gone.
//
// So we do not trust readyState, and we do not trust 'load' (which may have
// fired for about:blank before we could listen). We wait for the one thing that
// only exists in the document we actually want: pane.html's #fb-pane-root.
function _whenReady(w, onReady, onFail) {
const deadline = performance.now() + 10000;
let reachFailure = null; // why we could never see the pop-out's document
let reachFailureAt = 0; // when we first couldn't
const tick = () => {
if (w.closed) return;
let doc = null;
try { doc = w.document; }
catch (e) {
// A SecurityError here is the one that matters: it means the pop-out
// is not reachable from this realm at all (a separate process /
// browsing-context group), and no amount of waiting will fix it —
// adoptNode can never work.
doc = null;
if (!reachFailure) reachFailureAt = performance.now();
reachFailure = e;
}
// Unreachable, and it has stayed that way. Fail now rather than leaving
// the panel detached and the UI mid-pop-out for the full 10s deadline,
// when we already know this can never succeed.
if (reachFailure && !doc && performance.now() - reachFailureAt > UNREACHABLE_GRACE_MS) {
onFail(new Error('the pane window\'s document is NOT reachable from this window ('
+ reachFailure.name + ': ' + reachFailure.message
+ ') — it is in a separate process, so the element cannot be moved into it'));
return;
}
if (doc && doc.readyState !== 'loading') {
// Only ever adopt into the document we actually navigated TO.
// about:blank reports readyState 'complete' from the moment
// window.open() returns, and adopting into it means the panel is
// destroyed when /pane replaces it a moment later.
const href = (doc.location && doc.location.href) || '';
const isPaneDoc = href.indexOf('/pane') >= 0;
if (isPaneDoc) {
// Prefer pane.html's own root, but never fail for want of it —
// a stale cached copy of the page (or a future rename) must not
// leave the user with a blank window and no panel.
const root = doc.getElementById('fb-pane-root') || doc.body;
if (root) { onReady(root); return; }
}
}
if (performance.now() > deadline) {
let why;
if (reachFailure) {
why = 'the pane window\'s document is NOT reachable from this window ('
+ reachFailure.name + ': ' + reachFailure.message
+ ') — it is in a separate process, so the element cannot be moved into it';
} else if (!doc) {
why = 'the pane window exposed no document at all';
} else {
why = 'the pane window never loaded /pane (it is showing '
+ ((doc.location && doc.location.href) || 'an unknown URL')
+ ', readyState ' + doc.readyState + ')';
}
onFail(new Error(why));
return;
}
setTimeout(tick, 25);
};
tick();
}
function _adopt(w, root, spec, el) {
const doc = w.document;
_copyStyles(doc);
// The panel was almost certainly a fixed/absolute overlay pinned to a
// corner of the app. In a window of its own that positioning is nonsense —
// it would sit 72px from the top of a 380px window, still 288px wide, still
// casting a drop shadow over nothing. Neutralise the *placement* while
// touching nothing else about how it looks.
el.classList.add('fb-paned');
root.appendChild(doc.adoptNode(el));
doc.title = spec.title + ' — fee[dB]ack';
// Keep the pane window's theme and interface scale in step with the app for
// as long as it is open. Stopped in unplace().
_followChrome(spec.id, doc);
// THE ELEMENT MUST LEAVE BEFORE THE DOCUMENT DIES.
//
// When the user closes a pane window, its document is torn down — and the
// panel is inside it. The node itself survives (we hold a reference) and
// comes home looking perfect: right markup, right classes, right size. But
// it comes home DEAD: every event listener in the subtree is gone with the
// document that hosted them. A panel that renders and does nothing.
//
// The `closed` poll cannot save us: by the time `w.closed` is true, the
// document is already gone. `beforeunload` fires while it is still alive, so
// this is the last moment we can get the element out — and panes.close()
// adopts it back into the main document synchronously.
//
// We attach it HERE, not when the window was opened: back then the window
// still held its throwaway about:blank document, and a listener registered
// on that is discarded when /pane replaces it.
w.addEventListener('beforeunload', () => {
if (panes.isOpen(spec.id)) panes.close(spec.id);
});
}
function place(spec, el) {
const w = window.open(
window.location.origin + '/pane',
FRAME_PREFIX + spec.id,
'popup,width=' + spec.width + ',height=' + spec.height,
);
if (!w) {
// Popup blocked. Throw BEFORE the manager records anything, so the
// caller's panel stays exactly where it is — and say so out loud rather
// than appearing to do nothing.
if (window.fbNotify) {
window.fbNotify.show({
title: 'Pop-out blocked',
message: 'Allow pop-ups for this site to detach ' + spec.title + '.',
icon: '⚠️', accent: '#f59e0b',
});
}
throw new Error('pop-up blocked');
}
wins.set(spec.id, w);
_startReaper();
// Take the element out of the document NOW, not when the window is ready.
//
// Everything below this line is async: the window has to load /pane before
// there is anything to adopt into. But the manager emits `panes:opened` as
// soon as we return, and the chip reacts by putting its "popped out" stub
// where the element used to be — so for that whole gap the user would see
// BOTH the real panel and a stub claiming it had left. On a window that
// never loads, that lasts the full 10s timeout.
//
// Detaching is not destructive: the node keeps its owner document (this
// one), its listeners and its closures. It is simply out of the tree,
// waiting — and if the window never loads, closePane() puts it straight
// back at its home.
el.remove();
_whenReady(w, (root) => {
try { _adopt(w, root, spec, el); }
catch (e) {
console.error('[panes] failed to move', spec.id, 'into its window', e);
panes.close(spec.id); // brings the element home
}
}, (err) => {
console.error('[panes]', spec.id, err);
panes.close(spec.id); // never strand the element in a dead window
});
// The pane window's 'beforeunload' listener is registered in _adopt(), NOT
// here: a listener added now would attach to the window's throwaway
// about:blank document and be discarded when /pane replaces it.
}
function unplace(id, el) {
_unfollowChrome(id);
// Hand the element back unmarked. The manager returns it to its home right
// after this, and it must arrive as the plugin left it — a panel that
// stayed .fb-paned would come back with its own positioning stripped.
if (el) el.classList.remove('fb-paned');
const w = wins.get(id);
wins.delete(id);
// The manager adopts the element back into this document immediately after
// this returns, so the window is empty by the time it closes.
if (w && !w.closed) { try { w.close(); } catch (e) { /* already gone */ } }
}
function focus(id) {
const w = wins.get(id);
if (w && !w.closed) { try { w.focus(); } catch (e) { /* the OS may refuse */ } }
}
// A BROWSER blocks window.open() outside a user gesture, so a pane remembered
// here cannot be restored on page load — it would only ever produce a "blocked"
// toast. Such a pane comes back in the dock, and the chip pops it out again on
// the user's next click. The DESKTOP app has no such restriction, so there a
// pane left popped out comes back popped out, where you left it.
const isDesktop = !!(window.feedBackDesktop && window.feedBackDesktop.panes);
panes.registerHost({
id: 'window',
priority: 10,
autoRestore: isDesktop,
place, unplace, focus,
});
// Our windows; they must not outlive us. A pane window whose opener is gone
// holds an element belonging to a dead document — there is nothing left to
// dock it back into.
window.addEventListener('beforeunload', () => {
wins.forEach((w) => { if (!w.closed) { try { w.close(); } catch (e) { /* ignore */ } } });
});
})();
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en" class="fb-pane-window">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>fee[dB]ack</title>
<link rel="icon" href="/static/assets/favicon.png">
<!-- Deliberately almost empty.
This document does not build a pane; it RECEIVES one. The opener moves the
real element in here with document.adoptNode() and copies the app's
stylesheets across, so the panel arrives complete — its own markup, its own
CSS, its own listeners, its own closures, still running the plugin's code
back in the main window.
So there is nothing to load, nothing to boot, and nothing to keep in step
with the app. Only panes.css, for the window chrome and the layout reset the
adopted element needs. -->
<link rel="stylesheet" href="/static/panes/panes.css">
</head>
<body>
<main id="fb-pane-root"></main>
</body>
</html>
+193
View File
@@ -0,0 +1,193 @@
/* fee[dB]ack — detachable panes.
*
* Hand-authored (not Tailwind-scanned) so a runtime-installed plugin gets the chip
* and the dock without shipping its own stylesheet — the same reason .fb-selectable
* is hand-authored in core CSS.
*
* Z-index: the dock is a child of <body>, so it is NOT on the ladder from
* docs/plugin-v3-ui.md (transport 20, rail 30, popovers 40) — those numbers live
* *inside* #player's stacking context, and #player itself is `position:fixed;
* z-index:100` covering the viewport. A dock below 100 is invisible on the one
* screen panes exist for. Body-level ladder: #player 100 < dock 110 < toasts 120
* < modals 200.
*/
/* ── The popped-out element ──────────────────────────────────────────────────
*
* The single most important rule in this file.
*
* A plugin's panel is almost always a fixed overlay pinned to a corner of the app:
* `position:fixed; top:72px; right:18px; width:288px; z-index:99999`, with a drop
* shadow and a max-height sized against the viewport. Inside a dock card, or alone
* in a 320px window, every one of those is wrong — it would float 72px down from
* the top of its own window, still 288px wide, still casting a shadow over nothing.
*
* So we neutralise PLACEMENT and nothing else. Colours, borders, radius, padding,
* fonts, the panel's own internal layout: all untouched, because the whole promise
* of this feature is that what you popped out is what you get. */
.fb-paned {
position: static !important;
inset: auto !important;
margin: 0 !important;
width: 100% !important;
max-width: none !important;
max-height: none !important;
z-index: auto !important;
box-shadow: none !important;
/* Deliberately NO `display` override. Forcing `display:block` would silently
re-lay-out a panel that is `display:flex` or `grid` — which is the opposite
of "placement only", and exactly the kind of surprise this feature exists to
avoid. Making a hidden panel visible is the manager's job (it clears the
element's `hidden`/inline `display:none` on open and restores them on dock),
and it does it without touching the panel's own display mode. */
}
/* ── The pop-out chip ────────────────────────────────────────────────────── */
.fb-pane-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: 1px solid rgba(51, 65, 85, .7);
border-radius: .4rem;
background: rgba(30, 41, 59, .8);
color: #94a3b8;
font-size: .8rem;
line-height: 1;
cursor: pointer;
transition: color .15s, border-color .15s, background .15s;
}
.fb-pane-chip:hover {
color: #e2e8f0;
border-color: #4080e0;
background: rgba(64, 128, 224, .18);
}
.fb-pane-chip:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
/* The panel, while its pane is popped out. A dedicated class rather than
.hidden/[hidden]: the panels we attach to toggle those themselves, and two owners
of one class is a bug waiting for a bad day. */
.fb-pane-detached { display: none !important; }
/* What the user sees in the panel's place. */
.fb-pane-stub {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .35rem .6rem;
border: 1px dashed rgba(64, 128, 224, .55);
border-radius: .5rem;
background: rgba(64, 128, 224, .08);
color: #93b4e8;
font-size: .72rem;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.fb-pane-stub:hover { background: rgba(64, 128, 224, .18); border-color: #4080e0; }
.fb-pane-stub:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
.fb-pane-stub-glyph { font-size: .85rem; }
/* ── The dock ────────────────────────────────────────────────────────────── */
.fb-pane-dock {
position: fixed;
top: 4.5rem;
right: 1rem;
bottom: 1rem;
z-index: 110; /* above #player (100), below toasts (120) */
width: 22rem;
max-width: calc(100vw - 2rem);
display: flex;
flex-direction: column;
gap: .6rem;
overflow-y: auto;
overflow-x: hidden;
/* A frame around cards, not a surface — the empty space below them must never
eat a click meant for the highway. */
pointer-events: none;
scrollbar-width: thin;
}
.fb-pane-dock.is-empty { display: none; }
.fb-pane-card {
pointer-events: auto;
flex: 0 0 auto;
display: flex;
flex-direction: column;
background: rgba(15, 23, 42, .96);
border: 1px solid rgba(51, 65, 85, .6);
border-radius: .9rem;
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
overflow: hidden;
}
.fb-pane-card-head {
display: flex;
align-items: center;
gap: .5rem;
padding: .5rem .7rem;
border-bottom: 1px solid rgba(51, 65, 85, .5);
background: rgba(30, 41, 59, .6);
}
.fb-pane-card-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: .78rem;
font-weight: 600;
color: #cbd5e1;
}
.fb-pane-card-btn {
flex: 0 0 auto;
width: 1.4rem;
height: 1.4rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: .35rem;
background: transparent;
color: #94a3b8;
font-size: .75rem;
line-height: 1;
cursor: pointer;
}
.fb-pane-card-btn:hover { background: rgba(51, 65, 85, .7); color: #e2e8f0; }
.fb-pane-card-btn:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
.fb-pane-card-body { overflow: auto; }
/* focus(id) — a brief highlight, so re-opening an already-open pane says so
instead of appearing to do nothing. */
.fb-pane-card.is-flash { animation: fb-pane-flash .7s ease-out; }
@keyframes fb-pane-flash {
0% { border-color: #4080e0; box-shadow: 0 0 0 3px rgba(64, 128, 224, .35), 0 12px 40px rgba(0, 0, 0, .5); }
100% { border-color: rgba(51, 65, 85, .6); box-shadow: 0 12px 40px rgba(0, 0, 0, .5); }
}
@media (prefers-reduced-motion: reduce) {
.fb-pane-card.is-flash { animation: none; }
}
/* ── The pop-out window (static/panes/pane.html) ─────────────────────────────
*
* The window's own chrome — everything INSIDE it is the adopted element, styled by
* the app's stylesheets, which the host copies into this document. */
html.fb-pane-window,
html.fb-pane-window body {
margin: 0;
padding: 0;
height: 100%;
background: #0f172a;
}
html.fb-pane-window body { display: flex; flex-direction: column; overflow: hidden; }
#fb-pane-root {
flex: 1 1 auto;
overflow: auto;
padding: .6rem;
}
+2 -1
View File
@@ -33,7 +33,8 @@
// Accuracy badge ramp (design/04-badges.md §C): ≥90% good, 5089% mid, <50% low.
function accuracyBadge(acc) {
if (acc == null) return '';
const pct = Math.round(acc * 100);
// Floor, never round: 100% must mean every note hit.
const pct = Math.floor(acc * 100);
const color = acc >= 0.9 ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low');
const text = acc >= 0.5 && acc < 0.9 ? 'text-black' : 'text-white';
return '<span class="absolute bottom-0 right-0 ' + color + '/90 ' + text +
+26
View File
@@ -99,6 +99,10 @@
<link rel="stylesheet" href="/static/tour-engine.css">
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
<link rel="stylesheet" href="/static/v3/v3.css">
<!-- Detachable panes: the pop-out chip, the dock, and the widgets built-in
panes render with. Hand-authored (not Tailwind-scanned) so a
runtime-installed plugin can use the chip without shipping its own CSS. -->
<link rel="stylesheet" href="/static/panes/panes.css">
<!-- EVERY external script below is `defer`. Do not add a plain one.
`defer` and `type="module"` scripts share a single "execute after
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
@@ -1048,6 +1052,10 @@
<span class="v3-rail-border"></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z"/></svg>
</button>
<button class="v3-rail-icon" type="button" data-rail="panes" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-panes" title="Panes" aria-label="Panes">
<span class="v3-rail-border"></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M19,4H5A2,2 0 0,0 3,6V18A2,2 0 0,0 5,20H19A2,2 0 0,0 21,18V6A2,2 0 0,0 19,4M13,18H5V6H13V18M19,18H15V6H19V18Z"/></svg>
</button>
<button class="v3-rail-icon" type="button" data-rail="plugins" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-plugins" title="Plugin controls" aria-label="Plugin controls">
<span class="v3-rail-border"></span>
<span class="v3-rail-badge" id="v3-plugin-count" hidden></span>
@@ -1064,6 +1072,15 @@
injects into #player-controls (the auto-hiding transport) into
this stable, always-reachable popover. See player-chrome.js
(rehoming MutationObserver). -->
<!-- Panes: open/close any registered detachable pane. Populated from
the pane registry by static/panes/pane-launcher.js — a plugin
that calls feedBack.panes.register() shows up here for free. -->
<div id="v3-rail-pop-panes" class="v3-rail-pop hidden" role="group" aria-label="Panes">
<div class="v3-pop-label">Panes</div>
<div id="v3-rail-panes-list" class="flex flex-col gap-1"></div>
<p class="text-xs text-gray-500 px-1 pb-1 leading-snug">Panes stay open while you play, and across song switches.</p>
</div>
<div id="v3-rail-pop-plugins" class="v3-rail-pop hidden" role="group" aria-label="Plugin controls">
<div class="v3-pop-label">Plugin controls</div>
<div id="v3-plugin-controls-slot" class="v3-plugin-slot"></div>
@@ -1300,6 +1317,15 @@
<script defer src="/static/v3/interface-size-nudge.js"></script>
<script defer src="/static/v3/feedbarcade.js"></script>
<script defer src="/static/v3/player-chrome.js"></script>
<!-- Detachable panes. The manager first; then the hosts, which register
themselves with it; then the chip and the launcher, which drive it.
pane-desktop only does anything inside the desktop app. -->
<script defer src="/static/panes/pane-manager.js"></script>
<script defer src="/static/panes/pane-dock.js"></script>
<script defer src="/static/panes/pane-window-host.js"></script>
<script defer src="/static/panes/pane-desktop.js"></script>
<script defer src="/static/panes/pane-chip.js"></script>
<script defer src="/static/panes/pane-launcher.js"></script>
<script>
// Navbar scroll effect
window.addEventListener('scroll', () => {
+1 -1
View File
@@ -74,7 +74,7 @@
if (st && st.passed) return '<span class="text-fb-good text-xs font-bold flex items-center gap-1">✓ Passed</span>';
if (st && st.best_accuracy != null && st.best_accuracy > 0) {
const acc = st.best_accuracy;
const pct = Math.round(acc * 100);
const pct = Math.floor(acc * 100);
const color = acc >= 0.9 ? 'text-fb-good' : (acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low');
return '<span class="' + color + ' text-xs font-bold">' + pct + '%</span>';
}
+2 -1
View File
@@ -21,7 +21,8 @@
function accuracyPct(hits, misses) {
const judged = hits + misses;
if (judged <= 0) return null;
return Math.round((hits / Math.max(1, judged)) * 100);
// Floor, never round: 100% must mean every judged note was hit.
return Math.floor((hits / Math.max(1, judged)) * 100);
}
function calculateLivePerformanceState({ hits = 0, misses = 0, streak = 0, bestStreak = 0 } = {}) {
+1 -1
View File
@@ -73,7 +73,7 @@
? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '')
: '';
const acc = (isAlbum && typeof opts.acc === 'number')
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(opts.acc * 100) + '%</span>'
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.floor(opts.acc * 100) + '%</span>'
: '';
const pin = (isAlbum && s.arrangement)
? '<span class="ml-2 text-[0.625rem] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
+1 -1
View File
@@ -232,7 +232,7 @@
host.innerHTML =
'<ol class="space-y-2">' + rows.map((s, i) => {
const acc = Number(s.best_accuracy) || 0;
const pct = Math.round(acc * 100);
const pct = Math.floor(acc * 100);
const score = Number(s.best_score) || 0;
return '<li data-fn="' + esc(s.filename) + '" class="flex items-center gap-3 cursor-pointer rounded-md px-2 py-1.5 hover:bg-fb-card transition">' +
'<span class="w-5 text-center text-fb-textDim font-semibold shrink-0">' + (i + 1) + '</span>' +
+1 -1
View File
@@ -265,7 +265,7 @@
const onboarding = st.onboarding || {};
if (onboarding.calibration_status === 'completed') return; // raced a 100% run
const pending = onboarding.calibration_status === 'pending';
const pct = Math.max(0, Math.min(100, Math.round((detail.accuracy || 0) * 100)));
const pct = Math.max(0, Math.min(100, Math.floor((detail.accuracy || 0) * 100)));
const overlay = document.createElement('div');
overlay.id = 'v3-calibration-retry';
+3 -2
View File
@@ -482,7 +482,8 @@
function accuracyBadge(filename, variant) {
const acc = state.accuracy[filename];
if (acc == null) return '';
const pct = Math.round(acc * 100);
// Floor, never round: 100% must mean every note hit.
const pct = Math.floor(acc * 100);
if (variant === 'tree') {
const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low';
return '<span class="fb-acc-badge text-xs font-bold ' + color + '">' + pct + '%</span>';
@@ -1312,7 +1313,7 @@
c.year ? String(c.year) : '']
.filter(Boolean).join(' · ');
const acc = (typeof c.best_accuracy === 'number')
? '<span class="font-bold ' + (c.best_accuracy >= MASTERY_ACCURACY ? 'text-fb-good' : c.best_accuracy >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(c.best_accuracy * 100) + '%</span>'
? '<span class="font-bold ' + (c.best_accuracy >= MASTERY_ACCURACY ? 'text-fb-good' : c.best_accuracy >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.floor(c.best_accuracy * 100) + '%</span>'
: '<span class="text-fb-textDim/60">not played</span>';
return '<div role="radio" aria-checked="' + (checked ? 'true' : 'false') + '" tabindex="0" data-ch="' + esc(c.filename) + '"' +
' title="' + (checked ? esc(prefLabel) : 'Make this the preferred chart') + '"' +
+18
View File
@@ -33,6 +33,24 @@ test('venues.json defines the 3 ascending tiers with star thresholds', () => {
}
});
test('bar venue pack ships with intro media in the plugin checkout', () => {
const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'bar');
const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
assert.deepEqual(Object.keys(manifest.loops).sort(),
['bored', 'ecstatic', 'engaged', 'neutral']);
assert.equal(manifest.intro.video, 'intro.mp4');
assert.equal(manifest.intro.audio, 'bar-ambience.mp3');
for (const f of [
...Object.values(manifest.loops),
...Object.values(manifest.stingers),
manifest.intro.video,
manifest.intro.audio,
]) {
const stat = fs.statSync(path.join(packDir, f));
assert.ok(stat.size > 0, `${f} must be present`);
}
});
test('shell promotes the career plugin into the sidebar', () => {
const src = fs.readFileSync(SHELL_JS, 'utf8');
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
+2 -1
View File
@@ -170,7 +170,8 @@ test('DOM text updates after hit and miss events', () => {
runtime.onHit();
runtime.onMiss();
assert.equal(els.percent.textContent, '67%');
// Floored, not rounded: 100% must mean every judged note was hit.
assert.equal(els.percent.textContent, '66%');
assert.equal(els.hits.textContent, 'Hits 2 / 3');
assert.equal(els.streak.textContent, 'Streak 0');
assert.match(els.state.textContent, /Recovering/);
+29 -8
View File
@@ -1,3 +1,4 @@
import json
import sqlite3
import sys
from pathlib import Path
@@ -14,25 +15,45 @@ import routes as career_routes
class FakeMetaDb:
"""song_stats-only stand-in for MetadataDB (the plugin reads nothing else)."""
"""song_stats/songs stand-in for MetadataDB (the plugin reads nothing else).
The real song_stats.arrangement is an INTEGER index into the song's
arrangements JSON; the legacy star tests pass strings ("guitar"), which
the passport code treats as index-less → instrument defaults to guitar."""
def __init__(self):
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
self.conn.execute(
"""CREATE TABLE song_stats (
filename TEXT, arrangement TEXT, best_accuracy REAL
filename TEXT, arrangement TEXT, best_accuracy REAL,
last_played_at TEXT
)"""
)
self.conn.execute(
"""CREATE TABLE songs (
filename TEXT, title TEXT, artist TEXT,
genre TEXT DEFAULT '', arrangements TEXT
)"""
)
self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)")
def add(self, filename, arrangement, best_accuracy, in_library=True):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)",
(filename, arrangement, best_accuracy))
def add(self, filename, arrangement, best_accuracy, in_library=True,
genre="", arrangements=None, last_played_at=None):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?)",
(filename, arrangement, best_accuracy, last_played_at))
if in_library:
self.conn.execute(
"INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS "
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
"(SELECT 1 FROM songs WHERE filename = ?)",
(filename, filename.replace(".feedpak", "").title(), "Test Artist", filename))
(filename, filename.replace(".feedpak", "").title(), "Test Artist",
genre,
json.dumps(arrangements) if arrangements is not None else None,
filename))
self.conn.commit()
def add_song_only(self, filename, genre=""):
"""A library song with no plays — feeds the genre (brochure) list."""
self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?)",
(filename, filename, "Test Artist", genre, None))
self.conn.commit()
+145
View File
@@ -0,0 +1,145 @@
"""HTTP-level tests for the passport layer: badges, stubs, genres, drill intake.
Badges are computed on read (never stored): N genre songs at min_stars — with
stars ≥2 meaning best_accuracy ≥ 0.75 under the default 0.6/0.75/0.85
thresholds — plus any configured virtuoso drills.
"""
import routes as career_routes
LEAD = [{"type": "lead", "name": "Lead"}]
BASS = [{"type": "bass", "name": "Bass"}]
def _open(client, instrument="guitar", genre="Blues"):
res = client.post("/api/plugins/career/passports/open",
json={"instrument": instrument, "genre": genre})
assert res.status_code == 200
return res.json()
def _passport(client, instrument="guitar", genre_key="blues"):
view = client.get("/api/plugins/career/passports").json()
for p in view["instruments"][instrument]["passports"]:
if p["genre_key"] == genre_key:
return p
return None
def test_badge_earned_at_five_genre_songs_two_stars(client, meta_db):
for i in range(5):
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
_open(client)
p = _passport(client)
assert p["badge"] == "earned"
assert p["qualifying_count"] == 5
assert all(s["qualifies"] and s["stars"] == 2 for s in p["songs"])
def test_badge_in_progress_below_the_bar(client, meta_db):
for i in range(4):
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
meta_db.add("weak.feedpak", 0, 0.65, genre="Blues", arrangements=LEAD) # 1★
_open(client)
p = _passport(client)
assert p["badge"] == "in_progress"
assert p["qualifying_count"] == 4
# Qualifying stubs sort ahead of the near-misses.
assert [s["qualifies"] for s in p["songs"]] == [True] * 4 + [False]
def test_instruments_split_and_bass_is_shown_not_judged(client, meta_db):
# Same 5 songs but played on the BASS arrangement: no guitar badge credit.
for i in range(5):
meta_db.add(f"blues{i}.feedpak", 0, 0.9, genre="Blues", arrangements=BASS)
_open(client, "guitar")
_open(client, "bass")
guitar = _passport(client, "guitar")
bass = _passport(client, "bass")
assert guitar["qualifying_count"] == 0 and guitar["badge"] == "in_progress"
assert bass["qualifying_count"] == 5
# Bass isn't a graded instrument: repertoire shows, no pass/fail bar.
assert bass["badge"] == "shown_not_judged" and bass["graded"] is False
def test_best_accuracy_per_instrument_across_arrangements(client, meta_db):
both = [{"type": "lead", "name": "Lead"}, {"type": "lead", "name": "Alt. Lead"}]
meta_db.add("song.feedpak", 0, 0.7, genre="Blues", arrangements=both)
meta_db.add("song.feedpak", 1, 0.9, genre="Blues", arrangements=both)
_open(client)
p = _passport(client)
assert len(p["songs"]) == 1
assert p["songs"][0]["best_accuracy"] == 0.9
assert p["songs"][0]["stars"] == 3
def test_orphaned_songs_do_not_feed_stubs(client, meta_db):
meta_db.add("gone.feedpak", 0, 0.9, genre="Blues", arrangements=LEAD,
in_library=False)
_open(client)
assert _passport(client)["songs"] == []
def test_genre_rack_collapses_case_and_skips_blank(client, meta_db):
meta_db.add_song_only("a.feedpak", genre="Blues")
meta_db.add_song_only("b.feedpak", genre="blues")
meta_db.add_song_only("c.feedpak", genre="Funk")
meta_db.add_song_only("d.feedpak", genre="")
genres = client.get("/api/plugins/career/passports").json()["genres"]
assert genres == [
{"genre_key": "blues", "genre": "Blues", "songs_in_library": 2},
{"genre_key": "funk", "genre": "Funk", "songs_in_library": 1},
]
def test_commit_is_idempotent_and_open_implies_commit(client):
first = client.post("/api/plugins/career/passports/commit",
json={"instrument": "guitar"}).json()
again = client.post("/api/plugins/career/passports/commit",
json={"instrument": "guitar"}).json()
assert first["committed_at"] == again["committed_at"]
_open(client, "bass", "Funk")
view = client.get("/api/plugins/career/passports").json()
assert view["instruments"]["bass"]["committed_at"]
# Re-opening the same passport keeps the original opened_at.
opened = view["instruments"]["bass"]["passports"][0]["opened_at"]
_open(client, "bass", " funk ") # normalizes to the same key
view = client.get("/api/plugins/career/passports").json()
assert [p["opened_at"] for p in view["instruments"]["bass"]["passports"]] == [opened]
def test_open_and_commit_validation(client):
assert client.post("/api/plugins/career/passports/commit",
json={"instrument": "theremin"}).status_code == 400
assert client.post("/api/plugins/career/passports/open",
json={"instrument": "guitar", "genre": " "}).status_code == 400
assert client.post("/api/plugins/career/passports/open",
json={"instrument": "guitar", "genre": "x" * 65}).status_code == 400
def test_drill_requirement_gates_badge_until_snapshot_clears_it(client, meta_db):
for i in range(5):
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
career_routes._state["passports_content"]["genres"]["blues"] = {
"virtuoso_nodes": ["node.shuffle"]}
_open(client)
p = _passport(client)
assert p["badge"] == "in_progress"
assert p["drills"] == {"required": ["node.shuffle"], "cleared": []}
res = client.post("/api/plugins/career/drill-state", json={
"mode": "casual", "xp": 120,
"byNode": {"node.shuffle": {"masteredAt": 1720000000,
"depth": {"travel": None}}}})
assert res.status_code == 200
p = _passport(client)
assert p["drills"]["cleared"] == ["node.shuffle"]
assert p["badge"] == "earned"
def test_drill_state_validation(client):
assert client.post("/api/plugins/career/drill-state",
json={"mode": "casual"}).status_code == 400
huge = {"byNode": {"pad": "x" * (300 * 1024)}}
assert client.post("/api/plugins/career/drill-state",
json=huge).status_code == 413
+29 -10
View File
@@ -76,7 +76,8 @@ def test_download_unknown_venue_404s(client):
def test_download_without_published_pack_404s(client):
# venues.json ships pack: null until packs are released.
# Bundled packs are already installed; download still requires a published
# remote pack entry.
assert client.post("/api/plugins/career/packs/bar/download").status_code == 404
@@ -86,28 +87,46 @@ def test_download_locked_venue_403s(client, monkeypatch):
assert client.post("/api/plugins/career/packs/club/download").status_code == 403
def test_pack_file_serving_and_traversal_guard(client):
_install_fake_pack("bar")
def test_bundled_bar_pack_is_installed_and_served(client):
state = client.get("/api/plugins/career/state").json()
bar = {v["id"]: v for v in state["venues"]}["bar"]
assert bar["installed"] is True
assert bar["bundled"] is True
assert bar["has_pack"] is True
ok = client.get("/api/plugins/career/venues/bar/manifest.json")
assert ok.status_code == 200
manifest = ok.json()
assert manifest["loops"]["ecstatic"] == "ecstatic.mp4"
assert manifest["intro"] == {"video": "intro.mp4", "audio": "bar-ambience.mp3"}
assert client.get("/api/plugins/career/venues/bar/intro.mp4").status_code == 200
audio = client.get("/api/plugins/career/venues/bar/bar-ambience.mp3")
assert audio.status_code == 200
assert audio.headers["content-type"].startswith("audio/mpeg")
def test_pack_file_serving_and_traversal_guard(client):
_install_fake_pack("club")
ok = client.get("/api/plugins/career/venues/club/manifest.json")
assert ok.status_code == 200
assert ok.json()["loops"]["ecstatic"] == "ecstatic.mp4"
video = client.get("/api/plugins/career/venues/bar/bored.mp4")
video = client.get("/api/plugins/career/venues/club/bored.mp4")
assert video.status_code == 200
assert video.headers["content-type"].startswith("video/mp4")
assert video.headers["x-content-type-options"] == "nosniff"
# Traversal / junk shapes never resolve.
for bad in ("../manifest.json", "..%2Fmanifest.json", "x.sh", "MANIFEST.JSON"):
assert client.get(f"/api/plugins/career/venues/bar/{bad}").status_code == 404
assert client.get("/api/plugins/career/venues/../bar/manifest.json").status_code == 404
assert client.get(f"/api/plugins/career/venues/club/{bad}").status_code == 404
assert client.get("/api/plugins/career/venues/../club/manifest.json").status_code == 404
def test_state_reports_installed_and_delete_removes(client):
_install_fake_pack("bar")
_install_fake_pack("club")
state = client.get("/api/plugins/career/state").json()
assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is True
assert client.delete("/api/plugins/career/packs/bar").status_code == 200
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True
assert client.delete("/api/plugins/career/packs/club").status_code == 200
state = client.get("/api/plugins/career/state").json()
assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is False
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is False
def test_download_worker_end_to_end(client, tmp_path):