<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Nebula – Browser in your Browser</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23aaf' d='M2 10a6 6 0 0 1 10.9-3A4 4 0 1 1 12 14H5.5A3.5 3.5 0 0 1 2 10z'/%3E%3C/svg%3E">
<style>
:root{
--bg:#0b1220; --bg2:#0e1630; --panel:#111a32; --border:#1a2a54;
--text:#e6eefc; --muted:#9fb0d4; --accent:#6fb1ff; --accent2:#93d0ff;
--radius:12px; --radius-sm:9px; --shadow:0 10px 30px rgba(0,0,0,.28);
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:linear-gradient(180deg,var(--bg),#0a152c);color:var(--text);
font:14px/1.4 Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial}
a{color:var(--accent)}
.app{height:100vh;display:grid;grid-template-rows:56px 44px 1fr}
.topbar{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--border);
background:linear-gradient(180deg,#0f1932,#0d1730)}
.brand{display:flex;align-items:center;gap:10px;font-weight:700}
.brand i{width:26px;height:26px;border-radius:8px;background:conic-gradient(from 210deg,#4e8cff,#17c964,#ffd055,#d66bff,#4e8cff);box-shadow:inset 0 0 0 2px rgba(255,255,255,.12),var(--shadow)}
.controls{display:flex;gap:8px;align-items:center}
.btn, .chip{
background:#122147;border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:10px;display:inline-flex;gap:8px;align-items:center;cursor:pointer
}
.btn:hover{background:#162a57}
.btn:disabled{opacity:.5;cursor:not-allowed}
.urlbar{
flex:1;display:flex;gap:8px;align-items:center;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:6px 8px
}
.urlbar input{flex:1;background:transparent;border:none;color:var(--text);outline:none;font:inherit}
.switch{display:inline-flex;align-items:center;gap:6px}
.switch input{appearance:none;width:36px;height:20px;background:#24406f;border:1px solid #33558b;border-radius:999px;position:relative;cursor:pointer}
.switch input:checked{background:#2d65ff}
.switch input::after{content:"";position:absolute;inset:2px auto 2px 2px;width:16px;height:16px;background:#fff;border-radius:50%;transition:.18s}
.switch input:checked::after{left:calc(100% - 18px)}
.tabbar{display:flex;gap:8px;overflow:auto;padding:6px 8px;background:linear-gradient(180deg,#0e1830,#0b152c);border-bottom:1px solid var(--border)}
.tab{display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--border);border-radius:999px;background:#0f1a33;cursor:pointer;white-space:nowrap;max-width:280px}
.tab.active{background:#13254d}
.tab .title{overflow:hidden;text-overflow:ellipsis}
.tab .close{opacity:.7}
.tab .close:hover{opacity:1}
.workspace{position:relative}
.webview{position:absolute;inset:0;border:0;background:#0a1326}
.toolbar-right{display:flex;gap:8px;align-items:center}
kbd{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:#0f1a33;border:1px solid #213a74;border-radius:6px;padding:2px 6px;font-size:12px;color:var(--muted)}
.dropdown{position:relative}
.menu{position:absolute;right:0;top:calc(100% + 6px);min-width:240px;background:var(--panel);border:1px solid var(--border);
border-radius:12px;box-shadow:var(--shadow);padding:8px;display:none}
.menu.show{display:block}
.menu .row{display:flex;align-items:center;justify-content:space-between;padding:8px;border-radius:8px}
.menu .row:hover{background:#0f1d3e}
.badge{font-size:11px;padding:2px 6px;border:1px solid var(--border);border-radius:999px;background:#122147;color:var(--muted)}
.notice{position:absolute;right:12px;bottom:12px;background:#0f1a33;border:1px solid var(--border);padding:10px;border-radius:10px;box-shadow:var(--shadow)}
.start{
height:100%;display:grid;place-items:center;background:radial-gradient(1200px 600px at 50% -150px rgba(117,165,255,.22), transparent), #0a1428;
color:#dfe9ff
}
.start .card{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:18px;box-shadow:var(--shadow);min-width:min(680px,92vw)}
.start h1{margin:4px 0 12px;font-size:22px}
.start .search{display:flex;gap:8px;align-items:center;background:#0e1934;border:1px solid var(--border);border-radius:12px;padding:10px}
.start input{flex:1;background:transparent;border:0;color:var(--text);font-size:16px;outline:none}
.start .row{display:flex;gap:12px;flex-wrap:wrap;color:var(--muted)}
hr{border:none;border-top:1px solid var(--border);margin:12px 0}
</style>
</head>
<body>
<div class="app" id="app">
<!-- Top bar -->
<div class="topbar">
<div class="brand"><i></i> Nebula <span class="badge">in‑browser web browser</span></div>
<div class="controls">
<button class="btn" id="btnBack" title="Back (Alt+←)">⬅︎</button>
<button class="btn" id="btnFwd" title="Forward (Alt+→)">➡︎</button>
<button class="btn" id="btnReload" title="Reload (⌘/Ctrl+R)">⟳</button>
</div>
<div class="urlbar" id="urlbar">
<span>🔗</span>
<input id="address" placeholder="Type a URL or search…" spellcheck="false" autocomplete="off" />
<button class="btn" id="btnGo" title="Go (Enter)">Go</button>
</div>
<div class="toolbar-right">
<div class="switch" title="Allow popups in page">
<label>Popups<input id="togglePopups" type="checkbox"></label>
</div>
<div class="switch" title="Treat iframe as same-origin (still cannot read cross-origin DOM)">
<label>Same‑origin<input id="toggleSameOrigin" type="checkbox" checked></label>
</div>
<div class="switch" title="Allow camera/mic for embedded apps">
<label>Cam/Mic<input id="toggleMedia" type="checkbox"></label>
</div>
<div class="dropdown">
<button class="btn" id="btnMore">⋯</button>
<div class="menu" id="menu">
<div class="row"><span>New Tab <kbd>⌘/Ctrl+T</kbd></span><button class="btn" id="mNewTab">New</button></div>
<div class="row"><span>Duplicate Tab</span><button class="btn" id="mDupTab">Duplicate</button></div>
<div class="row"><span>New Start Page</span><button class="btn" id="mStart">Home</button></div>
<div class="row"><span>Incognito (don’t save tabs)</span><input id="toggleIncog" type="checkbox"></div>
<div class="row"><span>Clear Saved Tabs</span><button class="btn" id="mClear">Clear</button></div>
</div>
</div>
<button class="btn" id="btnNewTab" title="New Tab (⌘/Ctrl+T)">+</button>
</div>
</div>
<!-- Tabs -->
<div class="tabbar" id="tabbar"></div>
<!-- Workspace -->
<div class="workspace" id="workspace"></div>
</div>
<div class="notice" id="notice" style="display:none"></div>
<script>
/* ========= Helpers ========= */
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
const isProbablyURL = v => /^[a-z]+:\/\//i.test(v) || v.includes('.') || v.startsWith('/');
function normalizeInput(input){
input = input.trim();
if (!input) return "about:blank";
// Search if it doesn't look like a URL
if (!/^[a-z]+:\/\//i.test(input) && !input.includes('.') && !input.startsWith('/')){
const q = encodeURIComponent(input);
return `https://duckduckgo.com/?q=${q}`;
}
// Add scheme if missing
if (!/^[a-z]+:\/\//i.test(input)){
return 'https://' + input.replace(/^\/+/,'');
}
return input;
}
const storageKey = 'nebula:tabs:v1';
/* ========= State ========= */
const App = {
tabs: [], // { id, title, url, iframe, icon, history:[], hIndex: number }
activeId: null,
persist: true
};
/* ========= Start Page ========= */
const START_HTML = (tip='') => `
<!doctype html>
<meta charset="utf-8">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Nebula Start</title>
<style>
:root{--border:#1a2a54;--panel:#0f1a33;--text:#e6eefc;--muted:#9fb0d4}
body{margin:0;height:100vh;display:grid;place-items:center;background:#0a1428;color:var(--text);font:14px/1.4 system-ui,Segoe UI,Roboto}
.card{min-width:min(720px,92vw);background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:18px}
h1{margin:6px 0 10px;font-size:22px}
.search{display:flex;gap:8px;align-items:center;background:#0e1934;border:1px solid var(--border);border-radius:12px;padding:12px}
input{flex:1;border:0;background:transparent;color:var(--text);font-size:16px;outline:none}
.row{display:flex;gap:10px;flex-wrap:wrap;color:var(--muted)}
.chip{border:1px solid var(--border);border-radius:999px;padding:8px 10px;background:#0e1832}
a{color:#93d0ff;text-decoration:none}
</style>
<div class=card>
<h1>✨ Welcome to Nebula</h1>
<div class=search>
<span>🔎</span><input id=q placeholder="Search the web… (Enter)" autofocus />
</div>
<div class=row style="margin-top:12px">
<span class=chip>Tip: ${tip}</span>
<span class=chip>Open a URL like <b>https://example.com</b></span>
<span class=chip>DuckDuckGo for search</span>
</div>
</div>
<script>
const $=s=>document.querySelector(s);
addEventListener('keydown',e=>{
if(e.key==='Enter'){ const v=$('#q').value.trim();
let url=v;
if(!/^([a-z]+:\\/\\/)/i.test(v)){
if(!v.includes('.') && !v.includes(' ') && !v.startsWith('/')) url='https://'+v;
else url='https://duckduckgo.com/?q='+encodeURIComponent(v);
}
location.href=url;
}
});
<\/script>
`;
/* ========= Tab Management ========= */
function saveTabs(){
if (!App.persist) return;
const serial = App.tabs.map(t => ({id:t.id,title:t.title,url:t.url, history:t.history, hIndex:t.hIndex||0}));
localStorage.setItem(storageKey, JSON.stringify(serial));
}
function loadTabs(){
const raw = localStorage.getItem(storageKey);
if (!raw) return;
try{
const list = JSON.parse(raw);
for(const t of list){ createTab(t.url || 'about:blank', t.title||'New Tab', t.history||[], t.hIndex||0); }
}catch(e){ console.warn('restore failed', e); }
}
function createTab(url='about:blank', title='New Tab', history=[], hIndex=history.length-1){
const id = 't'+Math.random().toString(36).slice(2,8);
const tab = { id, title, url, iframe:null, icon:'🌐', history: history.length? history : [url], hIndex: hIndex };
App.tabs.push(tab);
mountTab(tab);
setActive(id);
return tab;
}
function closeTab(id){
const idx = App.tabs.findIndex(t=>t.id===id);
if (idx<0) return;
const wasActive = App.activeId===id;
const t = App.tabs[idx];
t.iframe?.remove();
App.tabs.splice(idx,1);
renderTabbar();
if (!App.tabs.length) createTab('data:text/html,'+encodeURIComponent(START_HTML('Use ⌘/Ctrl+L to focus URL bar')));
if (wasActive) setActive(App.tabs[Math.max(0,idx-1)].id);
saveTabs();
}
function duplicateTab(){
const t = getActive(); if(!t) return;
createTab(t.url, t.title, [...t.history], t.hIndex);
}
function renderTabbar(){
const bar = $('#tabbar'); bar.innerHTML='';
for(const t of App.tabs){
const el = document.createElement('div');
el.className = 'tab'+(t.id===App.activeId?' active':'');
el.title = t.url;
el.innerHTML = `<span>${t.icon}</span><span class=title>${t.title||'New Tab'}</span>
<span class=close title="Close (⌘/Ctrl+W)">✕</span>`;
el.querySelector('.close').onclick = (e)=>{ e.stopPropagation(); closeTab(t.id); };
el.onclick = ()=> setActive(t.id);
bar.appendChild(el);
}
}
function setActive(id){
App.activeId = id;
for(const t of App.tabs){ t.iframe?.classList.add('hidden'); }
const t = App.tabs.find(x=>x.id===id);
if (!t) return;
t.iframe?.classList.remove('hidden');
$('#address').value = t.url || '';
renderTabbar();
updateNavButtons();
}
function getActive(){ return App.tabs.find(t=>t.id===App.activeId); }
/* ========= Iframe creation ========= */
function buildSandboxFlags(){
const allowPopups = $('#togglePopups').checked;
const sameOrigin = $('#toggleSameOrigin').checked;
const media = $('#toggleMedia').checked;
const allowTokens = [
'allow-forms',
'allow-pointer-lock',
'allow-scripts',
'allow-downloads',
allowPopups ? 'allow-popups allow-popups-to-escape-sandbox' : '',
sameOrigin ? 'allow-same-origin' : '',
media ? 'allow-modals' : '',
].filter(Boolean).join(' ');
const allowAttr = [
'geolocation *',
media ? 'camera *; microphone *' : '',
'payment *; clipboard-read *; clipboard-write *',
'fullscreen *',
].filter(Boolean).join('; ');
return { sandbox: allowTokens, allow: allowAttr };
}
function mountTab(tab){
const ws = $('#workspace');
// Create iframe
const frame = document.createElement('iframe');
frame.className = 'webview';
const { sandbox, allow } = buildSandboxFlags();
frame.setAttribute('sandbox', sandbox);
frame.setAttribute('allow', allow);
// Set src (start page via data: URL if about:blank)
const firstUrl = tab.url || 'about:blank';
frame.src = firstUrl.startsWith('data:') || firstUrl==='about:blank'
? firstUrl
: firstUrl;
frame.addEventListener('load', ()=>{
// Best-effort title update (cross-origin safe: we can't read the DOM unless same-origin,
// but the browser will still give us a generic title we can keep; fallback to URL host)
try{
const doc = frame.contentDocument; // will throw if cross-origin
if (doc && doc.title) tab.title = doc.title;
}catch(e){
// Cross-origin: derive a nicer label from URL
try{
const u = new URL(tab.url);
tab.title = u.host;
tab.icon = '🕸️';
}catch{}
}
renderTabbar();
updateNavButtons();
});
// Keep reference
tab.iframe = frame;
ws.appendChild(frame);
// If it's a special start page, inject it
if (tab.url === 'about:blank' || tab.url.startsWith('nebula:start')) {
frame.src = 'data:text/html;charset=utf-8,' + encodeURIComponent(START_HTML('Press ⌘/Ctrl+L to focus the URL bar'));
tab.title = 'Start';
}
renderTabbar();
}
/* ========= Navigation ========= */
function navigateCurrent(to){
const t = getActive(); if(!t) return;
const url = normalizeInput(to);
t.url = url;
// Update sandbox flags dynamically by reconstructing iframe if toggles changed
const { sandbox, allow } = buildSandboxFlags();
const f = t.iframe;
f.setAttribute('sandbox', sandbox);
f.setAttribute('allow', allow);
try{
f.src = url;
}catch(e){
// Fallback to start page showing an error
f.src = 'data:text/html,'+encodeURIComponent(`<h1>Navigation blocked</h1><p>${e.message}</p>`);
}
// History we track based on user-initiated navigations (we cannot observe in-page navigations cross-origin)
if (t.history[t.hIndex] !== url){
t.history = t.history.slice(0, t.hIndex+1);
t.history.push(url);
t.hIndex = t.history.length-1;
}
$('#address').value = url;
t.title = url.replace(/^https?:\/\//,'');
renderTabbar();
updateNavButtons();
saveTabs();
}
function back(){
const t = getActive(); if(!t) return;
if (t.hIndex>0){
t.hIndex--; const url = t.history[t.hIndex];
t.url = url; t.iframe.src = url; $('#address').value = url; updateNavButtons(); saveTabs();
} else {
// Try page history (cross-origin safe)
try{ t.iframe.contentWindow.history.back(); }catch{}
}
}
function forward(){
const t = getActive(); if(!t) return;
if (t.hIndex < t.history.length-1){
t.hIndex++; const url = t.history[t.hIndex];
t.url = url; t.iframe.src = url; $('#address').value = url; updateNavButtons(); saveTabs();
} else {
try{ t.iframe.contentWindow.history.forward(); }catch{}
}
}
function reload(){
const t = getActive(); if(!t) return;
try{ t.iframe.contentWindow.location.reload(); }
catch{ t.iframe.src = t.url; }
}
function updateNavButtons(){
const t = getActive(); if(!t){ $('#btnBack').disabled = $('#btnFwd').disabled = $('#btnReload').disabled = true; return; }
$('#btnBack').disabled = t.hIndex<=0;
$('#btnFwd').disabled = t.hIndex>=t.history.length-1;
$('#btnReload').disabled = !t.url;
}
/* ========= UI Wiring ========= */
$('#btnGo').onclick = ()=> navigateCurrent($('#address').value);
$('#address').addEventListener('keydown', e=>{
if (e.key==='Enter'){ navigateCurrent(e.currentTarget.value); }
if ((e.metaKey||e.ctrlKey) && e.key.toLowerCase()==='l'){ e.preventDefault(); e.currentTarget.select(); }
});
$('#btnBack').onclick = back;
$('#btnFwd').onclick = forward;
$('#btnReload').onclick = reload;
$('#btnMore').onclick = ()=>{
$('#menu').classList.toggle('show');
};
addEventListener('click', (e)=>{
if (!$('#btnMore').contains(e.target) && !$('#menu').contains(e.target)) $('#menu').classList.remove('show');
});
$('#mNewTab').onclick = ()=> createTab('about:blank');
$('#mDupTab').onclick = duplicateTab;
$('#mStart').onclick = ()=> { const t=getActive(); if(!t) return; t.url='about:blank'; t.iframe.src='data:text/html,'+encodeURIComponent(START_HTML('Welcome back!')); t.title='Start'; renderTabbar(); };
$('#mClear').onclick = ()=> { localStorage.removeItem(storageKey); showNotice('Saved tabs cleared.'); };
$('#toggleIncog').onchange = (e)=>{ App.persist = !e.currentTarget.checked; showNotice(App.persist?'Saving tabs is ON':'Incognito: tabs won’t be saved'); if (App.persist) saveTabs(); else localStorage.removeItem(storageKey); };
$('#togglePopups').onchange = $('#toggleSameOrigin').onchange = $('#toggleMedia').onchange = ()=>{
// Re-apply sandbox/allow for current tab
const t=getActive(); if(!t) return;
const { sandbox, allow } = buildSandboxFlags();
t.iframe.setAttribute('sandbox', sandbox);
t.iframe.setAttribute('allow', allow);
showNotice('Updated permissions for this tab.');
};
$('#btnNewTab').onclick = ()=> createTab('about:blank');
// Global keys
addEventListener('keydown', (e)=>{
const meta = e.metaKey || e.ctrlKey;
if (meta && e.key.toLowerCase()==='l'){ e.preventDefault(); $('#address').focus(); $('#address').select(); }
if (meta && e.key.toLowerCase()==='t'){ e.preventDefault(); createTab('about:blank'); }
if (meta && e.key.toLowerCase()==='w'){ e.preventDefault(); closeTab(App.activeId); }
if ((e.altKey && e.key==='ArrowLeft') || (meta && e.key==='[')){ e.preventDefault(); back(); }
if ((e.altKey && e.key==='ArrowRight') || (meta && e.key===']')){ e.preventDefault(); forward(); }
if (meta && (e.key.toLowerCase()==='r')){ e.preventDefault(); reload(); }
});
// Close tab with middle click on tabbar
$('#tabbar').addEventListener('auxclick', (e)=>{
const tabEl = e.target.closest('.tab'); if (!tabEl) return;
if (e.button===1){ // middle click
const idx = $$('.tab').indexOf(tabEl);
const id = App.tabs[idx]?.id; if (id) closeTab(id);
}
});
function showNotice(msg, ms=2200){
const n = $('#notice'); n.textContent = msg; n.style.display='block';
clearTimeout(showNotice._t); showNotice._t=setTimeout(()=>{ n.style.display='none'; }, ms);
}
/* ========= Boot ========= */
(function boot(){
// Restore saved tabs or start fresh
loadTabs();
if (!App.tabs.length){
createTab('data:text/html,' + encodeURIComponent(START_HTML('Try typing “example.com” or a search.')));
}
// Activate the last tab
setActive(App.tabs[App.tabs.length-1].id);
// If user types in the *embedded* start page, pressing Enter will navigate the iframe out of data:.
// We update our address bar on iframe navigation we initiate, not internal links (cross-origin limits).
})();
</script>
</body>
</html>