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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
says so), a checkpoint dropped by the undo cap or a session reset just falls
back to that, and a refused undo can never spin.

- **Onboarding signposts + first-win cues** (workspace-shell C2). Two quiet,
un-gamified helpers, both editor-pref (never the pack): **signposts** are
suggest-only hints triggered by what you're *doing* — e.g. resnapping notes
repeatedly surfaces "the beat grid may be off — line it up with the Tempo
tools (T), or Ctrl+K" — that only ever point at the menu/shortcut, never move
a surface, fire once per capability, and are permanently dismissible when
browser storage is available (session-only in private/storage-blocked mode;
capped at ≤3). **First-win cues** are calm, one-time visual acknowledgements of a
*correctness* milestone — locking a barline to the recording, and a section
first gaining content — with no sound, score, or token. The section
completeness shading stays presence-only (a span with content vs. not), never
a density target and never scolding an intentionally-empty region.
- **Tempo & meter markers on the ruler.** Sparse labeled chips now show where the
tempo changes (e.g. `90 BPM`) and where the meter changes (e.g. `3/4`) through
the song, so a variable tempo map is legible at a glance. They're **derived
Expand Down
18 changes: 18 additions & 0 deletions assets/v3-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,21 @@ html.fb-immersive #plugin-editor .editor-root {
#plugin-editor .editor-guide-body figcaption {
margin-top: 0.4rem; font-size: 0.78rem; color: #9ca3af;
}

/* ── Onboarding first-win cue (workspace-shell C2) ─────────────────── */
/* A calm one-time acknowledgement: fade in, hold, fade out. No motion beyond
opacity + a small rise, so it reads as "noticed", never as a reward pop. */
#plugin-editor #editor-cue.editor-cue-show {
animation: editor-cue-fade 4.2s ease-in-out forwards;
}
@keyframes editor-cue-fade {
0% { opacity: 0; transform: translate(-50%, 6px); }
12% { opacity: 1; transform: translate(-50%, 0); }
80% { opacity: 1; transform: translate(-50%, 0); }
100% { opacity: 0; transform: translate(-50%, 0); }
}
@media (prefers-reduced-motion: reduce) {
#plugin-editor #editor-cue.editor-cue-show { animation-duration: 4.2s; }
#plugin-editor #editor-cue.editor-cue-show { animation-name: editor-cue-fade-plain; }
@keyframes editor-cue-fade-plain { 0%,100% { opacity: 0; } 12%,80% { opacity: 1; } }
}
12 changes: 12 additions & 0 deletions screen.html
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,18 @@
</div>
<!-- Playability-lint popover (P9) — anchored bottom-right of the timeline. -->
<div id="editor-lint-pop" role="dialog" aria-label="Playability notes" class="hidden absolute right-3 bottom-2 z-30 editor-lint-pop"></div>
<!-- Onboarding signpost (workspace-shell C2) — a quiet, SUGGEST-ONLY,
one-shot, permanently-dismissible hint pinned bottom-centre. It
only ever points at the menu/shortcut; it never moves a surface. -->
<div id="editor-signpost" role="status" aria-live="polite" class="hidden absolute bottom-3 left-1/2 -translate-x-1/2 z-30 max-w-lg flex items-start gap-2 px-3 py-2 rounded-lg bg-dark-800/95 border border-gray-700 shadow-xl backdrop-blur text-xs text-gray-200">
<span class="mt-0.5 text-sky-300" aria-hidden="true">💡</span>
<span id="editor-signpost-msg" class="leading-snug"></span>
<button onclick="editorDismissSignpost()" class="ml-1 -mr-0.5 text-gray-500 hover:text-white text-sm leading-none shrink-0" aria-label="Dismiss — won't show again" title="Dismiss — won't show again">&times;</button>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<!-- First-win cue (workspace-shell C2) — a calm, one-time visual
acknowledgement of a correctness milestone; auto-fades, no sound,
no score, no token. Non-interactive. -->
<div id="editor-cue" class="hidden absolute bottom-14 left-1/2 -translate-x-1/2 z-30 px-3 py-1.5 rounded-full bg-emerald-500/15 border border-emerald-400/40 text-xs text-emerald-100 pointer-events-none"></div>
<!-- Drum-pad companion strip — kit legend / cursor input / GM MIDI
monitor; built by src/drum-pad-strip.js, drum edit mode only. -->
<div id="editor-drum-pad-strip" class="hidden absolute bottom-2 left-[52px] right-3 z-20 h-44 select-none rounded-md border border-gray-700/80 bg-dark-800/90 backdrop-blur-sm px-2 py-1.5 flex items-center gap-2">
Expand Down
4 changes: 4 additions & 0 deletions src/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
} from './lanes.js';
import { _isSuggested, notes } from './notes.js';
import { _lintFlaggedSet } from './playability-lint.js';
import { _maybeFireFirstCovered } from './signposts.js';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import {
SCALE_INTERVALS,
_SCALE_DEGREE_LABELS,
Expand Down Expand Up @@ -259,6 +260,9 @@ function _sectionCoverage() {
key, notesRef: ns,
value: _sectionCoveragePure(secs, _currentNoteTimes(), S.duration || 0),
};
// Coverage only recomputes on an edit (editGen/section/duration change),
// not per frame — the right place to check the first-covered first-win.
_maybeFireFirstCovered(_covCache.value);
}
return _covCache.value;
}
Expand Down
5 changes: 5 additions & 0 deletions src/file-ops.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
disposeBackendSession, guardSessionTransition, stopSessionProcesses,
} from './session-lifecycle.js';
import { _liftAllBeats, _restoreBeatLocks, _stripBeatsFromSaveBody } from './tempo.js';
import { _resetSignpostCounters } from './signposts.js';
import { surfaceMigrateFilename, surfaceOnSongLoaded } from './toolbars.js';
import { _editorEscHtml, setStatus } from './ui.js';
import { host } from './host.js';
Expand Down Expand Up @@ -205,6 +206,10 @@ export async function loadCDLC(filename, options = {}) {
// Reset offset UI so _effectiveAudioOffset() doesn't carry over a
// delta from a previous session's sync nudge into this one.
_resetOffsetUI();
// Signposts are session-scoped (they react to what you're doing in THIS
// song); re-baseline the first-covered cue against the freshly loaded
// chart so opening an already-charted song never fires it.
_resetSignpostCounters();

// Flatten chord notes into main notes array for unified editing
flattenChords();
Expand Down
10 changes: 8 additions & 2 deletions src/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { SNAP_VALUES, _editorEffectiveSnapValuePure, _editorSnapSubdivisionsPure
import { S } from './state.js';
import { _editorShowTabPreview, _tabPreviewKeyPolicyPure } from './tab-preview.js';
import { TempoGridCmd, _editorModulateTempoAtSelection, _editorTapTempoAtSelection, _editorToggleSyncLock, _editorToggleTempoMapMode, _tapTempoHandleKey, _tempoDeleteSelection, _tempoInsertSyncPoint, _tempoMapOnContextMenu, _tempoMeasureBeatCount, _tempoMeasureDenominator, _tempoPromptMeasureBpm, _tempoSetBeatsPerMeasure, _tempoSetDenominatorOnBeatsPure, _tempoPromptPickup } from './tempo.js';
import { _signpostNote } from './signposts.js';
import { _editorPromptText, setStatus } from './ui.js';
import { host } from './host.js';

Expand Down Expand Up @@ -276,6 +277,7 @@ function _editorGotoBookmark(n) {
return true;
}
_editorSeekToTime(t);
_signpostNote('navJump');
setStatus(`Bookmark ${n}`);
return true;
}
Expand All @@ -284,14 +286,14 @@ function _editorJumpBeat(dir) {
const beats = (S.beats || []).map(b => b.time).filter(t => Number.isFinite(t)).sort((a, b) => a - b);
const cur = S.cursorTime || 0;
const next = dir > 0 ? beats.find(t => t > cur + 0.0001) : [...beats].reverse().find(t => t < cur - 0.0001);
if (next !== undefined) _editorSeekToTime(next);
if (next !== undefined) { _editorSeekToTime(next); _signpostNote('navJump'); }
}

function _editorJumpNote(dir) {
const times = notes().map(n => n.time).filter(t => Number.isFinite(t)).sort((a, b) => a - b);
const cur = S.cursorTime || 0;
const next = dir > 0 ? times.find(t => t > cur + 0.0001) : [...times].reverse().find(t => t < cur - 0.0001);
if (next !== undefined) _editorSeekToTime(next);
if (next !== undefined) { _editorSeekToTime(next); _signpostNote('navJump'); }
}

function _editorJumpGrid(dir) {
Expand Down Expand Up @@ -412,6 +414,10 @@ function _editorResnapSelection() {
const dtimes = newTimes.map((t, i) => t - oldTimes[i]);
const dstrings = idxs.map(() => 0);
S.history.exec(new MoveNoteCmd(idxs, dtimes, dstrings, null));
// Repeated resnapping is the canonical "fighting the grid" signal (charrette
// §3.2): the notes keep landing off the beat, so the GRID may be the thing
// that's wrong — nudge toward the Tempo tools.
_signpostNote('gridFight');
host.draw();
host.updateStatus();
return true;
Expand Down
2 changes: 2 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
import { _fretboardStripRefresh, editorToggleFretboardStrip, initFretboardStrip } from './fretboard-strip.js';
import { initMenuBar } from './menu-bar.js';
import { initToolbars } from './toolbars.js';
import { editorDismissSignpost } from './signposts.js';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm role/aria-live wiring on the onboarding surfaces in screen.html
rg -n -B2 -A2 'id="editor-(signpost|cue)"' screen.html

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 1304


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the onboarding markup with surrounding context
sed -n '200,228p' screen.html | cat -n

Repository: got-feedBack/feedBack-plugin-editor

Length of output: 4115


Add live-region semantics to the onboarding cues. #editor-signpost and #editor-cue still render as plain <div>s, so their messages won’t be announced; add role="status" and aria-live="polite" to both.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main.js` at line 120, Update the onboarding cue elements `#editor-signpost`
and `#editor-cue` to include role="status" and aria-live="polite", ensuring both
message containers expose live-region semantics while preserving their existing
markup and behavior.

import { _transportBarTick, initTransportBar } from './transport-bar.js';
import {
MIN_MEASURE, TempoGridCmd, TempoMapCmd, TempoOffsetCmd, _r3, _refreshTempoMapButton, _refreshTempoSyncInspector, _respaceWithLocksPure,
Expand Down Expand Up @@ -544,6 +545,7 @@
window.editorRefreshTabPreview = editorRefreshTabPreview;

// Input layer (input.js owns the keyboard/command/shortcut-panel logic).
window.editorDismissSignpost = editorDismissSignpost;
window.editorToggleShortcutPanel = editorToggleShortcutPanel;
window.editorRunShortcutCommand = editorRunShortcutCommand;
window.editorHideTabPreview = editorHideTabPreview;
Expand Down Expand Up @@ -1631,7 +1633,7 @@
// the same save path as the Save button (in-place sloppak write, not the
// heavy create-mode build).
if (S.sessionId) {
try { await saveCDLC(); } catch (e) { /* surfaced via setStatus */ }

Check warning on line 1636 in src/main.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}
// Capture where we are so the return trip lands on the same spot.
const returnCtx = {
Expand Down
154 changes: 154 additions & 0 deletions src/signposts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Onboarding signposts + first-win cues (workspace-shell C2, charrette §3.2/§3.4).
//
// Two un-gamified surfaces, both editor-pref (localStorage), never the pack:
//
// SIGNPOSTS — SUGGEST-ONLY. A signpost never moves or adds a surface; it just
// points at the menu / Ctrl+K in words. It is ACTION-triggered (never a timer
// or idle nag), fires ONCE per capability, and is permanently dismissible.
// Capped at ≤3 hand-audited entries (charrette §3.2).
//
// CUES — a calm, one-time VISUAL acknowledgement of a CORRECTNESS milestone
// (a barline locked to the recording; a section first gaining content). No
// sound, no %, no score, no token (charrette §3.4: reward the music getting
// more accurate, never the user doing more actions).
//
// All one-shot state is a single localStorage flag per id when storage is
// available, with an in-memory fallback for private/sandboxed sessions.

import { S } from './state.js';

const LS_SIGNPOST = (id) => `editorSignpost:${id}`;
const LS_CUE = (id) => `editorCue:${id}`;

const _memStore = new Map();
function _lsGet(k) {
try {
const v = localStorage.getItem(k);
return v || (_memStore.has(k) ? _memStore.get(k) : null);
}
catch (_) { return _memStore.has(k) ? _memStore.get(k) : null; }
}
function _lsSet(k, v) {
const s = String(v);
_memStore.set(k, s);
try { localStorage.setItem(k, s); }
catch (_) { /* private mode */ }
}
export function _resetSignpostMemoryForTests() { _memStore.clear(); }

export function _signpostSeen(id) { return _lsGet(LS_SIGNPOST(id)) === 'seen'; }
export function _cueSeen(id) { return _lsGet(LS_CUE(id)) === 'seen'; }

// Session action counters. Session-scoped BY DESIGN: a signpost reacts to what
// you are doing right now, then (once shown) never nags again. Reset on song
// load so each song's session starts clean.
const _counts = { gridFight: 0, navJump: 0, enteredTempoMap: false };
// Coverage baseline: the first-covered cue is a first-WIN, so it must fire only
// on an empty→covered transition the USER makes — never on a load of a chart
// that already has covered sections. The first coverage read after a load seeds
// the baseline silently; later flips fire.
let _prevAnyCovered = false;
let _coverageBaselined = false;
export function _resetSignpostCounters() {
_counts.gridFight = 0;
_counts.navJump = 0;
_counts.enteredTempoMap = false;
_coverageBaselined = false;
}
export function _signpostCounts() { return { ..._counts }; } // test/read only

// ── Signpost registry (≤3) ──────────────────────────────────────────
// message points ONLY at the menu / shortcut — never an action button that
// mutates the surface. eligible(counts, S) is a PURE predicate over the session
// counters + read-only state.
export const SIGNPOSTS = [
{
id: 'grid-fit',
message: 'Notes keep needing a resnap? The beat grid may be off — line it up with the Tempo tools (press T for Tempo Map), or search commands with Ctrl+K.',
eligible: (c) => c.gridFight >= 3 && !c.enteredTempoMap,
},
{
id: 'sections',
message: 'Jumping around a lot? Mark sections with Shift+M to move through a long song quickly.',
eligible: (c, s) => c.navJump >= 10 && ((s.sections || []).length === 0) && ((Number(s.duration) || 0) >= 60),
},
];

// The first not-yet-seen signpost whose predicate holds (or null). Pure.
export function _eligibleSignpostPure(counts, s, seenIds) {
for (const sp of SIGNPOSTS) {
if (seenIds && seenIds.has(sp.id)) continue;
if (sp.eligible(counts, s)) return sp;
}
return null;
}

function _showSignpost(sp) {
const el = document.getElementById('editor-signpost');
if (!el) return;
_lsSet(LS_SIGNPOST(sp.id), 'seen'); // one-shot: never eligible again
const msg = document.getElementById('editor-signpost-msg');
if (msg) msg.textContent = sp.message;
el.dataset.signpostId = sp.id;
el.classList.remove('hidden');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function editorDismissSignpost() {
const el = document.getElementById('editor-signpost');
if (el) el.classList.add('hidden');
}

// Record an action; if it makes a not-yet-seen signpost eligible, show it.
// Called from instrumented action sites (resnap / note time-drag / navigation /
// tempo-map entry). Unknown actions are ignored.
export function _signpostNote(action) {
switch (action) {
case 'gridFight': _counts.gridFight++; break; // resnap, whole-song BPM rescale, big time-drag
case 'navJump': _counts.navJump++; break;
case 'enterTempoMap': _counts.enteredTempoMap = true; break;
default: return;
}
const seen = new Set(SIGNPOSTS.filter((sp) => _signpostSeen(sp.id)).map((sp) => sp.id));
const sp = _eligibleSignpostPure(_counts, S, seen);
if (sp) _showSignpost(sp);
}

// ── First-win cues ──────────────────────────────────────────────────
function _showCue(text) {
const el = document.getElementById('editor-cue');
if (!el) return false;
el.textContent = text;
el.classList.remove('hidden', 'editor-cue-show');
void el.offsetWidth; // reflow so the fade restarts
el.classList.add('editor-cue-show');
// Short-lived flash, like the drum-pad listen flash; a stale timer after a
// screen re-inject only re-hides an already-hidden element (harmless).
setTimeout(() => { const e = document.getElementById('editor-cue'); if (e) e.classList.add('hidden'); }, 4200);
return true;
}

export function _fireCueOnce(id, text) {
if (_cueSeen(id)) return false;
if (!_showCue(text)) return false;
_lsSet(LS_CUE(id), 'seen');
return true;
}

// First time the user locks a barline to the recording (charrette §3.4 cue #1).
export function _signpostFirstLock() {
_fireCueOnce('first-lock', 'Locked to the recording — this barline holds through automatic re-fits.');
}

// First time ANY section gains authored content — presence, not a density
// target, and never for an intentionally-empty span (charrette §3.4 cue #2).
// `coverage` is _sectionCoveragePure()'s output, passed in from the draw memo so
// this module needn't import draw.js. Fires only on an empty→covered transition
// AFTER the post-load baseline, so opening an already-charted song stays quiet.
export function _maybeFireFirstCovered(coverage) {
const now = Array.isArray(coverage) && coverage.some((s) => s && s.hasContent);
if (!_coverageBaselined) { _prevAnyCovered = now; _coverageBaselined = true; return; }
if (now && !_prevAnyCovered && !_cueSeen('first-covered')) {
_fireCueOnce('first-covered', 'That section has content — the chart is taking shape.');
}
_prevAnyCovered = now;
}
8 changes: 8 additions & 0 deletions src/tempo.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
_suggestStopReason,
} from './tempo-suggest.js';
import { _editorPromptText, setStatus } from './ui.js';
import { _signpostFirstLock, _signpostNote } from './signposts.js';

// ════════════════════════════════════════════════════════════════════
// Tempo Map editor — EOF-style sync-point editing of the song-wide
Expand Down Expand Up @@ -637,6 +638,9 @@ export function _editorToggleTempoMapMode() {
// Ctrl+Alt+Z can undo the whole session at once. Entering the mode is not
// itself a history event, so the stamp lands on the last edit before it.
if (S.history) S.history.checkpoint('Tempo Map session');
// Opening the Tempo tools resolves the grid-fighting signpost's premise,
// so it must never fire afterwards (charrette §3.2: action-triggered).
_signpostNote('enterTempoMap');
}
_refreshTempoMapButton();
host.refreshDrumEditButton();
Expand Down Expand Up @@ -882,6 +886,7 @@ export function _tempoMapOnMouseDown(e, x, y) {
const beatHit = _tempoSubBeatAtX(x, y);
if (beatHit >= 0) {
_tapTempo = null;
if (S.tempoSelMulti) S.tempoSelMulti.clear();
S.drag = {
type: 'tempo-beat',
beatIdx: beatHit,
Expand Down Expand Up @@ -1848,6 +1853,9 @@ export function _editorToggleSyncLock() {
// A lock toggle is not a history command, so record the moment as a
// checkpoint on the current top-of-undo — Ctrl+Alt+Z can rewind to it.
if (S.history) S.history.checkpoint(b.locked ? 'Lock barline' : 'Unlock barline');
// First-win cue #1 (charrette §3.4): the first time a barline is locked to
// the recording — a correctness milestone, not an action count.
if (b.locked) _signpostFirstLock();
setStatus(_lockStatusTextPure(b.locked));
return true;
}
Expand Down
Loading
Loading