@jhizzard/termdeck 0.4.3 → 0.4.6
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/README.md +3 -2
- package/package.json +1 -1
- package/packages/cli/src/index.js +17 -2
- package/packages/cli/src/stack.js +467 -0
- package/packages/client/public/app.js +282 -18
- package/packages/client/public/index.html +1 -1
- package/packages/client/public/style.css +73 -7
- package/packages/server/src/index.js +353 -0
- package/packages/server/src/setup/index.js +2 -1
- package/packages/server/src/setup/migration-runner.js +136 -0
|
@@ -1852,6 +1852,15 @@
|
|
|
1852
1852
|
const grid = document.getElementById('termGrid');
|
|
1853
1853
|
grid.className = `grid-container layout-${layout}`;
|
|
1854
1854
|
|
|
1855
|
+
// Orchestrator layout: set column count based on worker panels (total - 1)
|
|
1856
|
+
if (layout === 'orch') {
|
|
1857
|
+
const panelCount = grid.querySelectorAll('.term-panel').length;
|
|
1858
|
+
const workerCount = Math.max(0, panelCount - 1);
|
|
1859
|
+
grid.setAttribute('data-orch-cols', String(workerCount || panelCount));
|
|
1860
|
+
} else {
|
|
1861
|
+
grid.removeAttribute('data-orch-cols');
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1855
1864
|
// Remove focus/half states
|
|
1856
1865
|
document.querySelectorAll('.term-panel').forEach(p => {
|
|
1857
1866
|
p.classList.remove('focused', 'primary');
|
|
@@ -2019,6 +2028,20 @@
|
|
|
2019
2028
|
}
|
|
2020
2029
|
}
|
|
2021
2030
|
|
|
2031
|
+
// Debounce: collapse a burst of calls (e.g. a window-resize drag firing
|
|
2032
|
+
// dozens of events/sec) into a single invocation after `wait` ms of quiet.
|
|
2033
|
+
function debounce(fn, wait) {
|
|
2034
|
+
let timer = null;
|
|
2035
|
+
return function debounced(...args) {
|
|
2036
|
+
if (timer) clearTimeout(timer);
|
|
2037
|
+
timer = setTimeout(() => { timer = null; fn.apply(this, args); }, wait);
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
const fitAllDebounced = debounce(() => {
|
|
2042
|
+
requestAnimationFrame(() => fitAll());
|
|
2043
|
+
}, 100);
|
|
2044
|
+
|
|
2022
2045
|
// ===== ONBOARDING TOUR =====
|
|
2023
2046
|
// Spotlight + tooltip walkthrough of every TermDeck surface. Runs once on
|
|
2024
2047
|
// first visit (localStorage gate) and replays on demand via the "how this
|
|
@@ -2038,7 +2061,7 @@
|
|
|
2038
2061
|
{
|
|
2039
2062
|
target: '.topbar-center',
|
|
2040
2063
|
title: 'Layout modes',
|
|
2041
|
-
body: `Eight preset grid layouts — <kbd>1x1</kbd> through <kbd>4x2</kbd>, <strong>orch</strong> (
|
|
2064
|
+
body: `Eight preset grid layouts — <kbd>1x1</kbd> through <kbd>4x2</kbd>, <strong>orch</strong> (4 workers across the top + 1 full-width orchestrator across the bottom, for 4+1 sprints), plus <strong>control</strong> (aggregate activity feed). Click any layout to switch instantly; all terminals re-fit to the new grid. Keyboard shortcuts <kbd>Cmd+Shift+1</kbd>–<kbd>Cmd+Shift+7</kbd> (or <kbd>Ctrl+Shift+1</kbd>–<kbd>7</kbd>) do the same.`,
|
|
2042
2065
|
},
|
|
2043
2066
|
{
|
|
2044
2067
|
target: '#termSwitcher',
|
|
@@ -2555,15 +2578,21 @@
|
|
|
2555
2578
|
? 'active'
|
|
2556
2579
|
: status === 'partial' ? 'partial' : 'not configured';
|
|
2557
2580
|
const isCurrent = Number(tier.id) === currentTier + 1 && status !== 'active';
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2581
|
+
|
|
2582
|
+
// Sprint 23 T2: tier 2 renders a credential form instead of CLI commands
|
|
2583
|
+
// when not active, so users can paste URL/keys directly in the browser.
|
|
2584
|
+
const isCredentialForm = tier.id === '2' && status !== 'active';
|
|
2585
|
+
const cmds = isCredentialForm
|
|
2586
|
+
? renderSetupCredentialForm()
|
|
2587
|
+
: (status === 'active' || tier.commands.length === 0)
|
|
2588
|
+
? ''
|
|
2589
|
+
: `<div class="setup-cmds">${tier.commands.map((c) => {
|
|
2590
|
+
const copyable = /^termdeck\s/.test(c);
|
|
2591
|
+
return `<div class="setup-cmd">
|
|
2592
|
+
<code>${escapeHtml(c)}</code>
|
|
2593
|
+
${copyable ? `<button type="button" class="setup-copy" data-copy="${escapeHtml(c)}">copy</button>` : ''}
|
|
2594
|
+
</div>`;
|
|
2595
|
+
}).join('')}</div>`;
|
|
2567
2596
|
|
|
2568
2597
|
return `
|
|
2569
2598
|
<div class="setup-tier setup-tier-${status}${isCurrent ? ' setup-tier-next' : ''}">
|
|
@@ -2600,26 +2629,263 @@
|
|
|
2600
2629
|
}).catch(() => {});
|
|
2601
2630
|
});
|
|
2602
2631
|
});
|
|
2632
|
+
|
|
2633
|
+
const credSaveBtn = tiersEl.querySelector('#setupCredSave');
|
|
2634
|
+
if (credSaveBtn) {
|
|
2635
|
+
credSaveBtn.addEventListener('click', submitSetupCredentials);
|
|
2636
|
+
}
|
|
2637
|
+
const credForm = tiersEl.querySelector('#setupCredForm');
|
|
2638
|
+
if (credForm) {
|
|
2639
|
+
credForm.addEventListener('keydown', (e) => {
|
|
2640
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
2641
|
+
e.preventDefault();
|
|
2642
|
+
submitSetupCredentials();
|
|
2643
|
+
}
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
// Sprint 23 T2 — credential form for Tier 2.
|
|
2649
|
+
// Uses inline styles because wizard CSS is owned by T1 this sprint; these
|
|
2650
|
+
// stubs fall back to CSS vars already defined in style.css so they inherit
|
|
2651
|
+
// theme colours automatically.
|
|
2652
|
+
function renderSetupCredentialForm() {
|
|
2653
|
+
const inputStyle = 'width:100%;padding:6px 8px;background:var(--bg, #0f1017);color:var(--text, #e2e3e8);border:1px solid var(--border, #2a2c3a);border-radius:4px;font-family:monospace;font-size:12px;margin-top:4px;box-sizing:border-box;';
|
|
2654
|
+
const labelStyle = 'display:block;font-size:11px;color:var(--text-dim, #8a8d9a);margin-top:10px;';
|
|
2655
|
+
const helpStyle = 'font-size:10px;color:var(--text-dim, #8a8d9a);margin-top:3px;min-height:12px;';
|
|
2656
|
+
const btnStyle = 'margin-top:14px;padding:8px 18px;background:var(--accent, #7aa2f7);color:#000;border:none;border-radius:4px;font-weight:600;cursor:pointer;font-size:12px;';
|
|
2657
|
+
return `
|
|
2658
|
+
<form class="setup-credentials" id="setupCredForm" autocomplete="off" style="margin-top:10px;padding:12px;background:rgba(0,0,0,0.2);border-radius:6px;border:1px solid var(--border, #2a2c3a);">
|
|
2659
|
+
<div style="font-size:11px;color:var(--text-dim, #8a8d9a);margin-bottom:4px;">
|
|
2660
|
+
Paste your Supabase + OpenAI credentials. They are written to <code>~/.termdeck/secrets.env</code> (chmod 600) and never leave this machine.
|
|
2661
|
+
</div>
|
|
2662
|
+
<label style="${labelStyle}">
|
|
2663
|
+
Supabase URL
|
|
2664
|
+
<input type="text" name="supabaseUrl" placeholder="https://xxxxxx.supabase.co" spellcheck="false" autocapitalize="off" autocorrect="off" style="${inputStyle}">
|
|
2665
|
+
</label>
|
|
2666
|
+
<div class="setup-cred-status" data-field="supabase" style="${helpStyle}"></div>
|
|
2667
|
+
<label style="${labelStyle}">
|
|
2668
|
+
Service Role Key
|
|
2669
|
+
<input type="password" name="supabaseServiceRoleKey" placeholder="sb_secret_..." spellcheck="false" autocomplete="new-password" style="${inputStyle}">
|
|
2670
|
+
</label>
|
|
2671
|
+
<label style="${labelStyle}">
|
|
2672
|
+
OpenAI API Key
|
|
2673
|
+
<input type="password" name="openaiApiKey" placeholder="sk-proj-..." spellcheck="false" autocomplete="new-password" style="${inputStyle}">
|
|
2674
|
+
</label>
|
|
2675
|
+
<div class="setup-cred-status" data-field="openai" style="${helpStyle}"></div>
|
|
2676
|
+
<label style="${labelStyle}">
|
|
2677
|
+
Database URL
|
|
2678
|
+
<input type="password" name="databaseUrl" placeholder="postgresql://postgres:...@...pooler.supabase.com:6543/postgres" spellcheck="false" autocomplete="new-password" style="${inputStyle}">
|
|
2679
|
+
</label>
|
|
2680
|
+
<div class="setup-cred-status" data-field="database" style="${helpStyle}"></div>
|
|
2681
|
+
<label style="${labelStyle}">
|
|
2682
|
+
Anthropic API Key <span style="opacity:0.6;">(optional — powers session-log summaries)</span>
|
|
2683
|
+
<input type="password" name="anthropicApiKey" placeholder="sk-ant-..." spellcheck="false" autocomplete="new-password" style="${inputStyle}">
|
|
2684
|
+
</label>
|
|
2685
|
+
<div class="setup-cred-error" id="setupCredError" style="font-size:11px;margin-top:10px;min-height:14px;"></div>
|
|
2686
|
+
<button type="button" class="setup-cred-save" id="setupCredSave" style="${btnStyle}">Save & Connect</button>
|
|
2687
|
+
</form>
|
|
2688
|
+
`;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
async function submitSetupCredentials() {
|
|
2692
|
+
const form = document.getElementById('setupCredForm');
|
|
2693
|
+
if (!form) return;
|
|
2694
|
+
const btn = document.getElementById('setupCredSave');
|
|
2695
|
+
const errorEl = document.getElementById('setupCredError');
|
|
2696
|
+
const statuses = form.querySelectorAll('.setup-cred-status');
|
|
2697
|
+
|
|
2698
|
+
// Reset state
|
|
2699
|
+
if (errorEl) { errorEl.textContent = ''; errorEl.style.color = ''; }
|
|
2700
|
+
statuses.forEach((el) => { el.textContent = ''; el.style.color = ''; });
|
|
2701
|
+
|
|
2702
|
+
const body = {
|
|
2703
|
+
supabaseUrl: (form.supabaseUrl.value || '').trim(),
|
|
2704
|
+
supabaseServiceRoleKey: (form.supabaseServiceRoleKey.value || '').trim(),
|
|
2705
|
+
openaiApiKey: (form.openaiApiKey.value || '').trim(),
|
|
2706
|
+
anthropicApiKey: (form.anthropicApiKey.value || '').trim(),
|
|
2707
|
+
databaseUrl: (form.databaseUrl.value || '').trim()
|
|
2708
|
+
};
|
|
2709
|
+
|
|
2710
|
+
const missing = [];
|
|
2711
|
+
if (!body.supabaseUrl) missing.push('Supabase URL');
|
|
2712
|
+
if (!body.supabaseServiceRoleKey) missing.push('Service Role Key');
|
|
2713
|
+
if (!body.openaiApiKey) missing.push('OpenAI API Key');
|
|
2714
|
+
if (!body.databaseUrl) missing.push('Database URL');
|
|
2715
|
+
if (missing.length) {
|
|
2716
|
+
if (errorEl) {
|
|
2717
|
+
errorEl.textContent = `Please fill in: ${missing.join(', ')} (Anthropic is optional).`;
|
|
2718
|
+
errorEl.style.color = 'var(--red, #f7768e)';
|
|
2719
|
+
}
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
if (btn) { btn.disabled = true; btn.textContent = 'Validating…'; }
|
|
2724
|
+
try {
|
|
2725
|
+
const res = await fetch(`${API}/api/setup/configure`, {
|
|
2726
|
+
method: 'POST',
|
|
2727
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2728
|
+
body: JSON.stringify(body)
|
|
2729
|
+
});
|
|
2730
|
+
let data = {};
|
|
2731
|
+
try { data = await res.json(); } catch { data = {}; }
|
|
2732
|
+
|
|
2733
|
+
if (data && data.validation) {
|
|
2734
|
+
for (const field of ['supabase', 'openai', 'database']) {
|
|
2735
|
+
const result = data.validation[field];
|
|
2736
|
+
if (!result) continue;
|
|
2737
|
+
const el = form.querySelector(`.setup-cred-status[data-field="${field}"]`);
|
|
2738
|
+
if (el) {
|
|
2739
|
+
el.textContent = (result.ok ? '✓ ' : '✗ ') + (result.detail || '');
|
|
2740
|
+
el.style.color = result.ok ? 'var(--green, #9ece6a)' : 'var(--red, #f7768e)';
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
if (res.ok && data && data.success) {
|
|
2746
|
+
if (errorEl) {
|
|
2747
|
+
errorEl.textContent = (data.detail || 'Credentials saved.') + ' Running migrations…';
|
|
2748
|
+
errorEl.style.color = 'var(--green, #9ece6a)';
|
|
2749
|
+
}
|
|
2750
|
+
if (btn) btn.textContent = 'Migrating…';
|
|
2751
|
+
await runSetupMigrations(errorEl);
|
|
2752
|
+
setTimeout(() => { refreshSetupStatus(); }, 600);
|
|
2753
|
+
} else {
|
|
2754
|
+
if (errorEl) {
|
|
2755
|
+
errorEl.textContent = (data && data.error) || `Configuration failed (HTTP ${res.status})`;
|
|
2756
|
+
errorEl.style.color = 'var(--red, #f7768e)';
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
} catch (err) {
|
|
2760
|
+
if (errorEl) {
|
|
2761
|
+
errorEl.textContent = `Request failed: ${err && err.message ? err.message : String(err)}`;
|
|
2762
|
+
errorEl.style.color = 'var(--red, #f7768e)';
|
|
2763
|
+
}
|
|
2764
|
+
} finally {
|
|
2765
|
+
if (btn) { btn.disabled = false; btn.textContent = 'Save & Connect'; }
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
// Sprint 23 audit fix: chain /api/setup/migrate after credentials save so
|
|
2770
|
+
// the wizard fulfills the sprint mission ("write config AND run migrations")
|
|
2771
|
+
// in a single click instead of leaving the user to run `termdeck init`.
|
|
2772
|
+
async function runSetupMigrations(statusEl) {
|
|
2773
|
+
try {
|
|
2774
|
+
const res = await fetch(`${API}/api/setup/migrate`, {
|
|
2775
|
+
method: 'POST',
|
|
2776
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2777
|
+
body: '{}'
|
|
2778
|
+
});
|
|
2779
|
+
let data = {};
|
|
2780
|
+
try { data = await res.json(); } catch { data = {}; }
|
|
2781
|
+
if (statusEl) {
|
|
2782
|
+
if (res.ok && data && data.ok) {
|
|
2783
|
+
statusEl.textContent = `Migrations applied (${data.applied}/${data.total}). Tier 2 active.`;
|
|
2784
|
+
statusEl.style.color = 'var(--green, #9ece6a)';
|
|
2785
|
+
} else {
|
|
2786
|
+
const applied = (data && data.applied != null) ? `${data.applied}/${data.total} applied` : '';
|
|
2787
|
+
const detail = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
|
2788
|
+
statusEl.textContent = `Migrations failed: ${detail}${applied ? ` (${applied})` : ''}`;
|
|
2789
|
+
statusEl.style.color = 'var(--red, #f7768e)';
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
} catch (err) {
|
|
2793
|
+
if (statusEl) {
|
|
2794
|
+
statusEl.textContent = `Migration request failed: ${err && err.message ? err.message : String(err)}`;
|
|
2795
|
+
statusEl.style.color = 'var(--red, #f7768e)';
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2603
2798
|
}
|
|
2604
2799
|
|
|
2605
2800
|
async function maybeAutoOpenSetupWizard() {
|
|
2606
|
-
//
|
|
2607
|
-
//
|
|
2608
|
-
// endpoint doesn't exist (server predates Sprint 19 T1).
|
|
2801
|
+
// First-run users get the full wizard; returning users with at least
|
|
2802
|
+
// tier 1 configured get a brief welcome-back toast (Sprint 23 T4).
|
|
2803
|
+
// Silent-fail if the endpoint doesn't exist (server predates Sprint 19 T1).
|
|
2609
2804
|
try {
|
|
2610
2805
|
const res = await fetch(`${API}/api/setup`);
|
|
2611
2806
|
if (!res.ok) return;
|
|
2612
2807
|
const data = await res.json();
|
|
2613
|
-
if (data
|
|
2808
|
+
if (!data || tourState.active) return;
|
|
2809
|
+
if (data.firstRun) {
|
|
2614
2810
|
setTimeout(() => {
|
|
2615
2811
|
if (!tourState.active && !setupModalOpen) openSetupModal();
|
|
2616
2812
|
}, 800);
|
|
2813
|
+
} else if (Number(data.tier) >= 1) {
|
|
2814
|
+
setTimeout(() => {
|
|
2815
|
+
if (!tourState.active && !setupModalOpen) showWelcomeBackToast(data);
|
|
2816
|
+
}, 800);
|
|
2617
2817
|
}
|
|
2618
2818
|
} catch {
|
|
2619
2819
|
// API not available — skip silently
|
|
2620
2820
|
}
|
|
2621
2821
|
}
|
|
2622
2822
|
|
|
2823
|
+
// Sprint 23 T4: returning-user welcome-back toast. Non-blocking, dismisses
|
|
2824
|
+
// on click or after 5s. Inline-styled so we don't touch style.css (T1's
|
|
2825
|
+
// ownership). The config button still opens the full wizard.
|
|
2826
|
+
function showWelcomeBackToast(data) {
|
|
2827
|
+
const existing = document.getElementById('welcomeBackToast');
|
|
2828
|
+
if (existing) existing.remove();
|
|
2829
|
+
|
|
2830
|
+
const tier = Number(data.tier) || 1;
|
|
2831
|
+
const tierNames = {
|
|
2832
|
+
1: 'TermDeck core',
|
|
2833
|
+
2: 'TermDeck + Mnestra',
|
|
2834
|
+
3: 'TermDeck + Mnestra + Rumen',
|
|
2835
|
+
4: 'Full stack + projects'
|
|
2836
|
+
};
|
|
2837
|
+
const stackLabel = tierNames[tier] || 'TermDeck';
|
|
2838
|
+
|
|
2839
|
+
const tiers = data.tiers || {};
|
|
2840
|
+
const mnestraDetail = (tiers[2] && tiers[2].detail) || '';
|
|
2841
|
+
const rumenDetail = (tiers[3] && tiers[3].detail) || '';
|
|
2842
|
+
|
|
2843
|
+
const memMatch = mnestraDetail.match(/([\d,]+)\s*memories/i);
|
|
2844
|
+
const memoryCount = memMatch ? memMatch[1] : null;
|
|
2845
|
+
const rumenMatch = rumenDetail.match(/last job\s+([^,]+?)(?:\s+ago|,|$)/i);
|
|
2846
|
+
const rumenAgo = rumenMatch ? rumenMatch[1].trim() : null;
|
|
2847
|
+
|
|
2848
|
+
const parts = [`Stack: ${stackLabel}.`];
|
|
2849
|
+
if (memoryCount) parts.push(`${memoryCount} memories.`);
|
|
2850
|
+
if (rumenAgo) parts.push(`Last Rumen job: ${rumenAgo} ago.`);
|
|
2851
|
+
|
|
2852
|
+
const toast = document.createElement('div');
|
|
2853
|
+
toast.id = 'welcomeBackToast';
|
|
2854
|
+
toast.setAttribute('role', 'status');
|
|
2855
|
+
toast.style.cssText = [
|
|
2856
|
+
'position:fixed',
|
|
2857
|
+
'top:64px',
|
|
2858
|
+
'right:16px',
|
|
2859
|
+
'z-index:9999',
|
|
2860
|
+
'max-width:360px',
|
|
2861
|
+
'padding:10px 14px',
|
|
2862
|
+
'background:rgba(20,22,28,0.95)',
|
|
2863
|
+
'color:#cdd6f4',
|
|
2864
|
+
'border:1px solid rgba(137,180,250,0.35)',
|
|
2865
|
+
'border-radius:6px',
|
|
2866
|
+
'box-shadow:0 4px 18px rgba(0,0,0,0.4)',
|
|
2867
|
+
'font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace',
|
|
2868
|
+
'cursor:pointer',
|
|
2869
|
+
'opacity:0',
|
|
2870
|
+
'transition:opacity 180ms ease'
|
|
2871
|
+
].join(';');
|
|
2872
|
+
toast.innerHTML = `
|
|
2873
|
+
<div style="font-weight:600;color:#89b4fa;margin-bottom:2px">Welcome back</div>
|
|
2874
|
+
<div>${parts.map(p => escapeHtml(p)).join(' ')}</div>
|
|
2875
|
+
`;
|
|
2876
|
+
|
|
2877
|
+
document.body.appendChild(toast);
|
|
2878
|
+
requestAnimationFrame(() => { toast.style.opacity = '1'; });
|
|
2879
|
+
|
|
2880
|
+
const dismiss = () => {
|
|
2881
|
+
clearTimeout(timer);
|
|
2882
|
+
toast.style.opacity = '0';
|
|
2883
|
+
setTimeout(() => toast.remove(), 200);
|
|
2884
|
+
};
|
|
2885
|
+
toast.addEventListener('click', dismiss);
|
|
2886
|
+
const timer = setTimeout(dismiss, 5000);
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2623
2889
|
function fmtUptime(sec) {
|
|
2624
2890
|
const s = Math.floor(sec);
|
|
2625
2891
|
const h = Math.floor(s / 3600);
|
|
@@ -2675,10 +2941,8 @@
|
|
|
2675
2941
|
if (e.target.id === 'tourBackdrop') endTour();
|
|
2676
2942
|
});
|
|
2677
2943
|
|
|
2678
|
-
// Resize handler
|
|
2679
|
-
window.addEventListener('resize',
|
|
2680
|
-
requestAnimationFrame(() => fitAll());
|
|
2681
|
-
});
|
|
2944
|
+
// Resize handler — debounced so a resize drag doesn't re-fit every frame.
|
|
2945
|
+
window.addEventListener('resize', fitAllDebounced);
|
|
2682
2946
|
|
|
2683
2947
|
// Re-render the tour on viewport changes so the spotlight tracks resizes
|
|
2684
2948
|
window.addEventListener('resize', () => {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
<button class="layout-btn" data-layout="3x2">3x2</button>
|
|
42
42
|
<button class="layout-btn" data-layout="2x4">2x4</button>
|
|
43
43
|
<button class="layout-btn" data-layout="4x2">4x2</button>
|
|
44
|
-
<button class="layout-btn" data-layout="orch" title="Orchestrator: 1
|
|
44
|
+
<button class="layout-btn" data-layout="orch" title="Orchestrator: 4 workers across top, 1 full-width orchestrator across bottom">orch</button>
|
|
45
45
|
<button class="layout-btn control-btn" data-layout="control" title="Aggregate activity feed">control</button>
|
|
46
46
|
</div>
|
|
47
47
|
</div>
|
|
@@ -312,14 +312,25 @@
|
|
|
312
312
|
.grid-container.layout-4x2 { grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; }
|
|
313
313
|
.grid-container.layout-2x4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr 1fr 1fr; }
|
|
314
314
|
|
|
315
|
-
/* Orchestrator:
|
|
315
|
+
/* Orchestrator: workers across the top (60%), one full-width orchestrator
|
|
316
|
+
panel across the bottom (40%). The last panel is always the orchestrator.
|
|
317
|
+
JS sets a data-orch-cols attribute on the grid to match the worker count. */
|
|
318
|
+
/* Orchestrator: 2x2 workers on top (60%), full-width orchestrator bottom (40%) */
|
|
316
319
|
.grid-container.layout-orch {
|
|
317
|
-
grid-template-columns:
|
|
318
|
-
grid-template-rows:
|
|
320
|
+
grid-template-columns: repeat(2, 1fr);
|
|
321
|
+
grid-template-rows: 2fr 2fr 2fr;
|
|
319
322
|
}
|
|
320
|
-
.grid-container.layout-orch .term-panel:
|
|
321
|
-
grid-
|
|
322
|
-
grid-
|
|
323
|
+
.grid-container.layout-orch .term-panel:last-child {
|
|
324
|
+
grid-column: 1 / -1;
|
|
325
|
+
grid-row: 3;
|
|
326
|
+
}
|
|
327
|
+
/* Single panel: fill entire grid */
|
|
328
|
+
.grid-container.layout-orch[data-orch-cols="0"] {
|
|
329
|
+
grid-template-rows: 1fr;
|
|
330
|
+
grid-template-columns: 1fr;
|
|
331
|
+
}
|
|
332
|
+
.grid-container.layout-orch[data-orch-cols="0"] .term-panel:last-child {
|
|
333
|
+
grid-row: 1;
|
|
323
334
|
}
|
|
324
335
|
|
|
325
336
|
/* Focus mode: single terminal fills the grid */
|
|
@@ -344,7 +355,8 @@
|
|
|
344
355
|
border-radius: var(--tg-radius);
|
|
345
356
|
overflow: hidden;
|
|
346
357
|
transition: border-color 0.2s;
|
|
347
|
-
min-height:
|
|
358
|
+
min-height: 150px;
|
|
359
|
+
min-width: 200px;
|
|
348
360
|
}
|
|
349
361
|
|
|
350
362
|
.term-panel:hover { border-color: var(--tg-border-active); }
|
|
@@ -2278,3 +2290,57 @@
|
|
|
2278
2290
|
::-webkit-scrollbar-track { background: transparent; }
|
|
2279
2291
|
::-webkit-scrollbar-thumb { background: var(--tg-border); border-radius: 3px; }
|
|
2280
2292
|
::-webkit-scrollbar-thumb:hover { background: var(--tg-border-active); }
|
|
2293
|
+
|
|
2294
|
+
/* ===== RESPONSIVE BREAKPOINTS (Sprint 23 T1) =====
|
|
2295
|
+
13" MacBook Air (1280x800 / 1440x900) up through 27" iMac (2560x1440).
|
|
2296
|
+
Goals: (1) compact toolbar on short viewports to reclaim vertical space
|
|
2297
|
+
for terminals, (2) collapse wide grids (3x2, 4x2) to 2 columns on
|
|
2298
|
+
narrow viewports so panels never drop below readable size (combined
|
|
2299
|
+
with the 200px min-width on .term-panel). */
|
|
2300
|
+
|
|
2301
|
+
/* Short viewports (13" laptops at 1280x800 / 1440x900): shrink the two
|
|
2302
|
+
toolbar rows from 74px to 64px total, and tighten button padding so
|
|
2303
|
+
the quick-launch + layout buttons still fit on one row at 1280px. */
|
|
2304
|
+
@media (max-height: 800px) {
|
|
2305
|
+
.topbar-row-1 { height: 36px; }
|
|
2306
|
+
.topbar-row-2 { height: 28px; }
|
|
2307
|
+
.layout-btn { padding: 3px 7px; font-size: 10px; }
|
|
2308
|
+
.topbar-right button { padding: 3px 6px; font-size: 10px; }
|
|
2309
|
+
.topbar-ql-btn { padding: 3px 6px; font-size: 10px; }
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/* Narrow viewports: 3x2 (6 panels) and 4x2 (8 panels) become
|
|
2313
|
+
too cramped below ~1280px given the 200px panel min-width. Degrade to
|
|
2314
|
+
2 columns and add rows so every panel stays visible. */
|
|
2315
|
+
@media (max-width: 1280px) {
|
|
2316
|
+
.grid-container.layout-3x2 {
|
|
2317
|
+
grid-template-columns: repeat(2, 1fr);
|
|
2318
|
+
grid-template-rows: repeat(3, 1fr);
|
|
2319
|
+
}
|
|
2320
|
+
.grid-container.layout-4x2 {
|
|
2321
|
+
grid-template-columns: repeat(2, 1fr);
|
|
2322
|
+
grid-template-rows: repeat(4, 1fr);
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
/* Very narrow viewports (rare — sub-1024 widths): also collapse 2x4
|
|
2327
|
+
and orch to something sane. 2x4 already uses 2 columns, so we just
|
|
2328
|
+
let it scroll; orch falls back to a single-column stack. */
|
|
2329
|
+
@media (max-width: 900px) {
|
|
2330
|
+
.grid-container.layout-orch {
|
|
2331
|
+
grid-template-columns: 1fr;
|
|
2332
|
+
grid-template-rows: repeat(var(--orch-worker-rows, 3), 1fr) 1.2fr;
|
|
2333
|
+
}
|
|
2334
|
+
.grid-container.layout-orch .term-panel:last-child {
|
|
2335
|
+
grid-column: 1;
|
|
2336
|
+
}
|
|
2337
|
+
.grid-container { overflow: auto; }
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
/* Large viewports (27" iMac and ultrawide monitors): keep panels filling
|
|
2341
|
+
their allocated grid area. Raise the panel floor so a single terminal
|
|
2342
|
+
in 1x1 mode on a 2560-wide display still fills its space rather than
|
|
2343
|
+
sitting at the 150px min. */
|
|
2344
|
+
@media (min-width: 1920px) {
|
|
2345
|
+
.term-panel { min-height: 200px; min-width: 300px; }
|
|
2346
|
+
}
|