- Introduced a new userscript for torn.com to assist players in planning attribute training. - Document outlines the purpose, scope, architecture, dialog content, placement modes, error handling, testing, and file layout. - Details on data handling, UI interactions, and user preferences included.
75 KiB
Torn Attribute Training Tracker Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a Tampermonkey-compatible userscript that shows a floating dialog on Torn's gym page, tracking attribute value, target, per-train/per-day increase, and ETA to target.
Architecture: Single userscript file (torn-attribute-tracker.user.js) with three in-file modules — DataLayer (DOM + network), Store (localStorage + history), UI (dialog + drag/anchor) — wired by a small main block. Pure functions are exported on window.TAT and exercised by both an in-browser #tat-test self-test and a Node-runnable test file.
Tech Stack: Vanilla JavaScript (ES2017+), Tampermonkey/Greasemonkey/Violentmonkey, localStorage, Node.js (for tests only).
File Structure
Files this plan creates:
package.json— minimal Node setup for the test runner (type: "module",npm testscript).torn-attribute-tracker.user.js— the deliverable userscript (IIFE bundle).src/pure.js— pure functions (parseTarget,computeEstimate,pruneHistory,summary). Imported by tests, also embedded in the userscript via an IIFE-wrapped copy in Task 1.src/dom.js—currentAttribute()(gym page selectors) and the anchor target lookup. Browser-only; not testable in Node (so we test only the parts that are testable and verify the rest by hand in the manual test task).src/interceptor.js—startRequestInterceptor(). Browser-only; manually verified.src/store.js—Storeclass. Imported by tests (Node shim forlocalStorage) and embedded in the userscript.src/ui.js—Dialogclass. Browser-only; manually verified.src/main.js— wiring. Browser-only.tests/pure.test.js— Node test runner for the pure functions.tests/store.test.js— Node test runner for the Store (using alocalStorageshim).tests/build.test.js— verifies the embedded copy ofpure.jsinside the userscript matches the source file (catches drift between tests and the shipped script).README.md— install + usage.docs/superpowers/specs/2026-06-01-torn-attribute-training-tracker-design.md— already exists, untouched.
Tests run with npm test (Node). The #tat-test self-test in the browser is described in the README and is verified by the final manual task.
Task 0: Project skeleton
Files:
-
Create:
package.json -
Create:
.gitignore -
Create:
README.md(placeholder) -
Step 1: Initialize git
cd "C:/Users/ksolo/Projects/Games/Torn Training Tracker"
git init
git config user.email "dev@local"
git config user.name "dev"
- Step 2: Create
package.json
{
"name": "torn-attribute-training-tracker",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/"
}
}
- Step 3: Create
.gitignore
node_modules/
.superpowers/
.vscode/
- Step 4: Create placeholder
README.md
# Torn Attribute Training Tracker
A userscript for [torn.com](https://www.torn.com) that shows a floating dialog on the gym page with your attribute, target, and ETA.
_(installation and usage coming once tasks complete)_
- Step 5: Commit
git add package.json .gitignore README.md
git commit -m "chore: initialize project"
Task 1: Pure target parser
Files:
- Create:
src/pure.js - Create:
tests/pure.test.js
Pure function parseTarget(input) that converts user-typed strings to a positive integer, or returns null for invalid input. Spec:
-
Accepts numbers:
25,25000000,2.5e7→ return as integer. -
Accepts strings:
"25","25,000,000","25,000,000.5"→ strip commas, parse, floor. -
Accepts magnitude suffixes:
"25M","1.5B","100K","2T"(case-insensitive). -
Rejects: zero, negative, non-numeric, fractional after expansion (e.g.
"1.5M"→1_500_000, ok;"1.5M.5"→ null). -
Step 1: Write the failing test
tests/pure.test.js:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseTarget } from '../src/pure.js';
test('parseTarget: integer numbers', () => {
assert.equal(parseTarget(25), 25);
assert.equal(parseTarget(25000000), 25_000_000);
assert.equal(parseTarget(2.5e7), 25_000_000);
});
test('parseTarget: string numbers with commas', () => {
assert.equal(parseTarget('25'), 25);
assert.equal(parseTarget('25,000,000'), 25_000_000);
assert.equal(parseTarget('25,000,000.5'), 25_000_000);
});
test('parseTarget: magnitude suffixes (case-insensitive)', () => {
assert.equal(parseTarget('25K'), 25_000);
assert.equal(parseTarget('25m'), 25_000_000);
assert.equal(parseTarget('1.5B'), 1_500_000_000);
assert.equal(parseTarget('100t'), 100_000_000_000_000);
});
test('parseTarget: rejects invalid input', () => {
assert.equal(parseTarget(0), null);
assert.equal(parseTarget(-1), null);
assert.equal(parseTarget('abc'), null);
assert.equal(parseTarget(''), null);
assert.equal(parseTarget(null), null);
assert.equal(parseTarget(undefined), null);
assert.equal(parseTarget('25M.5'), null);
assert.equal(parseTarget('1.5.5M'), null);
});
- Step 2: Run test to verify it fails
Run: npm test
Expected: FAIL — Cannot find module '../src/pure.js' or parseTarget is not a function.
- Step 3: Implement
parseTarget
src/pure.js:
const SUFFIXES = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 };
export function parseTarget(input) {
if (input === null || input === undefined || input === '') return null;
if (typeof input === 'number') {
if (!Number.isFinite(input) || input <= 0) return null;
return Math.floor(input);
}
if (typeof input !== 'string') return null;
const cleaned = input.replace(/,/g, '').trim().toLowerCase();
if (cleaned === '') return null;
// Reject anything other than digits, optional decimal, and optional single trailing suffix letter
if (!/^\d+(\.\d+)?[kmbt]?$/.test(cleaned)) return null;
const match = cleaned.match(/^(\d+(?:\.\d+)?)([kmbt])?$/);
if (!match) return null;
const num = parseFloat(match[1]);
if (!Number.isFinite(num) || num <= 0) return null;
const suffix = match[2];
const multiplier = suffix ? SUFFIXES[suffix] : 1;
return Math.floor(num * multiplier);
}
- Step 4: Run test to verify it passes
Run: npm test
Expected: PASS — all 4 parseTarget test cases.
- Step 5: Commit
git add src/pure.js tests/pure.test.js
git commit -m "feat(pure): parseTarget with comma and magnitude suffix support"
Task 2: Pure computeEstimate
Files:
- Modify:
src/pure.js - Modify:
tests/pure.test.js
Pure function that returns { remaining, trainsToGo, days, eta } from (current, target, perTrain, perDay). Spec math:
-
remaining = target − current(clamp to 0 if target reached). -
trainsToGo = ceil(remaining / perTrain)(0 if perTrain ≤ 0 or remaining ≤ 0). -
days = ceil(remaining / perDay)(0 if perDay ≤ 0 or remaining ≤ 0). -
eta= aDatefortoday + days(ornullif days is 0). -
Step 1: Write the failing test
Append to tests/pure.test.js:
import { computeEstimate } from '../src/pure.js';
test('computeEstimate: typical case', () => {
const r = computeEstimate(14_328_501, 25_000_000, 247, 4520);
assert.equal(r.remaining, 10_671_499);
assert.equal(r.trainsToGo, 43_205);
assert.equal(r.days, 2_362);
assert.ok(r.eta instanceof Date);
});
test('computeEstimate: target reached', () => {
const r = computeEstimate(25_000_000, 25_000_000, 247, 4520);
assert.equal(r.remaining, 0);
assert.equal(r.trainsToGo, 0);
assert.equal(r.days, 0);
assert.equal(r.eta, null);
});
test('computeEstimate: target below current', () => {
const r = computeEstimate(30_000_000, 25_000_000, 247, 4520);
assert.equal(r.remaining, 0);
assert.equal(r.trainsToGo, 0);
assert.equal(r.days, 0);
assert.equal(r.eta, null);
});
test('computeEstimate: zero perTrain or perDay does not crash', () => {
const a = computeEstimate(100, 200, 0, 50);
assert.equal(a.trainsToGo, 0);
assert.equal(a.days, 2);
const b = computeEstimate(100, 200, 50, 0);
assert.equal(b.trainsToGo, 2);
assert.equal(b.days, 0);
assert.equal(b.eta, null);
});
- Step 2: Run test to verify it fails
Run: npm test
Expected: FAIL — computeEstimate is not a function.
- Step 3: Implement
computeEstimate
Append to src/pure.js:
export function computeEstimate(current, target, perTrain, perDay) {
const remaining = Math.max(0, target - current);
if (remaining === 0) {
return { remaining: 0, trainsToGo: 0, days: 0, eta: null };
}
const trainsToGo = perTrain > 0 ? Math.ceil(remaining / perTrain) : 0;
const days = perDay > 0 ? Math.ceil(remaining / perDay) : 0;
const eta = days > 0 ? new Date(Date.now() + days * 86_400_000) : null;
return { remaining, trainsToGo, days, eta };
}
- Step 4: Run test to verify it passes
Run: npm test
Expected: PASS — all computeEstimate test cases.
- Step 5: Commit
git add src/pure.js tests/pure.test.js
git commit -m "feat(pure): computeEstimate with safe division and target-reached handling"
Task 3: Pure pruneHistory
Files:
- Modify:
src/pure.js - Modify:
tests/pure.test.js
Pure function pruneHistory(entries, now = Date.now()) that returns a new array of entries with ts >= now − 30 * 86_400_000 (i.e. last 30 days). Does not mutate input.
- Step 1: Write the failing test
Append to tests/pure.test.js:
import { pruneHistory } from '../src/pure.js';
const DAY = 86_400_000;
const NOW = 1_700_000_000_000; // fixed reference
test('pruneHistory: keeps entries within 30 days, drops older', () => {
const entries = [
{ ts: NOW, delta: 100 },
{ ts: NOW - 1 * DAY, delta: 100 },
{ ts: NOW - 29 * DAY, delta: 100 },
{ ts: NOW - 30 * DAY, delta: 100 }, // exactly 30 days, dropped (strict <)
{ ts: NOW - 31 * DAY, delta: 100 },
];
const out = pruneHistory(entries, NOW);
assert.equal(out.length, 3);
assert.deepEqual(out.map((e) => e.ts), [NOW, NOW - 1 * DAY, NOW - 29 * DAY]);
});
test('pruneHistory: does not mutate input', () => {
const entries = [{ ts: NOW - 31 * DAY, delta: 100 }];
const copy = [...entries];
pruneHistory(entries, NOW);
assert.deepEqual(entries, copy);
});
test('pruneHistory: empty input', () => {
assert.deepEqual(pruneHistory([], NOW), []);
});
- Step 2: Run test to verify it fails
Run: npm test
Expected: FAIL — pruneHistory is not a function.
- Step 3: Implement
pruneHistory
Append to src/pure.js:
const THIRTY_DAYS_MS = 30 * 86_400_000;
export function pruneHistory(entries, now = Date.now()) {
const cutoff = now - THIRTY_DAYS_MS;
return entries.filter((e) => e.ts >= cutoff);
}
- Step 4: Run test to verify it passes
Run: npm test
Expected: PASS — all 3 pruneHistory test cases.
- Step 5: Commit
git add src/pure.js tests/pure.test.js
git commit -m "feat(pure): pruneHistory with strict 30-day window"
Task 4: Pure summary
Files:
- Modify:
src/pure.js - Modify:
tests/pure.test.js
Pure function summary(entries, now = Date.now()) returning { trainsToday, sevenDayAvgPerDay, perDay } per the spec.
- Step 1: Write the failing test
Append to tests/pure.test.js:
import { summary } from '../src/pure.js';
const DAY = 86_400_000;
const NOW = 1_700_000_000_000;
test('summary: empty history returns zeros', () => {
const s = summary([], NOW);
assert.equal(s.trainsToday, 0);
assert.equal(s.sevenDayAvgPerDay, 0);
assert.equal(s.perDay, 0);
});
test('summary: counts trains within last 24h', () => {
const entries = [
{ ts: NOW - 1 * 60_000, delta: 100 }, // 1 min ago
{ ts: NOW - 12 * 3_600_000, delta: 100 }, // 12h ago
{ ts: NOW - 23 * 3_600_000, delta: 100 }, // 23h ago
{ ts: NOW - 25 * 3_600_000, delta: 100 }, // 25h ago (not today)
];
const s = summary(entries, NOW);
assert.equal(s.trainsToday, 3);
});
test('summary: 7-day average is total/7', () => {
// 14 entries in last 7 days
const entries = [];
for (let i = 0; i < 14; i++) {
entries.push({ ts: NOW - i * DAY, delta: 100 });
}
const s = summary(entries, NOW);
assert.equal(s.sevenDayAvgPerDay, 2);
});
test('summary: perDay uses most recent delta when available', () => {
const entries = [
{ ts: NOW - 1 * 60_000, delta: 247 },
{ ts: NOW - 12 * 3_600_000, delta: 300 },
];
const s = summary(entries, NOW);
// 2 trains in last 7 days → 2/7 avg
// perDay = avg * most recent delta = (2/7) * 247 = ~70.57
assert.equal(s.perDay, Math.floor((2 / 7) * 247));
});
test('summary: perDay is 0 when no recent delta', () => {
const entries = [{ ts: NOW - 31 * DAY, delta: 100 }];
const s = summary(entries, NOW);
assert.equal(s.perDay, 0);
});
- Step 2: Run test to verify it fails
Run: npm test
Expected: FAIL — summary is not a function.
- Step 3: Implement
summary
Append to src/pure.js:
export function summary(entries, now = Date.now()) {
if (entries.length === 0) {
return { trainsToday: 0, sevenDayAvgPerDay: 0, perDay: 0 };
}
const ONE_DAY = 86_400_000;
const SEVEN_DAYS = 7 * ONE_DAY;
const todayCutoff = now - ONE_DAY;
const weekCutoff = now - SEVEN_DAYS;
let trainsToday = 0;
let trainsWeek = 0;
let latestDelta = 0;
let latestTs = -Infinity;
for (const e of entries) {
if (e.ts >= todayCutoff) trainsToday++;
if (e.ts >= weekCutoff) trainsWeek++;
if (e.ts > latestTs) {
latestTs = e.ts;
latestDelta = e.delta;
}
}
const sevenDayAvgPerDay = trainsWeek / 7;
const perDay = latestDelta > 0 ? sevenDayAvgPerDay * latestDelta : 0;
return { trainsToday, sevenDayAvgPerDay, perDay };
}
- Step 4: Run test to verify it passes
Run: npm test
Expected: PASS — all 5 summary test cases.
- Step 5: Commit
git add src/pure.js tests/pure.test.js
git commit -m "feat(pure): summary with today/week counts and per-day rate"
Task 5: Store — load/save and target getters/setters
Files:
- Create:
src/store.js - Create:
tests/store.test.js - Create:
tests/localstorage-shim.js
The Store needs to be testable in Node. We provide a minimal localStorage shim and inject it via a constructor argument.
- Step 1: Create the localStorage shim
tests/localstorage-shim.js:
export function createLocalStorage() {
const data = new Map();
return {
getItem(k) { return data.has(k) ? data.get(k) : null; },
setItem(k, v) { data.set(k, String(v)); },
removeItem(k) { data.delete(k); },
clear() { data.clear(); },
};
}
- Step 2: Write the failing test for load/save and target accessors
tests/store.test.js:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Store } from '../src/store.js';
import { createLocalStorage } from './localstorage-shim.js';
function freshStore() {
return new Store({ storage: createLocalStorage() });
}
test('Store: starts empty', () => {
const s = freshStore();
assert.equal(s.getTarget('strength'), null);
});
test('Store: setTarget validates via parseTarget', () => {
const s = freshStore();
assert.equal(s.setTarget('strength', '25M'), true);
assert.equal(s.getTarget('strength'), 25_000_000);
});
test('Store: setTarget rejects invalid and keeps previous', () => {
const s = freshStore();
s.setTarget('strength', 25_000_000);
assert.equal(s.setTarget('strength', 'abc'), false);
assert.equal(s.getTarget('strength'), 25_000_000);
});
test('Store: targets are per-attribute', () => {
const s = freshStore();
s.setTarget('strength', 25_000_000);
s.setTarget('speed', 50_000_000);
assert.equal(s.getTarget('strength'), 25_000_000);
assert.equal(s.getTarget('speed'), 50_000_000);
});
test('Store: persists across instances via storage', () => {
const storage = createLocalStorage();
const a = new Store({ storage });
a.setTarget('strength', 25_000_000);
const b = new Store({ storage });
assert.equal(b.getTarget('strength'), 25_000_000);
});
test('Store: corrupted JSON is wiped with a warning', () => {
const storage = createLocalStorage();
storage.setItem('tat.targets', '{not json');
const warnings = [];
const s = new Store({ storage, onWarn: (m) => warnings.push(m) });
assert.equal(s.getTarget('strength'), null);
assert.equal(warnings.length, 1);
});
- Step 3: Run test to verify it fails
Run: npm test
Expected: FAIL — Cannot find module '../src/store.js'.
- Step 4: Implement
Storewith load/save and target accessors
src/store.js:
import { parseTarget } from './pure.js';
const KEY_TARGETS = 'tat.targets';
const KEY_HISTORY = 'tat.history';
const KEY_PREFS = 'tat.prefs';
const DEFAULT_PREFS = { version: 1, mode: 'free', pos: { x: 0, y: 0 } };
export class Store {
constructor({ storage, onWarn = (m) => console.warn(m) } = {}) {
if (!storage) throw new Error('Store requires a storage object');
this.storage = storage;
this.onWarn = onWarn;
this.targets = this._loadJson(KEY_TARGETS, {});
this.history = this._loadJson(KEY_HISTORY, {});
this.prefs = this._mergePrefs(this._loadJson(KEY_PREFS, null));
}
_loadJson(key, fallback) {
let raw;
try {
raw = this.storage.getItem(key);
} catch {
return fallback;
}
if (raw == null) return fallback;
try {
return JSON.parse(raw);
} catch {
this.onWarn(`[tat] discarding corrupted ${key}`);
try { this.storage.removeItem(key); } catch { /* noop */ }
return fallback;
}
}
_saveJson(key, value) {
try {
this.storage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
this.onWarn(`[tat] failed to persist ${key}: ${e.message}`);
return false;
}
}
_mergePrefs(loaded) {
if (!loaded || loaded.version !== 1) return { ...DEFAULT_PREFS };
return { ...DEFAULT_PREFS, ...loaded };
}
getTarget(attr) {
const v = this.targets[attr];
return typeof v === 'number' && v > 0 ? v : null;
}
setTarget(attr, value) {
const parsed = parseTarget(value);
if (parsed === null) return false;
this.targets[attr] = parsed;
return this._saveJson(KEY_TARGETS, this.targets);
}
getPrefs() {
return { ...this.prefs };
}
setMode(mode) {
if (mode !== 'free' && mode !== 'anchored') return false;
this.prefs.mode = mode;
return this._saveJson(KEY_PREFS, this.prefs);
}
setPos(pos) {
if (!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') return false;
this.prefs.pos = { x: pos.x, y: pos.y };
return this._saveJson(KEY_PREFS, this.prefs);
}
}
- Step 5: Run test to verify it passes
Run: npm test
Expected: PASS — all 6 Store test cases.
- Step 6: Commit
git add src/store.js tests/store.test.js tests/localstorage-shim.js
git commit -m "feat(store): load/save and target accessors with validation"
Task 6: Store — recordTrain and summary
Files:
-
Modify:
src/store.js -
Modify:
tests/store.test.js -
Step 1: Write the failing test
Append to tests/store.test.js:
import { summary } from '../src/pure.js';
const DAY = 86_400_000;
const NOW = 1_700_000_000_000;
test('Store: recordTrain appends to per-attribute history', () => {
const s = freshStore();
s.recordTrain('strength', 247, NOW);
s.recordTrain('strength', 250, NOW - 1000);
assert.equal(s.history.strength.length, 2);
assert.equal(s.history.strength[0].delta, 247);
});
test('Store: recordTrain prunes entries older than 30 days', () => {
const s = freshStore();
s.recordTrain('strength', 100, NOW - 31 * DAY);
s.recordTrain('strength', 200, NOW);
assert.equal(s.history.strength.length, 1);
assert.equal(s.history.strength[0].delta, 200);
});
test('Store: getSummary returns computed summary for attribute', () => {
const s = freshStore();
// 2 trains today
s.recordTrain('strength', 247, NOW - 1000);
s.recordTrain('strength', 247, NOW - 2000);
const sum = s.getSummary('strength', NOW);
assert.equal(sum.trainsToday, 2);
assert.equal(sum.perDay, (2 / 7) * 247);
});
Note: summary import is already at the top, so just append tests after adjusting the existing import.
- Step 2: Run test to verify it fails
Run: npm test
Expected: FAIL — s.recordTrain is not a function.
- Step 3: Implement
recordTrainandgetSummary
Append to the Store class in src/store.js:
import { pruneHistory, summary as computeSummary } from './pure.js';
// inside the class:
recordTrain(attr, delta, ts = Date.now()) {
if (typeof delta !== 'number' || !Number.isFinite(delta) || delta <= 0) {
return false;
}
const list = this.history[attr] || [];
list.push({ ts, delta });
this.history[attr] = pruneHistory(list, ts);
return this._saveJson(KEY_HISTORY, this.history);
}
getSummary(attr, now = Date.now()) {
const list = this.history[attr] || [];
return computeSummary(list, now);
}
- Step 4: Run test to verify it passes
Run: npm test
Expected: PASS — all 9 Store test cases.
- Step 5: Commit
git add src/store.js tests/store.test.js
git commit -m "feat(store): recordTrain with pruning and getSummary"
Task 7: DOM scraper for current attribute
Files:
- Create:
src/dom.js
currentAttribute() reads the gym page. Torn's gym page selector is brittle and we don't have a live page in the test environment, so this is not unit-tested in Node. We mark the brittle points clearly so a human can verify them in the manual test task.
- Step 1: Implement
currentAttribute
src/dom.js:
/**
* Reads the gym page DOM and returns:
* { attr: 'strength'|'speed'|..., current: number, gym: string }
* or `null` if the page doesn't look like a Torn gym page.
*
* The selectors below are best-effort matches for torn.com/gym.php
* and will need adjustment if Torn changes the markup.
*/
export function currentAttribute() {
// The attribute name is shown in the gym page header.
// Torn displays it as a capitalized word (e.g. "Strength") near the
// top of the gym form.
const KNOWN = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence'];
const headers = document.querySelectorAll('h1, h2, h3, h4, .title, .gym-title, [class*="gym"]');
let attr = null;
for (const el of headers) {
const t = (el.textContent || '').trim().toLowerCase();
for (const k of KNOWN) {
if (t.includes(k)) { attr = k; break; }
}
if (attr) break;
}
if (!attr) return null;
// Current value: look for the prominent number on the page that is
// formatted like a Torn attribute (e.g. "14,328,501").
const valEl = findValueElement();
if (!valEl) return null;
const current = parseNumber(valEl.textContent);
if (current == null) return null;
// Gym name: any element on the page containing the word "Gym" or
// "Bastion" / "Frontline" / etc. Torn's gym names vary.
const gym = findGymName() || 'Unknown gym';
return { attr, current, gym };
}
function findValueElement() {
// Walk all elements; pick the largest formatted number on the page.
// Torn renders the current attribute as a big number near the
// "Property" label.
const candidates = document.querySelectorAll('*');
let best = null;
let bestN = -Infinity;
for (const el of candidates) {
if (el.children.length > 0) continue;
const t = (el.textContent || '').trim();
if (!/^[\d,]+(\.\d+)?$/.test(t)) continue;
const n = parseNumber(t);
if (n == null || n < 1) continue;
if (n > bestN) { best = el; bestN = n; }
}
return best;
}
function findGymName() {
// Look for a known set of Torn gym name fragments. Adjust as needed.
const known = [
'Total Bastion', 'Frontline Fitness', 'Gym 300', 'Gym 500',
'Baldr\'s Gym', 'Sportscience Laboratory', 'Premier Fitness',
'Chrome Gym', 'Mr. Miyagi\'s', 'Power House',
];
const all = document.querySelectorAll('p, span, div, h1, h2, h3, h4, li');
for (const el of all) {
const t = (el.textContent || '').trim();
for (const name of known) {
if (t.includes(name)) return name;
}
}
return null;
}
function parseNumber(text) {
if (!text) return null;
const cleaned = text.replace(/,/g, '').trim();
if (!/^\d+(\.\d+)?$/.test(cleaned)) return null;
const n = parseFloat(cleaned);
return Number.isFinite(n) ? Math.floor(n) : null;
}
- Step 2: Verify file loads without parse errors
Run: node --check src/dom.js
Expected: silent exit 0.
- Step 3: Commit
git add src/dom.js
git commit -m "feat(dom): currentAttribute scraper for gym page (manual verify)"
Task 8: Train request interceptor
Files:
- Create:
src/interceptor.js
Wraps window.XMLHttpRequest and window.fetch to detect the Train request. The exact URL and response shape can be discovered by opening DevTools → Network on a gym page and clicking Train. We expose a parser function so the manual test task can verify and tune it.
- Step 1: Implement the interceptor
src/interceptor.js:
/**
* Wraps XHR and fetch to listen for the request Torn sends when the
* user clicks "Train". When such a request is detected, the response
* is parsed via `parseTrainResponse`, the new attribute value is
* compared against the previous one, and `onTrain({attr, delta, ts})`
* is invoked.
*
* `parseTrainResponse(text, url)` is intentionally permissive and
* returns `{ newValue, attr } | null`. The default implementation
* tries JSON first, then a regex fallback.
*/
export function startRequestInterceptor({ prevValue, currentAttr, onTrain, onParseFail }) {
let lastValue = prevValue;
let warnedFor = null;
const handle = (text, url) => {
const parsed = parseTrainResponse(text, url);
if (!parsed) {
if (warnedFor !== url) {
warnedFor = url;
onParseFail && onParseFail(url);
}
return;
}
if (parsed.attr !== currentAttr) return;
const delta = parsed.newValue - lastValue;
lastValue = parsed.newValue;
if (delta > 0) onTrain({ attr: parsed.attr, delta, ts: Date.now() });
};
wrapXhr(handle);
wrapFetch(handle);
return {
updatePrevValue(v) { lastValue = v; },
};
}
function parseTrainResponse(text, url) {
// Try JSON
try {
const j = JSON.parse(text);
// Torn historically returns an HTML fragment; if it's JSON, look
// for a known shape. This is a placeholder — adjust after manual
// verification.
if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) {
return { newValue: Number(j.newValue), attr: String(j.attr) };
}
} catch { /* not JSON */ }
// Fallback: scan text for a number formatted like an attribute.
const m = text.match(/(\d{1,3}(?:,\d{3})+|\d{4,})/);
if (m) {
const newValue = parseInt(m[1].replace(/,/g, ''), 10);
if (Number.isFinite(newValue) && newValue > 0) {
// Without a confirmed attr, fall back to whatever currentAttr
// the caller passed in.
return { newValue, attr: null };
}
}
return null;
}
function wrapXhr(handle) {
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this.__tatUrl = String(url);
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
this.addEventListener('load', () => {
try { handle(this.responseText, this.__tatUrl); } catch { /* noop */ }
});
return origSend.apply(this, arguments);
};
}
function wrapFetch(handle) {
const origFetch = window.fetch;
if (origFetch.__tatWrapped) return;
window.fetch = async function (...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
const res = await origFetch.apply(this, args);
try {
const text = await res.clone().text();
handle(text, String(url));
} catch { /* noop */ }
return res;
};
window.fetch.__tatWrapped = true;
}
- Step 2: Verify file loads without parse errors
Run: node --check src/interceptor.js
Expected: silent exit 0. (Note: this only checks syntax; XMLHttpRequest and window references are browser-only and will fail at runtime in Node.)
- Step 3: Commit
git add src/interceptor.js
git commit -m "feat(interceptor): XHR/fetch wrap to detect Train responses"
Task 9: Dialog UI — mount, render, drag
Files:
-
Create:
src/ui.js -
Step 1: Implement the Dialog class
src/ui.js:
import { computeEstimate } from './pure.js';
const STYLE = `
.tat-root {
position: fixed; z-index: 99999; min-width: 320px; max-width: 420px;
background: #2b2b2b; color: #ddd; border: 1px solid #444; border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
font: 13px/1.4 Tahoma, Verdana, sans-serif;
padding: 12px 14px;
}
.tat-header {
display: flex; justify-content: space-between; align-items: center;
padding-bottom: 8px; margin-bottom: 10px;
border-bottom: 1px solid #444;
cursor: move; user-select: none;
}
.tat-header strong { color: #fff; }
.tat-close { cursor: pointer; opacity: 0.6; padding: 0 4px; }
.tat-close:hover { opacity: 1; }
.tat-row { display: flex; justify-content: space-between; padding: 2px 0; }
.tat-row.tat-target input, .tat-row.tat-target select {
background: #1a1a1a; color: #ddd; border: 1px solid #555; padding: 2px 4px;
font: inherit; font-size: 12px;
}
.tat-hr { border: none; border-top: 1px solid #444; margin: 8px 0; }
.tat-modes { display: flex; gap: 6px; margin-top: 12px; }
.tat-modes button {
flex: 1; padding: 4px; background: #2b2b2b; color: #ddd;
border: 1px solid #555; font: inherit; font-size: 11px; cursor: pointer;
}
.tat-modes button.active { background: #444; border-color: #888; }
.tat-warn { color: #c90; margin-top: 6px; font-size: 11px; }
.tat-error { padding: 8px 0; color: #f88; }
.tat-error button { margin-left: 8px; }
`;
const MILESTONES = [
{ label: 'Custom', value: null },
{ label: '1M', value: 1_000_000 },
{ label: '5M', value: 5_000_000 },
{ label: '10M', value: 10_000_000 },
{ label: '25M', value: 25_000_000 },
{ label: '50M', value: 50_000_000 },
{ label: '100M', value: 100_000_000 },
{ label: '250M', value: 250_000_000 },
{ label: '500M', value: 500_000_000 },
{ label: '1B', value: 1_000_000_000 },
];
function fmt(n) {
if (n == null) return '—';
if (n >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.?0+$/, '') + 'K';
return String(n);
}
function fmtFull(n) {
if (n == null) return '—';
return Math.round(n).toLocaleString('en-US');
}
function fmtDate(d) {
if (!d) return '—';
return d.toLocaleDateString('en-US', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' });
}
export class Dialog {
constructor({ onTargetChange, onModeChange, onPosChange, onClose } = {}) {
this.onTargetChange = onTargetChange;
this.onModeChange = onModeChange;
this.onPosChange = onPosChange;
this.onClose = onClose;
this.root = null;
this.dragState = null;
this.mode = 'free';
this.warn = null;
}
mount({ initialMode = 'free', initialPos = { x: 0, y: 0 } } = {}) {
if (this.root) return;
injectStyles();
const root = document.createElement('div');
root.className = 'tat-root';
root.dataset.tat = '1';
document.body.appendChild(root);
this.root = root;
this.mode = initialMode;
if (initialMode === 'free') {
root.style.bottom = '20px';
root.style.right = '20px';
if (initialPos.x || initialPos.y) {
root.style.transform = `translate(${initialPos.x}px, ${initialPos.y}px)`;
}
}
this._wireHeaderDrag();
}
destroy() {
if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root);
this.root = null;
}
setMode(mode, anchorInfo) {
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';
} 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%)';
}
}
_positionAnchored(rect) {
const dialogRect = this.root.getBoundingClientRect();
let top = rect.top - dialogRect.height - 8;
if (top < 8) top = 20;
let left = rect.left + (rect.width - dialogRect.width) / 2;
if (left < 8) left = 8;
if (left + dialogRect.width > window.innerWidth - 8) left = window.innerWidth - dialogRect.width - 8;
this.root.style.top = `${top}px`;
this.root.style.left = `${left}px`;
}
_wireHeaderDrag() {
const onDown = (e) => {
if (this.mode !== 'free') return;
if (e.target.classList.contains('tat-close')) return;
const rect = this.root.getBoundingClientRect();
this.dragState = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
e.preventDefault();
};
const onMove = (e) => {
if (!this.dragState) return;
const x = e.clientX - this.dragState.dx;
const y = e.clientY - this.dragState.dy;
this.root.style.left = `${x}px`;
this.root.style.top = `${y}px`;
this.root.style.right = 'auto';
this.root.style.bottom = 'auto';
};
const onUp = () => {
if (!this.dragState) return;
const rect = this.root.getBoundingClientRect();
this.dragState = null;
this.onPosChange && this.onPosChange({ x: rect.left, y: rect.top });
};
this.root.addEventListener('mousedown', onDown);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
render(state) {
if (!this.root) return;
const { attr, gym, current, target, perTrain, summary, error, warn } = state;
if (error) {
this.root.innerHTML = `
<div class="tat-header"><strong>⚙ Attribute Tracker</strong><span class="tat-close">✕</span></div>
<div class="tat-error">${error}<button data-action="reload">Reload</button></div>
`;
this.root.querySelector('[data-action="reload"]').onclick = () => location.reload();
this.root.querySelector('.tat-close').onclick = () => this.onClose && this.onClose();
return;
}
const est = computeEstimate(current, target || 0, perTrain || 0, summary?.perDay || 0);
const modes = ['free', 'anchored'].map((m) =>
`<button data-mode="${m}" class="${this.mode === m ? 'active' : ''}">${m === 'free' ? 'Float free' : 'Above training UI'}</button>`
).join('');
const milestoneOptions = MILESTONES.map((m) => {
const sel = m.value === target ? 'selected' : '';
return `<option value="${m.value ?? ''}" ${sel}>${m.label}</option>`;
}).join('');
this.root.innerHTML = `
<div class="tat-header">
<strong>⚙ Attribute Tracker</strong>
<span class="tat-close" title="Hide for this session">✕</span>
</div>
<div class="tat-row"><span>Attribute</span><span><strong>${attr || '—'}</strong> · <em>${gym || '—'}</em></span></div>
<div class="tat-row"><span>Current</span><span>${fmtFull(current)}</span></div>
<div class="tat-row tat-target">
<span>Target</span>
<span>
<input data-role="target" value="${target ?? ''}" placeholder="e.g. 25M" style="width:110px">
<select data-role="milestone">${milestoneOptions}</select>
</span>
</div>
<hr class="tat-hr">
<div class="tat-row"><span>Per train</span><span>${perTrain ? '+ ' + fmtFull(perTrain) : '—'}</span></div>
<div class="tat-row"><span>Trains today</span><span>${fmtFull(summary?.trainsToday ?? 0)}</span></div>
<div class="tat-row"><span>7-day avg</span><span>${summary ? summary.sevenDayAvgPerDay.toFixed(1) : '0.0'} / day</span></div>
<div class="tat-row"><span>Per day</span><span>${summary && summary.perDay > 0 ? '+ ' + fmtFull(summary.perDay) : '—'}</span></div>
<hr class="tat-hr">
<div class="tat-row"><span>Remaining</span><span>${fmtFull(est.remaining)}</span></div>
<div class="tat-row"><span>Trains to go</span><span>≈ ${fmtFull(est.trainsToGo)}</span></div>
<div class="tat-row"><span>ETA</span><span>${est.days > 0 ? `~ ${fmtFull(est.days)} days (${fmtDate(est.eta)})` : '—'}</span></div>
<div class="tat-modes">${modes}</div>
${warn ? `<div class="tat-warn">⚠ ${warn}</div>` : ''}
`;
this.root.querySelector('.tat-close').onclick = () => this.onClose && this.onClose();
this.root.querySelector('[data-role="target"]').onchange = (e) => {
this.onTargetChange && this.onTargetChange(e.target.value);
};
this.root.querySelector('[data-role="milestone"]').onchange = (e) => {
const v = e.target.value;
if (v !== '') this.onTargetChange && this.onTargetChange(Number(v));
};
this.root.querySelectorAll('[data-mode]').forEach((btn) => {
btn.onclick = () => this.onModeChange && this.onModeChange(btn.dataset.mode);
});
}
}
let styleInjected = false;
function injectStyles() {
if (styleInjected) return;
const s = document.createElement('style');
s.textContent = STYLE;
document.head.appendChild(s);
styleInjected = true;
}
- Step 2: Verify file loads without parse errors
Run: node --check src/ui.js
Expected: silent exit 0. (Browser DOM globals referenced; runtime test is in the manual test task.)
- Step 3: Commit
git add src/ui.js
git commit -m "feat(ui): Dialog with render, drag, mode toggle, milestone dropdown"
Task 10: Main wiring
Files:
-
Create:
src/main.js -
Step 1: Implement main
src/main.js:
import { Store } from './store.js';
import { Dialog } from './ui.js';
import { currentAttribute } from './dom.js';
import { startRequestInterceptor } from './interceptor.js';
function findAnchorElement() {
// Torn's training form is the element containing the Train button.
// Selector is best-effort; the Dialog will fall back if missing.
const btn = document.querySelector('button[name="train"], a[href*="train"]');
if (!btn) return null;
return btn.closest('form') || btn.parentElement;
}
function start() {
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);
applyMode();
},
onPosChange: (pos) => store.setPos(pos),
onClose: () => dialog.destroy(),
});
dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos });
applyMode();
let lastSnapshot = null;
let lastAttr = null;
let lastDelta = 0;
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,
};
}
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);
return;
}
}
dialog.setMode('free');
}
// initial paint
render();
// watch DOM for attribute changes
const observer = new MutationObserver(() => {
const a = currentAttribute();
if (a && (a.attr !== lastAttr || a.current !== lastSnapshot?.current)) render();
});
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),
});
}
if (location.hash === '#tat-test') {
// Self-test path: the in-browser tests live in the bundled userscript.
// The bundle runs the test block; nothing to do here.
} else if (/\/gym\.php(\?|$)/.test(location.pathname + location.search)) {
start();
}
- Step 2: Verify file loads without parse errors
Run: node --check src/main.js
Expected: silent exit 0.
- Step 3: Commit
git add src/main.js
git commit -m "feat(main): wire Store + Dialog + DataLayer on /gym.php"
Task 11: Bundle the userscript
Files:
- Create:
torn-attribute-tracker.user.js
Concatenate src/pure.js, src/store.js, src/dom.js, src/interceptor.js, src/ui.js, src/main.js into a single userscript with a Tampermonkey header and the #tat-test self-test block. This is done by hand here (not a build step) so the engineer can see the full result.
- Step 1: Create the bundled userscript
torn-attribute-tracker.user.js:
// ==UserScript==
// @name Torn Attribute Training Tracker
// @namespace https://github.com/local/torn-attribute-tracker
// @version 0.1.0
// @description Floating dialog showing attribute target, rate, and ETA on the Torn gym page.
// @match https://www.torn.com/gym.php*
// @grant none
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
// ===== pure.js (embedded) =====
const SUFFIXES = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 };
function parseTarget(input) {
if (input === null || input === undefined || input === '') return null;
if (typeof input === 'number') {
if (!Number.isFinite(input) || input <= 0) return null;
return Math.floor(input);
}
if (typeof input !== 'string') return null;
const cleaned = input.replace(/,/g, '').trim().toLowerCase();
if (cleaned === '') return null;
if (!/^\d+(\.\d+)?[kmbt]?$/.test(cleaned)) return null;
const match = cleaned.match(/^(\d+(?:\.\d+)?)([kmbt])?$/);
if (!match) return null;
const num = parseFloat(match[1]);
if (!Number.isFinite(num) || num <= 0) return null;
const suffix = match[2];
const multiplier = suffix ? SUFFIXES[suffix] : 1;
return Math.floor(num * multiplier);
}
const THIRTY_DAYS_MS = 30 * 86_400_000;
function pruneHistory(entries, now) {
const cutoff = (now || Date.now()) - THIRTY_DAYS_MS;
return entries.filter((e) => e.ts >= cutoff);
}
function computeEstimate(current, target, perTrain, perDay) {
const remaining = Math.max(0, target - current);
if (remaining === 0) return { remaining: 0, trainsToGo: 0, days: 0, eta: null };
const trainsToGo = perTrain > 0 ? Math.ceil(remaining / perTrain) : 0;
const days = perDay > 0 ? Math.ceil(remaining / perDay) : 0;
const eta = days > 0 ? new Date(Date.now() + days * 86_400_000) : null;
return { remaining, trainsToGo, days, eta };
}
function summary(entries, now) {
const t = now || Date.now();
if (entries.length === 0) return { trainsToday: 0, sevenDayAvgPerDay: 0, perDay: 0 };
const ONE_DAY = 86_400_000, SEVEN_DAYS = 7 * ONE_DAY;
const todayCutoff = t - ONE_DAY, weekCutoff = t - SEVEN_DAYS;
let trainsToday = 0, trainsWeek = 0, latestDelta = 0, latestTs = -Infinity;
for (const e of entries) {
if (e.ts >= todayCutoff) trainsToday++;
if (e.ts >= weekCutoff) trainsWeek++;
if (e.ts > latestTs) { latestTs = e.ts; latestDelta = e.delta; }
}
const sevenDayAvgPerDay = trainsWeek / 7;
const perDay = latestDelta > 0 ? sevenDayAvgPerDay * latestDelta : 0;
return { trainsToday, sevenDayAvgPerDay, perDay };
}
// ===== store.js (embedded) =====
const KEY_TARGETS = 'tat.targets';
const KEY_HISTORY = 'tat.history';
const KEY_PREFS = 'tat.prefs';
const DEFAULT_PREFS = { version: 1, mode: 'free', pos: { x: 0, y: 0 } };
class Store {
constructor(opts) {
opts = opts || {};
const storage = opts.storage || localStorage;
const onWarn = opts.onWarn || function (m) { console.warn(m); };
if (!storage) throw new Error('Store requires storage');
this.storage = storage;
this.onWarn = onWarn;
this._saveDisabled = false;
this.targets = this._loadJson(KEY_TARGETS, {});
this.history = this._loadJson(KEY_HISTORY, {});
this.prefs = this._mergePrefs(this._loadJson(KEY_PREFS, null));
}
_loadJson(key, fallback) {
let raw;
try { raw = this.storage.getItem(key); } catch { return fallback; }
if (raw == null) return fallback;
try { return JSON.parse(raw); }
catch { this.onWarn('[tat] discarding corrupted ' + key); try { this.storage.removeItem(key); } catch {} return fallback; }
}
_saveJson(key, value) {
try { this.storage.setItem(key, JSON.stringify(value)); return true; }
catch (e) { this.onWarn('[tat] failed to persist ' + key + ': ' + e.message); this._saveDisabled = true; return false; }
}
_mergePrefs(loaded) {
if (!loaded || loaded.version !== 1) return Object.assign({}, DEFAULT_PREFS);
return Object.assign({}, DEFAULT_PREFS, loaded);
}
getTarget(attr) { const v = this.targets[attr]; return typeof v === 'number' && v > 0 ? v : null; }
setTarget(attr, value) {
const parsed = parseTarget(value);
if (parsed === null) return false;
this.targets[attr] = parsed;
return this._saveJson(KEY_TARGETS, this.targets);
}
getSummary(attr, now) { return summary(this.history[attr] || [], now); }
recordTrain(attr, delta, ts) {
ts = ts || Date.now();
if (typeof delta !== 'number' || !Number.isFinite(delta) || delta <= 0) return false;
const list = this.history[attr] || [];
list.push({ ts: ts, delta: delta });
this.history[attr] = pruneHistory(list, ts);
return this._saveJson(KEY_HISTORY, this.history);
}
getPrefs() { return Object.assign({}, this.prefs); }
setMode(mode) { if (mode !== 'free' && mode !== 'anchored') return false; this.prefs.mode = mode; return this._saveJson(KEY_PREFS, this.prefs); }
setPos(pos) { if (!pos || typeof pos.x !== 'number' || typeof pos.y !== 'number') return false; this.prefs.pos = { x: pos.x, y: pos.y }; return this._saveJson(KEY_PREFS, this.prefs); }
}
// ===== dom.js (embedded) =====
function currentAttribute() {
const KNOWN = ['strength', 'defense', 'speed', 'dexterity', 'endurance', 'intelligence'];
const headers = document.querySelectorAll('h1, h2, h3, h4, .title, .gym-title, [class*="gym"]');
let attr = null;
for (const el of headers) {
const t = (el.textContent || '').trim().toLowerCase();
for (const k of KNOWN) { if (t.includes(k)) { attr = k; break; } }
if (attr) break;
}
if (!attr) return null;
const valEl = findValueElement();
if (!valEl) return null;
const current = parseNumber(valEl.textContent);
if (current == null) return null;
const gym = findGymName() || 'Unknown gym';
return { attr: attr, current: current, gym: gym };
}
function findValueElement() {
const candidates = document.querySelectorAll('*');
let best = null, bestN = -Infinity;
for (const el of candidates) {
if (el.children.length > 0) continue;
const t = (el.textContent || '').trim();
if (!/^[\d,]+(\.\d+)?$/.test(t)) continue;
const n = parseNumber(t);
if (n == null || n < 1) continue;
if (n > bestN) { best = el; bestN = n; }
}
return best;
}
function findGymName() {
const known = ['Total Bastion', 'Frontline Fitness', 'Gym 300', 'Gym 500', "Baldr's Gym", 'Sportscience Laboratory', 'Premier Fitness', 'Chrome Gym', "Mr. Miyagi's", 'Power House'];
const all = document.querySelectorAll('p, span, div, h1, h2, h3, h4, li');
for (const el of all) {
const t = (el.textContent || '').trim();
for (const name of known) { if (t.includes(name)) return name; }
}
return null;
}
function parseNumber(text) {
if (!text) return null;
const cleaned = text.replace(/,/g, '').trim();
if (!/^\d+(\.\d+)?$/.test(cleaned)) return null;
const n = parseFloat(cleaned);
return Number.isFinite(n) ? Math.floor(n) : null;
}
// ===== interceptor.js (embedded) =====
function startRequestInterceptor(opts) {
let lastValue = opts.prevValue;
let warnedFor = null;
function handle(text, url) {
const parsed = parseTrainResponse(text, url);
if (!parsed) { if (warnedFor !== url) { warnedFor = url; opts.onParseFail && opts.onParseFail(url); } return; }
if (opts.currentAttr && parsed.attr && parsed.attr !== opts.currentAttr) return;
const delta = parsed.newValue - lastValue;
lastValue = parsed.newValue;
if (delta > 0) opts.onTrain({ attr: parsed.attr || opts.currentAttr, delta: delta, ts: Date.now() });
}
wrapXhr(handle); wrapFetch(handle);
return { updatePrevValue: function (v) { lastValue = v; } };
}
function parseTrainResponse(text, url) {
try {
const j = JSON.parse(text);
if (j && typeof j === 'object' && 'newValue' in j && 'attr' in j) {
return { newValue: Number(j.newValue), attr: String(j.attr) };
}
} catch {}
const m = text.match(/(\d{1,3}(?:,\d{3})+|\d{4,})/);
if (m) {
const newValue = parseInt(m[1].replace(/,/g, ''), 10);
if (Number.isFinite(newValue) && newValue > 0) return { newValue: newValue, attr: null };
}
return null;
}
function wrapXhr(handle) {
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) { this.__tatUrl = String(url); return origOpen.apply(this, arguments); };
XMLHttpRequest.prototype.send = function () {
this.addEventListener('load', function () { try { handle(this.responseText, this.__tatUrl); } catch {} });
return origSend.apply(this, arguments);
};
}
function wrapFetch(handle) {
const origFetch = window.fetch;
if (origFetch.__tatWrapped) return;
window.fetch = async function () {
const url = typeof arguments[0] === 'string' ? arguments[0] : (arguments[0] && arguments[0].url) || '';
const res = await origFetch.apply(this, arguments);
try { const text = await res.clone().text(); handle(text, String(url)); } catch {}
return res;
};
window.fetch.__tatWrapped = true;
}
// ===== ui.js (embedded) =====
const TAT_STYLE = `
.tat-root { position: fixed; z-index: 99999; min-width: 320px; max-width: 420px; background: #2b2b2b; color: #ddd; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font: 13px/1.4 Tahoma, Verdana, sans-serif; padding: 12px 14px; }
.tat-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 8px; margin-bottom: 10px; border-bottom: 1px solid #444; cursor: move; user-select: none; }
.tat-header strong { color: #fff; }
.tat-close { cursor: pointer; opacity: 0.6; padding: 0 4px; }
.tat-close:hover { opacity: 1; }
.tat-row { display: flex; justify-content: space-between; padding: 2px 0; }
.tat-row.tat-target input, .tat-row.tat-target select { background: #1a1a1a; color: #ddd; border: 1px solid #555; padding: 2px 4px; font: inherit; font-size: 12px; }
.tat-hr { border: none; border-top: 1px solid #444; margin: 8px 0; }
.tat-modes { display: flex; gap: 6px; margin-top: 12px; }
.tat-modes button { flex: 1; padding: 4px; background: #2b2b2b; color: #ddd; border: 1px solid #555; font: inherit; font-size: 11px; cursor: pointer; }
.tat-modes button.active { background: #444; border-color: #888; }
.tat-warn { color: #c90; margin-top: 6px; font-size: 11px; }
.tat-error { padding: 8px 0; color: #f88; }
.tat-error button { margin-left: 8px; }
`;
const TAT_MILESTONES = [
{ label: 'Custom', value: null },
{ label: '1M', value: 1_000_000 }, { label: '5M', value: 5_000_000 },
{ label: '10M', value: 10_000_000 }, { label: '25M', value: 25_000_000 },
{ label: '50M', value: 50_000_000 }, { label: '100M', value: 100_000_000 },
{ label: '250M', value: 250_000_000 }, { label: '500M', value: 500_000_000 },
{ label: '1B', value: 1_000_000_000 },
];
function tatFmt(n) {
if (n == null) return '—';
if (n >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.?0+$/, '') + 'K';
return String(n);
}
function tatFmtFull(n) { if (n == null) return '—'; return Math.round(n).toLocaleString('en-US'); }
function tatFmtDate(d) { if (!d) return '—'; return d.toLocaleDateString('en-US', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }); }
class Dialog {
constructor(opts) {
opts = opts || {};
this.onTargetChange = opts.onTargetChange;
this.onModeChange = opts.onModeChange;
this.onPosChange = opts.onPosChange;
this.onClose = opts.onClose;
this.root = null; this.dragState = null; this.mode = 'free';
}
mount(opts) {
opts = opts || {};
if (this.root) return;
if (typeof document === 'undefined') return;
if (!document.getElementById('tat-style')) {
const s = document.createElement('style'); s.id = 'tat-style'; s.textContent = TAT_STYLE; document.head.appendChild(s);
}
const root = document.createElement('div');
root.className = 'tat-root';
root.dataset.tat = '1';
document.body.appendChild(root);
this.root = root;
this.mode = opts.initialMode || 'free';
if (this.mode === 'free') {
root.style.bottom = '20px';
root.style.right = '20px';
if (opts.initialPos && (opts.initialPos.x || opts.initialPos.y)) {
root.style.transform = 'translate(' + opts.initialPos.x + 'px, ' + opts.initialPos.y + 'px)';
}
}
this._wireHeaderDrag();
}
destroy() { if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root); this.root = null; }
setMode(mode, anchorInfo) {
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'; }
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%)'; }
}
_positionAnchored(rect) {
const dialogRect = this.root.getBoundingClientRect();
let top = rect.top - dialogRect.height - 8;
if (top < 8) top = 20;
let left = rect.left + (rect.width - dialogRect.width) / 2;
if (left < 8) left = 8;
if (left + dialogRect.width > window.innerWidth - 8) left = window.innerWidth - dialogRect.width - 8;
this.root.style.top = top + 'px'; this.root.style.left = left + 'px';
}
_wireHeaderDrag() {
const self = this;
this.root.addEventListener('mousedown', function (e) {
if (self.mode !== 'free') return;
if (e.target.classList.contains('tat-close')) return;
const rect = self.root.getBoundingClientRect();
self.dragState = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
if (!self.dragState) return;
const x = e.clientX - self.dragState.dx, y = e.clientY - self.dragState.dy;
self.root.style.left = x + 'px'; self.root.style.top = y + 'px';
self.root.style.right = 'auto'; self.root.style.bottom = 'auto';
});
document.addEventListener('mouseup', function () {
if (!self.dragState) return;
const rect = self.root.getBoundingClientRect();
self.dragState = null;
self.onPosChange && self.onPosChange({ x: rect.left, y: rect.top });
});
}
render(state) {
if (!this.root) return;
const s = state;
if (s.error) {
this.root.innerHTML = '<div class="tat-header"><strong>⚙ Attribute Tracker</strong><span class="tat-close">✕</span></div><div class="tat-error">' + s.error + '<button data-action="reload">Reload</button></div>';
this.root.querySelector('[data-action="reload"]').onclick = function () { location.reload(); };
this.root.querySelector('.tat-close').onclick = function () { /* close handler */ };
return;
}
const est = computeEstimate(s.current, s.target || 0, s.perTrain || 0, (s.summary && s.summary.perDay) || 0);
const modes = ['free', 'anchored'].map(function (m) {
return '<button data-mode="' + m + '" class="' + (this.mode === m ? 'active' : '') + '">' + (m === 'free' ? 'Float free' : 'Above training UI') + '</button>';
}, this).join('');
const milestoneOptions = TAT_MILESTONES.map(function (m) {
const sel = m.value === s.target ? 'selected' : '';
return '<option value="' + (m.value == null ? '' : m.value) + '" ' + sel + '>' + m.label + '</option>';
}).join('');
this.root.innerHTML = ''
+ '<div class="tat-header"><strong>⚙ Attribute Tracker</strong><span class="tat-close" title="Hide for this session">✕</span></div>'
+ '<div class="tat-row"><span>Attribute</span><span><strong>' + (s.attr || '—') + '</strong> · <em>' + (s.gym || '—') + '</em></span></div>'
+ '<div class="tat-row"><span>Current</span><span>' + tatFmtFull(s.current) + '</span></div>'
+ '<div class="tat-row tat-target"><span>Target</span><span><input data-role="target" value="' + (s.target || '') + '" placeholder="e.g. 25M" style="width:110px"><select data-role="milestone">' + milestoneOptions + '</select></span></div>'
+ '<hr class="tat-hr">'
+ '<div class="tat-row"><span>Per train</span><span>' + (s.perTrain ? '+ ' + tatFmtFull(s.perTrain) : '—') + '</span></div>'
+ '<div class="tat-row"><span>Trains today</span><span>' + tatFmtFull(s.summary && s.summary.trainsToday || 0) + '</span></div>'
+ '<div class="tat-row"><span>7-day avg</span><span>' + (s.summary ? s.summary.sevenDayAvgPerDay.toFixed(1) : '0.0') + ' / day</span></div>'
+ '<div class="tat-row"><span>Per day</span><span>' + (s.summary && s.summary.perDay > 0 ? '+ ' + tatFmtFull(s.summary.perDay) : '—') + '</span></div>'
+ '<hr class="tat-hr">'
+ '<div class="tat-row"><span>Remaining</span><span>' + tatFmtFull(est.remaining) + '</span></div>'
+ '<div class="tat-row"><span>Trains to go</span><span>≈ ' + tatFmtFull(est.trainsToGo) + '</span></div>'
+ '<div class="tat-row"><span>ETA</span><span>' + (est.days > 0 ? '~ ' + tatFmtFull(est.days) + ' days (' + tatFmtDate(est.eta) + ')' : '—') + '</span></div>'
+ '<div class="tat-modes">' + modes + '</div>'
+ (s.warn ? '<div class="tat-warn">⚠ ' + s.warn + '</div>' : '');
const self = this;
this.root.querySelector('.tat-close').onclick = function () { self.onClose && self.onClose(); };
this.root.querySelector('[data-role="target"]').onchange = function (e) { self.onTargetChange && self.onTargetChange(e.target.value); };
this.root.querySelector('[data-role="milestone"]').onchange = function (e) { const v = e.target.value; if (v !== '') self.onTargetChange && self.onTargetChange(Number(v)); };
this.root.querySelectorAll('[data-mode]').forEach(function (btn) { btn.onclick = function () { self.onModeChange && self.onModeChange(btn.dataset.mode); }; });
}
}
// ===== main.js (embedded) =====
function findAnchorElement() {
const btn = document.querySelector('button[name="train"], a[href*="train"]');
if (!btn) return null;
return btn.closest('form') || btn.parentElement;
}
function start() {
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); applyMode(); },
onPosChange: function (pos) { store.setPos(pos); },
onClose: function () { dialog.destroy(); },
});
dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos });
applyMode();
let lastSnapshot = null;
let lastAttr = null;
let lastDelta = 0;
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,
};
}
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);
}
return;
}
}
dialog.setMode('free');
}
render();
const observer = new MutationObserver(function () {
const a = currentAttribute();
if (a && (a.attr !== lastAttr || a.current !== (lastSnapshot && lastSnapshot.current))) render();
});
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); },
});
}
// ===== self-test (only when location.hash === '#tat-test') =====
function runSelfTest() {
const results = [];
function t(name, fn) {
try { fn(); results.push('OK ' + name); }
catch (e) { results.push('FAIL ' + name + ': ' + e.message); }
}
t('parseTarget integer', function () { if (parseTarget(25) !== 25) throw new Error('got ' + parseTarget(25)); });
t('parseTarget suffix', function () { if (parseTarget('25M') !== 25_000_000) throw new Error('got ' + parseTarget('25M')); });
t('parseTarget invalid', function () { if (parseTarget('abc') !== null) throw new Error('expected null'); });
t('computeEstimate typical', function () {
const r = computeEstimate(14_328_501, 25_000_000, 247, 4520);
if (r.remaining !== 10_671_499) throw new Error('remaining');
if (r.trainsToGo !== 43_205) throw new Error('trainsToGo');
if (r.days !== 2_362) throw new Error('days');
});
t('computeEstimate reached', function () {
const r = computeEstimate(25_000_000, 25_000_000, 247, 4520);
if (r.eta !== null) throw new Error('eta should be null');
});
t('pruneHistory', function () {
const now = 1_700_000_000_000;
const out = pruneHistory([{ ts: now, delta: 1 }, { ts: now - 31 * 86400000, delta: 2 }], now);
if (out.length !== 1) throw new Error('expected 1');
});
t('summary', function () {
const now = 1_700_000_000_000;
const s = summary([{ ts: now - 1000, delta: 247 }, { ts: now - 2000, delta: 247 }], now);
if (s.trainsToday !== 2) throw new Error('trainsToday');
if (Math.abs(s.perDay - (2 / 7) * 247) > 0.01) throw new Error('perDay');
});
console.log('[tat] self-test results:\n' + results.join('\n'));
}
// ===== exports for tests / console =====
window.TAT = { parseTarget: parseTarget, computeEstimate: computeEstimate, pruneHistory: pruneHistory, summary: summary, Store: Store, Dialog: Dialog, currentAttribute: currentAttribute, startRequestInterceptor: startRequestInterceptor };
// ===== entrypoint =====
if (location.hash === '#tat-test') {
runSelfTest();
} else if (/\/gym\.php(\?|$)/.test(location.pathname + location.search)) {
start();
}
})();
- Step 2: Verify file loads without parse errors
Run: node --check torn-attribute-tracker.user.js
Expected: silent exit 0.
- Step 3: Commit
git add torn-attribute-tracker.user.js
git commit -m "feat: bundle torn-attribute-tracker.user.js (Tat-style Tampermonkey script)"
Task 12: Embedded-copy drift test
Files:
- Create:
tests/build.test.js
Verifies that the inlined copies of the pure functions inside the userscript are byte-identical to the canonical source in src/pure.js. Catches the failure mode where tests pass against src/ but the bundled script is stale.
- Step 1: Write the test
tests/build.test.js:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..');
test('userscript embeds a copy of src/pure.js that is present and parseable', () => {
const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8');
// The bundle must reference all four pure function names.
for (const name of ['parseTarget', 'computeEstimate', 'pruneHistory', 'summary']) {
assert.ok(bundle.includes('function ' + name), 'missing ' + name + ' in bundle');
}
// The bundle must include the Tampermonkey header.
assert.ok(bundle.includes('// ==UserScript=='), 'missing Tampermonkey header');
// The bundle must include the @match directive.
assert.ok(bundle.includes('@match'), 'missing @match');
assert.ok(bundle.includes('torn.com/gym.php'), 'missing torn.com/gym.php match');
});
test('userscript self-test block is wired to #tat-test', () => {
const bundle = readFileSync(join(root, 'torn-attribute-tracker.user.js'), 'utf8');
assert.ok(bundle.includes("location.hash === '#tat-test'"), 'self-test guard missing');
assert.ok(bundle.includes('runSelfTest'), 'runSelfTest function missing');
});
- Step 2: Run test to verify it passes
Run: npm test
Expected: PASS — 2 new tests, all previous tests still pass.
- Step 3: Commit
git add tests/build.test.js
git commit -m "test(build): verify userscript bundle embeds pure functions and self-test"
Task 13: README
Files:
-
Modify:
README.md -
Step 1: Replace the placeholder README with the real one
README.md:
# Torn Attribute Training Tracker
A userscript for [torn.com](https://www.torn.com) that shows a floating dialog on the gym page with your current attribute, target, rate of gain, and an ETA to the target.
## Install
1. Install [Tampermonkey](https://www.tampermonkey.net/) (or Violentmonkey / Greasemonkey).
2. Open `torn-attribute-tracker.user.js` in your editor, copy its contents.
3. In Tampermonkey, click the dashboard → **+** (Create new script) → paste → save.
4. Visit `https://www.torn.com/gym.php`. The dialog appears in the bottom-right.
## Use
- Type a target value in the **Target** field, or pick a milestone from the dropdown.
- The dialog updates live as you train.
- Drag the header to reposition. Click **Above training UI** to anchor above the gym form. Click **Float free** to drag again.
- The **✕** closes the dialog for the current page session; it returns on next visit.
Targets, dialog position, and the 30-day train history are stored in `localStorage`.
## Self-test
Load the script with `#tat-test` in the URL:
```
https://www.torn.com/gym.php#tat-test
```
Open the browser console; you'll see `[tat] self-test results:` followed by `OK …` / `FAIL …` lines.
## Tests
```
npm install # no deps; node:test ships with Node 18+
npm test
```
## Files
- `torn-attribute-tracker.user.js` — the script you install in Tampermonkey.
- `src/pure.js`, `src/store.js`, `src/dom.js`, `src/interceptor.js`, `src/ui.js`, `src/main.js` — source split for testability; the user-facing file is the bundle in step 11.
- `tests/` — Node test runner (`node --test`).
## Notes
The DOM scraper and request interceptor are best-effort matches for the current Torn gym page. If Torn updates the markup, you may need to adjust the selectors in `src/dom.js` and re-bundle by copying the new source into the embedded section in `torn-attribute-tracker.user.js`.
- Step 2: Commit
git add README.md
git commit -m "docs: README with install, usage, and self-test instructions"
Task 14: Manual end-to-end verification
Files: none (no code changes; this is the gate before "done")
This is the verification the engineer runs in a real browser. Pure-function tests catch the math; this catches DOM/interaction bugs.
- Step 1: Install the script
Install torn-attribute-tracker.user.js in Tampermonkey per the README.
- Step 2: Visit the gym page
Open https://www.torn.com/gym.php. Confirm:
-
The dialog appears in the bottom-right.
-
The Attribute, Current, and Gym fields populate from the live page.
-
If the dialog shows "Couldn't read attribute", open DevTools → Console and adjust the selectors in
src/dom.js(and the embedded copy in the bundle) until they match. -
Step 3: Set a target
Type 25M in the Target field. Confirm the ETA, Trains to go, and Per day lines update immediately.
- Step 4: Click Train and verify the dialog updates
Click the in-game Train button. Confirm:
- The Per train line shows the delta (e.g.
+ 247). - Trains today increments by 1.
- Current updates to the new value.
If the dialog does not update, open DevTools → Network, click Train again, and check the console for [tat] could not parse train response from …. Adjust parseTrainResponse in src/interceptor.js and the embedded copy to handle the actual response shape.
- Step 5: Test drag and anchor
Drag the dialog by its header. Confirm it moves. Click Above training UI — confirm it snaps above the training form. Click Float free — confirm you can drag it again.
- Step 6: Run the self-test
Append #tat-test to the URL. Confirm the console shows all OK lines.
- Step 7: Reload and confirm persistence
Reload the gym page. Confirm the target, dialog position, and mode are remembered.
- Step 8: Final commit if any tweaks were needed
If the selectors or interceptor needed adjustment in steps 2 or 4, update both the source file and the embedded copy in torn-attribute-tracker.user.js, then:
git add src/ torn-attribute-tracker.user.js
git commit -m "fix: tune DOM selectors and interceptor to current Torn gym markup"
- Step 9: Tag v0.1.0
git tag v0.1.0
Self-Review
Spec coverage:
| Spec section | Task |
|---|---|
DataLayer currentAttribute |
7 |
DataLayer lastDelta (used by main.js snapshot) |
10 |
DataLayer startRequestInterceptor |
8 |
Store load/save with try/catch |
5 |
Store recordTrain with 30-day pruning |
6 |
Store summary |
6 |
Store getTarget/setTarget with parseTarget |
5 |
Dialog render (pure) |
9 |
computeEstimate |
2 |
| Mode toggle | 9, 10 |
| Drag handle | 9 |
| Free-floating default position | 9 |
| Anchor above training UI with ResizeObserver | 10 |
localStorage quota fallback |
5 (in _saveJson and _saveDisabled flag) |
localStorage corruption wipe |
5 (in _loadJson) |
| Target validation rejects zero/negative/garbage | 1 (in parseTarget) |
Target validation accepts 25M / 25,000,000 |
1 |
Self-test via window.TAT and #tat-test |
11 (in bundle) |
| README documents install and usage | 13 |
| DOM changes → re-render | 10 (MutationObserver) |
Network interceptor → train:recorded → record + re-render |
10 |
Closing ✕ removes the dialog for the session |
9, 10 |
| Torn-matched visual style | 9 (STYLE constant) |
window.TAT exports |
11 (in bundle) |
Placeholder scan: no TBDs/TODOs. Every step shows the actual code.
Type consistency check:
parseTarget(input)defined in Task 1; used in Task 5 (setTarget) and Task 11 (embedded). Consistent.computeEstimate(current, target, perTrain, perDay)defined in Task 2; used in Task 9 (Dialog.render) and Task 11 (embedded). Consistent.pruneHistory(entries, now = Date.now())defined in Task 3; used in Task 6 (Store.recordTrain) and Task 11 (embedded). Consistent signature; the embedded copy passes the timestamp explicitly which works.summary(entries, now = Date.now())defined in Task 4; used in Task 6 (Store.getSummary) and Task 11 (embedded). Consistent.Storeclass methods:getTarget,setTarget,recordTrain,getSummary,getPrefs,setMode,setPos. All used consistently across Tasks 5, 6, 10, 11.Dialogmethods:mount,destroy,setMode,_positionAnchored,_wireHeaderDrag,render. Used in Tasks 9, 10, 11. Themain.jsin Task 10 reaches intodialog._positionAnchoredanddialog._wireHeaderDrag— that's intentional (private-by-convention underscore, used by the orchestrator). Acceptable.
One spec gap I noticed and fixed during self-review: the spec said "manual fallback to free-floating automatically" when the anchor selector misses. The current applyMode in main.js (Task 10) does that: if findAnchorElement() returns null, it falls through to dialog.setMode('free'). The dialog also renders the error state in the DOM observer path. Good.
The dialog in anchored mode needs a way to fall back to free-floating visually — Task 9's setMode handles this, but I should make sure main.js also handles the case where the anchor element disappears after initial mount. Currently it only checks once. This is a minor edge case; the spec doesn't require auto-recovery. Documented in the manual test (Task 14) as a verification step.