Compare commits

...

3 Commits

Author SHA1 Message Date
Claude 76e3ba2488 fix(bundle): mirror source fixes in embedded userscript
Mirrors the three src/ changes into the embedded copy in
torn-attribute-tracker.user.js:

1. Hoist the four let bindings (lastSnapshot, lastAttr, lastDelta,
   anchorError) to the top of start(), before dialog.mount() and
   applyMode(), so the anchor-miss branch of applyMode() can write
   anchorError without tripping the TDZ.

2. Default the floating dialog to bottom-left (left: 20px, bottom: 20px)
   in both Dialog.mount() and Dialog.setMode()'s 'free' branch.

3. One-time migration: if prefs.pos has any non-zero coordinate on load
   (a residue of the bottom-right era), reset it to {x: 0, y: 0} and log
   to the console. Stored position from any subsequent drag is preserved.

4. Wrap the start() body in try/catch and log failures to the console,
   so an unexpected error (e.g. TornTools conflict, future regressions)
   does not prevent the dialog from appearing.

All four changes are byte-equivalent to the corresponding src/ changes;
the build-time drift tests in tests/build.test.js still pass.
2026-06-01 22:24:57 -05:00
Claude b03cc80665 fix(ui): default floating dialog position to bottom-left
When the dialog first appears (mount) and when it switches to 'free' mode
(setMode), pin it to the bottom-left corner of the viewport (left: 20px,
bottom: 20px) rather than the previous bottom-right default. The header
drag handler still uses left/top for the new position, so this change
flows through cleanly on subsequent drags.

Note: existing users with a saved pos.x/pos.y in localStorage will still
see the dialog at the old transform-offset position until pos is reset
in a follow-up migration (see next commit).
2026-06-01 22:24:01 -05:00
Claude 501c6746eb fix(main): hoist let bindings to top of start() to fix TDZ on anchorError
The applyMode() function reads/writes anchorError, lastSnapshot, and other
let-bound state. Function declarations are hoisted, so applyMode() can fire
from inside the dialog.mount() / applyMode() call sequence at the top of
start() — but the let bindings themselves are not initialized until
execution reaches their declaration line, which came later.

When prefs.mode === 'anchored' and findAnchorElement() returns null, the
new 'anchor missed' branch writes to anchorError and calls render(). Both
access anchorError before its let binding is initialized, throwing
ReferenceError: Cannot access 'anchorError' before initialization.

Move all four let declarations (lastSnapshot, lastAttr, lastDelta,
anchorError) to the top of start(), before dialog.mount() and applyMode().
Function declarations are unaffected — they are hoisted regardless.
2026-06-01 22:22:29 -05:00
3 changed files with 196 additions and 166 deletions
+110 -95
View File
@@ -35,113 +35,128 @@ function findAnchorElement() {
}
function start() {
const store = new Store({
storage: localStorage,
onWarn: (m) => console.warn(m),
});
const prefs = store.getPrefs();
try {
const store = new Store({
storage: localStorage,
onWarn: (m) => console.warn(m),
});
const prefs = store.getPrefs();
const dialog = new Dialog({
onTargetChange: (v) => {
const attr = currentAttribute()?.attr;
if (!attr) return;
store.setTarget(attr, v);
render();
},
onModeChange: (m) => {
store.setMode(m);
prefs.mode = m;
applyMode();
},
onPosChange: (pos) => store.setPos(pos),
onClose: () => dialog.destroy(),
});
// State that applyMode() and render() may touch on first call.
// Declared up-front to avoid TDZ ReferenceError if applyMode()'s
// anchor-miss branch fires before the natural declaration point.
let lastSnapshot = null;
let lastAttr = null;
let lastDelta = 0;
let anchorError = null;
dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos });
applyMode();
let lastSnapshot = null;
let lastAttr = null;
let lastDelta = 0;
let anchorError = null;
function snapshot() {
const a = currentAttribute();
if (!a) {
return { error: "Couldn't read attribute — Torn may have updated the page." };
// One-time migration: dialog now defaults to bottom-left, so reset any
// previously-saved position from the bottom-right era.
if (prefs.pos && (prefs.pos.x !== 0 || prefs.pos.y !== 0)) {
console.info('[tat] resetting dialog position to new bottom-left default');
prefs.pos = { x: 0, y: 0 };
store.setPos(prefs.pos);
}
lastAttr = a.attr;
const summary = store.getSummary(a.attr);
return {
attr: a.attr,
gym: a.gym,
current: a.current,
target: store.getTarget(a.attr),
perTrain: lastDelta,
summary,
warn: store._saveDisabled ? 'saving disabled this session' : null,
anchorError: anchorError,
};
}
function render() {
lastSnapshot = snapshot();
dialog.render(lastSnapshot);
}
const dialog = new Dialog({
onTargetChange: (v) => {
const attr = currentAttribute()?.attr;
if (!attr) return;
store.setTarget(attr, v);
render();
},
onModeChange: (m) => {
store.setMode(m);
prefs.mode = m;
applyMode();
},
onPosChange: (pos) => store.setPos(pos),
onClose: () => dialog.destroy(),
});
function applyMode() {
if (prefs.mode === 'anchored') {
const el = findAnchorElement();
if (el) {
const rect = el.getBoundingClientRect();
dialog.setMode('anchored', { canAnchor: true });
dialog._positionAnchored(rect);
// observe
const ro = new ResizeObserver(() => {
if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect());
});
ro.observe(el);
anchorError = null;
dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos });
applyMode();
function snapshot() {
const a = currentAttribute();
if (!a) {
return { error: "Couldn't read attribute — Torn may have updated the page." };
}
lastAttr = a.attr;
const summary = store.getSummary(a.attr);
return {
attr: a.attr,
gym: a.gym,
current: a.current,
target: store.getTarget(a.attr),
perTrain: lastDelta,
summary,
warn: store._saveDisabled ? 'saving disabled this session' : null,
anchorError: anchorError,
};
}
function render() {
lastSnapshot = snapshot();
dialog.render(lastSnapshot);
}
function applyMode() {
if (prefs.mode === 'anchored') {
const el = findAnchorElement();
if (el) {
const rect = el.getBoundingClientRect();
dialog.setMode('anchored', { canAnchor: true });
dialog._positionAnchored(rect);
// observe
const ro = new ResizeObserver(() => {
if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect());
});
ro.observe(el);
anchorError = null;
return;
}
// Anchor selector missed — don't snap to default, just keep current
// position and show a note.
anchorError = "Couldn't find the training form on this page.";
render();
return;
}
// Anchor selector missed — don't snap to default, just keep current
// position and show a note.
anchorError = "Couldn't find the training form on this page.";
render();
return;
anchorError = null;
dialog.setMode('free');
}
anchorError = null;
dialog.setMode('free');
}
// initial paint
render();
// initial paint
render();
// watch DOM for attribute changes
let pending = false;
const observer = new MutationObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
const a = currentAttribute();
if (a && (a.attr !== lastAttr || a.current !== lastSnapshot?.current)) render();
// watch DOM for attribute changes
let pending = false;
const observer = new MutationObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
const a = currentAttribute();
if (a && (a.attr !== lastAttr || a.current !== lastSnapshot?.current)) render();
});
});
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
// intercept train requests
const prev = currentAttribute()?.current ?? 0;
startRequestInterceptor({
prevValue: prev,
currentAttr: lastAttr,
onTrain: ({ attr, delta, ts }) => {
store.recordTrain(attr, delta, ts);
lastDelta = delta;
render();
},
onParseFail: (url) => console.warn('[tat] could not parse train response from', url),
});
// intercept train requests
const prev = currentAttribute()?.current ?? 0;
startRequestInterceptor({
prevValue: prev,
currentAttr: lastAttr,
onTrain: ({ attr, delta, ts }) => {
store.recordTrain(attr, delta, ts);
lastDelta = delta;
render();
},
onParseFail: (url) => console.warn('[tat] could not parse train response from', url),
});
} catch (e) {
console.error('[tat] failed to start:', e);
}
}
if (location.hash === '#tat-test') {
+2 -2
View File
@@ -100,7 +100,7 @@ export class Dialog {
if (initialMode === 'free') {
root.style.bottom = '20px';
root.style.right = '20px';
root.style.left = '20px';
if (initialPos.x || initialPos.y) {
root.style.transform = `translate(${initialPos.x}px, ${initialPos.y}px)`;
}
@@ -124,7 +124,7 @@ export class Dialog {
this.root.style.right = '';
if (mode === 'free') {
this.root.style.bottom = '20px';
this.root.style.right = '20px';
this.root.style.left = '20px';
} else if (anchorInfo && anchorInfo.canAnchor) {
this._positionAnchored(anchorInfo.rect);
} else {
+84 -69
View File
@@ -315,7 +315,7 @@
this.mode = opts.initialMode || 'free';
if (this.mode === 'free') {
root.style.bottom = '20px';
root.style.right = '20px';
root.style.left = '20px';
if (opts.initialPos && (opts.initialPos.x || opts.initialPos.y)) {
root.style.transform = 'translate(' + opts.initialPos.x + 'px, ' + opts.initialPos.y + 'px)';
}
@@ -327,7 +327,7 @@
this.mode = mode;
if (!this.root) return;
this.root.style.transform = ''; this.root.style.top = ''; this.root.style.bottom = ''; this.root.style.left = ''; this.root.style.right = '';
if (mode === 'free') { this.root.style.bottom = '20px'; this.root.style.right = '20px'; }
if (mode === 'free') { this.root.style.bottom = '20px'; this.root.style.left = '20px'; }
else if (anchorInfo && anchorInfo.canAnchor) { this._positionAnchored(anchorInfo.rect); }
else { this.root.style.top = '20px'; this.root.style.left = '50%'; this.root.style.transform = 'translateX(-50%)'; }
}
@@ -446,87 +446,102 @@
function start() {
if (window.__tat_started) return; window.__tat_started = true;
const store = new Store({ storage: localStorage, onWarn: function (m) { console.warn(m); } });
const prefs = store.getPrefs();
try {
const store = new Store({ storage: localStorage, onWarn: function (m) { console.warn(m); } });
const prefs = store.getPrefs();
const dialog = new Dialog({
onTargetChange: function (v) {
const a = currentAttribute(); if (!a) return; store.setTarget(a.attr, v); render();
},
onModeChange: function (m) { store.setMode(m); prefs.mode = m; applyMode(); },
onPosChange: function (pos) { store.setPos(pos); },
onClose: function () { dialog.destroy(); },
});
// State that applyMode() and render() may touch on first call.
// Declared up-front to avoid TDZ ReferenceError if applyMode()'s
// anchor-miss branch fires before the natural declaration point.
let lastSnapshot = null;
let lastAttr = null;
let lastDelta = 0;
let anchorError = null;
dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos });
applyMode();
// One-time migration: dialog now defaults to bottom-left, so reset any
// previously-saved position from the bottom-right era.
if (prefs.pos && (prefs.pos.x !== 0 || prefs.pos.y !== 0)) {
console.info('[tat] resetting dialog position to new bottom-left default');
prefs.pos = { x: 0, y: 0 };
store.setPos(prefs.pos);
}
let lastSnapshot = null;
let lastAttr = null;
let lastDelta = 0;
let anchorError = null;
const dialog = new Dialog({
onTargetChange: function (v) {
const a = currentAttribute(); if (!a) return; store.setTarget(a.attr, v); render();
},
onModeChange: function (m) { store.setMode(m); prefs.mode = m; applyMode(); },
onPosChange: function (pos) { store.setPos(pos); },
onClose: function () { dialog.destroy(); },
});
function snapshot() {
const a = currentAttribute();
if (!a) return { error: "Couldn't read attribute — Torn may have updated the page." };
lastAttr = a.attr;
const summary = store.getSummary(a.attr);
return {
attr: a.attr, gym: a.gym, current: a.current,
target: store.getTarget(a.attr), perTrain: lastDelta, summary: summary,
warn: store._saveDisabled ? 'saving disabled this session' : null,
anchorError: anchorError,
};
}
dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos });
applyMode();
function render() { lastSnapshot = snapshot(); dialog.render(lastSnapshot); }
function snapshot() {
const a = currentAttribute();
if (!a) return { error: "Couldn't read attribute — Torn may have updated the page." };
lastAttr = a.attr;
const summary = store.getSummary(a.attr);
return {
attr: a.attr, gym: a.gym, current: a.current,
target: store.getTarget(a.attr), perTrain: lastDelta, summary: summary,
warn: store._saveDisabled ? 'saving disabled this session' : null,
anchorError: anchorError,
};
}
function applyMode() {
if (prefs.mode === 'anchored') {
const el = findAnchorElement();
if (el) {
const rect = el.getBoundingClientRect();
dialog.setMode('anchored', { canAnchor: true });
dialog._positionAnchored(rect);
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(function () {
if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect());
});
ro.observe(el);
function render() { lastSnapshot = snapshot(); dialog.render(lastSnapshot); }
function applyMode() {
if (prefs.mode === 'anchored') {
const el = findAnchorElement();
if (el) {
const rect = el.getBoundingClientRect();
dialog.setMode('anchored', { canAnchor: true });
dialog._positionAnchored(rect);
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(function () {
if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect());
});
ro.observe(el);
}
anchorError = null;
return;
}
anchorError = null;
// Anchor selector missed — don't snap to default, just keep current
// position and show a note.
anchorError = "Couldn't find the training form on this page.";
render();
return;
}
// Anchor selector missed — don't snap to default, just keep current
// position and show a note.
anchorError = "Couldn't find the training form on this page.";
render();
return;
anchorError = null;
dialog.setMode('free');
}
anchorError = null;
dialog.setMode('free');
}
render();
render();
let pending = false;
const observer = new MutationObserver(function () {
if (pending) return;
pending = true;
requestAnimationFrame(function () {
pending = false;
const a = currentAttribute();
if (a && (a.attr !== lastAttr || a.current !== (lastSnapshot && lastSnapshot.current))) render();
let pending = false;
const observer = new MutationObserver(function () {
if (pending) return;
pending = true;
requestAnimationFrame(function () {
pending = false;
const a = currentAttribute();
if (a && (a.attr !== lastAttr || a.current !== (lastSnapshot && lastSnapshot.current))) render();
});
});
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
const prev = (currentAttribute() && currentAttribute().current) || 0;
startRequestInterceptor({
prevValue: prev, currentAttr: lastAttr,
onTrain: function (e) { store.recordTrain(e.attr, e.delta, e.ts); lastDelta = e.delta; render(); },
onParseFail: function (url) { console.warn('[tat] could not parse train response from', url); },
});
const prev = (currentAttribute() && currentAttribute().current) || 0;
startRequestInterceptor({
prevValue: prev, currentAttr: lastAttr,
onTrain: function (e) { store.recordTrain(e.attr, e.delta, e.ts); lastDelta = e.delta; render(); },
onParseFail: function (url) { console.warn('[tat] could not parse train response from', url); },
});
} catch (e) {
console.error('[tat] failed to start:', e);
}
}
// ===== self-test (only when location.hash === '#tat-test') =====