Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b477717
perf: bound WBS badge template caches
seonghobae Aug 14, 2026
b201a9a
test(perf): compare 5000-row render against protected base
seonghobae Aug 15, 2026
ff2b5bf
test(perf): prove metadata edits preserve the 5k-row grid
seonghobae Aug 15, 2026
1c4f294
perf(render): avoid rebuilding task grid for project metadata
seonghobae Aug 15, 2026
335e667
merge(perf): reconcile current protected develop
seonghobae Aug 16, 2026
e043b1d
test(perf): reject row data in badge template caches
seonghobae Aug 16, 2026
711f34a
fix(perf): cache immutable badge shells only
seonghobae Aug 16, 2026
5ded4af
fix(ui): move owner badge colors to stylesheet
seonghobae Aug 16, 2026
d5e0e10
test(perf): require zero allocation on every metadata sample
seonghobae Aug 16, 2026
e7ad20d
docs(perf): require immutable DOM cache shells
seonghobae Aug 16, 2026
dd799cd
docs(perf): record immutable badge shell boundary
seonghobae Aug 16, 2026
fbe0291
docs(changelog): record immutable badge shells
seonghobae Aug 16, 2026
59ba07f
merge(perf): reconcile protected develop
seonghobae Aug 16, 2026
323a2d4
merge(perf): preserve current protected develop behavior
seonghobae Aug 16, 2026
285e3d0
merge(develop): preserve toast delivery with render performance work
seonghobae Aug 16, 2026
61b9d6e
test(perf): prove progress template clone isolation
seonghobae Aug 17, 2026
b3dd6e5
merge(develop): reconcile render performance root with OpenCode config
seonghobae Aug 17, 2026
3c9f244
merge(develop): reconcile metadata render performance with adaptive o…
seonghobae Aug 19, 2026
b0523bd
merge(develop): preserve protected dependency upgrades in performance…
seonghobae Aug 21, 2026
16fcc6f
test(ui): model native progress select semantics
seonghobae Aug 21, 2026
086d00d
test(a11y): require accessible owner badge contrast
seonghobae Aug 21, 2026
72da018
test(a11y): register owner badge contrast regression
seonghobae Aug 21, 2026
60d3a4c
test(a11y): evaluate effective badge CSS cascade
seonghobae Aug 21, 2026
bfa7b68
fix(a11y): make owner badge palette WCAG AA
seonghobae Aug 21, 2026
671fb05
test(perf): cover immutable render benchmark base resolution
seonghobae Aug 23, 2026
031dd50
test(perf): register benchmark-base regression
seonghobae Aug 23, 2026
2249e6e
fix(perf): resolve benchmark base for protected pushes
seonghobae Aug 23, 2026
8d11eb3
fix(perf): require immutable baseline evidence on push and PR
seonghobae Aug 23, 2026
3c5e657
fix(ui): normalize owner values before badge hashing
seonghobae Aug 28, 2026
dcdc8da
fix: preload browser modules for static shell
seonghobae Aug 28, 2026
9cc7ecc
fix(ui): keep owner badge palette index unsigned
seonghobae Aug 28, 2026
05b646f
fix(a11y): enforce owner badge contrast
seonghobae Aug 29, 2026
b17f2d6
fix(a11y): restore existing stylesheet tail
seonghobae Aug 29, 2026
b5d46cd
test(perf): keep local cloud e2e independent of benchmark base
seonghobae Aug 29, 2026
aa28031
fix(perf): isolate baseline benchmark from local cloud e2e
seonghobae Aug 29, 2026
3906b37
test(perf): require benchmark CI coverage
seonghobae Aug 29, 2026
9c2cc0b
fix(perf): keep render benchmark in CI
seonghobae Aug 29, 2026
0390160
test(perf): tighten benchmark script contracts
seonghobae Aug 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/server-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,5 @@ jobs:
run: npx playwright install chromium --with-deps
- name: Cloud UI e2e
run: npm run test:e2e:cloud
- name: Metadata render performance benchmark
run: npm run test:e2e:performance
6 changes: 6 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@
## 2026-07-12 - Optimize renderTaskRow DOM allocations
**Learning:** Caching unattached template nodes and instantiating them via `.cloneNode(false)` reduces DOM instantiation overhead in O(N) render loops significantly.
**Action:** Apply this optimization to other hot-path rendering elements such as rows, cells, and stack containers.
## 2026-07-13 - Cache immutable DOM shells, not input-bearing nodes
**Learning:** In high-frequency rendering loops, repeated `document.createElement()` calls and repeated configuration of static structure create avoidable DOM bridge and GC overhead. Templates must contain only immutable, non-customer-specific structure. Caching fully configured nodes keyed by owner names, labels, descriptions, titles, or accessible names retains row/user data in detached DOM and turns input cardinality into memory retention.
**Action:** Cache one bounded immutable shell per structural element type, clone it in the hot path, and apply row-specific text, classes, titles, and accessibility attributes only to the returned clone. Use fixed stylesheet classes for deterministic visual variants instead of inline styles or input-keyed DOM caches.
## 2026-07-13 - Correctly caching element attributes with cloneNode
**Learning:** Both `Node.cloneNode(false)` and `Node.cloneNode(true)` copy HTML attributes and their values, including reflected properties such as `title`. The `deep` argument controls only whether child nodes are cloned. JavaScript extension properties and listeners registered with `addEventListener()` are not cloned.
**Action:** Select shallow or deep cloning from the required child-node structure, not to preserve reflected attributes. Reapply JavaScript extension properties and event listeners explicitly when cached templates require them.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added workflow ownership regression coverage so central review
workflows stay inherited from `ContextualWisdomLab/.github`, not copied
into this repository.
- Added a reproducible 5,000-row production-browser rendering benchmark that
records median and p95 duration, long tasks, heap deltas, live DOM nodes,
element creation, and edit, drag, and inline-progress interaction evidence.

### Security

Expand Down Expand Up @@ -61,6 +64,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
mode, delegating provider/model/topology policy to the shared service without
weakening ScopeWeave's authenticated, fail-closed transport or response
boundary controls.
- Reused one immutable owner-badge shell and one immutable status-badge shell in
the WBS table render path. Row text, accessible descriptions, and deterministic
owner color classes are applied only after cloning, so detached templates do
not retain task/user values or inline color styles.
- Accepted XML whitespace before exact Microsoft Project element delimiters
while preserving the linear, regex-free import scanner and rejecting
attributes, longer names, non-XML whitespace, nested unmatched blocks, and
Expand Down
76 changes: 49 additions & 27 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ const STORAGE_KEY = 'scopeweave:planner-state:v1';
const DEFAULT_PROJECT_NAME = 'ScopeWeave Planner';
const MAX_PROJECT_NAME_LENGTH = 120;
const MAX_BASE_DATE_LENGTH = 10;
const OWNER_COLORS = [
'#3f51b5', '#8e24aa', '#d81b60', '#ef6c00', '#6d4c41',
'#00897b', '#1e88e5', '#3949ab', '#7cb342', '#f4511e',
'#5e35b1', '#c0ca33', '#00acc1', '#fb8c00', '#546e7a',
'#43a047', '#e53935', '#6a1b9a', '#039be5', '#5d4037'
];
const OWNER_COLOR_CLASS_COUNT = 20;

const ACTUAL_PROGRESS_OPTIONS = [
'미착수(0%)',
Expand Down Expand Up @@ -263,7 +258,11 @@ async function bootstrap() {
}

function bindEvents() {
const persistAndRenderMetadata = debounce(() => {
const persistProjectMetadata = debounce(() => {
persistState();
renderAll({ metadataOnly: true });
}, 150);
const persistAndRenderPlan = debounce(() => {
persistState();
renderAll();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, 150);
Expand All @@ -277,13 +276,13 @@ function bindEvents() {
return true;
};

bindHeaderEvents(persistAndRenderMetadata);
bindHeaderEvents(persistProjectMetadata, persistAndRenderPlan);
bindModalEvents();
bindGlobalEvents();
bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent);
}

function bindHeaderEvents(persistAndRenderMetadata) {
function bindHeaderEvents(persistProjectMetadata, persistAndRenderPlan) {
elements.projectNameInput.addEventListener('input', (event) => {
const sanitized = String(event.target.value).slice(0, MAX_PROJECT_NAME_LENGTH);
if (event.target.value !== sanitized) {
Expand All @@ -292,15 +291,15 @@ function bindHeaderEvents(persistAndRenderMetadata) {
event.target.setSelectionRange(cursor, cursor);
}
state.projectName = sanitized.trim() || DEFAULT_PROJECT_NAME;
persistAndRenderMetadata();
persistProjectMetadata();
});
elements.projectNameInput.addEventListener('blur', persistAndRenderMetadata.flush);
elements.projectNameInput.addEventListener('blur', persistProjectMetadata.flush);

elements.baseDateInput.addEventListener('input', (event) => {
state.baseDate = String(event.target.value).trim().slice(0, MAX_BASE_DATE_LENGTH) || formatLocalDateInput(new Date());
persistAndRenderMetadata();
persistAndRenderPlan();
});
elements.baseDateInput.addEventListener('blur', persistAndRenderMetadata.flush);
elements.baseDateInput.addEventListener('blur', persistAndRenderPlan.flush);

elements.addRootButton.addEventListener('click', () => openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }));
elements.exportCsvButton.addEventListener('click', (e) => {
Expand Down Expand Up @@ -502,17 +501,24 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) {
});
}

const cachedHasChildrenSet = new Set();
function renderAll() {
const metrics = computeTaskMetrics();

function renderProjectMetadata() {
elements.projectNameInput.value = state.projectName;
document.title = state.projectName === DEFAULT_PROJECT_NAME ? DEFAULT_PROJECT_NAME : `${state.projectName} - ${DEFAULT_PROJECT_NAME}`;
elements.baseDateInput.value = state.baseDate;
elements.syncStatus.textContent = state.jsonSyncHandle ? '연결된 wbs.json 파일에 자동저장 중' : '브라우저 로컬 자동저장 사용 중';
}

const cachedHasChildrenSet = new Set();
function renderAll({ metadataOnly = false } = {}) {
renderProjectMetadata();
if (metadataOnly) {
return;
}
Comment thread
seonghobae marked this conversation as resolved.

const metrics = computeTaskMetrics();
elements.totalDays.textContent = `${formatNumber(metrics.totalDays)}일`;
elements.plannedProgress.textContent = formatPercent(metrics.totalWeightedPlannedRatio * 100, 2);
elements.actualProgress.textContent = formatPercent(metrics.totalWeightedActualRatio * 100, 2);
elements.syncStatus.textContent = state.jsonSyncHandle ? '연결된 wbs.json 파일에 자동저장 중' : '브라우저 로컬 자동저장 사용 중';

if (typeof window !== 'undefined') {
window.ScopeWeaveAnalytics?.render?.({
Expand Down Expand Up @@ -971,29 +977,45 @@ function createWarningBadge(warning) {
return badge;
}

const persistentOwnerColorMap = new Map();
// Badge templates are immutable shells only. Customer/task text and accessible names
// are applied to each clone after cloning so cached detached nodes never retain row data.
let ownerBadgeTemplate = null;
let statusBadgeTemplate = null;

function getOwnerColorIndex(owner) {
let hash = 0;
for (let index = 0; index < owner.length; index += 1) {
hash = ((hash << 5) - hash + owner.charCodeAt(index)) | 0;
}
return (hash >>> 0) % OWNER_COLOR_CLASS_COUNT;
}
Comment thread
seonghobae marked this conversation as resolved.

function createOwnerCellContent(owner) {
if (!owner) {
return createEmptyCell();
}
const ownerValue = String(owner);

if (!persistentOwnerColorMap.has(owner)) {
persistentOwnerColorMap.set(owner, OWNER_COLORS[persistentOwnerColorMap.size % OWNER_COLORS.length]);
if (!ownerBadgeTemplate) {
ownerBadgeTemplate = document.createElement('span');
ownerBadgeTemplate.className = 'owner-badge';
}

const badge = document.createElement('span');
badge.className = 'owner-badge';
badge.style.background = persistentOwnerColorMap.get(owner);
badge.textContent = owner;
const badge = ownerBadgeTemplate.cloneNode(false);
badge.className = `owner-badge owner-badge--color-${getOwnerColorIndex(ownerValue)}`;
badge.textContent = ownerValue;
return badge;
}
Comment thread
seonghobae marked this conversation as resolved.

function createStatusCellContent(progressState) {
if (!progressState.label) {
return createEmptyCell();
}
const badge = document.createElement('span');

if (!statusBadgeTemplate) {
statusBadgeTemplate = document.createElement('span');
statusBadgeTemplate.className = 'status-badge';
}
const badge = statusBadgeTemplate.cloneNode(false);
badge.className = `status-badge ${progressState.className}`;
badge.textContent = progressState.label;
if (progressState.description) {
Expand Down
109 changes: 109 additions & 0 deletions docs/doctoring/dom-template-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Immutable DOM badge shells and browser evidence

## Decision status

This record describes an **active pull-request implementation**, not protected-`develop`
truth until integration completes. ScopeWeave may reuse unattached owner/status badge
shells in the WBS render loop only when all of the following remain true:

- every returned node is a clone rather than the cached shell itself;
- cached shells contain no task, owner, status, title, description, accessible name,
or other row-specific value;
- row-specific text, classes, `title`, and `aria-label` values are applied only after
cloning;
- owner colors use a fixed set of stylesheet classes rather than inline styles or an
owner-value registry;
- empty cells and warning paths keep their existing semantics; and
- production-browser interaction tests accompany allocation-focused unit tests.

The optimization is deliberately limited to small immutable badge structures.
Editable controls, validation relationships, and elements whose event listeners or
mutable child state differ per row are not cached here.

## DOM correctness and privacy boundary

`cloneNode()` copies the node and its attributes. Its `deep` argument controls whether
child nodes are copied; it does not transfer listeners registered through
`addEventListener()`. ScopeWeave therefore keeps the two cached badge shells free of
row-specific attributes and child text, clones them shallowly, and mutates only the
returned clone.

This boundary is also a data-retention control. Owner names and status explanations
are not used as DOM-cache keys and are not retained in detached template nodes.
High-cardinality customer values therefore cannot grow a detached-node cache or leave
historical row text in reusable templates.

## Resource bound and deterministic color

The owner badge uses one immutable shell and the status badge uses one immutable
shell. Their memory bound is therefore structural rather than an input-cardinality
LRU limit. A deterministic integer hash maps an owner string to one of 20 fixed
`owner-badge--color-N` classes defined in `styles.css`; the shell itself contains no
owner value and no inline `background` style.

This supersedes the earlier 256-entry input-keyed owner/status template maps. That
approach bounded entry count but still retained task/user data in cache keys and
detached DOM nodes.

## Metadata-only render integration

Project-name persistence remains on the single user-visible `renderAll()` integration
path. `renderAll({ metadataOnly: true })` refreshes project metadata and returns before
metric calculation, analytics, visible-task construction, and task-grid replacement.
Base-date changes continue through the full render path because they affect schedule
metrics.

## Test-first evidence contract

The focused unit contract verifies that:

- cached owner/status shells do not retain row text, title, accessible name, or inline
color;
- returned nodes are distinct clones populated with the correct current row value;
- a changed status description appears on the returned clone without mutating the
cached shell;
- 300 unique status values and 300 unique owners allocate no new template elements
after their respective shell is initialized;
- 5,000 identical owners likewise allocate no new template elements after shell
initialization; and
- empty-value behavior remains unchanged.

The Playwright benchmark drives the production bootstrap and rendering path with 5,000
rows. For each warm project-name edit it records duration, `document.createElement()`
calls, heap delta when available, live DOM-node count, and whether the first task-row
node retained identity. The candidate contract requires **every** warm metadata sample
to create zero elements and preserve task-grid identity. Edit, inline-progress, and
drag/reorder probes remain acceptance checks in the same browser run.

## Evidence interpretation

A prior hosted A/B run demonstrated a large metadata-edit improvement against its then
protected base, but predecessor-head or predecessor-base success is not exact-current-
head evidence. After any source, test, documentation, stylesheet, or base-reconciliation
change, the PR must regenerate browser and repository-native evidence for the unchanged
exact contributor head and independently resolved live protected base before the result
can support merge or release.

Cold-load and long-task values remain diagnostic unless the benchmark is explicitly
designed and powered for claims about those outcomes. The performance claim for this
slice is limited to the project-name metadata-edit hot path.

## Rollback

Revert the immutable badge-shell helpers, fixed owner color classes, focused unit
contract, metadata-sample assertion, browser benchmark registration, changelog entry,
and this record together. A rollback does not change persisted WBS data or server APIs.

## References

Mozilla. (2026). *Node: cloneNode() method*. MDN Web Docs.
https://developer.mozilla.org/en-US/docs/Web/API/Node/cloneNode

Web Hypertext Application Technology Working Group. (2026). *DOM standard*.
https://dom.spec.whatwg.org/

World Wide Web Consortium. (2017). *Long Tasks API 1*.
https://www.w3.org/TR/longtasks-1/

World Wide Web Consortium. (2024). *High Resolution Time Level 3*.
https://www.w3.org/TR/hr-time-3/
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; form-action 'self';" />
<title>ScopeWeave Planner</title>
<link rel="preload" href="styles.css" as="style" />
<link rel="modulepreload" href="cloud-sync.js" />
<link rel="modulepreload" href="analytics.js" />
<link rel="modulepreload" href="app.js" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="toast-state.css" />
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@
"coverage": "npm run test:coverage",
"server": "node server/server.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/render-performance-base.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/caching.test.mjs && node tests/unit/owner-badge-contrast.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/caching.test.mjs && npm run test:api",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js",
"test:e2e:performance": "playwright install chromium && playwright test tests/e2e/render-performance.spec.js",
Comment thread
seonghobae marked this conversation as resolved.
"test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js",
"fuzz": "node --test tests/fuzz/*.mjs"
},
Expand Down
Loading
Loading