// startup-panel.js — 6 階段啟動進度面板
// 對齊 Design Spec v2.1 startup-progress.md
import { t } from './i18n.js';
const TOTAL_STAGES = 6;
// 本地狀態:stages[1..6] = {status, startedAt, slow, manualHint}
const stages = {};
for (let i = 1; i <= TOTAL_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false };
}
// 啟動流程旗標:stage 5 skipped → stage 6 進入「manual hint」模式
// 對齊 Design Spec §4.1「階段 6 Settings OFF 情境」
let manualMode = false;
// 觀察者:當進入 manual mode 時通知外層(app.js)加 pulse / enable primary CTA
const manualModeListeners = new Set();
export function onManualModeChange(fn) {
manualModeListeners.add(fn);
return () => manualModeListeners.delete(fn);
}
function emitManualMode(enabled) {
manualModeListeners.forEach((fn) => {
try { fn(enabled); } catch (e) { console.warn('manualMode listener error:', e); }
});
}
// ---------- 渲染 stage 列 skeleton ----------
export function renderStages() {
const container = document.getElementById('stages');
if (!container) return;
container.innerHTML = '';
for (let i = 1; i <= TOTAL_STAGES; i++) {
const row = document.createElement('div');
row.className = 'stage-item';
row.dataset.stage = String(i);
row.dataset.state = stages[i].status;
row.innerHTML = `
${i} ·
`;
container.appendChild(row);
paintStageRow(i);
}
// 更新標題
const titleEl = document.getElementById('startup-title');
if (titleEl) titleEl.textContent = t('startup.panel.title');
paintProgressBar();
}
function paintStageRow(stage) {
const row = document.querySelector(`.stage-item[data-stage="${stage}"]`);
if (!row) return;
const st = stages[stage];
row.dataset.state = st.slow && st.status === 'running' ? 'running-slow' : st.status;
const iconEl = row.querySelector('.stage-icon');
const labelPrimary = row.querySelector('.stage-label-primary');
const labelSecondary = row.querySelector('.stage-label-secondary');
const statusEl = row.querySelector('.stage-status');
const hintEl = row.querySelector('.stage-hint');
// icon
let icon = '○';
switch (st.status) {
case 'pending': icon = '○'; break;
case 'running': icon = ''; break;
case 'completed':
case 'done': icon = '✓'; break;
case 'failed': icon = '✕'; break;
case 'skipped': icon = '⏭'; break;
}
iconEl.innerHTML = icon;
// label
labelPrimary.textContent = t(`startup.stage.${stage}.label`);
// Stage 5 skipped → label secondary 顯示 skipped 原因
// Stage 6 manual mode → description 改顯示 manual hint
if (stage === 5 && st.status === 'skipped') {
labelSecondary.textContent = t('startup.stage.5.skipped.label');
} else if (stage === 6 && st.manualHint) {
labelSecondary.textContent = t('startup.stage.6.manualHint');
} else {
labelSecondary.textContent = t(`startup.stage.${stage}.description`);
}
// status text
const statusMap = {
pending: 'startup.status.pending',
running: 'startup.status.running',
completed: 'startup.status.done',
done: 'startup.status.done',
failed: 'startup.status.failed',
skipped: 'startup.status.skipped',
};
statusEl.textContent = t(statusMap[st.status] || 'startup.status.pending');
// slow hint line
// Design Spec §4.1:stage 6 manual mode 不套 20 秒 retry hint(等待人為動作)
if (st.slow && st.status === 'running' && !st.manualHint) {
hintEl.textContent = t('startup.timeout.message');
hintEl.removeAttribute('hidden');
} else {
hintEl.setAttribute('hidden', '');
}
}
function paintProgressBar() {
const bar = document.getElementById('progress-bar');
const textEl = document.getElementById('progress-text');
if (!bar || !textEl) return;
bar.innerHTML = '';
let completed = 0;
let current = 0;
for (let i = 1; i <= TOTAL_STAGES; i++) {
const cell = document.createElement('span');
cell.className = 'progress-cell state-' + stages[i].status;
bar.appendChild(cell);
if (stages[i].status === 'completed' || stages[i].status === 'done' || stages[i].status === 'skipped') completed++;
if (stages[i].status === 'running') current = i;
}
const progressNum = current || completed;
// 若有 slow 狀態,顯示 elapsed
let elapsedText = '';
for (let i = 1; i <= TOTAL_STAGES; i++) {
if (stages[i].slow && stages[i].status === 'running' && stages[i].startedAt) {
const elapsed = Math.floor((Date.now() - stages[i].startedAt) / 1000);
elapsedText = t('startup.progressWithElapsed', { current: progressNum, max: TOTAL_STAGES, elapsed });
break;
}
}
textEl.textContent = elapsedText || t('startup.progressLabel', { current: progressNum, max: TOTAL_STAGES });
// aria
const panel = document.getElementById('startup-panel');
if (panel) panel.setAttribute('aria-valuenow', String(progressNum));
}
// ---------- Panel 顯示 / 隱藏 ----------
export function showStartupPanel() {
const panel = document.getElementById('startup-panel');
if (!panel) return;
panel.removeAttribute('hidden');
// Error mode 預設隱藏
document.getElementById('startup-error').setAttribute('hidden', '');
}
export function hideStartupPanel() {
const panel = document.getElementById('startup-panel');
if (!panel) return;
panel.setAttribute('hidden', '');
// reset
for (let i = 1; i <= TOTAL_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false };
}
if (manualMode) {
manualMode = false;
emitManualMode(false);
}
renderStages();
}
// ---------- 更新階段(收到 startup:progress)----------
export function updateStage(ev) {
if (!ev || !ev.stage) return;
const n = ev.stage;
if (n < 1 || n > TOTAL_STAGES) return;
stages[n].status = ev.status || 'pending';
if (ev.startedAt) stages[n].startedAt = ev.startedAt;
if (stages[n].status !== 'running') stages[n].slow = false;
// Design Spec §4.1:stage 5 skipped → 立即推 stage 6 進入 manual hint mode
// (此時 Go 層也會送 stage 6 running,但這裡先行設定 manualHint 旗標,
// 以便收到後續 stage 6 progress 時仍能維持 hint 文案。)
if (n === 5 && stages[5].status === 'skipped') {
enterManualMode();
}
// stage 6 收到 done → 離開 manual mode(使用者已成功點擊並建立連線)
if (n === 6 && (stages[6].status === 'completed' || stages[6].status === 'done')) {
if (manualMode) {
manualMode = false;
stages[6].manualHint = false;
emitManualMode(false);
}
}
// running 狀態下,把其他仍 pending 的顯示維持
paintStageRow(n);
paintProgressBar();
// 無障礙 live region
const live = document.getElementById('startup-live');
if (live) {
const statusKey = {
pending: 'startup.status.pending',
running: 'startup.status.running',
completed: 'startup.status.done',
failed: 'startup.status.failed',
skipped: 'startup.status.skipped',
}[stages[n].status] || 'startup.status.pending';
live.textContent = `${n} · ${t(`startup.stage.${n}.label`)} · ${t(statusKey)}`;
}
}
// ---------- 進入 Manual Hint 模式(stage 5 skipped → stage 6 等待人為動作)----------
// 對齊 Design Spec §4.1「階段 6 Settings OFF 情境」
function enterManualMode() {
if (manualMode) return;
manualMode = true;
// 把 stage 6 標成 running + manualHint(若 Go 層還沒送 stage 6 running,這裡主動 paint)
if (stages[6].status === 'pending') {
stages[6].status = 'running';
stages[6].startedAt = Date.now();
}
stages[6].manualHint = true;
// 不套 soft timeout(因為是等人為動作)
stages[6].slow = false;
paintStageRow(6);
paintProgressBar();
emitManualMode(true);
}
// 外部呼叫:查詢是否處於 manual mode
export function isManualMode() {
return manualMode;
}
// ---------- 階段 soft timeout ----------
export function markStageTimeout(ev) {
if (!ev || !ev.stage) return;
const n = ev.stage;
if (!stages[n]) return;
// Design Spec §4.1:stage 6 manual mode 不套 20 秒 retry hint
if (n === 6 && stages[6].manualHint) return;
stages[n].slow = true;
if (!stages[n].startedAt) stages[n].startedAt = Date.now();
paintStageRow(n);
paintProgressBar();
}
// ---------- Error mode ----------
export function showStartupError(ev) {
const errorBlock = document.getElementById('startup-error');
const descEl = document.getElementById('error-desc');
const stageEl = document.getElementById('error-stage');
if (!errorBlock) return;
const cause = ev && ev.cause || 'stage-failure';
const failedStage = ev && ev.stage || 0;
if (cause === 'total-timeout') {
descEl.textContent = t('startup.error.description.timeout');
} else {
const stageLabel = failedStage ? t(`startup.stage.${failedStage}.label`) : '';
descEl.textContent = t('startup.error.description.stageFailed', { stageLabel });
}
if (failedStage) {
stageEl.textContent = t('startup.error.failedStage', {
n: failedStage,
label: t(`startup.stage.${failedStage}.label`),
});
// 標記該階段為 failed
stages[failedStage].status = 'failed';
paintStageRow(failedStage);
paintProgressBar();
}
errorBlock.removeAttribute('hidden');
// 確保 panel 顯示
const panel = document.getElementById('startup-panel');
if (panel) panel.removeAttribute('hidden');
}