Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 66 additions & 33 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Section Map — AI Agent Guide

Draws a minimap bar of the current song's structure over the player, with
click/wheel seeking and a per-section "glass fill" difficulty indicator.
click/wheel seeking and a per-section difficulty "tier rail" indicator.
Frontend-only — there's no `routes.py`; all logic lives in `screen.js`,
reading state straight off the Host's `window.highway` object.

Expand All @@ -23,21 +23,39 @@ reading state straight off the Host's `window.highway` object.
feedBack#923/#924. Patching `window.showScreen` here would silently never
fire for real navigation; this was an actual regression until fixed.
- **Section-difficulty data comes from `difficulty_ladder`'s
`difficulty:sections-updated` event, not independent `highway` reads
(issue #63).** This plugin used to independently read `highway.getPhrases()`
/ `hasPhraseData()` / `getMastery()` for section-difficulty data — that
changed when the glass-fill rendering was rewritten to consume
`difficulty_ladder`'s emitted event instead (`_smUpdateDifficultyFills` /
`_smGetSectionDifficulty` just render whatever `fillPercentage` /
`glassSize` the event's payload carries per section). This plugin still
works standalone with no ladder plugin installed — the glasses are its only
dependent feature, and `_smIsDynamicDifficultyAvailable()` gates the
subscription, so absent means no glasses rather than an error — but when a
`difficulty:sections-updated` / `difficulty:sections-updated-v3` events, not
independent `highway` reads (issue #63).** This plugin used to independently
read `highway.getPhrases()` / `hasPhraseData()` / `getMastery()` for
section-difficulty data — that changed when the rendering was rewritten to
consume `difficulty_ladder`'s emitted events instead. Issue #14 then replaced
the old glass-fill rendering (which consumed the v2 `fillPercentage` /
`glassSize` fields) with a semantic tier rail: `_smOnSectionsUpdatedV3` /
`_smNormalizeSectionTiers` / `_smGetSectionTier` carry each section's
`current_tier` / `top_tier` / `max_tier` / `has_chart_content`, and
`_smTierModel` / `_smRenderTierRail` / `_smUpdateTierRails` render it. This
plugin still works standalone with no ladder plugin installed — the rails are
its only dependent feature, and `_smIsDynamicDifficultyAvailable()` gates the
subscription, so absent means no rails rather than an error — but when a
ladder plugin *is* installed, this is the API, not a coincidence of both
sides reading the same Host state. See `difficulty_ladder`'s `INTEGRATION.md`
for the full contract (fill formula, fallback/timing behavior). The short
for the full contract (tier semantics, fallback/timing behavior). The short
version is pinned as a comment block above `_smIsDynamicDifficultyAvailable()`
in `screen.js`; keep the two in sync if either changes.
- **v3 is preferred; v2 is a bounded-transition fallback.** When
`_smPeerSpeaksV3()` sees `window._ddCapabilities.sectionsSchema ===
'difficulty_ladder.sections.v3'`, the subscription is to
`difficulty:sections-updated-v3` and `_smNormalizeSectionTiers` reads its
`sections` array directly. A peer that predates v3 (marker present, no
`sectionsSchema`) still gets `difficulty:sections-updated`, and
`_smNormalizeSectionTiersV2` reconstructs a coarse rail from `fillPercentage`
(`topTier = maxTier = maxDifficulty`, `currentTier = round(fill × topTier)`)
while discarding `glassSize` entirely. v3 is feature-detected, never
version-pinned — the marker carries no version. This is not the "don't sniff
`schema`" rule: we key off the capability marker the peer publishes, not the
payload's own `schema` field (which we deliberately ignore). The v2 fallback
is kept only while the peer's transition policy keeps emitting it; when that
policy drops v2, delete `_smNormalizeSectionTiersV2` / `_smOnSectionsUpdated`
and the v2 branch of `_smEnsureDifficultySubscription`.
- **The peer handshake is re-checked, not decided once (issue #13).**
`_smEnsureDifficultySubscription()` runs from `_smStartRealtimeHooks()` *and*
from every `_smUpdate()` tick, so a Difficulty Ladder that loads or reloads
Expand Down Expand Up @@ -72,28 +90,43 @@ reading state straight off the Host's `window.highway` object.
`v0.12.0` the real floor, so that's the number this repo publishes. The floor
is documentation, not enforcement, and shouldn't be restated as a check: the
marker carries no version and predates v0.2.0, so a below-floor peer passes it
and still paints glasses. What actually changed below the floor is the
and still paints rails. What actually changed below the floor is the
arithmetic behind `fillPercentage` (v0.9.11 aligned it with the peer's own
HUD), so an old peer's glasses can *disagree* with Difficulty Ladder's HUD
rather than fail to render. Keep the README table and the `screen.js` comment
block on the same number.
- **Payloads are normalized, not trusted (`_smNormalizeSectionDifficulties`).**
Entries without a finite numeric `fillPercentage` are dropped, which is what
keeps a malformed payload from painting a misleading near-empty glass — and
what stops the peer's NaN arithmetic (a NaN `mastery` or phrase difficulty)
from reaching the style attribute as `height:NaN%`. A container that isn't an
object is ignored entirely so the last good state survives. `null` return
means "ignore the event", `{}` means "clear the glasses" — don't collapse those
two. `glassSize` is the one payload field that reaches markup unescaped (as
`data-size`), so `_smGlassSize()` resolves it against `SM_GLASS_SIZES` before
either render path interpolates it.
- **v3 of the peer contract is deliberately not consumed.**
`difficulty:sections-updated-v3` is a render-neutral payload that
`difficulty_ladder` emits *alongside* v2. Consuming it means reworking the
glass rendering (v3 carries the facts, not the metaphor) — that's this repo's
issue #14, and the peer's own transition policy exists so an un-upgraded
Section Map keeps working on the frozen v2 event meanwhile. Don't sniff
`schema` to "upgrade" this plugin to v3 in passing.
HUD), so an old peer's rails can *disagree* with Difficulty Ladder's HUD
rather than fail to render. The v3 payload supersedes this arithmetic
entirely, which is why it is preferred. Keep the README table and the
`screen.js` comment block on the same number.
- **Payloads are normalized, not trusted (`_smNormalizeSectionTiers` /
`_smNormalizeSectionTiersV2`).** v3 entries without finite numeric `index`,
`max_tier` and `top_tier` are dropped, which keeps the peer's NaN arithmetic
from reaching the style attribute. v2 entries without a finite numeric
`fillPercentage` are dropped the same way. A container that isn't an object,
or a v3 payload whose `sections` isn't an array, is ignored entirely so the
last good state survives. `null` return means "ignore the event", `{}` means
"clear the rails" — don't collapse those two. `current_tier` is allowed to be
`null` (the peer's "no usable mastery" case): it renders an unlit rail, not a
zero tier. In v2 `glassSize` is discarded, and in v3 the payload's own `schema`
field is ignored (we key off the capability marker, not the payload).
- **The rail encodes difficulty semantically, never by size.** `_smTierModel`
produces `{ maxTier, topTier, currentTier, full }`: `top_tier` 0 with
`has_chart_content` is the easy-but-playable "full at every position" case
(`full: true`), `top_tier` 0 without content is silent (`null`, no rail), and
`current_tier` is clamped to `top_tier`. `_smRenderTierRail` draws
`maxTier + 1` fixed-size segments, lights the first `currentTier + 1`, and
outlines the `topTier` segment; `_smShouldCollapse` falls back to a single
marker on a block too narrow for the segments (a non-positive measured width
means "not laid out yet", where segments are kept). Colors are the literals
the peer's own guide uses (`SM_TIER_LIT` `#e8c040`, `SM_TIER_UNLIT`
`rgba(200,200,200,0.25)`, `SM_TIER_TOP` `#f4d35e`) — deliberately shared by
meaning, not Host CSS variables. The readable detail `Section name · tier X of
Y · full detail at Z` (`_smTierSuffix`) is the same wording in the block's
`title` and its `aria-label`; only tier-bearing blocks get a `tabindex`.
`_smUpdateTierRails` repaints in place, and a per-section signature
(`_smTierSignatures`) skips the rails whose data didn't change — no full
`_smRender()` per peer event. Because the signature tracks tier data, not
width, a `resize` listener (inside the hooks guard) calls `_smHandleResize`,
which clears the signatures and repaints so the width-dependent
segments-vs-marker choice is re-decided.
- **Seeking must go through the Host's canonical funnel**
(`window.feedBack.seek` / `window.slopsmith.seek`, wrapped by `_smSeek`),
not by poking `audio.currentTime` directly — see the comment on `_smSeek`
Expand Down
46 changes: 29 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A plugin for [feedBack](https://github.com/got-feedBack/feedBack) that shows a m
- **Playback position** — white marker shows current position, active section highlighted
- **Always visible** — sits between the HUD and the highway, doesn't obstruct notes
- **Automatic** — appears when you play a song, disappears when you leave the player
- **Difficulty glasses (optional)** — each section fills a small glass with its current difficulty, when [Difficulty Ladder](https://github.com/get-flashbacks/feedback-plugin-difficulty-ladder) is installed. See below.
- **Difficulty tier rail (optional)** — each section shows a small rail of lit segments for its current difficulty tier, with an outline marking the tier it plays in full, when [Difficulty Ladder](https://github.com/get-flashbacks/feedback-plugin-difficulty-ladder) is installed. See below.

## Installation

Expand All @@ -21,40 +21,52 @@ docker compose restart

The section map automatically appears at the top of the player when you play a song.

## Difficulty glasses (optional)
## Difficulty tier rail (optional)

Section Map is standalone: section colors, labels, click-to-seek and wheel nudging
all work with no other plugin installed.

The per-section **difficulty glass** is the one optional feature, and it needs
The per-section **difficulty tier rail** is the one optional feature, and it needs
[Difficulty Ladder](https://github.com/get-flashbacks/feedback-plugin-difficulty-ladder)
installed alongside it. Difficulty Ladder works out how hard each section is and
publishes that as a fill level and a glass size; Section Map renders whatever it
hands over. Without it, the section map looks the same minus the glasses — no
errors, no placeholder.
publishes each section's current tier and the tier it plays in full; Section Map
renders that as a rail of segments — one per tier, the first `current + 1` lit, and
the full-detail tier outlined. A block too narrow for segments collapses to a
single lit marker. Without the peer, the section map looks the same minus the rails
— no errors, no placeholder.

| Requirement | Value |
|---|---|
| Peer plugin | [Difficulty Ladder](https://github.com/get-flashbacks/feedback-plugin-difficulty-ladder), optional |
| Payload floor | **v0.12.0** — the first release documenting the payload shape below |
| Event consumed | `difficulty:sections-updated` (schema `difficulty_ladder.sections.v2`) |
| Feature-detected via | `window._ddCapabilities.sectionDifficulty` |
| Preferred event | `difficulty:sections-updated-v3` (schema `difficulty_ladder.sections.v3`) |
| Fallback event | `difficulty:sections-updated` (schema `difficulty_ladder.sections.v2`), floor **v0.12.0** |
| Feature-detected via | `window._ddCapabilities.sectionDifficulty` (v3 additionally via `window._ddCapabilities.sectionsSchema === 'difficulty_ladder.sections.v3'`) |

Notes on compatibility:

- "Difficulty Ladder is installed" is not on its own a compatibility statement.
Section Map subscribes only once the peer advertises the capability marker
above; a build that doesn't is treated as not providing glasses.
- Older builds did emit the event (it shipped in v0.2.0), and Section Map cannot
read a peer's version, so they still render glasses — computed with the
above; a build that doesn't is treated as not providing rails.
- **v3 is preferred.** When the peer advertises the render-neutral
`difficulty_ladder.sections.v3` payload, Section Map consumes it and renders the
tiers directly. This is a feature detection (`sectionsSchema`), not a
version-pin; the peer emits no version on its marker.
- **v2 is the bounded-transition fallback.** A peer that predates v3 still emits
`difficulty:sections-updated`; Section Map reconstructs a coarse rail from its
`fillPercentage` so un-upgraded peers keep painting. v2 cannot show ladder depth
above the full-detail threshold the way v3 can, which is why v3 is preferred.
The v2 payload floor is **v0.12.0**, the first release documenting that shape —
and it is documentation, not enforcement (see below).
- Older v2 builds did emit the event (it shipped in v0.2.0), and Section Map
cannot read a peer's version, so they still render rails — computed with the
formula that plugin used before v0.9.11, which can disagree with Difficulty
Ladder's own HUD. That, not a rendering failure, is what the v0.12.0 floor is
about. If your glasses and Difficulty Ladder's HUD disagree, update it.
about. If your rail and Difficulty Ladder's HUD disagree, update it.
- Any payload entry Section Map can't read is dropped rather than painted, so a
malformed or unexpected payload shows no glass instead of a wrong one.
- Difficulty Ladder also emits a newer, render-neutral
`difficulty:sections-updated-v3` payload. Section Map does not consume it yet —
it renders whatever v2 carries, which that plugin keeps emitting unchanged.
malformed or unexpected payload shows no rail instead of a wrong one.
- The rail is a focus stop: hovering or focusing a section with tier data reports
`Section name · tier X of Y · full detail at Z` in the same wording Difficulty
Ladder's own guide uses.

## License

Expand Down
8 changes: 4 additions & 4 deletions plugin.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
{
"id": "section_map",
"name": "Section Map",
"version": "1.3.0",
"version": "1.4.0",
"private": false,
"script": "screen.js",
"category": "practice",
"description": "Visual song-section map and navigation. Optional: Difficulty Ladder v0.12.0+ adds per-section difficulty glasses.",
"description": "Visual song-section map and navigation. Optional: Difficulty Ladder adds a per-section difficulty tier rail.",
"icon": "assets/thumb.svg",
"capabilities": {
"difficulty-ladder": {
"roles": ["observer"],
"observes": ["difficulty:sections-updated"],
"observes": ["difficulty:sections-updated", "difficulty:sections-updated-v3"],
"compatibility": "degrade-noop",
"safety": "safe",
"version": 1,
"description": "Optional peer plugin, used for the difficulty glasses only; section navigation, layout and seeking are standalone. Renders the peer's difficulty_ladder.sections.v2 sectionDifficulties payload (payload floor: Difficulty Ladder v0.12.0) behind window._ddCapabilities.sectionDifficulty. No peer, a peer that doesn't advertise the marker, or a payload entry we can't read all mean no glasses rather than an error."
"description": "Optional peer plugin, used for the difficulty tier rail only; section navigation, layout and seeking are standalone. Prefers the peer's render-neutral difficulty_ladder.sections.v3 payload (feature-detected via window._ddCapabilities.sectionsSchema), falling back to the frozen difficulty_ladder.sections.v2 sectionDifficulties payload (payload floor: Difficulty Ladder v0.12.0) behind window._ddCapabilities.sectionDifficulty. No peer, a peer that doesn't advertise the marker, or a payload entry we can't read all mean no rail rather than an error."
}
}
}
Loading
Loading