@ijfw/memory-server 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ijfw-memorize +14 -7
- package/fixtures/team/book.json +6 -6
- package/fixtures/team/business.json +146 -20
- package/fixtures/team/content.json +6 -6
- package/fixtures/team/design.json +148 -20
- package/fixtures/team/mixed.json +206 -27
- package/fixtures/team/research.json +146 -20
- package/fixtures/team/software.json +148 -20
- package/package.json +6 -3
- package/src/cross-orchestrator-cli.js +204 -145
- package/src/cross-orchestrator.js +50 -1
- package/src/dispatch/extension.js +1 -1
- package/src/hardware-signer.js +4 -2
- package/src/lib/ui-review-runner.js +48 -7
- package/src/memory/auto-linker.js +116 -1
- package/src/memory/migration-runner.js +6 -1
- package/src/memory/migrations/009-obsidian-backfill.js +50 -0
- package/src/memory/obsidian-parser.js +62 -1
- package/src/memory/search.js +46 -25
- package/src/orchestrator/debug-trident-trigger.js +374 -0
- package/src/orchestrator/post-done-runner.js +36 -8
- package/src/orchestrator/state-sdk.js +174 -6
- package/src/orchestrator/subagent-telemetry.js +19 -0
- package/src/override-resolver.js +5 -3
- package/src/recovery/code-fixer.js +310 -5
- package/src/runtime-mediator.js +0 -1
- package/src/server.js +198 -59
- package/src/swarm-config.js +30 -22
- package/src/team/domain-templates/business.json +4 -1
- package/src/team/domain-templates/research.json +4 -1
- package/src/team/generator.js +162 -0
- package/src/update-apply.js +1 -1
- package/src/dashboard-charts.js +0 -239
- package/src/orchestrator/runtime-loop.js +0 -430
package/src/team/generator.js
CHANGED
|
@@ -19,8 +19,20 @@ import {
|
|
|
19
19
|
} from './schemas.js';
|
|
20
20
|
|
|
21
21
|
const FIXTURE_DIR = resolve(fileURLToPath(new URL('../../fixtures/team/', import.meta.url)));
|
|
22
|
+
// W1.5.B (ADR W1.5 Option C "merge"): domain-templates is the canonical
|
|
23
|
+
// agent-id spec; fixtures remain canonical for the executable team bundle.
|
|
24
|
+
// `loadTeamTemplate` cross-validates the two sources at load time so any
|
|
25
|
+
// future drift surfaces as a load-time signal rather than a silent
|
|
26
|
+
// runtime mismatch. See `.planning/1.5.1/decisions/W1.5-canonical-source.md`.
|
|
27
|
+
const DOMAIN_TEMPLATES_DIR = resolve(fileURLToPath(new URL('./domain-templates/', import.meta.url)));
|
|
22
28
|
const SUPPORTED_ARCHETYPES = new Set(['software', 'design', 'content', 'book', 'research', 'business', 'mixed']);
|
|
23
29
|
|
|
30
|
+
// W1.5.B: one-time warning suppression per archetype so drift between
|
|
31
|
+
// fixtures and domain-templates emits a single, clearly-attributed
|
|
32
|
+
// warning rather than spamming stderr on every loadTeamTemplate call
|
|
33
|
+
// (tests + the CLI may both call into the loader inside the same process).
|
|
34
|
+
const _crossValidationWarned = new Set();
|
|
35
|
+
|
|
24
36
|
// T24 / G7-core: the four universal software-core agents. Any software-
|
|
25
37
|
// domain roster MUST include all four. The ids resolve to static markdown
|
|
26
38
|
// files under `claude/agents/<id>.md`; the generator does not synthesise
|
|
@@ -273,9 +285,121 @@ export function loadTeamTemplate(archetype) {
|
|
|
273
285
|
const path = join(FIXTURE_DIR, `${normalized}.json`);
|
|
274
286
|
const bundle = JSON.parse(readFileSync(path, 'utf8'));
|
|
275
287
|
assertValidTeamBundle(bundle);
|
|
288
|
+
|
|
289
|
+
// W1.5.B cross-validation gate. Read the matching T26 domain-template
|
|
290
|
+
// (if any) and cross-check that the fixture's `charter.roles[].name`
|
|
291
|
+
// set agrees with the template's `agent_ids`. Two sources of truth
|
|
292
|
+
// that SHOULD agree by construction (ADR W1.5 Option C "merge"); this
|
|
293
|
+
// gate makes future drift self-detect at load time rather than as a
|
|
294
|
+
// silent runtime miss far from the cause.
|
|
295
|
+
//
|
|
296
|
+
// Behaviour:
|
|
297
|
+
// - template file missing
|
|
298
|
+
// → e.g. `mixed` (deliberately template-free per ADR W1.5).
|
|
299
|
+
// Fixture is the sole source of truth; gate is a no-op.
|
|
300
|
+
// - template present but `agent_ids` empty
|
|
301
|
+
// → fall back to fixture as ground truth; emit a one-time
|
|
302
|
+
// warning so the unpopulated template is visible. W1.5.C
|
|
303
|
+
// populated research + business; if any other archetype ever
|
|
304
|
+
// lands empty in future, operators see it immediately.
|
|
305
|
+
// - template populated AND agrees with fixture role names
|
|
306
|
+
// (every fixture role name appears in template `agent_ids`)
|
|
307
|
+
// → silent pass; the canonical contract holds.
|
|
308
|
+
// - template populated AND disagrees
|
|
309
|
+
// → emit a one-time warning naming the drifting role(s) and the
|
|
310
|
+
// template ids they failed to match. Fail-loud at load time
|
|
311
|
+
// via `process.emitWarning` so CI logs and operators see the
|
|
312
|
+
// drift, but DON'T throw during the v1.5.1 transitional
|
|
313
|
+
// window where W1.5.E (fixture rename) and W1.5.B (this gate)
|
|
314
|
+
// ship in separate commits. Once W1.5.E lands and shipped
|
|
315
|
+
// fixtures are realigned, this warning never fires in the
|
|
316
|
+
// normal codepath; downstream may graduate the warning to a
|
|
317
|
+
// thrown error once the steady state is confirmed.
|
|
318
|
+
//
|
|
319
|
+
// The contract for callers: a non-throwing return ALWAYS means the
|
|
320
|
+
// fixture bundle is structurally valid (assertValidTeamBundle above);
|
|
321
|
+
// drift between fixture role names and the domain-template agent_ids
|
|
322
|
+
// surfaces as a one-time `IjfwTeamFixtureDrift` warning on stderr.
|
|
323
|
+
crossValidateAgainstDomainTemplate(normalized, bundle);
|
|
324
|
+
|
|
276
325
|
return structuredClone(bundle);
|
|
277
326
|
}
|
|
278
327
|
|
|
328
|
+
// W1.5.B: read the T26 domain-template for an archetype (if any) and
|
|
329
|
+
// compare it against the fixture bundle's role names. See
|
|
330
|
+
// loadTeamTemplate above for the contract and ADR W1.5 for the rationale.
|
|
331
|
+
function crossValidateAgainstDomainTemplate(archetype, bundle) {
|
|
332
|
+
const templatePath = join(DOMAIN_TEMPLATES_DIR, `${archetype}.json`);
|
|
333
|
+
if (!existsSync(templatePath)) {
|
|
334
|
+
// No T26 template for this archetype (e.g. `mixed` — deliberately
|
|
335
|
+
// template-free per ADR W1.5). Fixture is the sole source of truth.
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let template;
|
|
340
|
+
try {
|
|
341
|
+
template = JSON.parse(readFileSync(templatePath, 'utf8'));
|
|
342
|
+
} catch (err) {
|
|
343
|
+
// Unparseable template should not silently bypass the gate, but a
|
|
344
|
+
// parse error in the template is a separate failure mode from
|
|
345
|
+
// drift; emit a warning so the broken file is visible without
|
|
346
|
+
// breaking the generator for users whose fixture is structurally fine.
|
|
347
|
+
warnOnce(
|
|
348
|
+
`team-generator: domain-template "${archetype}.json" is unreadable (${err.message}); ` +
|
|
349
|
+
'falling back to fixture as ground truth.',
|
|
350
|
+
`parse:${archetype}`,
|
|
351
|
+
);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const templateIds = Array.isArray(template.agent_ids) ? template.agent_ids : [];
|
|
356
|
+
if (templateIds.length === 0) {
|
|
357
|
+
// ADR W1.5 step 5: T26 empty. Fall back to fixture as ground truth
|
|
358
|
+
// and emit a one-time warning so the gap is visible to operators.
|
|
359
|
+
warnOnce(
|
|
360
|
+
`team-generator: domain-template "${archetype}" has empty agent_ids — ` +
|
|
361
|
+
'falling back to fixture as ground truth.',
|
|
362
|
+
`empty:${archetype}`,
|
|
363
|
+
);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const fixtureRoleNames = (bundle.charter && Array.isArray(bundle.charter.roles))
|
|
368
|
+
? bundle.charter.roles.map((role) => role && role.name).filter(Boolean)
|
|
369
|
+
: [];
|
|
370
|
+
|
|
371
|
+
// The contract (ADR W1.5 Option C): every shipped-fixture role.name
|
|
372
|
+
// MUST appear in the template's agent_ids set. Drift is a load-time
|
|
373
|
+
// observability signal — emitted as a one-time warning so CI/operators
|
|
374
|
+
// see it, but NOT thrown during the v1.5.1 transitional window where
|
|
375
|
+
// W1.5.E (fixture rename) and W1.5.B (this gate) ship in separate
|
|
376
|
+
// commits. Steady state post-W1.5.E: this warning never fires; the
|
|
377
|
+
// gate becomes effectively-throw because mismatch can no longer exist.
|
|
378
|
+
const templateSet = new Set(templateIds);
|
|
379
|
+
const missing = fixtureRoleNames.filter((name) => !templateSet.has(name));
|
|
380
|
+
if (missing.length === 0) return;
|
|
381
|
+
|
|
382
|
+
warnOnce(
|
|
383
|
+
`team-generator: fixtures/team/${archetype}.json drifted from ` +
|
|
384
|
+
`domain-templates/${archetype}.json — role name(s) ${missing.map((m) => `"${m}"`).join(', ')} ` +
|
|
385
|
+
`not present in template agent_ids [${templateIds.join(', ')}]. ` +
|
|
386
|
+
'See ADR .planning/1.5.1/decisions/W1.5-canonical-source.md (Option C "merge"): ' +
|
|
387
|
+
'fixture roles MUST be a subset of the domain-template agent_ids. ' +
|
|
388
|
+
'W1.5.E (fixture rename) will close any remaining drift; until then ' +
|
|
389
|
+
'the fixture is treated as ground truth for the executable team bundle.',
|
|
390
|
+
`drift:${archetype}`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function warnOnce(message, key) {
|
|
395
|
+
if (_crossValidationWarned.has(key)) return;
|
|
396
|
+
_crossValidationWarned.add(key);
|
|
397
|
+
// process.emitWarning is the Node-canonical channel for non-fatal
|
|
398
|
+
// load-time signals — visible to CI logs, suppressible via
|
|
399
|
+
// --no-warnings, and includes a stack so the source is traceable.
|
|
400
|
+
process.emitWarning(message, { type: 'IjfwTeamFixtureDrift', code: 'IJFW_W1_5_B_DRIFT' });
|
|
401
|
+
}
|
|
402
|
+
|
|
279
403
|
export function createTeamAssembly(projectRoot = process.cwd(), options = {}) {
|
|
280
404
|
const root = resolve(projectRoot);
|
|
281
405
|
// F-FUN-1: `options.brief` flows through to detectTeamArchetype so a
|
|
@@ -306,10 +430,12 @@ export function createTeamAssembly(projectRoot = process.cwd(), options = {}) {
|
|
|
306
430
|
writeAtomic(workflowPath, `${JSON.stringify(bundle.workflow, null, 2)}\n`, { mode: 0o600 });
|
|
307
431
|
|
|
308
432
|
const agentFiles = [];
|
|
433
|
+
const writtenAgentNames = new Set();
|
|
309
434
|
for (const role of bundle.charter.roles) {
|
|
310
435
|
const agentPath = join(agentsDir, `${role.name}.md`);
|
|
311
436
|
writeAtomic(agentPath, renderAgent(role, bundle), { mode: 0o600 });
|
|
312
437
|
agentFiles.push(agentPath);
|
|
438
|
+
writtenAgentNames.add(role.name);
|
|
313
439
|
}
|
|
314
440
|
const codexAgents = syncCodexAgents(root, { bundle });
|
|
315
441
|
|
|
@@ -328,6 +454,30 @@ export function createTeamAssembly(projectRoot = process.cwd(), options = {}) {
|
|
|
328
454
|
const domainSpecialistAgentIds = resolveDomainSpecialistAgentIds(archetype);
|
|
329
455
|
const rosterAgentIds = resolveRosterForDomain(archetype);
|
|
330
456
|
|
|
457
|
+
// W1.5.B: wire DOMAIN_SPECIALIST_AGENT_IDS through to file creation.
|
|
458
|
+
// Previously the canonical specialist ids were surfaced in the return
|
|
459
|
+
// value (`domainSpecialistAgentIds`, `rosterAgentIds`) but no matching
|
|
460
|
+
// `.md` files were written into `.ijfw/agents/` — so a swarm
|
|
461
|
+
// dispatcher resolving "the book domain specialists" by id got a list
|
|
462
|
+
// pointing at non-existent local files. This loop closes that gap by
|
|
463
|
+
// emitting a stub agent .md for every canonical specialist id that
|
|
464
|
+
// the fixture role-write loop above did NOT already cover. When
|
|
465
|
+
// fixture role names already match the canonical ids (the steady
|
|
466
|
+
// state post-W1.5.E), this loop is a no-op because `writtenAgentNames`
|
|
467
|
+
// already contains them.
|
|
468
|
+
//
|
|
469
|
+
// The stub references the canonical `claude/agents/<id>.md` so a
|
|
470
|
+
// swarm dispatcher resolving by id still gets a discoverable local
|
|
471
|
+
// entry; the installer is responsible for materialising the full
|
|
472
|
+
// agent spec.
|
|
473
|
+
for (const specialistId of domainSpecialistAgentIds) {
|
|
474
|
+
if (writtenAgentNames.has(specialistId)) continue;
|
|
475
|
+
const agentPath = join(agentsDir, `${specialistId}.md`);
|
|
476
|
+
writeAtomic(agentPath, renderSpecialistStub(specialistId, archetype, bundle), { mode: 0o600 });
|
|
477
|
+
agentFiles.push(agentPath);
|
|
478
|
+
writtenAgentNames.add(specialistId);
|
|
479
|
+
}
|
|
480
|
+
|
|
331
481
|
return {
|
|
332
482
|
ok: true,
|
|
333
483
|
archetype,
|
|
@@ -383,6 +533,18 @@ function normalizeArchetype(value) {
|
|
|
383
533
|
return 'mixed';
|
|
384
534
|
}
|
|
385
535
|
|
|
536
|
+
// W1.5.B: stub renderer for canonical domain-specialist ids that the
|
|
537
|
+
// shipped fixture doesn't yet name as a `charter.roles[].name`. Keeps
|
|
538
|
+
// the `.ijfw/agents/<id>.md` directory in agreement with
|
|
539
|
+
// `domainSpecialistAgentIds` so downstream swarm dispatchers resolving
|
|
540
|
+
// by id always find a local file. The stub is intentionally minimal —
|
|
541
|
+
// the full agent spec lives in `claude/agents/<id>.md` and is deployed
|
|
542
|
+
// by the installer; this is a discovery breadcrumb, not a duplicated spec.
|
|
543
|
+
function renderSpecialistStub(specialistId, archetype, bundle) {
|
|
544
|
+
const archetypes = bundle.charter.project_archetypes.join(', ');
|
|
545
|
+
return `---\nname: ${specialistId}\nmodel: sonnet\neffort: medium\ndescription: ${specialistId} — canonical ${archetype} domain specialist (T26 domain-template).\nallowed-tools: Read, Write, Edit, Bash\n---\n\n# ${specialistId}\n\nCanonical domain specialist for ${archetypes} projects.\n\nFull agent specification: claude/agents/${specialistId}.md (deployed by the IJFW installer).\n\nThis stub exists so swarm dispatchers resolving by id find a local entry; the canonical spec is the source of truth.\n\nRecord claims, findings, blockers, and decisions in .ijfw/blackboard/ when swarm execution is active.\n`;
|
|
546
|
+
}
|
|
547
|
+
|
|
386
548
|
function renderAgent(role, bundle) {
|
|
387
549
|
const owned = role.owns.map((item) => `- ${item.artifact_type}: ${(item.paths || item.refs || []).join(', ')}`).join('\n');
|
|
388
550
|
const reviews = role.reviews.map((item) => `- ${item.artifact_type}: ${item.criteria.join(', ')}`).join('\n') || '- None';
|
package/src/update-apply.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// terminal CLI does not require the sentinel to confirm — the token itself is
|
|
8
8
|
// authoritative. The tool is retained for v1.5.0 back-compat (older skills that
|
|
9
9
|
// still call it work unchanged) and slated for retirement in v1.6.0 to free the
|
|
10
|
-
// MCP-tool slot (see CLAUDE.md "MCP server: ≤
|
|
10
|
+
// MCP-tool slot (see CLAUDE.md "MCP server: ≤13 tools" cap).
|
|
11
11
|
//
|
|
12
12
|
// Does NOT execute the update. Validates the token, writes (or overwrites)
|
|
13
13
|
// the pending sentinel, returns instruction telling the user to run the
|
package/src/dashboard-charts.js
DELETED
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dashboard-charts.js — IJFW v1.4.3 W9-C (B19)
|
|
3
|
-
*
|
|
4
|
-
* Pure-JS canvas/DOM chart helpers for the dashboard. No external libs.
|
|
5
|
-
* Theme-aware: reads CSS custom properties `--ijfw-chart-fg`,
|
|
6
|
-
* `--ijfw-chart-bg`, `--ijfw-chart-warning` from the element's computed
|
|
7
|
-
* style. Falls back to sane defaults if the host page hasn't set them.
|
|
8
|
-
*
|
|
9
|
-
* All helpers are defensive against malformed input so a bad payload from
|
|
10
|
-
* the API can never throw inside the dashboard render loop.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
const DEFAULTS = {
|
|
14
|
-
fg: '#9ad2ff',
|
|
15
|
-
bg: 'rgba(154,210,255,0.18)',
|
|
16
|
-
warning: '#ff9b3a',
|
|
17
|
-
text: '#cfd6dd',
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
function _readTheme(el) {
|
|
21
|
-
// Both `canvas` and plain `div` go through `getComputedStyle`. In the test
|
|
22
|
-
// environment the element is a hand-rolled mock that doesn't reach the DOM
|
|
23
|
-
// — guard so we don't blow up if `getComputedStyle` is unavailable.
|
|
24
|
-
let cs = null;
|
|
25
|
-
try {
|
|
26
|
-
if (el && typeof globalThis.getComputedStyle === 'function') {
|
|
27
|
-
cs = globalThis.getComputedStyle(el);
|
|
28
|
-
}
|
|
29
|
-
} catch { cs = null; }
|
|
30
|
-
const read = (prop, fallback) => {
|
|
31
|
-
if (!cs || typeof cs.getPropertyValue !== 'function') return fallback;
|
|
32
|
-
const v = cs.getPropertyValue(prop);
|
|
33
|
-
return (v && v.trim()) ? v.trim() : fallback;
|
|
34
|
-
};
|
|
35
|
-
return {
|
|
36
|
-
fg: read('--ijfw-chart-fg', DEFAULTS.fg),
|
|
37
|
-
bg: read('--ijfw-chart-bg', DEFAULTS.bg),
|
|
38
|
-
warning: read('--ijfw-chart-warning', DEFAULTS.warning),
|
|
39
|
-
text: read('--ijfw-chart-text', DEFAULTS.text),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function _safeNum(v, def = 0) {
|
|
44
|
-
return (typeof v === 'number' && Number.isFinite(v)) ? v : def;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* lineChart(canvas, points, opts)
|
|
49
|
-
* points: [{ x: number, y: number }, ...] OR [number, ...]
|
|
50
|
-
* opts: { xMin?, xMax?, yMax?, color?, fill? }
|
|
51
|
-
*
|
|
52
|
-
* Empty data renders nothing (clears the canvas) without throwing.
|
|
53
|
-
*/
|
|
54
|
-
export function lineChart(canvas, points, opts = {}) {
|
|
55
|
-
if (!canvas || typeof canvas.getContext !== 'function') return;
|
|
56
|
-
const ctx = canvas.getContext('2d');
|
|
57
|
-
if (!ctx) return;
|
|
58
|
-
const W = _safeNum(canvas.width, 200);
|
|
59
|
-
const H = _safeNum(canvas.height, 100);
|
|
60
|
-
const theme = _readTheme(canvas);
|
|
61
|
-
const color = opts.color || theme.fg;
|
|
62
|
-
const fill = opts.fill === false ? null : theme.bg;
|
|
63
|
-
|
|
64
|
-
try { ctx.clearRect(0, 0, W, H); } catch {}
|
|
65
|
-
|
|
66
|
-
const pts = Array.isArray(points) ? points : [];
|
|
67
|
-
const norm = pts.map((p, i) => {
|
|
68
|
-
if (typeof p === 'number') return { x: i, y: _safeNum(p, 0) };
|
|
69
|
-
return { x: _safeNum(p && p.x, i), y: _safeNum(p && p.y, 0) };
|
|
70
|
-
}).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
|
|
71
|
-
|
|
72
|
-
if (norm.length === 0) return;
|
|
73
|
-
|
|
74
|
-
let xMin = _safeNum(opts.xMin, norm[0].x);
|
|
75
|
-
let xMax = _safeNum(opts.xMax, norm[norm.length - 1].x);
|
|
76
|
-
if (xMax === xMin) xMax = xMin + 1;
|
|
77
|
-
const yMax = _safeNum(opts.yMax, Math.max(1, ...norm.map((p) => p.y)));
|
|
78
|
-
const yScale = yMax > 0 ? yMax : 1;
|
|
79
|
-
|
|
80
|
-
const pad = 4;
|
|
81
|
-
const innerW = Math.max(1, W - pad * 2);
|
|
82
|
-
const innerH = Math.max(1, H - pad * 2);
|
|
83
|
-
|
|
84
|
-
const px = (x) => pad + ((x - xMin) / (xMax - xMin)) * innerW;
|
|
85
|
-
const py = (y) => pad + (1 - (Math.max(0, y) / yScale)) * innerH;
|
|
86
|
-
|
|
87
|
-
// Filled area
|
|
88
|
-
if (fill) {
|
|
89
|
-
try {
|
|
90
|
-
ctx.beginPath();
|
|
91
|
-
ctx.moveTo(px(norm[0].x), H - pad);
|
|
92
|
-
for (const p of norm) ctx.lineTo(px(p.x), py(p.y));
|
|
93
|
-
ctx.lineTo(px(norm[norm.length - 1].x), H - pad);
|
|
94
|
-
ctx.closePath();
|
|
95
|
-
ctx.fillStyle = fill;
|
|
96
|
-
ctx.fill();
|
|
97
|
-
} catch {}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Line stroke
|
|
101
|
-
try {
|
|
102
|
-
ctx.beginPath();
|
|
103
|
-
ctx.moveTo(px(norm[0].x), py(norm[0].y));
|
|
104
|
-
for (let i = 1; i < norm.length; i++) ctx.lineTo(px(norm[i].x), py(norm[i].y));
|
|
105
|
-
ctx.strokeStyle = color;
|
|
106
|
-
ctx.lineWidth = 1.5;
|
|
107
|
-
ctx.stroke();
|
|
108
|
-
} catch {}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* barChart(canvas, bars, opts)
|
|
113
|
-
* bars: [{ label: string, value: number, color?: string }, ...]
|
|
114
|
-
* opts: { horizontal?: boolean, color?: string, maxValue?: number }
|
|
115
|
-
*
|
|
116
|
-
* Zero-value bars render as empty rails. Negative values are clamped to 0.
|
|
117
|
-
*/
|
|
118
|
-
export function barChart(canvas, bars, opts = {}) {
|
|
119
|
-
if (!canvas || typeof canvas.getContext !== 'function') return;
|
|
120
|
-
const ctx = canvas.getContext('2d');
|
|
121
|
-
if (!ctx) return;
|
|
122
|
-
const W = _safeNum(canvas.width, 200);
|
|
123
|
-
const H = _safeNum(canvas.height, 100);
|
|
124
|
-
const theme = _readTheme(canvas);
|
|
125
|
-
const defaultColor = opts.color || theme.fg;
|
|
126
|
-
const horizontal = Boolean(opts.horizontal);
|
|
127
|
-
|
|
128
|
-
try { ctx.clearRect(0, 0, W, H); } catch {}
|
|
129
|
-
|
|
130
|
-
const rows = Array.isArray(bars) ? bars.filter((b) => b && typeof b === 'object') : [];
|
|
131
|
-
if (rows.length === 0) return;
|
|
132
|
-
|
|
133
|
-
const values = rows.map((b) => Math.max(0, _safeNum(b.value, 0)));
|
|
134
|
-
const maxVal = _safeNum(opts.maxValue, Math.max(1, ...values));
|
|
135
|
-
const scale = maxVal > 0 ? maxVal : 1;
|
|
136
|
-
|
|
137
|
-
const pad = 4;
|
|
138
|
-
const labelGutter = horizontal ? 80 : 14;
|
|
139
|
-
const innerW = Math.max(1, W - pad * 2 - (horizontal ? labelGutter : 0));
|
|
140
|
-
const innerH = Math.max(1, H - pad * 2 - (horizontal ? 0 : labelGutter));
|
|
141
|
-
const slot = (horizontal ? innerH : innerW) / rows.length;
|
|
142
|
-
const barW = Math.max(1, slot * 0.7);
|
|
143
|
-
|
|
144
|
-
for (let i = 0; i < rows.length; i++) {
|
|
145
|
-
const b = rows[i];
|
|
146
|
-
const v = values[i];
|
|
147
|
-
const color = b.color || defaultColor;
|
|
148
|
-
if (horizontal) {
|
|
149
|
-
const y = pad + i * slot + (slot - barW) / 2;
|
|
150
|
-
const len = (v / scale) * innerW;
|
|
151
|
-
try {
|
|
152
|
-
ctx.fillStyle = theme.bg;
|
|
153
|
-
ctx.fillRect(pad + labelGutter, y, innerW, barW);
|
|
154
|
-
ctx.fillStyle = color;
|
|
155
|
-
ctx.fillRect(pad + labelGutter, y, len, barW);
|
|
156
|
-
ctx.fillStyle = theme.text;
|
|
157
|
-
ctx.fillText(String(b.label || ''), pad, y + barW * 0.75);
|
|
158
|
-
} catch {}
|
|
159
|
-
} else {
|
|
160
|
-
const x = pad + i * slot + (slot - barW) / 2;
|
|
161
|
-
const len = (v / scale) * innerH;
|
|
162
|
-
try {
|
|
163
|
-
ctx.fillStyle = theme.bg;
|
|
164
|
-
ctx.fillRect(x, pad, barW, innerH);
|
|
165
|
-
ctx.fillStyle = color;
|
|
166
|
-
ctx.fillRect(x, pad + (innerH - len), barW, len);
|
|
167
|
-
ctx.fillStyle = theme.text;
|
|
168
|
-
ctx.fillText(String(b.label || '').slice(0, 8), x, H - pad);
|
|
169
|
-
} catch {}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* progressBar(div, data)
|
|
176
|
-
* data: { current: number, limit: number | null, label?: string, warning?: boolean }
|
|
177
|
-
*
|
|
178
|
-
* Mutates `div` in place. Renders an "unlimited" placeholder when limit is
|
|
179
|
-
* null. Applies a warning class when `warning` is truthy.
|
|
180
|
-
*/
|
|
181
|
-
export function progressBar(div, data = {}) {
|
|
182
|
-
if (!div) return;
|
|
183
|
-
const theme = _readTheme(div);
|
|
184
|
-
const cur = Math.max(0, _safeNum(data.current, 0));
|
|
185
|
-
const lim = (data.limit === null || data.limit === undefined) ? null : _safeNum(data.limit, null);
|
|
186
|
-
const label = typeof data.label === 'string' ? data.label : '';
|
|
187
|
-
const warn = Boolean(data.warning);
|
|
188
|
-
|
|
189
|
-
// Clear children.
|
|
190
|
-
try {
|
|
191
|
-
while (div.firstChild) div.removeChild(div.firstChild);
|
|
192
|
-
} catch {
|
|
193
|
-
// Some test mocks omit firstChild/removeChild. Skip cleanup.
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const setClass = (extra) => {
|
|
197
|
-
try {
|
|
198
|
-
div.className = ['ijfw-progress', extra, warn ? 'ijfw-progress--warn' : '']
|
|
199
|
-
.filter(Boolean).join(' ');
|
|
200
|
-
} catch {}
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
// Helper to create child elements safely.
|
|
204
|
-
function appendEl(tag, opts) {
|
|
205
|
-
try {
|
|
206
|
-
if (typeof div.ownerDocument === 'object' && div.ownerDocument && typeof div.ownerDocument.createElement === 'function') {
|
|
207
|
-
const el = div.ownerDocument.createElement(tag);
|
|
208
|
-
if (opts && opts.text) el.textContent = opts.text;
|
|
209
|
-
if (opts && opts.style) el.setAttribute('style', opts.style);
|
|
210
|
-
if (opts && opts.cls) el.className = opts.cls;
|
|
211
|
-
if (typeof div.appendChild === 'function') div.appendChild(el);
|
|
212
|
-
return el;
|
|
213
|
-
}
|
|
214
|
-
} catch {}
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (lim === null) {
|
|
219
|
-
setClass('ijfw-progress--unlimited');
|
|
220
|
-
appendEl('span', { cls: 'ijfw-progress-label', text: label });
|
|
221
|
-
appendEl('span', { cls: 'ijfw-progress-val', text: cur + ' / unlimited' });
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
setClass('');
|
|
226
|
-
const denom = lim > 0 ? lim : 1;
|
|
227
|
-
const pct = Math.max(0, Math.min(100, (cur / denom) * 100));
|
|
228
|
-
appendEl('span', { cls: 'ijfw-progress-label', text: label });
|
|
229
|
-
const rail = appendEl('span', { cls: 'ijfw-progress-rail', style: 'background:' + theme.bg + ';display:inline-block;height:6px;width:120px;border-radius:3px;overflow:hidden;vertical-align:middle;margin:0 6px' });
|
|
230
|
-
if (rail) {
|
|
231
|
-
try {
|
|
232
|
-
const fill = (rail.ownerDocument || div.ownerDocument).createElement('span');
|
|
233
|
-
fill.className = 'ijfw-progress-fill';
|
|
234
|
-
fill.setAttribute('style', 'display:block;height:100%;width:' + pct.toFixed(1) + '%;background:' + (warn ? theme.warning : theme.fg));
|
|
235
|
-
rail.appendChild(fill);
|
|
236
|
-
} catch {}
|
|
237
|
-
}
|
|
238
|
-
appendEl('span', { cls: 'ijfw-progress-val', text: cur + ' / ' + lim });
|
|
239
|
-
}
|