gitworld / src /ui-reality.js
SNAPKITTYWEST's picture
push from SNAPKITTYWEST/gitworld
6281708 verified
Raw
History Blame Contribute Delete
15.9 kB
import {
COLLECTIONS,
EVENT_LABELS,
EVENT_TYPES,
TOOLBELT,
commandTranscript,
formatTime
} from './shared.js';
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[character]));
}
function byId(root, id) {
return root.getElementById(id);
}
function setText(root, id, value) {
const element = byId(root, id);
if (element) element.textContent = value;
}
function selectedDistrict(state) {
return state.districts.find((district) => district.id === state.selectedDistrictId) || state.districts[0];
}
function selectedFile(state) {
const district = selectedDistrict(state);
return district.files.find((file) => file.id === state.selectedFileId) || district.files[0];
}
function eventMessage(event) {
return event.detail?.message || EVENT_LABELS[event.type] || event.type;
}
function renderAgents(root, state) {
const agentList = byId(root, 'agent-list');
if (!agentList) return;
agentList.innerHTML = state.agents.map((agent) => `<li class="agent-row" style="--agent-color:${agent.color}">
<span class="agent-avatar">${agent.name.slice(0, 2)}</span><span class="agent-copy"><strong>${escapeHtml(agent.name)}</strong><small>${escapeHtml(agent.role)}</small><em>${escapeHtml(agent.task)}</em><i class="progress-line"><b style="width:${agent.progress}%"></b></i></span><span class="agent-state">● ${escapeHtml(agent.status)}</span>
</li>`).join('');
setText(root, 'active-agent-count', String(state.agents.length));
}
function renderEvents(root, state) {
const feed = byId(root, 'event-feed');
if (!feed) return;
feed.innerHTML = state.events.slice(0, 7).map((event) => `<li class="event-row event-${event.type.replace(/[^a-z]+/g, '-')}" data-event-type="${escapeHtml(event.type)}"><time>${escapeHtml(event.at || formatTime(event.tick))}</time><span class="event-dot"></span><p>${escapeHtml(eventMessage(event))}</p></li>`).join('');
setText(root, 'event-count', `${state.events.length} EVENTS`);
}
function renderToolbelt(root, state) {
const toolbelt = byId(root, 'toolbelt');
if (!toolbelt) return;
toolbelt.innerHTML = TOOLBELT.map((tool) => `<button class="tool-slot${tool.id === state.activeTool ? ' is-active' : ''}" data-action="tool" data-tool-id="${tool.id}" style="--tool-color:${tool.color}" aria-label="${escapeHtml(tool.name)}: ${escapeHtml(tool.verb)}"><span class="tool-key">${tool.key}</span><b>${tool.icon}</b><strong>${escapeHtml(tool.name)}</strong><small>${escapeHtml(tool.verb)}</small></button>`).join('');
}
function renderStatus(root, state) {
setText(root, 'repo-visibility', state.repository.visibility);
setText(root, 'repo-branch', `BRANCH / ${state.repository.branch}`);
setText(root, 'build-status', state.repository.build);
setText(root, 'test-status', state.repository.tests);
setText(root, 'deploy-status', state.repository.deployment);
setText(root, 'world-clock', formatTime(state.tick));
setText(root, 'player-location', state.view === 'district' ? `INSIDE ${selectedDistrict(state).path}` : 'SPAWN PLAZA');
setText(root, 'feedback-line', state.feedback);
const playerLevel = Math.min(99, 12 + Math.floor(state.tick / 3));
setText(root, 'player-level', `LEVEL ${playerLevel} DEV`);
const xp = Math.min(100, 34 + state.tick * 2);
const xpBar = byId(root, 'xp-fill');
if (xpBar) xpBar.style.width = `${xp}%`;
setText(root, 'xp-label', `${xp} / 100 XP`);
}
function renderFiles(state) {
const district = selectedDistrict(state);
return `<div class="collection-heading"><span>DIRECTORY</span><strong>${escapeHtml(district.path)}</strong><small>${escapeHtml(district.stat)}</small></div><div class="file-list">${district.files.map((file) => `<button class="file-row${file.id === state.selectedFileId ? ' is-active' : ''}" data-action="select-file" data-file-id="${file.id}"><span class="file-icon">⌁</span><strong>${escapeHtml(file.name)}</strong><small>${escapeHtml(file.kind)} · ${escapeHtml(file.size)}</small><b>›</b></button>`).join('')}</div>`;
}
function renderCommits(state) {
const commits = state.repository.commits || [];
return `<div class="collection-heading"><span>HISTORY ROAD</span><strong>RECENT COMMITS</strong><small>${commits.length} simulated events</small></div><div class="commit-list">${commits.map((commit) => `<button class="commit-row" data-action="commit-detail" data-commit-id="${commit.id}"><span class="commit-node"></span><span><strong>${escapeHtml(commit.hash)}</strong><small>${escapeHtml(commit.message)}</small></span><em>${escapeHtml(commit.author)}</em></button>`).join('')}</div>`;
}
function renderIssues(state) {
const issues = state.repository.issues || [];
return `<div class="collection-heading"><span>QUEST BOARD</span><strong>OPEN ISSUES</strong><small>Quests waiting in the city</small></div><div class="issue-list">${issues.map((issue) => `<button class="issue-row" data-action="issue-detail" data-issue-id="${issue.id}"><span class="issue-marker">!</span><span><strong>${escapeHtml(issue.title)}</strong><small>${escapeHtml(issue.detail)}</small></span><em>${escapeHtml(issue.status)}</em></button>`).join('')}</div>`;
}
function renderPullRequests(state) {
const prs = state.repository.pullRequests || [];
return `<div class="collection-heading"><span>MERGE GATE</span><strong>PULL REQUESTS</strong><small>Doors into the next branch</small></div><div class="pr-list">${prs.map((pr) => `<button class="pr-row" data-action="pr-detail" data-pr-id="${pr.id}"><span class="pr-marker">↔</span><span><strong>${escapeHtml(pr.title)}</strong><small>${escapeHtml(pr.detail)}</small></span><em>${escapeHtml(pr.status)}</em></button>`).join('')}</div>`;
}
function renderBuild(state) {
return `<div class="collection-heading"><span>AUTOMATION CENTER</span><strong>BUILD & TEST</strong><small>All values are simulated browser-local signals</small></div><div class="build-stack"><div><span>BUILD #812</span><b class="status-${state.repository.build.toLowerCase()}">${escapeHtml(state.repository.build)}</b></div><div><span>INSPECTORS</span><b class="status-${state.repository.tests.toLowerCase().split(' ')[0]}">${escapeHtml(state.repository.tests)}</b></div><div><span>PAGES RUNWAY</span><b>${escapeHtml(state.repository.deployment)}</b></div><div class="pipeline-steps"><i class="done">SOURCE</i><i class="done">COMPILE</i><i class="done">TEST</i><i class="active">DEPLOY</i></div></div>`;
}
function renderRecord(state) {
const record = state.selectedRecord;
if (!record) return renderBuild(state);
if (record.type === 'commit') {
const item = (state.repository.commits || []).find((candidate) => candidate.id === record.id);
return `<div class="collection-heading"><span>COMMIT EVENT</span><strong>${escapeHtml(item?.hash || record.id)}</strong><small>Historical event made physical</small></div><div class="record-card"><b>${escapeHtml(item?.message || 'Commit selected')}</b><p>This simulated commit is a construction event. The repository builder routes its changed files into the city.</p><em>author · ${escapeHtml(item?.author || 'builder')}</em></div><button class="panel-action" data-action="select-collection" data-collection="commits">← Back to commit history</button>`;
}
if (record.type === 'issue') {
const item = (state.repository.issues || []).find((candidate) => candidate.id === record.id);
return `<div class="collection-heading"><span>ISSUE QUEST</span><strong>${escapeHtml(item?.title || record.id)}</strong><small>${escapeHtml(item?.status || 'OPEN')}</small></div><div class="record-card issue-record"><b>Quest objective</b><p>${escapeHtml(item?.detail || 'Inspect the open issue in the quest district.')}</p><em>marker active · browser-local simulation</em></div><button class="panel-action" data-action="select-collection" data-collection="issues">← Back to issue board</button>`;
}
const item = (state.repository.pullRequests || []).find((candidate) => candidate.id === record.id);
return `<div class="collection-heading"><span>PULL REQUEST DOOR</span><strong>${escapeHtml(item?.title || record.id)}</strong><small>${escapeHtml(item?.status || 'REVIEW')}</small></div><div class="record-card pr-record"><b>Merge gate</b><p>${escapeHtml(item?.detail || 'The gate is waiting for review.')}</p><em>checks passing · browser-local simulation</em></div><button class="panel-action" data-action="select-collection" data-collection="prs">← Back to pull requests</button>`;
}
function renderInspector(root, state, nearby) {
const panel = byId(root, 'inspector-content');
if (!panel) return;
const district = selectedDistrict(state);
const file = selectedFile(state);
const collection = state.selectedCollection;
let content = '';
if (state.activePanel === 'file' || (state.activePanel === 'collection' && collection === 'files')) {
content = `<div class="inspect-header"><span class="eyebrow">${escapeHtml(file.kind)} OBJECT</span><h2>${escapeHtml(file.name)}</h2><p>${escapeHtml(file.detail)}</p></div><pre class="code-preview" aria-label="Selected file preview">${escapeHtml(file.code)}</pre><button class="panel-action" data-action="select-collection" data-collection="files">← Back to ${escapeHtml(district.path)}</button>`;
} else if (state.activePanel === 'record') {
content = renderRecord(state);
} else if (state.activePanel === 'collection') {
content = collection === 'commits' ? renderCommits(state) : collection === 'issues' ? renderIssues(state) : collection === 'prs' ? renderPullRequests(state) : renderBuild(state);
} else if (state.activePanel === 'inspector' || state.activePanel === 'world') {
const canEnter = nearby?.district?.id === district.id && nearby.inRange;
content = `<div class="inspect-header"><span class="eyebrow">${state.view === 'district' ? 'CURRENT DISTRICT' : 'NEAREST DISTRICT'}</span><h2>${escapeHtml(district.title)}</h2><p>${escapeHtml(district.summary)}</p><div class="district-metrics"><span>${escapeHtml(district.path)}</span><span>${escapeHtml(district.stat)}</span><span style="color:${district.accent}">${Math.round((state.construction[district.id] || 0) * 100)}% BUILT</span></div></div><div class="inspector-actions"><button class="panel-action primary" data-action="open-inspector">INSPECT FILES</button><button class="panel-action" data-action="enter-district" ${canEnter ? '' : 'disabled'}>${canEnter ? 'ENTER DISTRICT' : 'WALK CLOSER TO ENTER'}</button></div><div class="mini-file-list">${district.files.slice(0, 3).map((item) => `<button data-action="select-file" data-file-id="${item.id}"><span>⌘</span>${escapeHtml(item.name)}<b>›</b></button>`).join('')}</div>`;
} else {
content = `<div class="inspect-header"><span class="eyebrow">${escapeHtml(district.path)}</span><h2>Repository objects</h2><p>Select a building, tool, or activity to inspect the physical software around you.</p></div>`;
}
panel.innerHTML = content;
const nearbyPrompt = byId(root, 'nearby-prompt');
if (nearbyPrompt) {
nearbyPrompt.innerHTML = nearby?.district && nearby.inRange ? `<span>NEARBY</span><strong>${escapeHtml(nearby.district.name)}</strong><small>PRESS E / ENTER TO ENTER</small>` : `<span>WALKABLE CITY</span><strong>${escapeHtml(state.player.facing.toUpperCase())}</strong><small>WASD · ARROWS TO NAVIGATE</small>`;
nearbyPrompt.classList.toggle('is-ready', Boolean(nearby?.inRange));
}
}
function renderTerminal(root, state) {
const overlay = byId(root, 'terminal-phone');
if (!overlay) return;
overlay.classList.toggle('is-open', state.terminal.open);
overlay.setAttribute('aria-hidden', String(!state.terminal.open));
const output = byId(root, 'terminal-output');
if (output) output.innerHTML = state.terminal.lines.map((line) => `<div class="terminal-line${line.startsWith('$') ? ' command' : ''}">${escapeHtml(line)}</div>`).join('');
const input = byId(root, 'terminal-input');
if (input && input.value !== state.terminal.command) input.value = '';
}
function renderSearch(root) {
const search = byId(root, 'repo-search');
if (!search) return;
const query = search.value.trim().toLowerCase();
root.querySelectorAll('.world-building').forEach((building) => {
building.classList.toggle('is-search-hidden', Boolean(query) && !building.textContent.toLowerCase().includes(query));
});
}
export function renderHud(root, state, nearby) {
renderAgents(root, state);
renderEvents(root, state);
renderToolbelt(root, state);
renderStatus(root, state);
renderInspector(root, state, nearby);
renderTerminal(root, state);
renderSearch(root);
const drawer = byId(root, 'inspector-drawer');
if (drawer) drawer.classList.toggle('is-open', state.activePanel !== 'world' && state.activePanel !== 'terminal');
const shell = byId(root, 'game-shell');
if (shell) shell.dataset.view = state.view;
setText(root, 'selected-tool-label', (TOOLBELT.find((tool) => tool.id === state.activeTool) || TOOLBELT[0]).name);
}
export function bindUi(root, dispatch) {
root.addEventListener('click', (event) => {
const target = event.target.closest('[data-action], [data-district-id]');
if (!target) return;
const action = target.dataset.action || (target.dataset.districtId ? 'select-district' : '');
if (action === 'select-district') dispatch({ type: EVENT_TYPES.SELECT_DISTRICT, districtId: target.dataset.districtId });
if (action === 'open-inspector') dispatch({ type: EVENT_TYPES.OPEN_INSPECTOR });
if (action === 'enter-district') dispatch({ type: EVENT_TYPES.ENTER_DISTRICT, districtId: target.dataset.districtId });
if (action === 'close-inspector') dispatch({ type: EVENT_TYPES.CLOSE_INSPECTOR });
if (action === 'select-file') dispatch({ type: EVENT_TYPES.SELECT_FILE, fileId: target.dataset.fileId });
if (action === 'select-collection') dispatch({ type: EVENT_TYPES.SELECT_COLLECTION, collection: target.dataset.collection });
if (action === 'commit-detail') dispatch({ type: EVENT_TYPES.SELECT_RECORD, recordType: 'commit', recordId: target.dataset.commitId });
if (action === 'issue-detail') dispatch({ type: EVENT_TYPES.SELECT_RECORD, recordType: 'issue', recordId: target.dataset.issueId });
if (action === 'pr-detail') dispatch({ type: EVENT_TYPES.SELECT_RECORD, recordType: 'pr', recordId: target.dataset.prId });
if (action === 'move') dispatch({ type: EVENT_TYPES.PLAYER_MOVE, dx: target.dataset.dx, dy: target.dataset.dy });
if (action === 'tool') {
const tool = TOOLBELT.find((item) => item.id === target.dataset.toolId);
dispatch({ type: EVENT_TYPES.USE_TOOL, toolId: target.dataset.toolId, message: tool?.verb ? `${tool.name}: ${tool.verb}.` : undefined });
}
if (action === 'open-terminal') dispatch({ type: EVENT_TYPES.OPEN_TERMINAL });
if (action === 'close-terminal') dispatch({ type: EVENT_TYPES.CLOSE_TERMINAL });
if (action === 'command') dispatch({ type: EVENT_TYPES.RUN_COMMAND, command: target.dataset.command, lines: commandTranscript(target.dataset.command, window.gitWorldState || { selectedFileId: 'README.md' }) });
});
root.addEventListener('submit', (event) => {
if (!event.target.matches('#terminal-form')) return;
event.preventDefault();
const input = byId(root, 'terminal-input');
const command = input?.value.trim() || '';
if (command) dispatch({ type: EVENT_TYPES.RUN_COMMAND, command, lines: commandTranscript(command, window.gitWorldState || { selectedFileId: 'README.md' }) });
});
const search = byId(root, 'repo-search');
if (search) search.addEventListener('input', () => renderSearch(root));
}