v6.8.0: light theme

The palette lived as ~620 hex literals in inline styles plus a few hundred more in
the stylesheets, so there was no single thing to change. It is now 113 custom
properties in src/styles/theme.css, in two blocks.

src/scripts/theme-boot.js decides the theme before first paint — blocking, in <head>,
above the stylesheet, because a deferred script paints dark first and corrects itself.
It stores the mode ('system' | 'light' | 'dark'), never the colour it resolved to, and
stamps data-theme so an explicit choice can beat the media query. The switcher in the
header is a view onto it.

A filled accent stays the brand colour in both themes — the ink on it is near-black
either way — while an accent used as text darkens to clear 4.5:1 on white. A colour
reaches a fill by four routes (a style property, a constant, a helper argument, an SVG
source string), and tests/theme-switching.test.mjs covers all four.

The dark theme is unchanged: every colour declaration the previous build produced comes
out of this one identically once the properties are resolved.

Also: the roadmap drops its status chips on mobile, and Roadmap.jsx no longer splits a
colour with parseInt at runtime, which a var() reference cannot survive.
This commit is contained in:
lockbitchat
2026-09-04 17:38:41 -04:00
parent 1c153b290a
commit 9e63cf65a4
109 changed files with 4320 additions and 1910 deletions
+374 -359
View File
File diff suppressed because it is too large Load Diff
+23 -23
View File
@@ -149,7 +149,7 @@ const UpdateChecker = ({ children, onUpdateAvailable, debug = false }) => {
key: 'update-modal',
style: {
position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '24px', background: 'rgba(8,8,10,0.55)', backdropFilter: 'blur(3px)', WebkitBackdropFilter: 'blur(3px)',
padding: '24px', background: 'rgba(var(--sb-scrim-rgb), 0.55)', backdropFilter: 'blur(3px)', WebkitBackdropFilter: 'blur(3px)',
animation: 'unFade .3s ease', fontFamily: SANS
}
}, [
@@ -163,39 +163,39 @@ const UpdateChecker = ({ children, onUpdateAvailable, debug = false }) => {
key: 'card',
style: {
position: 'relative', width: '440px', maxWidth: 'calc(100vw - 48px)', borderRadius: '22px',
background: '#121214', border: '1px solid rgba(255,255,255,0.08)', padding: '36px 32px 28px',
textAlign: 'center', boxShadow: '0 30px 70px rgba(0,0,0,0.6)', animation: 'unPop .32s cubic-bezier(.2,.7,.3,1)'
background: 'var(--sb-bg)', border: '1px solid rgba(var(--sb-ink), 0.08)', padding: '36px 32px 28px',
textAlign: 'center', boxShadow: '0 30px 70px rgba(var(--sb-shadow-rgb), calc(0.6 * var(--sb-shadow-k)))', animation: 'unPop .32s cubic-bezier(.2,.7,.3,1)'
}
}, [
// spinning update icon
React.createElement('div', {
key: 'icon',
style: { display: 'inline-flex', width: '64px', height: '64px', borderRadius: '50%', alignItems: 'center', justifyContent: 'center', background: 'rgba(240,137,42,0.12)', border: '1px solid rgba(240,137,42,0.3)', marginBottom: '20px' }
style: { display: 'inline-flex', width: '64px', height: '64px', borderRadius: '50%', alignItems: 'center', justifyContent: 'center', background: 'rgba(var(--sb-orange-rgb), 0.12)', border: '1px solid rgba(var(--sb-orange-rgb), 0.3)', marginBottom: '20px' }
}, React.createElement('svg', {
width: 28, height: 28, viewBox: '0 0 24 24', fill: 'none', stroke: '#f0892a', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
width: 28, height: 28, viewBox: '0 0 24 24', fill: 'none', stroke: 'var(--sb-orange-solid)', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
style: { animation: 'unSpin 6s linear infinite' },
dangerouslySetInnerHTML: { __html: '<path d="M21 8a8.5 8.5 0 0 0-15.6-2.5M3 4v4h4"/><path d="M3 16a8.5 8.5 0 0 0 15.6 2.5M21 20v-4h-4"/>' }
})),
React.createElement('h2', { key: 'title', style: { margin: '0 0 9px', fontSize: '26px', fontWeight: 800, letterSpacing: '-0.7px', color: '#f4f4f6' } }, t('update.title')),
React.createElement('p', { key: 'sub', style: { margin: '0 0 24px', fontSize: '14.5px', lineHeight: 1.55, color: '#9a9aa2' } }, t('update.desc')),
React.createElement('h2', { key: 'title', style: { margin: '0 0 9px', fontSize: '26px', fontWeight: 800, letterSpacing: '-0.7px', color: 'var(--sb-text-1)' } }, t('update.title')),
React.createElement('p', { key: 'sub', style: { margin: '0 0 24px', fontSize: '14.5px', lineHeight: 1.55, color: 'var(--sb-text-6)' } }, t('update.desc')),
// version comparison
React.createElement('div', {
key: 'vbox',
style: { borderRadius: '14px', background: '#0c0c0e', border: '1px solid rgba(255,255,255,0.06)', padding: '16px 18px', marginBottom: '24px', textAlign: 'start' }
style: { borderRadius: '14px', background: 'var(--sb-bg-deep)', border: '1px solid rgba(var(--sb-ink), 0.06)', padding: '16px 18px', marginBottom: '24px', textAlign: 'start' }
}, [
React.createElement('div', { key: 'cur', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '14px', padding: '5px 0' } }, [
React.createElement('span', { key: 'l', style: { fontSize: '13.5px', fontWeight: 500, color: '#8a8a92' } }, t('update.currentVersion')),
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '13px', fontWeight: 500, color: '#9a9aa2', whiteSpace: 'nowrap' } }, formatVersion(updateState.currentVersion))
React.createElement('span', { key: 'l', style: { fontSize: '13.5px', fontWeight: 500, color: 'var(--sb-text-7)' } }, t('update.currentVersion')),
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '13px', fontWeight: 500, color: 'var(--sb-text-6)', whiteSpace: 'nowrap' } }, formatVersion(updateState.currentVersion))
]),
React.createElement('div', { key: 'sep', style: { height: '1px', background: 'rgba(255,255,255,0.05)', margin: '4px 0' } }),
React.createElement('div', { key: 'sep', style: { height: '1px', background: 'rgba(var(--sb-ink), 0.05)', margin: '4px 0' } }),
React.createElement('div', { key: 'new', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '14px', padding: '5px 0' } }, [
React.createElement('span', { key: 'l', style: { display: 'inline-flex', alignItems: 'center', gap: '8px', fontSize: '13.5px', fontWeight: 600, color: '#e8e8eb' } }, [
React.createElement('span', { key: 'd', style: { width: '6px', height: '6px', borderRadius: '50%', background: '#f0892a' } }),
React.createElement('span', { key: 'l', style: { display: 'inline-flex', alignItems: 'center', gap: '8px', fontSize: '13.5px', fontWeight: 600, color: 'var(--sb-text-2)' } }, [
React.createElement('span', { key: 'd', style: { width: '6px', height: '6px', borderRadius: '50%', background: 'var(--sb-orange-solid)' } }),
t('update.newVersion')
]),
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '13px', fontWeight: 700, color: '#f0892a', whiteSpace: 'nowrap' } }, formatVersion(updateState.newVersion))
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '13px', fontWeight: 700, color: 'var(--sb-orange)', whiteSpace: 'nowrap' } }, formatVersion(updateState.newVersion))
])
]),
@@ -204,17 +204,17 @@ const UpdateChecker = ({ children, onUpdateAvailable, debug = false }) => {
? React.createElement('div', { key: 'progress' }, [
React.createElement('div', {
key: 'bar',
style: { width: '100%', height: '8px', borderRadius: '99px', background: '#0c0c0e', border: '1px solid rgba(255,255,255,0.06)', overflow: 'hidden', marginBottom: '10px' }
}, React.createElement('div', { key: 'fill', style: { height: '100%', width: `${updateState.progress}%`, background: 'linear-gradient(90deg,#3ecf8e,#f0892a)', transition: 'width .3s ease' } })),
React.createElement('p', { key: 't', style: { margin: 0, fontFamily: MONO, fontSize: '12px', color: '#8a8a92' } }, `Updating… ${updateState.progress}%`)
style: { width: '100%', height: '8px', borderRadius: '99px', background: 'var(--sb-bg-deep)', border: '1px solid rgba(var(--sb-ink), 0.06)', overflow: 'hidden', marginBottom: '10px' }
}, React.createElement('div', { key: 'fill', style: { height: '100%', width: `${updateState.progress}%`, background: 'linear-gradient(90deg,var(--sb-green),var(--sb-orange))', transition: 'width .3s ease' } })),
React.createElement('p', { key: 't', style: { margin: 0, fontFamily: MONO, fontSize: '12px', color: 'var(--sb-text-7)' } }, `Updating… ${updateState.progress}%`)
])
: React.createElement('div', { key: 'actions', style: { display: 'flex', alignItems: 'center', gap: '12px' } }, [
React.createElement('button', {
key: 'update',
onClick: handleForceUpdate,
style: { flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '10px', padding: '15px 20px', borderRadius: '13px', border: 'none', background: '#f0892a', color: '#1a0f04', fontFamily: 'inherit', fontSize: '15.5px', fontWeight: 700, letterSpacing: '-0.2px', cursor: 'pointer', boxShadow: '0 8px 24px rgba(240,137,42,0.28)', transition: 'all .2s cubic-bezier(.2,.7,.3,1)' },
onMouseEnter: (e) => { e.currentTarget.style.background = '#ff9637'; e.currentTarget.style.transform = 'translateY(-2px)'; },
onMouseLeave: (e) => { e.currentTarget.style.background = '#f0892a'; e.currentTarget.style.transform = 'none'; }
style: { flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '10px', padding: '15px 20px', borderRadius: '13px', border: 'none', background: 'var(--sb-orange-solid)', color: 'var(--sb-on-accent)', fontFamily: 'inherit', fontSize: '15.5px', fontWeight: 700, letterSpacing: '-0.2px', cursor: 'pointer', boxShadow: '0 8px 24px rgba(var(--sb-orange-rgb), 0.28)', transition: 'all .2s cubic-bezier(.2,.7,.3,1)' },
onMouseEnter: (e) => { e.currentTarget.style.background = 'var(--sb-orange-hi)'; e.currentTarget.style.transform = 'translateY(-2px)'; },
onMouseLeave: (e) => { e.currentTarget.style.background = 'var(--sb-orange)'; e.currentTarget.style.transform = 'none'; }
}, [
React.createElement('svg', { key: 'i', width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.1, strokeLinecap: 'round', strokeLinejoin: 'round', dangerouslySetInnerHTML: { __html: '<path d="M12 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/>' } }),
t('update.now')
@@ -223,9 +223,9 @@ const UpdateChecker = ({ children, onUpdateAvailable, debug = false }) => {
key: 'later',
onClick: handleCloseModal,
title: t('update.later'),
style: { flex: 'none', width: '50px', height: '50px', borderRadius: '13px', display: 'grid', placeItems: 'center', border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.025)', color: '#9a9aa2', cursor: 'pointer', transition: 'all .18s cubic-bezier(.2,.7,.3,1)' },
onMouseEnter: (e) => { e.currentTarget.style.color = '#e5727a'; e.currentTarget.style.borderColor = 'rgba(229,114,122,0.4)'; },
onMouseLeave: (e) => { e.currentTarget.style.color = '#9a9aa2'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; }
style: { flex: 'none', width: '50px', height: '50px', borderRadius: '13px', display: 'grid', placeItems: 'center', border: '1px solid rgba(var(--sb-ink), 0.1)', background: 'rgba(var(--sb-ink), 0.025)', color: 'var(--sb-text-6)', cursor: 'pointer', transition: 'all .18s cubic-bezier(.2,.7,.3,1)' },
onMouseEnter: (e) => { e.currentTarget.style.color = 'var(--sb-red)'; e.currentTarget.style.borderColor = 'rgba(var(--sb-red-rgb), 0.4)'; },
onMouseLeave: (e) => { e.currentTarget.style.color = 'var(--sb-text-6)'; e.currentTarget.style.borderColor = 'rgba(var(--sb-ink), 0.1)'; }
}, React.createElement('svg', { width: 17, height: 17, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.1, strokeLinecap: 'round', strokeLinejoin: 'round', dangerouslySetInnerHTML: { __html: '<path d="M6 6l12 12M18 6L6 18"/>' } }))
])
])
+22 -20
View File
@@ -18,7 +18,9 @@ const BecomePartner = () => {
};
}, []);
const ACCENT = '#f0892a';
const ACCENT = 'var(--sb-orange)';
// Fill form: brand colour in both themes. See app.jsx.
const ACCENT_SOLID = 'var(--sb-orange-solid)';
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
const SANS = "'Manrope', system-ui, -apple-system, sans-serif";
@@ -60,7 +62,7 @@ const BecomePartner = () => {
const roleTag = (role) => React.createElement('span', {
key: 'role',
style: { fontFamily: MONO, fontSize: '10.5px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '1.2px', padding: '6px 11px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.025)', whiteSpace: 'nowrap' }
style: { fontFamily: MONO, fontSize: '10.5px', fontWeight: 600, color: 'var(--sb-text-9)', textTransform: 'uppercase', letterSpacing: '1.2px', padding: '6px 11px', borderRadius: '8px', border: '1px solid rgba(var(--sb-ink), 0.07)', background: 'rgba(var(--sb-ink), 0.025)', whiteSpace: 'nowrap' }
}, role);
const partnerCard = (p) => React.createElement('a', {
@@ -70,14 +72,14 @@ const BecomePartner = () => {
rel: 'noopener noreferrer',
style: {
flex: '1 1 320px', minWidth: isMobile ? 'auto' : '300px',
borderRadius: '18px', background: '#141416', border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '18px', background: 'var(--sb-surface)', border: '1px solid rgba(var(--sb-ink), 0.06)',
padding: '30px 30px 26px', display: 'flex', flexDirection: 'column',
textDecoration: 'none', color: 'inherit',
transition: 'transform .28s cubic-bezier(.2,.7,.3,1), border-color .28s cubic-bezier(.2,.7,.3,1)',
animation: `ptUp ${p.delay} cubic-bezier(.2,.7,.3,1)`
},
onMouseEnter: (e) => { e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.13)'; },
onMouseLeave: (e) => { e.currentTarget.style.transform = 'none'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)'; }
onMouseEnter: (e) => { e.currentTarget.style.transform = 'translateY(-4px)'; e.currentTarget.style.borderColor = 'rgba(var(--sb-ink), 0.13)'; },
onMouseLeave: (e) => { e.currentTarget.style.transform = 'none'; e.currentTarget.style.borderColor = 'rgba(var(--sb-ink), 0.06)'; }
}, [
React.createElement('div', { key: 'logo', style: { display: 'flex', alignItems: 'center', marginBottom: '30px', height: '54px' } },
React.createElement('img', {
@@ -90,8 +92,8 @@ const BecomePartner = () => {
style: { height: p.logoHeight, width: 'auto', maxWidth: '190px', objectFit: 'contain', display: 'block' }
})
),
React.createElement('h3', { key: 'name', style: { margin: '0 0 9px', fontSize: '21px', fontWeight: 800, letterSpacing: '-0.4px', color: '#f4f4f6' } }, p.name),
React.createElement('p', { key: 'desc', style: { margin: '0 0 22px', fontSize: '14.5px', lineHeight: 1.6, color: '#9a9aa2' } }, p.desc),
React.createElement('h3', { key: 'name', style: { margin: '0 0 9px', fontSize: '21px', fontWeight: 800, letterSpacing: '-0.4px', color: 'var(--sb-text-1)' } }, p.name),
React.createElement('p', { key: 'desc', style: { margin: '0 0 22px', fontSize: '14.5px', lineHeight: 1.6, color: 'var(--sb-text-6)' } }, p.desc),
React.createElement('div', { key: 'foot', style: { marginTop: 'auto', paddingTop: '6px', display: 'flex', alignItems: 'center', gap: '12px' } }, [
roleTag(p.role)
])
@@ -104,30 +106,30 @@ const BecomePartner = () => {
rel: 'noopener noreferrer',
style: {
flex: '1 1 320px', minWidth: isMobile ? 'auto' : '300px',
borderRadius: '18px', background: '#111113', border: '1px dashed rgba(255,255,255,0.12)',
borderRadius: '18px', background: 'var(--sb-bg)', border: '1px dashed rgba(var(--sb-ink), 0.12)',
padding: '30px', display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
textDecoration: 'none', color: 'inherit',
transition: 'border-color .28s cubic-bezier(.2,.7,.3,1)',
animation: 'ptUp .62s cubic-bezier(.2,.7,.3,1)'
},
onMouseEnter: (e) => { e.currentTarget.style.borderColor = 'rgba(240,137,42,0.4)'; },
onMouseLeave: (e) => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)'; }
onMouseEnter: (e) => { e.currentTarget.style.borderColor = 'rgba(var(--sb-orange-rgb), 0.4)'; },
onMouseLeave: (e) => { e.currentTarget.style.borderColor = 'rgba(var(--sb-ink), 0.12)'; }
}, [
React.createElement('div', { key: 'top' }, [
React.createElement('div', {
key: 'icon',
style: { width: '48px', height: '48px', borderRadius: '13px', display: 'grid', placeItems: 'center', background: 'rgba(240,137,42,0.12)', border: '1px solid rgba(240,137,42,0.28)', marginBottom: '24px' }
}, svg('<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/>', 23, ACCENT, 1.9)),
React.createElement('h3', { key: 'title', style: { margin: '0 0 8px', fontSize: '21px', fontWeight: 800, letterSpacing: '-0.4px', color: '#f4f4f6' } }, t('partners.inviteTitle')),
React.createElement('p', { key: 'desc', style: { margin: 0, fontSize: '14.5px', lineHeight: 1.6, color: '#8a8a92' } }, t('partners.inviteDesc'))
style: { width: '48px', height: '48px', borderRadius: '13px', display: 'grid', placeItems: 'center', background: 'rgba(var(--sb-orange-rgb), 0.12)', border: '1px solid rgba(var(--sb-orange-rgb), 0.28)', marginBottom: '24px' }
}, svg('<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/>', 23, ACCENT_SOLID, 1.9)),
React.createElement('h3', { key: 'title', style: { margin: '0 0 8px', fontSize: '21px', fontWeight: 800, letterSpacing: '-0.4px', color: 'var(--sb-text-1)' } }, t('partners.inviteTitle')),
React.createElement('p', { key: 'desc', style: { margin: 0, fontSize: '14.5px', lineHeight: 1.6, color: 'var(--sb-text-7)' } }, t('partners.inviteDesc'))
]),
React.createElement('span', {
key: 'btn',
style: {
marginTop: '26px', width: '100%', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '10px',
padding: '15px 20px', borderRadius: '12px', border: 'none', background: ACCENT, color: '#1a0f04',
padding: '15px 20px', borderRadius: '12px', border: 'none', background: ACCENT_SOLID, color: 'var(--sb-on-accent)',
fontFamily: SANS, fontSize: '15px', fontWeight: 700, cursor: 'pointer',
boxShadow: '0 8px 24px rgba(240,137,42,0.28)', boxSizing: 'border-box',
boxShadow: '0 8px 24px rgba(var(--sb-orange-rgb), 0.28)', boxSizing: 'border-box',
transition: 'background .2s cubic-bezier(.2,.7,.3,1), transform .2s cubic-bezier(.2,.7,.3,1)'
}
}, [
@@ -144,13 +146,13 @@ const BecomePartner = () => {
React.createElement('div', { key: 'head', style: { marginBottom: '44px' } }, [
React.createElement('div', {
key: 'eyebrow',
style: { fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '1.6px', marginBottom: '14px' }
style: { fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: 'var(--sb-text-9)', textTransform: 'uppercase', letterSpacing: '1.6px', marginBottom: '14px' }
}, t('partners.eyebrow')),
// The heading used to share a flex row with a strapline; with that gone the
// row had one child and nothing left to arrange.
React.createElement('h2', {
key: 'h2',
style: { margin: 0, fontSize: isMobile ? '30px' : '40px', fontWeight: 800, letterSpacing: '-1.1px', lineHeight: 1.04, color: '#f4f4f6' }
style: { margin: 0, fontSize: isMobile ? '30px' : '40px', fontWeight: 800, letterSpacing: '-1.1px', lineHeight: 1.04, color: 'var(--sb-text-1)' }
}, t('partners.heading'))
]),
@@ -166,9 +168,9 @@ const BecomePartner = () => {
return React.createElement('section', {
style: {
width: '100%', color: '#e8e8eb', fontFamily: SANS,
width: '100%', color: 'var(--sb-text-2)', fontFamily: SANS,
padding: isMobile ? '48px 0' : '72px 0',
background: 'radial-gradient(1100px 640px at 50% -6%, rgba(240,137,42,0.055), transparent 62%), #0f0f11'
background: 'radial-gradient(1100px 640px at 50% -6%, rgba(var(--sb-orange-rgb), 0.055), transparent 62%), var(--sb-bg)'
}
}, [
React.createElement('style', { key: 'kf', dangerouslySetInnerHTML: { __html: '@keyframes ptUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}' } }),
+43 -43
View File
@@ -103,39 +103,39 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
// ── shared styles (from the design) ──────────────────────────────────────
const ctrlBase = {
width: '56px', height: '56px', borderRadius: '50%', display: 'grid', placeItems: 'center',
border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.05)',
color: '#cfcfd4', cursor: 'pointer', transition: 'all .15s'
border: '1px solid rgba(var(--sb-ink), 0.1)', background: 'rgba(var(--sb-ink), 0.05)',
color: 'var(--sb-text-4)', cursor: 'pointer', transition: 'all .15s'
};
const dangerCtrl = { ...ctrlBase, background: '#e5484d', color: '#fff', border: '1px solid transparent' };
const dangerCtrl = { ...ctrlBase, background: 'var(--sb-red-strong-solid)', color: '#fff', border: '1px solid transparent' };
const endBtn = {
width: '56px', height: '56px', borderRadius: '50%', display: 'grid', placeItems: 'center',
border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer',
boxShadow: '0 8px 24px rgba(229,72,77,0.35)', transition: 'transform .15s'
border: 'none', background: 'var(--sb-red-strong-solid)', color: '#fff', cursor: 'pointer',
boxShadow: '0 8px 24px rgba(var(--sb-red-strong-rgb), 0.35)', transition: 'transform .15s'
};
const minimizeBtn = (light) => ({
width: '36px', height: '36px', borderRadius: '9px', display: 'grid', placeItems: 'center',
border: '1px solid rgba(255,255,255,' + (light ? '0.15' : '0.1') + ')',
background: light ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.04)',
color: light ? '#fff' : '#cfcfd4', cursor: 'pointer', transition: 'all .15s'
border: '1px solid rgba(var(--sb-ink), ' + (light ? '0.15' : '0.1') + ')',
background: light ? 'rgba(0,0,0,0.35)' : 'rgba(var(--sb-ink), 0.04)',
color: light ? '#fff' : 'var(--sb-text-4)', cursor: 'pointer', transition: 'all .15s'
});
const encBadge = h('span', { key: 'enc', style: { display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '11px', fontWeight: 600, color: '#3ecf8e' } },
const encBadge = h('span', { key: 'enc', style: { display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '11px', fontWeight: 600, color: 'var(--sb-green)' } },
[svg(ICON.lock, 11, 2), t('call.encryptedShort')]);
// Connection-quality indicator — driven by the adaptation controller's
// getStats (loss + RTT). Signal bars + label; hidden until there's data.
const QUALITY = {
excellent: { bars: 4, color: '#3ecf8e', label: t('call.qualityExcellent') },
good: { bars: 3, color: '#3ecf8e', label: t('call.qualityGood') },
fair: { bars: 2, color: '#e3c84e', label: t('call.qualityFair') },
poor: { bars: 1, color: '#e5727a', label: t('call.qualityWeak') },
excellent: { bars: 4, color: 'var(--sb-green)', label: t('call.qualityExcellent') },
good: { bars: 3, color: 'var(--sb-green)', label: t('call.qualityGood') },
fair: { bars: 2, color: 'var(--sb-yellow)', label: t('call.qualityFair') },
poor: { bars: 1, color: 'var(--sb-red)', label: t('call.qualityWeak') },
};
const qualityIndicator = (compact) => {
const q = QUALITY[call.quality];
if (!q) return null;
const bars = h('span', { key: 'bars', style: { display: 'inline-flex', alignItems: 'flex-end', gap: '2px', height: '14px' } },
[0, 1, 2, 3].map(i => h('span', {
key: i, style: { width: '3px', height: (5 + i * 3) + 'px', borderRadius: '1px', background: i < q.bars ? q.color : 'rgba(255,255,255,0.18)' }
key: i, style: { width: '3px', height: (5 + i * 3) + 'px', borderRadius: '1px', background: i < q.bars ? q.color : 'rgba(var(--sb-ink), 0.18)' }
})));
if (compact) return bars;
return h('span', { key: 'q', title: t('call.quality'), style: { display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '11.5px', fontWeight: 600, color: q.color } }, [bars, q.label]);
@@ -153,73 +153,73 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
const hiddenAudio = h('audio', { key: 'ra', ref: remoteAudioRef, autoPlay: true, playsInline: true, style: { display: 'none' } });
const labeled = (key, btn, label) => h('div', { key, style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' } },
[btn, h('span', { key: 'l', style: { fontFamily: MONO, fontSize: '10.5px', color: '#8a8a92' } }, label)]);
[btn, h('span', { key: 'l', style: { fontFamily: MONO, fontSize: '10.5px', color: 'var(--sb-text-7)' } }, label)]);
// Avatar disc with optional ringing pulse (voice + incoming).
const avatarDisc = (size, ring) => h('div', { key: 'av', style: { position: 'relative', width: '120px', height: '120px', marginBottom: '28px', display: 'grid', placeItems: 'center' } }, [
ring && h('span', { key: 'p1', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(240,137,42,0.5)', animation: 'sbCallPulse 2s ease-out infinite' } }),
ring && h('span', { key: 'p2', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(240,137,42,0.4)', animation: 'sbCallPulse 2s ease-out infinite', animationDelay: '1s' } }),
h('div', { key: 'c', style: { width: '104px', height: '104px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'radial-gradient(circle at 35% 30%, #2a2a30, #161618)', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 12px 30px rgba(0,0,0,0.4)', color: '#8a8a92' } }, svg(ICON.user, size, 1.6))
ring && h('span', { key: 'p1', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(var(--sb-orange-rgb), 0.5)', animation: 'sbCallPulse 2s ease-out infinite' } }),
ring && h('span', { key: 'p2', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(var(--sb-orange-rgb), 0.4)', animation: 'sbCallPulse 2s ease-out infinite', animationDelay: '1s' } }),
h('div', { key: 'c', style: { width: '104px', height: '104px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'radial-gradient(circle at 35% 30%, var(--sb-surface-4), var(--sb-surface))', border: '1px solid rgba(var(--sb-ink), 0.1)', boxShadow: '0 12px 30px rgba(var(--sb-shadow-rgb), calc(0.4 * var(--sb-shadow-k)))', color: 'var(--sb-text-7)' } }, svg(ICON.user, size, 1.6))
]);
// ── INCOMING (ringing) prompt ────────────────────────────────────────────
if (phase === 'incoming') {
return h('div', { style: { position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column', background: 'radial-gradient(680px 460px at 50% 36%, rgba(240,137,42,0.08), transparent 70%), #0d0d0f', animation: 'sbExpand .2s ease' } }, [
return h('div', { style: { position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column', background: 'radial-gradient(680px 460px at 50% 36%, rgba(var(--sb-orange-rgb), 0.08), transparent 70%), var(--sb-bg-deep)', animation: 'sbExpand .2s ease' } }, [
hiddenAudio,
h('div', { key: 'top', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', padding: '16px 18px' } },
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: '#3ecf8e' } }, [svg(ICON.lock, 13, 2), t('call.encrypted')])),
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: 'var(--sb-green)' } }, [svg(ICON.lock, 13, 2), t('call.encrypted')])),
h('div', { key: 'mid', style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' } }, [
avatarDisc(46, true),
h('div', { key: 'nm', style: { fontSize: '24px', fontWeight: 800, letterSpacing: '-0.5px', color: '#f4f4f6' } }, name),
h('div', { key: 'st', style: { fontFamily: MONO, fontSize: '14px', fontWeight: 500, color: '#9a9aa2', marginTop: '8px' } }, call.withVideo ? t('call.incomingVideo') : t('call.incoming'))
h('div', { key: 'nm', style: { fontSize: '24px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--sb-text-1)' } }, name),
h('div', { key: 'st', style: { fontFamily: MONO, fontSize: '14px', fontWeight: 500, color: 'var(--sb-text-6)', marginTop: '8px' } }, call.withVideo ? t('call.incomingVideo') : t('call.incoming'))
]),
h('div', { key: 'ctrls', style: { flex: 'none', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', gap: '48px', padding: '28px 24px 40px' } }, [
labeled('dec', h('button', { onClick: doDecline, title: t('call.decline'), style: { ...endBtn, width: '62px', height: '62px' } }, svg(ICON.phoneHangup, 24, 1.9)), t('call.decline')),
labeled('acc', h('button', { onClick: doAccept, title: t('call.accept'), style: { width: '62px', height: '62px', borderRadius: '50%', display: 'grid', placeItems: 'center', border: 'none', background: '#3ecf8e', color: '#06231a', cursor: 'pointer', boxShadow: '0 8px 24px rgba(62,207,142,0.35)' } }, svg(ICON.phone, 24, 1.9)), t('call.accept'))
labeled('acc', h('button', { onClick: doAccept, title: t('call.accept'), style: { width: '62px', height: '62px', borderRadius: '50%', display: 'grid', placeItems: 'center', border: 'none', background: 'var(--sb-green-solid)', color: 'var(--sb-on-green)', cursor: 'pointer', boxShadow: '0 8px 24px rgba(var(--sb-green-rgb), 0.35)' } }, svg(ICON.phone, 24, 1.9)), t('call.accept'))
])
]);
}
// ── MINIMIZED widget ─────────────────────────────────────────────────────
if (minimized) {
return h('div', { style: { position: 'absolute', bottom: '18px', insetInlineEnd: '18px', zIndex: 40, width: '236px', borderRadius: '14px', overflow: 'hidden', background: '#161618', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 18px 44px rgba(0,0,0,0.55)', animation: 'sbExpand .18s ease' } }, [
return h('div', { style: { position: 'absolute', bottom: '18px', insetInlineEnd: '18px', zIndex: 40, width: '236px', borderRadius: '14px', overflow: 'hidden', background: 'var(--sb-surface)', border: '1px solid rgba(var(--sb-ink), 0.1)', boxShadow: '0 18px 44px rgba(var(--sb-shadow-rgb), calc(0.55 * var(--sb-shadow-k)))', animation: 'sbExpand .18s ease' } }, [
hiddenAudio,
isVideo && h('div', { key: 'v', style: { position: 'relative', height: '132px', background: '#111' } }, [
h('video', { key: 'rv', ref: remoteVideoRef, autoPlay: true, muted: true, playsInline: true, style: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' } }),
!call.remoteHasVideo && h('div', { key: 'off', style: { position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', background: 'linear-gradient(120deg,#15151b,#1d1a24)', color: '#6b6b73' } }, svg(ICON.camOff, 22, 1.8)),
!call.remoteHasVideo && h('div', { key: 'off', style: { position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', background: 'linear-gradient(120deg,var(--sb-grad-1),var(--sb-grad-2))', color: 'var(--sb-text-9)' } }, svg(ICON.camOff, 22, 1.8)),
h('span', { key: 's', style: { position: 'absolute', top: '8px', insetInlineStart: '9px', fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#fff', padding: '3px 7px', borderRadius: '6px', background: 'rgba(0,0,0,0.5)' } }, callStatus)
]),
h('div', { key: 'bar', style: { display: 'flex', alignItems: 'center', gap: '11px', padding: '11px 12px' } }, [
h('span', { key: 'ic', style: { position: 'relative', flex: 'none', width: '34px', height: '34px', borderRadius: '9px', display: 'grid', placeItems: 'center', background: 'rgba(62,207,142,0.1)', border: '1px solid rgba(62,207,142,0.25)', color: '#3ecf8e' } }, svg(ICON.user, 16, 1.9)),
h('span', { key: 'ic', style: { position: 'relative', flex: 'none', width: '34px', height: '34px', borderRadius: '9px', display: 'grid', placeItems: 'center', background: 'rgba(var(--sb-green-rgb), 0.1)', border: '1px solid rgba(var(--sb-green-rgb), 0.25)', color: 'var(--sb-green)' } }, svg(ICON.user, 16, 1.9)),
h('div', { key: 'tx', style: { flex: 1, minWidth: 0 } }, [
h('div', { key: 'n', style: { fontSize: '13px', fontWeight: 700, color: '#f4f4f6', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, name),
h('div', { key: 's', style: { display: 'flex', alignItems: 'center', gap: '7px', fontFamily: MONO, fontSize: '11px', color: '#9a9aa2' } }, [
h('div', { key: 'n', style: { fontSize: '13px', fontWeight: 700, color: 'var(--sb-text-1)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, name),
h('div', { key: 's', style: { display: 'flex', alignItems: 'center', gap: '7px', fontFamily: MONO, fontSize: '11px', color: 'var(--sb-text-6)' } }, [
(isVideo ? t('call.videoPrefix') : t('call.voicePrefix')) + callStatus,
phase === 'active' && qualityIndicator(true)
])
]),
h('button', { key: 'exp', onClick: () => setMinimized(false), title: t('call.expand'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: 'rgba(255,255,255,0.05)', color: '#cfcfd4', cursor: 'pointer', transition: 'all .15s' } }, svg(ICON.expand, 15, 2)),
h('button', { key: 'end', onClick: doEnd, title: t('call.end'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer', transition: 'transform .15s' } }, svg(ICON.phoneHangup, 15, 2))
h('button', { key: 'exp', onClick: () => setMinimized(false), title: t('call.expand'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: 'rgba(var(--sb-ink), 0.05)', color: 'var(--sb-text-4)', cursor: 'pointer', transition: 'all .15s' } }, svg(ICON.expand, 15, 2)),
h('button', { key: 'end', onClick: doEnd, title: t('call.end'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: 'var(--sb-red-strong-solid)', color: '#fff', cursor: 'pointer', transition: 'transform .15s' } }, svg(ICON.phoneHangup, 15, 2))
])
]);
}
// ── VIDEO call (expanded) ────────────────────────────────────────────────
if (isVideo) {
return h('div', { style: { position: 'absolute', inset: 0, zIndex: 40, overflow: 'hidden', background: '#0a0a0c', animation: 'sbExpand .2s ease' } }, [
return h('div', { style: { position: 'absolute', inset: 0, zIndex: 40, overflow: 'hidden', background: 'var(--sb-bg-deepest)', animation: 'sbExpand .2s ease' } }, [
hiddenAudio,
call.remoteHasVideo
? h('video', { key: 'rv', ref: remoteVideoRef, autoPlay: true, muted: true, playsInline: true, style: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', background: '#0a0a0c' } })
: h('div', { key: 'ph', style: { position: 'absolute', inset: 0, background: 'linear-gradient(120deg, #15151b, #1d1a24, #161620)', backgroundSize: '200% 200%', animation: 'sbLiveBg 9s ease-in-out infinite', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '18px' } }, [
h('div', { key: 'a', style: { width: '120px', height: '120px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'radial-gradient(circle at 35% 30%, #2a2a30, #161618)', border: '1px solid rgba(255,255,255,0.1)', color: '#9a9aa2' } }, svg(ICON.user, 54, 1.5)),
h('div', { key: 't', style: { fontSize: '15px', fontWeight: 600, color: '#8a8a92' } }, t('call.peerCameraOff'))
? h('video', { key: 'rv', ref: remoteVideoRef, autoPlay: true, muted: true, playsInline: true, style: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', background: 'var(--sb-bg-deepest)' } })
: h('div', { key: 'ph', style: { position: 'absolute', inset: 0, background: 'linear-gradient(120deg, var(--sb-grad-1), var(--sb-grad-2), var(--sb-grad-3))', backgroundSize: '200% 200%', animation: 'sbLiveBg 9s ease-in-out infinite', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '18px' } }, [
h('div', { key: 'a', style: { width: '120px', height: '120px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'radial-gradient(circle at 35% 30%, var(--sb-surface-4), var(--sb-surface))', border: '1px solid rgba(var(--sb-ink), 0.1)', color: 'var(--sb-text-6)' } }, svg(ICON.user, 54, 1.5)),
h('div', { key: 't', style: { fontSize: '15px', fontWeight: 600, color: 'var(--sb-text-7)' } }, t('call.peerCameraOff'))
]),
// Top bar
h('div', { key: 'top', style: { position: 'absolute', top: 0, left: 0, right: 0, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '14px', padding: '18px 20px', background: 'linear-gradient(180deg, rgba(0,0,0,0.55), transparent)' } }, [
h('div', { key: 'l' }, [
h('div', { key: 'n', style: { fontSize: '18px', fontWeight: 800, letterSpacing: '-0.3px', color: '#fff' } }, name),
h('div', { key: 's', style: { display: 'inline-flex', alignItems: 'center', gap: '9px', marginTop: '4px' } }, [
h('span', { key: 'st', style: { fontFamily: MONO, fontSize: '12.5px', fontWeight: 500, color: '#e8e8eb' } }, callStatus),
h('span', { key: 'st', style: { fontFamily: MONO, fontSize: '12.5px', fontWeight: 500, color: 'var(--sb-text-2)' } }, callStatus),
encBadge,
phase === 'active' && qualityIndicator(false)
])
@@ -227,11 +227,11 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
h('button', { key: 'min', onClick: () => setMinimized(true), title: t('call.minimize'), style: { flex: 'none', ...minimizeBtn(true) } }, svg(ICON.minimize, 16, 2))
]),
// Self-cam PiP
h('div', { key: 'self', style: { position: 'absolute', bottom: '108px', insetInlineEnd: '18px', width: '132px', height: '176px', borderRadius: '14px', overflow: 'hidden', border: '1px solid rgba(255,255,255,0.16)', boxShadow: '0 12px 30px rgba(0,0,0,0.5)', background: '#111' } }, [
h('div', { key: 'self', style: { position: 'absolute', bottom: '108px', insetInlineEnd: '18px', width: '132px', height: '176px', borderRadius: '14px', overflow: 'hidden', border: '1px solid rgba(var(--sb-ink), 0.16)', boxShadow: '0 12px 30px rgba(var(--sb-shadow-rgb), calc(0.5 * var(--sb-shadow-k)))', background: '#111' } }, [
h('video', { key: 'sv', ref: selfVideoRef, autoPlay: true, muted: true, playsInline: true, style: { width: '100%', height: '100%', objectFit: 'cover', transform: 'scaleX(-1)', display: 'block' } }),
!call.cameraEnabled && h('div', { key: 'off', style: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '8px', background: '#161618', color: '#6b6b73' } }, [
!call.cameraEnabled && h('div', { key: 'off', style: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '8px', background: 'var(--sb-surface)', color: 'var(--sb-text-9)' } }, [
svg(ICON.camOff, 24, 1.8),
h('span', { key: 't', style: { fontSize: '10.5px', color: '#6b6b73', fontFamily: MONO } }, t('call.cameraOff'))
h('span', { key: 't', style: { fontSize: '10.5px', color: 'var(--sb-text-9)', fontFamily: MONO } }, t('call.cameraOff'))
])
]),
// Control bar
@@ -245,16 +245,16 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
}
// ── VOICE call (expanded) ────────────────────────────────────────────────
return h('div', { style: { position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column', background: 'radial-gradient(680px 460px at 50% 36%, rgba(240,137,42,0.08), transparent 70%), #0d0d0f', animation: 'sbExpand .2s ease' } }, [
return h('div', { style: { position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column', background: 'radial-gradient(680px 460px at 50% 36%, rgba(var(--sb-orange-rgb), 0.08), transparent 70%), var(--sb-bg-deep)', animation: 'sbExpand .2s ease' } }, [
hiddenAudio,
h('div', { key: 'top', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 18px' } }, [
h('span', { key: 'enc', style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: '#3ecf8e' } }, [svg(ICON.lock, 13, 2), t('call.encrypted')]),
h('span', { key: 'enc', style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: 'var(--sb-green)' } }, [svg(ICON.lock, 13, 2), t('call.encrypted')]),
h('button', { key: 'min', onClick: () => setMinimized(true), title: t('call.minimize'), style: minimizeBtn(false) }, svg(ICON.minimize, 16, 2))
]),
h('div', { key: 'mid', style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' } }, [
avatarDisc(46, ringing),
h('div', { key: 'nm', style: { fontSize: '24px', fontWeight: 800, letterSpacing: '-0.5px', color: '#f4f4f6' } }, name),
h('div', { key: 'st', style: { fontFamily: MONO, fontSize: '14px', fontWeight: 500, color: '#9a9aa2', marginTop: '8px' } }, callStatus),
h('div', { key: 'nm', style: { fontSize: '24px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--sb-text-1)' } }, name),
h('div', { key: 'st', style: { fontFamily: MONO, fontSize: '14px', fontWeight: 500, color: 'var(--sb-text-6)', marginTop: '8px' } }, callStatus),
phase === 'active' && h('div', { key: 'q', style: { marginTop: '12px' } }, qualityIndicator(false))
]),
h('div', { key: 'ctrls', style: { flex: 'none', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', gap: '26px', padding: '28px 24px 34px' } }, [
+18 -16
View File
@@ -17,7 +17,9 @@ const CommunityCTA = () => {
};
}, []);
const ACCENT = '#f0892a';
const ACCENT = 'var(--sb-orange)';
// Fill form: brand colour in both themes. See app.jsx.
const ACCENT_SOLID = 'var(--sb-orange-solid)';
const SANS = "'Manrope', system-ui, -apple-system, sans-serif";
const githubUrl = 'https://github.com/SecureBitChat/securebit-chat/';
@@ -30,13 +32,13 @@ const CommunityCTA = () => {
rel: 'noopener noreferrer',
style: {
display: 'inline-flex', alignItems: 'center', gap: '11px', padding: '15px 26px',
borderRadius: '13px', background: ACCENT, color: '#1a0f04', textDecoration: 'none',
borderRadius: '13px', background: ACCENT_SOLID, color: 'var(--sb-on-accent)', textDecoration: 'none',
fontSize: '15.5px', fontWeight: 700, letterSpacing: '-0.2px',
boxShadow: '0 8px 24px rgba(240,137,42,0.28)', whiteSpace: 'nowrap',
boxShadow: '0 8px 24px rgba(var(--sb-orange-rgb), 0.28)', whiteSpace: 'nowrap',
transition: 'all .2s cubic-bezier(.2,.7,.3,1)'
},
onMouseEnter: (e) => { e.currentTarget.style.background = '#ff9637'; e.currentTarget.style.transform = 'translateY(-2px)'; },
onMouseLeave: (e) => { e.currentTarget.style.background = ACCENT; e.currentTarget.style.transform = 'none'; }
onMouseEnter: (e) => { e.currentTarget.style.background = 'var(--sb-orange-hi)'; e.currentTarget.style.transform = 'translateY(-2px)'; },
onMouseLeave: (e) => { e.currentTarget.style.background = ACCENT_SOLID; e.currentTarget.style.transform = 'none'; }
}, [
React.createElement('svg', {
key: 'i', width: 20, height: 20, viewBox: '0 0 24 24', fill: 'currentColor',
@@ -51,13 +53,13 @@ const CommunityCTA = () => {
rel: 'noopener noreferrer',
style: {
display: 'inline-flex', alignItems: 'center', gap: '11px', padding: '15px 26px',
borderRadius: '13px', background: 'rgba(255,255,255,0.03)', color: '#e8e8eb', textDecoration: 'none',
borderRadius: '13px', background: 'rgba(var(--sb-ink), 0.03)', color: 'var(--sb-text-2)', textDecoration: 'none',
fontSize: '15.5px', fontWeight: 700, letterSpacing: '-0.2px',
border: '1px solid rgba(255,255,255,0.1)', whiteSpace: 'nowrap',
border: '1px solid rgba(var(--sb-ink), 0.1)', whiteSpace: 'nowrap',
transition: 'all .2s cubic-bezier(.2,.7,.3,1)'
},
onMouseEnter: (e) => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.24)'; e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; },
onMouseLeave: (e) => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; e.currentTarget.style.background = 'rgba(255,255,255,0.03)'; }
onMouseEnter: (e) => { e.currentTarget.style.borderColor = 'rgba(var(--sb-ink), 0.24)'; e.currentTarget.style.background = 'rgba(var(--sb-ink), 0.06)'; },
onMouseLeave: (e) => { e.currentTarget.style.borderColor = 'rgba(var(--sb-ink), 0.1)'; e.currentTarget.style.background = 'rgba(var(--sb-ink), 0.03)'; }
}, [
React.createElement('svg', {
key: 'i', width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
@@ -72,16 +74,16 @@ const CommunityCTA = () => {
style: {
position: 'relative', overflow: 'hidden', maxWidth: '860px', width: '100%',
borderRadius: '24px',
background: 'radial-gradient(700px 360px at 50% 0%, rgba(240,137,42,0.1), transparent 65%), #121214',
border: '1px solid rgba(255,255,255,0.07)',
background: 'radial-gradient(700px 360px at 50% 0%, rgba(var(--sb-orange-rgb), 0.1), transparent 65%), var(--sb-bg)',
border: '1px solid rgba(var(--sb-ink), 0.07)',
padding: isMobile ? '40px 24px 36px' : '56px 56px 48px',
textAlign: 'center', boxShadow: '0 24px 60px rgba(0,0,0,0.4)'
textAlign: 'center', boxShadow: '0 24px 60px rgba(var(--sb-shadow-rgb), calc(0.4 * var(--sb-shadow-k)))'
}
}, [
// hairline accent
React.createElement('div', {
key: 'hairline',
style: { position: 'absolute', top: 0, left: '50%', transform: 'translateX(-50%)', width: '180px', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(240,137,42,0.7), transparent)' }
style: { position: 'absolute', top: 0, left: '50%', transform: 'translateX(-50%)', width: '180px', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(var(--sb-orange-rgb), 0.7), transparent)' }
}),
// brand mark (same SVG as the header — no border or background)
React.createElement('img', {
@@ -93,12 +95,12 @@ const CommunityCTA = () => {
// title
React.createElement('h2', {
key: 'title',
style: { margin: '0 0 16px', fontSize: isMobile ? '28px' : '36px', fontWeight: 800, letterSpacing: '-1px', lineHeight: 1.05, color: '#f4f4f6' }
style: { margin: '0 0 16px', fontSize: isMobile ? '28px' : '36px', fontWeight: 800, letterSpacing: '-1px', lineHeight: 1.05, color: 'var(--sb-text-1)' }
}, t('community.title')),
// description
React.createElement('p', {
key: 'desc',
style: { margin: '0 auto 32px', maxWidth: '560px', fontSize: '16px', lineHeight: 1.65, color: '#9a9aa2' }
style: { margin: '0 auto 32px', maxWidth: '560px', fontSize: '16px', lineHeight: 1.65, color: 'var(--sb-text-6)' }
}, t('community.description')),
// buttons
React.createElement('div', {
@@ -140,7 +142,7 @@ const CommunityCTA = () => {
return React.createElement('section', {
style: {
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: '#0f0f11', fontFamily: SANS,
background: 'var(--sb-bg)', fontFamily: SANS,
padding: isMobile ? '48px 18px' : '64px 48px'
}
}, [
+29 -29
View File
@@ -158,15 +158,15 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
key: i,
style: {
width: '11px', height: '11px', borderRadius: '2px',
background: i < filled ? color : 'rgba(255,255,255,0.07)',
border: '1px solid ' + (i < filled ? 'transparent' : 'rgba(255,255,255,0.05)'),
background: i < filled ? color : 'rgba(var(--sb-ink), 0.07)',
border: '1px solid ' + (i < filled ? 'transparent' : 'rgba(var(--sb-ink), 0.05)'),
boxShadow: i < filled ? `0 0 5px ${color}55` : 'none',
transition: 'background .2s ease, box-shadow .2s ease'
}
}))),
React.createElement('div', {
key: 'text',
style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '11.5px', color: '#8a8a92' }
style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '11.5px', color: 'var(--sb-text-7)' }
}, [
React.createElement('span', { key: 'status', style: { display: 'inline-flex', alignItems: 'center', gap: '5px' } }, [
React.createElement('i', { key: 'icon', className: getStatusIcon(transfer.status) }),
@@ -174,7 +174,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
]),
React.createElement('span', {
key: 'count',
style: { fontFamily: "'JetBrains Mono', ui-monospace, monospace", color: i_done(transfer) ? color : '#8a8a92' }
style: { fontFamily: "'JetBrains Mono', ui-monospace, monospace", color: i_done(transfer) ? color : 'var(--sb-text-7)' }
}, total > 0 ? `${Math.min(done, total)} / ${total} chunks` : `${(transfer.progress || 0).toFixed(0)}%`)
])
]);
@@ -221,9 +221,9 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
onDragLeave: handleDragLeave,
style: {
position: 'relative',
border: '1.5px dashed ' + (dragOver ? 'rgba(240,137,42,0.7)' : 'rgba(255,255,255,0.14)'),
border: '1.5px dashed ' + (dragOver ? 'rgba(var(--sb-orange-rgb), 0.7)' : 'rgba(var(--sb-ink), 0.14)'),
borderRadius: '14px',
background: dragOver ? 'rgba(240,137,42,0.07)' : '#141416',
background: dragOver ? 'rgba(var(--sb-orange-rgb), 0.07)' : 'var(--sb-surface)',
padding: '24px 22px',
textAlign: 'center',
transition: 'all .15s'
@@ -231,16 +231,16 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
}, [
React.createElement('div', {
key: 'icon-box',
style: { width: '42px', height: '42px', margin: '0 auto 10px', borderRadius: '12px', display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)' }
}, React.createElement('i', { className: 'fas fa-arrow-up-from-bracket', style: { color: '#9a9aa2', fontSize: '18px' } })),
React.createElement('div', { key: 'title', style: { fontSize: '14px', fontWeight: 700, color: '#e8e8eb' } }, t('file.drop')),
React.createElement('div', { key: 'sub', style: { fontSize: '12px', color: '#7b7b83', marginTop: '4px' } }, t('file.dropHint')),
style: { width: '42px', height: '42px', margin: '0 auto 10px', borderRadius: '12px', display: 'grid', placeItems: 'center', background: 'rgba(var(--sb-ink), 0.04)', border: '1px solid rgba(var(--sb-ink), 0.08)' }
}, React.createElement('i', { className: 'fas fa-arrow-up-from-bracket', style: { color: 'var(--sb-text-6)', fontSize: '18px' } })),
React.createElement('div', { key: 'title', style: { fontSize: '14px', fontWeight: 700, color: 'var(--sb-text-2)' } }, t('file.drop')),
React.createElement('div', { key: 'sub', style: { fontSize: '12px', color: 'var(--sb-text-8)', marginTop: '4px' } }, t('file.dropHint')),
React.createElement('button', {
key: 'browse',
type: 'button',
onClick: () => fileInputRef.current?.click(),
className: 'sb-send',
style: { marginTop: '14px', display: 'inline-flex', alignItems: 'center', gap: '7px', padding: '9px 16px', borderRadius: '9px', border: 'none', background: '#f0892a', color: '#1a0f04', fontFamily: 'inherit', fontSize: '13px', fontWeight: 700, cursor: 'pointer' }
style: { marginTop: '14px', display: 'inline-flex', alignItems: 'center', gap: '7px', padding: '9px 16px', borderRadius: '9px', border: 'none', background: 'var(--sb-orange-solid)', color: 'var(--sb-on-accent)', fontFamily: 'inherit', fontSize: '13px', fontWeight: 700, cursor: 'pointer' }
}, [
React.createElement('i', { key: 'i', className: 'fas fa-folder-open', style: { fontSize: '13px' } }),
t('file.browse')
@@ -262,23 +262,23 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
className: "mt-4 space-y-2"
}, pendingIncomingFiles.map(file => React.createElement('div', {
key: file.fileId,
style: { borderRadius: '12px', border: '1px solid rgba(255,255,255,0.08)', background: '#161618', padding: '12px 14px' }
style: { borderRadius: '12px', border: '1px solid rgba(var(--sb-ink), 0.08)', background: 'var(--sb-surface)', padding: '12px 14px' }
}, [
React.createElement('div', {
key: 'info',
style: { marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '11px' }
}, [
React.createElement('div', { key: 'ic', style: { flex: 'none', width: '34px', height: '34px', borderRadius: '9px', display: 'grid', placeItems: 'center', background: 'rgba(240,137,42,0.12)', border: '1px solid rgba(240,137,42,0.22)' } },
React.createElement('i', { className: 'fas fa-file-arrow-down', style: { color: '#f0892a', fontSize: '15px' } })
React.createElement('div', { key: 'ic', style: { flex: 'none', width: '34px', height: '34px', borderRadius: '9px', display: 'grid', placeItems: 'center', background: 'rgba(var(--sb-orange-rgb), 0.12)', border: '1px solid rgba(var(--sb-orange-rgb), 0.22)' } },
React.createElement('i', { className: 'fas fa-file-arrow-down', style: { color: 'var(--sb-orange)', fontSize: '15px' } })
),
React.createElement('div', { key: 'text', style: { minWidth: 0 } }, [
React.createElement('div', {
key: 'title',
style: { fontSize: '13px', fontWeight: 600, color: '#e8e8eb' }
style: { fontSize: '13px', fontWeight: 600, color: 'var(--sb-text-2)' }
}, t('file.incoming')),
React.createElement('div', {
key: 'meta',
style: { fontSize: '11.5px', color: '#7b7b83', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }
style: { fontSize: '11.5px', color: 'var(--sb-text-8)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }
}, `${file.fileName} · ${formatFileSize(file.fileSize)} · ${file.mimeType}`)
])
]),
@@ -289,12 +289,12 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
React.createElement('button', {
key: 'accept',
onClick: () => handleIncomingDecision(file.fileId, true),
style: { display: 'inline-flex', alignItems: 'center', gap: '6px', borderRadius: '8px', border: 'none', background: '#f0892a', color: '#1a0f04', padding: '8px 14px', fontSize: '13px', fontWeight: 700, cursor: 'pointer' }
style: { display: 'inline-flex', alignItems: 'center', gap: '6px', borderRadius: '8px', border: 'none', background: 'var(--sb-orange-solid)', color: 'var(--sb-on-accent)', padding: '8px 14px', fontSize: '13px', fontWeight: 700, cursor: 'pointer' }
}, [React.createElement('i', { key: 'i', className: 'fas fa-check', style: { fontSize: '12px' } }), t('file.accept')]),
React.createElement('button', {
key: 'reject',
onClick: () => handleIncomingDecision(file.fileId, false),
style: { display: 'inline-flex', alignItems: 'center', gap: '6px', borderRadius: '8px', border: '1px solid rgba(229,114,122,0.3)', background: 'rgba(229,114,122,0.08)', color: '#e5727a', padding: '8px 14px', fontSize: '13px', fontWeight: 600, cursor: 'pointer' }
style: { display: 'inline-flex', alignItems: 'center', gap: '6px', borderRadius: '8px', border: '1px solid rgba(var(--sb-red-rgb), 0.3)', background: 'rgba(var(--sb-red-rgb), 0.08)', color: 'var(--sb-red)', padding: '8px 14px', fontSize: '13px', fontWeight: 600, cursor: 'pointer' }
}, [React.createElement('i', { key: 'i', className: 'fas fa-xmark', style: { fontSize: '12px' } }), t('file.reject')])
])
]))),
@@ -306,7 +306,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
}, [
React.createElement('h4', {
key: 'title',
style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12.5px', fontWeight: 600, color: '#8a8a92', marginBottom: '10px' }
style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12.5px', fontWeight: 600, color: 'var(--sb-text-7)', marginBottom: '10px' }
}, [
React.createElement('i', {
key: 'icon',
@@ -320,7 +320,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
...transfers.sending.map(transfer =>
React.createElement('div', {
key: `send-${transfer.fileId}`,
style: { borderRadius: '11px', border: '1px solid rgba(255,255,255,0.07)', background: '#161618', padding: '12px', marginBottom: '8px' }
style: { borderRadius: '11px', border: '1px solid rgba(var(--sb-ink), 0.07)', background: 'var(--sb-surface)', padding: '12px', marginBottom: '8px' }
}, [
React.createElement('div', {
key: 'header',
@@ -333,17 +333,17 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
React.createElement('i', {
key: 'icon',
className: 'fas fa-arrow-up',
style: { color: '#f0892a', fontSize: '13px', marginInlineEnd: '8px' }
style: { color: 'var(--sb-orange)', fontSize: '13px', marginInlineEnd: '8px' }
}),
React.createElement('span', {
key: 'name',
className: "font-medium text-sm",
style: { color: '#e8e8eb' }
style: { color: 'var(--sb-text-2)' }
}, transfer.fileName),
React.createElement('span', {
key: 'size',
className: "text-xs ms-2",
style: { color: '#7b7b83' }
style: { color: 'var(--sb-text-8)' }
}, formatFileSize(transfer.fileSize))
]),
React.createElement('button', {
@@ -356,7 +356,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
})
])
]),
renderProgress(transfer, '#f0892a')
renderProgress(transfer, 'var(--sb-orange)')
])
),
@@ -364,7 +364,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
...transfers.receiving.map(transfer =>
React.createElement('div', {
key: `recv-${transfer.fileId}`,
style: { borderRadius: '11px', border: '1px solid rgba(255,255,255,0.07)', background: '#161618', padding: '12px', marginBottom: '8px' }
style: { borderRadius: '11px', border: '1px solid rgba(var(--sb-ink), 0.07)', background: 'var(--sb-surface)', padding: '12px', marginBottom: '8px' }
}, [
React.createElement('div', {
key: 'header',
@@ -377,17 +377,17 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
React.createElement('i', {
key: 'icon',
className: 'fas fa-arrow-down',
style: { color: '#3ecf8e', fontSize: '13px', marginInlineEnd: '8px' }
style: { color: 'var(--sb-green)', fontSize: '13px', marginInlineEnd: '8px' }
}),
React.createElement('span', {
key: 'name',
className: "font-medium text-sm",
style: { color: '#e8e8eb' }
style: { color: 'var(--sb-text-2)' }
}, transfer.fileName),
React.createElement('span', {
key: 'size',
className: "text-xs ms-2",
style: { color: '#7b7b83' }
style: { color: 'var(--sb-text-8)' }
}, formatFileSize(transfer.fileSize))
]),
React.createElement('div', { key: 'actions', className: 'flex items-center space-x-2' }, [
@@ -422,7 +422,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
])
])
]),
renderProgress(transfer, '#3ecf8e')
renderProgress(transfer, 'var(--sb-green)')
])
)
])
+49 -49
View File
@@ -40,26 +40,26 @@ const svg = (inner, size, sw) => h('span', {
const ctrlBase = {
width: '56px', height: '56px', borderRadius: '50%', display: 'grid', placeItems: 'center',
border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.05)',
color: '#cfcfd4', cursor: 'pointer', transition: 'all .15s',
border: '1px solid rgba(var(--sb-ink), 0.1)', background: 'rgba(var(--sb-ink), 0.05)',
color: 'var(--sb-text-4)', cursor: 'pointer', transition: 'all .15s',
};
const dangerCtrl = { ...ctrlBase, background: '#e5484d', color: '#fff', border: '1px solid transparent' };
const dangerCtrl = { ...ctrlBase, background: 'var(--sb-red-strong-solid)', color: '#fff', border: '1px solid transparent' };
const endBtn = {
width: '56px', height: '56px', borderRadius: '50%', display: 'grid', placeItems: 'center',
border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer',
boxShadow: '0 8px 24px rgba(229,72,77,0.35)', transition: 'transform .15s',
border: 'none', background: 'var(--sb-red-strong-solid)', color: '#fff', cursor: 'pointer',
boxShadow: '0 8px 24px rgba(var(--sb-red-strong-rgb), 0.35)', transition: 'transform .15s',
};
const minimizeBtn = {
width: '36px', height: '36px', borderRadius: '9px', display: 'grid', placeItems: 'center',
border: '1px solid rgba(255,255,255,0.15)', background: 'rgba(0,0,0,0.35)',
border: '1px solid rgba(var(--sb-ink), 0.15)', background: 'rgba(0,0,0,0.35)',
color: '#fff', cursor: 'pointer', transition: 'all .15s',
};
const QUALITY = {
excellent: { bars: 4, color: '#3ecf8e' },
good: { bars: 3, color: '#3ecf8e' },
fair: { bars: 2, color: '#e3c84e' },
poor: { bars: 1, color: '#e5727a' },
excellent: { bars: 4, color: 'var(--sb-green)' },
good: { bars: 3, color: 'var(--sb-green)' },
fair: { bars: 2, color: 'var(--sb-yellow)' },
poor: { bars: 1, color: 'var(--sb-red)' },
};
function qualityBars(quality) {
@@ -70,7 +70,7 @@ function qualityBars(quality) {
key: i,
style: {
width: '2.5px', height: (4 + i * 2.6) + 'px', borderRadius: '1px',
background: i < q.bars ? q.color : 'rgba(255,255,255,0.18)',
background: i < q.bars ? q.color : 'rgba(var(--sb-ink), 0.18)',
},
})));
}
@@ -180,9 +180,9 @@ function Tile({ peer, stream, self, localStream, cameraEnabled, speaking, tileW,
: (speaking ? t('groupCall.speaking', { name: peer.name }) : peer.name),
style: {
cursor: onSelect ? 'pointer' : 'default',
position: 'relative', borderRadius: '14px', overflow: 'hidden', background: '#141417',
border: speaking ? '2px solid #3ecf8e' : '2px solid rgba(255,255,255,0.08)',
boxShadow: speaking ? '0 0 0 3px rgba(62,207,142,0.16)' : 'none',
position: 'relative', borderRadius: '14px', overflow: 'hidden', background: 'var(--sb-surface)',
border: speaking ? '2px solid var(--sb-green-solid)' : '2px solid rgba(var(--sb-ink), 0.08)',
boxShadow: speaking ? '0 0 0 3px rgba(var(--sb-green-rgb), 0.16)' : 'none',
transition: 'border-color .12s ease, box-shadow .12s ease',
// Sized by the grid, which is the only thing that knows how much
// room there is. aspectRatio stays as the fallback for the first
@@ -205,7 +205,7 @@ function Tile({ peer, stream, self, localStream, cameraEnabled, speaking, tileW,
key: 'v', ref: videoRef, autoPlay: true, muted: true, playsInline: true,
style: {
position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover',
transform: self ? 'scaleX(-1)' : 'none', background: '#0f0f11',
transform: self ? 'scaleX(-1)' : 'none', background: 'var(--sb-bg)',
},
})
: h('div', {
@@ -215,9 +215,9 @@ function Tile({ peer, stream, self, localStream, cameraEnabled, speaking, tileW,
// large tile and crowds a small one.
width: avatarPx + 'px', height: avatarPx + 'px',
borderRadius: '50%', display: 'grid', placeItems: 'center',
background: 'radial-gradient(circle at 35% 30%, #2a2a30, #161618)',
border: speaking ? '1.5px solid rgba(62,207,142,0.65)' : '1px solid rgba(255,255,255,0.1)',
color: speaking ? '#3ecf8e' : '#cfcfd4',
background: 'radial-gradient(circle at 35% 30%, var(--sb-surface-4), var(--sb-surface))',
border: speaking ? '1.5px solid rgba(var(--sb-green-rgb), 0.65)' : '1px solid rgba(var(--sb-ink), 0.1)',
color: speaking ? 'var(--sb-green)' : 'var(--sb-text-4)',
fontFamily: MONO, fontSize: Math.round(avatarPx * 0.3) + 'px', fontWeight: 700,
transition: 'color .12s ease, border-color .12s ease',
},
@@ -233,18 +233,18 @@ function Tile({ peer, stream, self, localStream, cameraEnabled, speaking, tileW,
h('span', {
key: 'n',
style: {
flex: 1, minWidth: 0, fontSize: compact ? '10.5px' : '12.5px', fontWeight: 700, color: '#f4f4f6',
flex: 1, minWidth: 0, fontSize: compact ? '10.5px' : '12.5px', fontWeight: 700, color: 'var(--sb-text-1)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
},
}, peer.name),
speaking && h('span', {
key: 'sp', style: { flex: 'none', color: '#3ecf8e', display: 'grid', placeItems: 'center' },
key: 'sp', style: { flex: 'none', color: 'var(--sb-green)', display: 'grid', placeItems: 'center' },
}, svg(ICON.micOn, compact ? 11 : 13, 2)),
(statusWord && !compact) && h('span', {
key: 's',
style: {
flex: 'none', fontFamily: MONO, fontSize: '10.5px',
color: peer.state === LEG_STATE.FAILED ? '#e5727a' : '#9a9aa2',
color: peer.state === LEG_STATE.FAILED ? 'var(--sb-red)' : 'var(--sb-text-6)',
},
}, statusWord),
(!self && !compact) && qualityBars(peer.quality),
@@ -300,7 +300,7 @@ export function GroupCallUI({
const encBadge = h('span', {
key: 'enc',
style: { display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '11.5px', fontWeight: 600, color: '#3ecf8e' },
style: { display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '11.5px', fontWeight: 600, color: 'var(--sb-green)' },
}, [svg(ICON.lock, 12, 2), t('call.encryptedShort')]);
// ── somebody is calling the group and we have not joined ─────────────────
@@ -308,12 +308,12 @@ export function GroupCallUI({
return h('div', {
style: {
position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column',
background: 'radial-gradient(680px 460px at 50% 36%, rgba(240,137,42,0.08), transparent 70%), #0d0d0f',
background: 'radial-gradient(680px 460px at 50% 36%, rgba(var(--sb-orange-rgb), 0.08), transparent 70%), var(--sb-bg-deep)',
animation: 'sbExpand .2s ease',
},
}, [
h('div', { key: 'top', style: { flex: 'none', padding: '16px 18px' } },
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: '#3ecf8e' } },
h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '7px', fontSize: '12px', fontWeight: 600, color: 'var(--sb-green)' } },
[svg(ICON.lock, 13, 2), t('call.encrypted')])),
h('div', { key: 'mid', style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '0 24px', textAlign: 'center' } }, [
h('div', {
@@ -323,40 +323,40 @@ export function GroupCallUI({
display: 'grid', placeItems: 'center',
},
}, [
h('span', { key: 'p1', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(240,137,42,0.5)', animation: 'sbCallPulse 2s ease-out infinite' } }),
h('span', { key: 'p2', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(240,137,42,0.4)', animation: 'sbCallPulse 2s ease-out infinite', animationDelay: '1s' } }),
h('span', { key: 'p1', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(var(--sb-orange-rgb), 0.5)', animation: 'sbCallPulse 2s ease-out infinite' } }),
h('span', { key: 'p2', style: { position: 'absolute', inset: 0, borderRadius: '50%', border: '1.5px solid rgba(var(--sb-orange-rgb), 0.4)', animation: 'sbCallPulse 2s ease-out infinite', animationDelay: '1s' } }),
h('div', {
key: 'c',
style: {
width: '96px', height: '96px', borderRadius: '50%', display: 'grid', placeItems: 'center',
background: 'radial-gradient(circle at 35% 30%, #2a2a30, #161618)',
border: '1px solid rgba(255,255,255,0.1)', color: '#8a8a92',
background: 'radial-gradient(circle at 35% 30%, var(--sb-surface-4), var(--sb-surface))',
border: '1px solid rgba(var(--sb-ink), 0.1)', color: 'var(--sb-text-7)',
},
}, svg(ICON.users, 42, 1.6)),
]),
h('div', { key: 'n', style: { fontSize: '22px', fontWeight: 800, letterSpacing: '-0.4px', color: '#f4f4f6' } }, groupName),
h('div', { key: 's', style: { fontFamily: MONO, fontSize: '13.5px', color: '#9a9aa2', marginTop: '8px' } },
h('div', { key: 'n', style: { fontSize: '22px', fontWeight: 800, letterSpacing: '-0.4px', color: 'var(--sb-text-1)' } }, groupName),
h('div', { key: 's', style: { fontFamily: MONO, fontSize: '13.5px', color: 'var(--sb-text-6)', marginTop: '8px' } },
call.withVideo
? t('groupCall.startedVideo', { name: call.startedByName })
: t('groupCall.startedVoice', { name: call.startedByName })),
h('div', { key: 'p', style: { fontSize: '12.5px', color: '#6b6b73', marginTop: '6px' } },
h('div', { key: 'p', style: { fontSize: '12.5px', color: 'var(--sb-text-9)', marginTop: '6px' } },
t('groupCall.inCall', { count: call.participants.length })),
]),
h('div', { key: 'ctrls', style: { flex: 'none', display: 'flex', justifyContent: 'center', gap: '48px', padding: '24px 24px 40px' } }, [
h('div', { key: 'dec', style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' } }, [
h('button', { key: 'b', onClick: onDismiss, title: t('groupCall.dismiss'), style: { ...endBtn, width: '62px', height: '62px' } }, svg(ICON.phoneHangup, 24, 1.9)),
h('span', { key: 'l', style: { fontFamily: MONO, fontSize: '10.5px', color: '#8a8a92' } }, t('groupCall.dismiss')),
h('span', { key: 'l', style: { fontFamily: MONO, fontSize: '10.5px', color: 'var(--sb-text-7)' } }, t('groupCall.dismiss')),
]),
h('div', { key: 'acc', style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' } }, [
h('button', {
key: 'b', onClick: onJoin, title: t('groupCall.join'),
style: {
width: '62px', height: '62px', borderRadius: '50%', display: 'grid', placeItems: 'center',
border: 'none', background: '#3ecf8e', color: '#06231a', cursor: 'pointer',
boxShadow: '0 8px 24px rgba(62,207,142,0.35)',
border: 'none', background: 'var(--sb-green-solid)', color: 'var(--sb-on-green)', cursor: 'pointer',
boxShadow: '0 8px 24px rgba(var(--sb-green-rgb), 0.35)',
},
}, svg(ICON.phone, 24, 1.9)),
h('span', { key: 'l', style: { fontFamily: MONO, fontSize: '10.5px', color: '#8a8a92' } }, t('groupCall.join')),
h('span', { key: 'l', style: { fontFamily: MONO, fontSize: '10.5px', color: 'var(--sb-text-7)' } }, t('groupCall.join')),
]),
]),
]);
@@ -379,8 +379,8 @@ export function GroupCallUI({
return h('div', {
style: {
position: 'absolute', bottom: '18px', insetInlineEnd: '18px', zIndex: 40, width: '244px',
borderRadius: '14px', overflow: 'hidden', background: '#161618',
border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 18px 44px rgba(0,0,0,0.55)',
borderRadius: '14px', overflow: 'hidden', background: 'var(--sb-surface)',
border: '1px solid rgba(var(--sb-ink), 0.1)', boxShadow: '0 18px 44px rgba(var(--sb-shadow-rgb), calc(0.55 * var(--sb-shadow-k)))',
animation: 'sbExpand .18s ease',
},
}, [
@@ -389,24 +389,24 @@ export function GroupCallUI({
key: 'ic',
style: {
flex: 'none', width: '34px', height: '34px', borderRadius: '9px', display: 'grid',
placeItems: 'center', background: 'rgba(62,207,142,0.1)',
border: '1px solid rgba(62,207,142,0.25)', color: '#3ecf8e',
placeItems: 'center', background: 'rgba(var(--sb-green-rgb), 0.1)',
border: '1px solid rgba(var(--sb-green-rgb), 0.25)', color: 'var(--sb-green)',
},
}, svg(ICON.users, 16, 1.9)),
h('div', { key: 'tx', style: { flex: 1, minWidth: 0 } }, [
h('div', { key: 'n', style: { fontSize: '13px', fontWeight: 700, color: '#f4f4f6', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, groupName),
h('div', { key: 'n', style: { fontSize: '13px', fontWeight: 700, color: 'var(--sb-text-1)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, groupName),
h('div', {
key: 's',
style: {
fontFamily: MONO, fontSize: '11px', color: talking ? '#3ecf8e' : '#9a9aa2',
fontFamily: MONO, fontSize: '11px', color: talking ? 'var(--sb-green)' : 'var(--sb-text-6)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
},
}, talking
? t('groupCall.speaking', { name: talking })
: `${t('groupCall.inCall', { count: call.participants.length })} · ${fmt(seconds)}`),
]),
h('button', { key: 'exp', onClick: () => setMinimized(false), title: t('call.expand'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: 'rgba(255,255,255,0.05)', color: '#cfcfd4', cursor: 'pointer' } }, svg(ICON.expand, 15, 2)),
h('button', { key: 'end', onClick: onLeave, title: t('groupCall.leave'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: '#e5484d', color: '#fff', cursor: 'pointer' } }, svg(ICON.phoneHangup, 15, 2)),
h('button', { key: 'exp', onClick: () => setMinimized(false), title: t('call.expand'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: 'rgba(var(--sb-ink), 0.05)', color: 'var(--sb-text-4)', cursor: 'pointer' } }, svg(ICON.expand, 15, 2)),
h('button', { key: 'end', onClick: onLeave, title: t('groupCall.leave'), style: { flex: 'none', width: '32px', height: '32px', borderRadius: '8px', display: 'grid', placeItems: 'center', border: 'none', background: 'var(--sb-red-strong-solid)', color: '#fff', cursor: 'pointer' } }, svg(ICON.phoneHangup, 15, 2)),
]),
]);
}
@@ -436,7 +436,7 @@ export function GroupCallUI({
return h('div', {
style: {
position: 'absolute', inset: 0, zIndex: 40, display: 'flex', flexDirection: 'column',
background: '#0a0a0c', animation: 'sbExpand .2s ease',
background: 'var(--sb-bg-deepest)', animation: 'sbExpand .2s ease',
},
}, [
h('div', {
@@ -449,9 +449,9 @@ export function GroupCallUI({
h('div', { key: 'l', style: { minWidth: 0 } }, [
h('div', { key: 'n', style: { fontSize: '17px', fontWeight: 800, letterSpacing: '-0.3px', color: '#fff', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, groupName),
h('div', { key: 's', style: { display: 'inline-flex', alignItems: 'center', gap: '9px', marginTop: '4px' } }, [
h('span', { key: 'd', style: { fontFamily: MONO, fontSize: '12.5px', color: '#e8e8eb' } }, fmt(seconds)),
h('span', { key: 'd', style: { fontFamily: MONO, fontSize: '12.5px', color: 'var(--sb-text-2)' } }, fmt(seconds)),
encBadge,
h('span', { key: 'c', style: { fontSize: '11.5px', color: '#8a8a92' } },
h('span', { key: 'c', style: { fontSize: '11.5px', color: 'var(--sb-text-7)' } },
t('groupCall.inCall', { count: call.participants.length })),
]),
]),
@@ -460,7 +460,7 @@ export function GroupCallUI({
key: 'grid', onClick: () => setPinned(null), title: t('groupCall.showEveryone'),
style: {
height: '36px', padding: '0 12px', borderRadius: '9px', display: 'inline-flex',
alignItems: 'center', gap: '7px', border: '1px solid rgba(255,255,255,0.15)',
alignItems: 'center', gap: '7px', border: '1px solid rgba(var(--sb-ink), 0.15)',
background: 'rgba(0,0,0,0.35)', color: '#fff', cursor: 'pointer',
fontFamily: 'inherit', fontSize: '12.5px', fontWeight: 600,
},
@@ -473,8 +473,8 @@ export function GroupCallUI({
key: 'err',
style: {
flex: 'none', margin: '0 18px 10px', padding: '9px 12px', borderRadius: '10px',
background: 'rgba(229,114,122,0.1)', border: '1px solid rgba(229,114,122,0.28)',
color: '#e5727a', fontSize: '12.5px', lineHeight: 1.5,
background: 'rgba(var(--sb-red-rgb), 0.1)', border: '1px solid rgba(var(--sb-red-rgb), 0.28)',
color: 'var(--sb-red)', fontSize: '12.5px', lineHeight: 1.5,
},
}, t(`groupCall.err.${media.error}`)),
+29 -29
View File
@@ -15,18 +15,18 @@ import { GROUP_LIMITS } from '../../group/groupCrypto.js';
const h = (...args) => React.createElement(...args);
const C = {
bg: '#0c0c0e',
panel: '#141417',
panel2: '#1b1b1f',
line: 'rgba(255,255,255,0.07)',
line2: 'rgba(255,255,255,0.13)',
ink: '#f4f4f6',
ink2: '#a7a7b0',
ink3: '#6b6b73',
accent: '#f0892a',
good: '#3ecf8e',
warn: '#e3b341',
bad: '#e5727a',
bg: 'var(--sb-bg-deep)',
panel: 'var(--sb-surface)',
panel2: 'var(--sb-surface-2)',
line: 'rgba(var(--sb-ink), 0.07)',
line2: 'rgba(var(--sb-ink), 0.13)',
ink: 'var(--sb-text-1)',
ink2: 'var(--sb-text-5b)',
ink3: 'var(--sb-text-9)',
accent: 'var(--sb-orange)',
good: 'var(--sb-green)',
warn: 'var(--sb-yellow-2)',
bad: 'var(--sb-red)',
mono: "'JetBrains Mono', ui-monospace, monospace",
};
@@ -53,18 +53,18 @@ const btn = (accent = false) => ({
fontFamily: 'inherit', fontSize: '14px', fontWeight: 700,
border: accent ? 'none' : `1px solid ${C.line2}`,
background: accent ? C.accent : 'transparent',
color: accent ? '#1a0f04' : C.ink2,
color: accent ? 'var(--sb-on-accent)' : C.ink2,
});
const overlay = {
position: 'fixed', inset: 0, zIndex: 90, display: 'grid', placeItems: 'center',
background: 'rgba(5,5,7,0.72)', backdropFilter: 'blur(6px)', padding: '20px',
background: 'rgba(var(--sb-scrim-rgb), 0.72)', backdropFilter: 'blur(6px)', padding: '20px',
};
const card = {
width: '100%', maxWidth: '440px', background: C.panel, border: `1px solid ${C.line}`,
borderRadius: '16px', padding: '24px', display: 'flex', flexDirection: 'column', gap: '18px',
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
boxShadow: '0 24px 60px rgba(var(--sb-shadow-rgb), calc(0.5 * var(--sb-shadow-k)))',
};
/** Trim a string so its UTF-8 encoding fits `max` bytes, never mid-character. */
@@ -167,7 +167,7 @@ export function GroupSasModal({ group, onConfirm, onCancel }) {
key: 'code',
style: {
padding: '22px 16px', textAlign: 'center', borderRadius: '12px',
background: 'rgba(240,137,42,0.09)', border: '1px solid rgba(240,137,42,0.3)',
background: 'rgba(var(--sb-orange-rgb), 0.09)', border: '1px solid rgba(var(--sb-orange-rgb), 0.3)',
},
}, h('span', {
// Everyone in the group compares these digits against each other's
@@ -191,7 +191,7 @@ export function GroupSasModal({ group, onConfirm, onCancel }) {
key: 'warn',
style: {
margin: 0, padding: '11px 13px', borderRadius: '9px', fontSize: '12.5px', lineHeight: 1.55,
background: 'rgba(229,114,122,0.09)', border: '1px solid rgba(229,114,122,0.26)', color: '#f0a6ab',
background: 'rgba(var(--sb-red-rgb), 0.09)', border: '1px solid rgba(var(--sb-red-rgb), 0.26)', color: 'var(--sb-red-soft)',
},
}, failed
? t('group.errNothingSent')
@@ -284,8 +284,8 @@ export function CreateGroupModal({ candidates, relayOnly, onCreate, onCancel })
style: {
display: 'flex', alignItems: 'center', gap: '11px', padding: '10px 12px',
borderRadius: '10px', cursor: full ? 'not-allowed' : 'pointer', textAlign: 'start',
background: on ? 'rgba(240,137,42,0.1)' : 'transparent',
border: `1px solid ${on ? 'rgba(240,137,42,0.32)' : C.line}`,
background: on ? 'rgba(var(--sb-orange-rgb), 0.1)' : 'transparent',
border: `1px solid ${on ? 'rgba(var(--sb-orange-rgb), 0.32)' : C.line}`,
opacity: full ? 0.4 : 1, fontFamily: 'inherit',
},
}, [
@@ -318,7 +318,7 @@ export function CreateGroupModal({ candidates, relayOnly, onCreate, onCancel })
key: 'ip',
style: {
margin: 0, padding: '11px 13px', borderRadius: '9px', fontSize: '12.5px', lineHeight: 1.55,
background: 'rgba(227,179,65,0.08)', border: '1px solid rgba(227,179,65,0.26)', color: '#e3b341',
background: 'rgba(var(--sb-yellow-2-rgb), 0.08)', border: '1px solid rgba(var(--sb-yellow-2-rgb), 0.26)', color: 'var(--sb-yellow-2)',
},
}, t('group.relayOnlyOff')),
@@ -412,8 +412,8 @@ export function AddMembersModal({ candidates, remaining, onAdd, onCancel }) {
style: {
display: 'flex', alignItems: 'center', gap: '11px', padding: '10px 12px',
borderRadius: '10px', cursor: full ? 'not-allowed' : 'pointer', textAlign: 'start',
background: on ? 'rgba(240,137,42,0.1)' : 'transparent',
border: `1px solid ${on ? 'rgba(240,137,42,0.32)' : C.line}`,
background: on ? 'rgba(var(--sb-orange-rgb), 0.1)' : 'transparent',
border: `1px solid ${on ? 'rgba(var(--sb-orange-rgb), 0.32)' : C.line}`,
opacity: full ? 0.4 : 1, fontFamily: 'inherit',
},
}, [
@@ -521,7 +521,7 @@ function MemberStrip({ group, onRemove, isAdmin }) {
// fact that a dropped connection does not change — but the chip
// says plainly that nothing sent now reaches them.
background: lost ? 'transparent' : C.panel2,
border: `1px solid ${lost ? 'rgba(229,114,122,0.3)' : C.line}`,
border: `1px solid ${lost ? 'rgba(var(--sb-red-rgb), 0.3)' : C.line}`,
fontSize: '12.5px', color: self ? C.ink : (lost ? C.ink3 : C.ink2),
opacity: lost ? 0.75 : 1,
},
@@ -570,8 +570,8 @@ function Bubble({ msg }) {
key: 'b',
style: {
padding: '9px 13px', borderRadius: mine ? '13px 13px 4px 13px' : '13px 13px 13px 4px',
background: mine ? 'rgba(240,137,42,0.14)' : C.panel2,
border: `1px solid ${mine ? 'rgba(240,137,42,0.26)' : C.line}`,
background: mine ? 'rgba(var(--sb-orange-rgb), 0.14)' : C.panel2,
border: `1px solid ${mine ? 'rgba(var(--sb-orange-rgb), 0.26)' : C.line}`,
color: C.ink, fontSize: '14.5px', lineHeight: 1.5, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
},
}, msg.message),
@@ -643,8 +643,8 @@ export function GroupChatView({
key: 'av',
style: {
flex: 'none', width: '38px', height: '38px', borderRadius: '11px', display: 'grid',
placeItems: 'center', background: 'rgba(240,137,42,0.12)',
border: '1px solid rgba(240,137,42,0.24)', color: C.accent,
placeItems: 'center', background: 'rgba(var(--sb-orange-rgb), 0.12)',
border: '1px solid rgba(var(--sb-orange-rgb), 0.24)', color: C.accent,
fontFamily: C.mono, fontSize: '12px', fontWeight: 700,
},
}, groupInitials(group.name)),
@@ -693,7 +693,7 @@ export function GroupChatView({
h('button', {
key: 'leave', className: 'sb-gh-btn', onClick: onLeave, title: t('group.leaveThis'),
'aria-label': t('group.leaveThis'),
style: { ...btn(false), flex: 'none', padding: '8px 12px', fontSize: '12.5px', color: C.bad, borderColor: 'rgba(229,114,122,0.3)' },
style: { ...btn(false), flex: 'none', padding: '8px 12px', fontSize: '12.5px', color: C.bad, borderColor: 'rgba(var(--sb-red-rgb), 0.3)' },
}, [
svg(ICON.exit, { key: 'i' }),
h('span', { key: 'l', className: 'sb-gh-label' }, t('group.leave')),
@@ -706,7 +706,7 @@ export function GroupChatView({
key: 'relay-note',
style: {
flex: 'none', padding: '8px 16px', fontSize: '12px', lineHeight: 1.5, color: C.warn,
background: 'rgba(227,179,65,0.08)', borderBottom: `1px solid ${C.line}`,
background: 'rgba(var(--sb-yellow-2-rgb), 0.08)', borderBottom: `1px solid ${C.line}`,
},
}, t('group.relayNote')),
+31 -22
View File
@@ -5,6 +5,7 @@ import { t } from '../../i18n/index.js';
// instead of embedding the whole manifest.
import { version as packageVersion } from '../../../package.json';
import { LanguageSwitcher } from './LanguageSwitcher.jsx';
import { ThemeSwitcher } from './ThemeSwitcher.jsx';
const APP_VERSION = `v${packageVersion}`;
@@ -362,7 +363,7 @@ const EnhancedMinimalHeader = ({
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
background: rgba(var(--sb-scrim-rgb), 0.8);
z-index: 10000;
display: flex;
align-items: center;
@@ -372,15 +373,15 @@ const EnhancedMinimalHeader = ({
const content = document.createElement('div');
content.style.cssText = `
background: #1a1a1a;
color: #fff;
background: var(--sb-legacy-panel);
color: var(--sb-text-1);
padding: 20px;
border-radius: 8px;
max-width: 80%;
max-height: 80%;
overflow-y: auto;
white-space: pre-line;
border: 1px solid #333;
border: 1px solid var(--sb-surface-5);
`;
content.textContent = message;
@@ -516,12 +517,15 @@ const EnhancedMinimalHeader = ({
// ============================================
const secColor = displaySecurityLevel
? (displaySecurityLevel.color === 'green' ? '#3ecf8e' : displaySecurityLevel.color === 'orange' ? '#f0892a' : displaySecurityLevel.color === 'yellow' ? '#e3c84e' : '#e5727a')
: '#3ecf8e';
const dotColor = isConnected ? '#3ecf8e'
: (['connecting', 'verifying', 'retrying', 'reconnecting'].includes(status) ? '#e3c84e'
: (status === 'failed' ? '#e5727a' : '#6b6b73'));
const dotGlow = dotColor === '#3ecf8e' ? 'rgba(62,207,142,0.16)' : dotColor === '#e3c84e' ? 'rgba(227,200,78,0.16)' : dotColor === '#e5727a' ? 'rgba(229,114,122,0.16)' : 'rgba(107,107,115,0.16)';
? (displaySecurityLevel.color === 'green' ? 'var(--sb-green)' : displaySecurityLevel.color === 'orange' ? 'var(--sb-orange)' : displaySecurityLevel.color === 'yellow' ? 'var(--sb-yellow)' : 'var(--sb-red)')
: 'var(--sb-green)';
// The dot is a mark, not a label, so it takes the -solid accents: the brand colour
// in both themes. The text-safe accents darken on a light ground, which is right for
// a word and wrong for a 7px circle.
const dotColor = isConnected ? 'var(--sb-green-solid)'
: (['connecting', 'verifying', 'retrying', 'reconnecting'].includes(status) ? 'var(--sb-yellow-solid)'
: (status === 'failed' ? 'var(--sb-red-solid)' : 'var(--sb-text-9)'));
const dotGlow = dotColor === 'var(--sb-green-solid)' ? 'rgba(var(--sb-green-rgb), 0.16)' : dotColor === 'var(--sb-yellow-solid)' ? 'rgba(var(--sb-yellow-rgb), 0.16)' : dotColor === 'var(--sb-red-solid)' ? 'rgba(var(--sb-red-rgb), 0.16)' : 'rgba(var(--sb-text-9-rgb), 0.16)';
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
// On the landing / setup view (no verified connection) the new Start Secure
@@ -555,9 +559,9 @@ const EnhancedMinimalHeader = ({
const overlay = { position: 'fixed', top: 0, left: 0, right: 0, ...safeTop };
const headerStyle = onLanding
? (scrolled
? { ...overlay, background: 'rgba(15,15,17,0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(255,255,255,0.06)', transition: MATERIALISE }
? { ...overlay, background: 'rgba(var(--sb-bg-rgb), 0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(var(--sb-ink), 0.06)', transition: MATERIALISE }
: { ...overlay, background: 'transparent', backdropFilter: 'blur(0px) saturate(100%)', WebkitBackdropFilter: 'blur(0px) saturate(100%)', borderBottom: '1px solid transparent', transition: MATERIALISE })
: { ...safeTop, background: 'rgba(18,18,20,0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(255,255,255,0.06)' };
: { ...safeTop, background: 'rgba(var(--sb-bg-rgb), 0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(var(--sb-ink), 0.06)' };
return React.createElement('header', {
className: onLanding ? 'header-minimal z-50' : 'header-minimal sticky top-0 z-50',
@@ -580,10 +584,10 @@ const EnhancedMinimalHeader = ({
),
React.createElement('div', { key: 'txt', style: { lineHeight: 1.2, minWidth: 0 } }, [
React.createElement('div', { key: 'r1', style: { display: 'flex', alignItems: 'baseline', gap: '7px' } }, [
React.createElement('span', { key: 'n', style: { fontSize: '16px', fontWeight: 800, letterSpacing: '-0.3px', color: '#e8e8eb' } }, 'SecureBit'),
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '10px', fontWeight: 500, color: '#56565e' } }, APP_VERSION)
React.createElement('span', { key: 'n', style: { fontSize: '16px', fontWeight: 800, letterSpacing: '-0.3px', color: 'var(--sb-text-2)' } }, 'SecureBit'),
React.createElement('span', { key: 'v', style: { fontFamily: MONO, fontSize: '10px', fontWeight: 500, color: 'var(--sb-text-faint)' } }, APP_VERSION)
]),
React.createElement('div', { key: 'r2', className: 'hidden sm:block', style: { fontSize: '11px', color: '#6b6b73', fontWeight: 500 } }, t('hdr.tagline'))
React.createElement('div', { key: 'r2', className: 'hidden sm:block', style: { fontSize: '11px', color: 'var(--sb-text-9)', fontWeight: 500 } }, t('hdr.tagline'))
])
]),
// Right: controls
@@ -592,33 +596,38 @@ const EnhancedMinimalHeader = ({
// tear down an live peer connection if offered inside the chat.
onLanding && React.createElement(LanguageSwitcher, { key: 'lang' }),
// Landing only, alongside the language switcher: the connected
// header has no room left, and the preference survives the move
// into the chat regardless of where it was set.
onLanding && React.createElement(ThemeSwitcher, { key: 'theme' }),
!onLanding && React.createElement('button', {
key: 'net', type: 'button',
onClick: () => window.dispatchEvent(new CustomEvent('securebit:open-network-settings')),
title: t('hdr.netSettingsTitle'), 'aria-label': t('hdr.netSettings'),
className: 'sb-disconnect',
style: { display: 'grid', placeItems: 'center', width: '38px', height: '38px', borderRadius: '9px', border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)', color: '#9a9aa2', cursor: 'pointer', transition: 'all .15s' }
style: { display: 'grid', placeItems: 'center', width: '38px', height: '38px', borderRadius: '9px', border: '1px solid rgba(var(--sb-ink), 0.07)', background: 'rgba(var(--sb-ink), 0.02)', color: 'var(--sb-text-6)', cursor: 'pointer', transition: 'all .15s' }
}, React.createElement('i', { className: 'fas fa-network-wired', style: { fontSize: '13px' } })),
(!onLanding && displaySecurityLevel) && React.createElement('div', {
key: 'sec', onClick: handleSecurityClick,
onContextMenu: (e) => { e.preventDefault(); if (typeof onDisconnect === 'function') onDisconnect(); },
title: securityDetails.tooltip, className: 'sb-secpill',
style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '7px 12px', borderRadius: '9px', border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)', cursor: 'pointer' }
style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '7px 12px', borderRadius: '9px', border: '1px solid rgba(var(--sb-ink), 0.07)', background: 'rgba(var(--sb-ink), 0.02)', cursor: 'pointer' }
}, [
React.createElement('i', { key: 'i', className: 'fas fa-shield-halved', style: { fontSize: '13px', color: secColor } }),
React.createElement('span', { key: 'l', className: 'hidden sm:inline', style: { fontSize: '12.5px', fontWeight: 600, color: '#e8e8eb' } }, (t(`secLevel.${displaySecurityLevel.level}`) === `secLevel.${displaySecurityLevel.level}` ? String(displaySecurityLevel.level) : t(`secLevel.${displaySecurityLevel.level}`))),
React.createElement('span', { key: 's', style: { fontFamily: MONO, fontSize: '11.5px', color: '#8a8a92' } }, displaySecurityLevel.score + '%')
React.createElement('span', { key: 'l', className: 'hidden sm:inline', style: { fontSize: '12.5px', fontWeight: 600, color: 'var(--sb-text-2)' } }, (t(`secLevel.${displaySecurityLevel.level}`) === `secLevel.${displaySecurityLevel.level}` ? String(displaySecurityLevel.level) : t(`secLevel.${displaySecurityLevel.level}`))),
React.createElement('span', { key: 's', style: { fontFamily: MONO, fontSize: '11.5px', color: 'var(--sb-text-7)' } }, displaySecurityLevel.score + '%')
]),
!onLanding && React.createElement('div', { key: 'status', style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 13px', borderRadius: '9px', border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)' } }, [
!onLanding && React.createElement('div', { key: 'status', style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 13px', borderRadius: '9px', border: '1px solid rgba(var(--sb-ink), 0.07)', background: 'rgba(var(--sb-ink), 0.02)' } }, [
React.createElement('span', { key: 'dot', style: { width: '7px', height: '7px', borderRadius: '50%', background: dotColor, boxShadow: '0 0 0 3px ' + dotGlow } }),
React.createElement('span', { key: 't', className: 'hidden sm:inline', style: { fontSize: '13px', fontWeight: 600, color: '#cfcfd4' } }, config.text)
React.createElement('span', { key: 't', className: 'hidden sm:inline', style: { fontSize: '13px', fontWeight: 600, color: 'var(--sb-text-4)' } }, config.text)
]),
isConnected && React.createElement('button', {
key: 'dc', onClick: onDisconnect, className: 'sb-disconnect',
style: { display: 'flex', alignItems: 'center', gap: '7px', padding: '8px 14px', borderRadius: '9px', border: '1px solid rgba(255,255,255,0.08)', background: 'transparent', color: '#9a9aa2', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, cursor: 'pointer', transition: 'all .15s' }
style: { display: 'flex', alignItems: 'center', gap: '7px', padding: '8px 14px', borderRadius: '9px', border: '1px solid rgba(var(--sb-ink), 0.08)', background: 'transparent', color: 'var(--sb-text-6)', fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, cursor: 'pointer', transition: 'all .15s' }
}, [
React.createElement('i', { key: 'i', className: 'fas fa-power-off', style: { fontSize: '12px' } }),
React.createElement('span', { key: 't', className: 'sb-hide-sm' }, t('hdr.disconnect'))
+40 -37
View File
@@ -107,8 +107,11 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
// Design import: dark slide-up overlay (orange/green accents)
const h = React.createElement;
const C_ORANGE = '#f0892a';
const C_GREEN = '#3ecf8e';
const C_ORANGE = 'var(--sb-orange)';
const C_GREEN = 'var(--sb-green)';
// Fill forms of the two: brand colour in both themes. See app.jsx.
const C_ORANGE_SOLID = 'var(--sb-orange-solid)';
const C_GREEN_SOLID = 'var(--sb-green-solid)';
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
// radio card (public / own servers)
@@ -117,16 +120,16 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
style: Object.assign({
width: '100%', textAlign: 'start', display: 'flex', alignItems: 'flex-start', gap: '12px',
padding: '14px 15px', borderRadius: '13px',
border: `1px solid ${selected ? 'rgba(240,137,42,0.45)' : 'rgba(255,255,255,0.07)'}`,
background: selected ? 'rgba(240,137,42,0.06)' : '#141416',
border: `1px solid ${selected ? 'rgba(var(--sb-orange-rgb), 0.45)' : 'rgba(var(--sb-ink), 0.07)'}`,
background: selected ? 'rgba(var(--sb-orange-rgb), 0.06)' : 'var(--sb-surface)',
color: 'inherit', fontFamily: 'inherit', cursor: 'pointer', transition: 'all .15s', marginBottom: '10px'
}, extraStyle || {})
}, [
h('span', { key: 'ring', style: { flex: 'none', width: '18px', height: '18px', marginTop: '1px', borderRadius: '50%', border: `1.5px solid ${selected ? C_ORANGE : 'rgba(255,255,255,0.22)'}`, display: 'grid', placeItems: 'center' } },
h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: selected ? C_ORANGE : 'transparent' } })),
h('span', { key: 'ring', style: { flex: 'none', width: '18px', height: '18px', marginTop: '1px', borderRadius: '50%', border: `1.5px solid ${selected ? C_ORANGE : 'rgba(var(--sb-ink), 0.22)'}`, display: 'grid', placeItems: 'center' } },
h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: selected ? C_ORANGE_SOLID : 'transparent' } })),
h('span', { key: 'tx', style: { flex: 1 } }, [
h('span', { key: 't', style: { display: 'block', fontSize: '14px', fontWeight: 700, color: '#f4f4f6' } }, title),
h('span', { key: 'd', style: { display: 'block', fontSize: '12.5px', color: '#8a8a92', marginTop: '2px' } }, desc)
h('span', { key: 't', style: { display: 'block', fontSize: '14px', fontWeight: 700, color: 'var(--sb-text-1)' } }, title),
h('span', { key: 'd', style: { display: 'block', fontSize: '12.5px', color: 'var(--sb-text-7)', marginTop: '2px' } }, desc)
])
]);
@@ -136,67 +139,67 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
style: {
width: '100%', textAlign: 'start', display: 'flex', alignItems: 'flex-start', gap: '12px',
padding: '14px 15px', borderRadius: '13px',
border: `1px solid ${on ? 'rgba(62,207,142,0.3)' : 'rgba(255,255,255,0.07)'}`,
background: on ? 'rgba(62,207,142,0.05)' : '#141416',
border: `1px solid ${on ? 'rgba(var(--sb-green-rgb), 0.3)' : 'rgba(var(--sb-ink), 0.07)'}`,
background: on ? 'rgba(var(--sb-green-rgb), 0.05)' : 'var(--sb-surface)',
color: 'inherit', fontFamily: 'inherit', cursor: 'pointer', transition: 'all .15s', marginBottom: '10px'
}
}, [
h('span', { key: 'tx', style: { flex: 1 } }, [
h('span', { key: 'r1', style: { display: 'flex', alignItems: 'center', gap: '8px' } }, [
h('span', { key: 't', style: { fontSize: '14px', fontWeight: 700, color: '#f4f4f6' } }, title),
badge && h('span', { key: 'b', style: { fontSize: '10px', fontWeight: 700, color: C_GREEN, padding: '2px 7px', borderRadius: '5px', background: 'rgba(62,207,142,0.1)', border: '1px solid rgba(62,207,142,0.22)' } }, badge)
h('span', { key: 't', style: { fontSize: '14px', fontWeight: 700, color: 'var(--sb-text-1)' } }, title),
badge && h('span', { key: 'b', style: { fontSize: '10px', fontWeight: 700, color: C_GREEN, padding: '2px 7px', borderRadius: '5px', background: 'rgba(var(--sb-green-rgb), 0.1)', border: '1px solid rgba(var(--sb-green-rgb), 0.22)' } }, badge)
]),
h('span', { key: 'd', style: { display: 'block', fontSize: '12.5px', lineHeight: 1.5, color: '#8a8a92', marginTop: '3px' } }, desc)
h('span', { key: 'd', style: { display: 'block', fontSize: '12.5px', lineHeight: 1.5, color: 'var(--sb-text-7)', marginTop: '3px' } }, desc)
]),
h('span', { key: 'tr', style: { flex: 'none', width: '42px', height: '24px', borderRadius: '99px', background: on ? (accent || C_GREEN) : 'rgba(255,255,255,0.08)', border: `1px solid ${on ? (accent || C_GREEN) : 'rgba(255,255,255,0.12)'}`, position: 'relative', transition: 'all .18s', marginTop: '1px' } },
h('span', { key: 'tr', style: { flex: 'none', width: '42px', height: '24px', borderRadius: '99px', background: on ? (accent || C_GREEN_SOLID) : 'rgba(var(--sb-ink), 0.08)', border: `1px solid ${on ? (accent || C_GREEN) : 'rgba(var(--sb-ink), 0.12)'}`, position: 'relative', transition: 'all .18s', marginTop: '1px' } },
h('span', { style: { position: 'absolute', top: '2px', insetInlineStart: '2px', width: '18px', height: '18px', borderRadius: '50%', background: '#fff', transform: on ? `translateX(${18 * direction()}px)` : 'translateX(0)', transition: 'transform .18s' } }))
]);
// scrollable body
const body = [];
body.push(h('p', { key: 'intro', style: { margin: '0 0 18px', fontSize: '13.5px', lineHeight: 1.6, color: '#9a9aa2' } },
body.push(h('p', { key: 'intro', style: { margin: '0 0 18px', fontSize: '13.5px', lineHeight: 1.6, color: 'var(--sb-text-6)' } },
t('ice.intro')));
body.push(radioCard(!useCustom, () => setUseCustom(false), t('ice.publicTitle'), t('ice.publicDesc')));
body.push(radioCard(useCustom, () => setUseCustom(true), t('ice.customTitle'), t('ice.customDesc', { max: ICE_LIMITS.MAX_SERVERS }), useCustom ? { marginBottom: '14px' } : null));
if (useCustom) {
const custom = [];
custom.push(h('div', { key: 'ta', style: { borderRadius: '13px', border: '1px solid rgba(255,255,255,0.08)', background: '#0c0c0e', overflow: 'hidden', marginBottom: '12px' } },
custom.push(h('div', { key: 'ta', style: { borderRadius: '13px', border: '1px solid rgba(var(--sb-ink), 0.08)', background: 'var(--sb-bg-deep)', overflow: 'hidden', marginBottom: '12px' } },
h('textarea', {
dir: 'ltr',
value: serversText, onChange: (e) => setServersText(e.target.value), rows: 5, spellCheck: false, autoComplete: 'off',
placeholder: PLACEHOLDER,
style: { width: '100%', resize: 'vertical', border: 'none', outline: 'none', background: 'transparent', color: '#c9ccd8', fontFamily: MONO, fontSize: '12px', lineHeight: 1.65, padding: '13px 14px', minHeight: '104px' }
style: { width: '100%', resize: 'vertical', border: 'none', outline: 'none', background: 'transparent', color: 'var(--sb-text-code)', fontFamily: MONO, fontSize: '12px', lineHeight: 1.65, padding: '13px 14px', minHeight: '104px' }
})));
if (parsed.errors.length > 0) {
custom.push(h('ul', { key: 'err', style: { margin: '0 0 10px', paddingInlineStart: '18px', color: '#e5727a', fontSize: '12.5px' } },
custom.push(h('ul', { key: 'err', style: { margin: '0 0 10px', paddingInlineStart: '18px', color: 'var(--sb-red)', fontSize: '12.5px' } },
parsed.errors.slice(0, 6).map((err, i) => h('li', { key: i }, err))));
}
if (parsed.warnings.length > 0) {
custom.push(h('ul', { key: 'warn', style: { margin: '0 0 10px', paddingInlineStart: '18px', color: '#e3c84e', fontSize: '12.5px' } },
custom.push(h('ul', { key: 'warn', style: { margin: '0 0 10px', paddingInlineStart: '18px', color: 'var(--sb-yellow)', fontSize: '12.5px' } },
parsed.warnings.slice(0, 6).map((w, i) => h('li', { key: i }, w))));
}
if (parsed.servers.length > 0 && parsed.errors.length === 0) {
custom.push(h('p', { key: 'ok', style: { margin: '0 0 10px', fontSize: '12.5px', color: C_GREEN } },
`${parsed.servers.length} server(s) parsed${hasTurn ? ' (TURN present)' : ' (STUN only — does not hide IP)'}.`));
}
custom.push(h('div', { key: 'note', style: { display: 'flex', alignItems: 'flex-start', gap: '9px', padding: '12px 13px', borderRadius: '11px', border: '1px solid rgba(62,207,142,0.18)', background: 'rgba(62,207,142,0.05)', marginBottom: '12px' } }, [
custom.push(h('div', { key: 'note', style: { display: 'flex', alignItems: 'flex-start', gap: '9px', padding: '12px 13px', borderRadius: '11px', border: '1px solid rgba(var(--sb-green-rgb), 0.18)', background: 'rgba(var(--sb-green-rgb), 0.05)', marginBottom: '12px' } }, [
h('i', { key: 'i', className: 'fas fa-info-circle', style: { color: C_GREEN, fontSize: '13px', marginTop: '2px', flex: 'none' } }),
h('span', { key: 't', style: { fontSize: '12px', lineHeight: 1.55, color: '#a8b8ae' } }, [
h('span', { key: 't', style: { fontSize: '12px', lineHeight: 1.55, color: 'var(--sb-green-muted)' } }, [
t('ice.turnNote'),
h('span', { key: 'm', style: { fontFamily: MONO, color: C_GREEN } }, 'turns:'), t('ice.turnNoteTls')
])
]));
const testColor = testState === 'done' && testResult && !testResult.error ? C_GREEN : '#cfcfd4';
const testColor = testState === 'done' && testResult && !testResult.error ? C_GREEN : 'var(--sb-text-4)';
custom.push(h('div', { key: 'test', style: { display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap', marginBottom: '4px' } }, [
h('button', {
key: 'btn', type: 'button', disabled: !canApply || testState === 'running', onClick: handleTest,
style: { display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '10px 15px', borderRadius: '10px', border: `1px solid ${testState === 'done' && testResult && !testResult.error ? 'rgba(62,207,142,0.4)' : 'rgba(255,255,255,0.1)'}`, background: testState === 'done' && testResult && !testResult.error ? 'rgba(62,207,142,0.08)' : 'rgba(255,255,255,0.04)', color: testColor, fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, cursor: (!canApply || testState === 'running') ? 'not-allowed' : 'pointer', opacity: (!canApply || testState === 'running') ? 0.6 : 1 }
style: { display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '10px 15px', borderRadius: '10px', border: `1px solid ${testState === 'done' && testResult && !testResult.error ? 'rgba(var(--sb-green-rgb), 0.4)' : 'rgba(var(--sb-ink), 0.1)'}`, background: testState === 'done' && testResult && !testResult.error ? 'rgba(var(--sb-green-rgb), 0.08)' : 'rgba(var(--sb-ink), 0.04)', color: testColor, fontFamily: 'inherit', fontSize: '13px', fontWeight: 600, cursor: (!canApply || testState === 'running') ? 'not-allowed' : 'pointer', opacity: (!canApply || testState === 'running') ? 0.6 : 1 }
}, [
h('i', { key: 'i', className: testState === 'running' ? 'fas fa-circle-notch' : 'fas fa-play-circle', style: testState === 'running' ? { animation: 'sbSpin 1s linear infinite' } : null }),
testState === 'running' ? t('ice.testing') : t('ice.test')
]),
(testState === 'done' && testResult) ? h('span', { key: 'res', style: { fontSize: '12px', color: testResult.error ? '#e5727a' : '#8a8a92' } },
(testState === 'done' && testResult) ? h('span', { key: 'res', style: { fontSize: '12px', color: testResult.error ? 'var(--sb-red)' : 'var(--sb-text-7)' } },
testResult.error
? `Test failed: ${testResult.error}`
: (testResult.srflx > 0 || testResult.relay > 0)
@@ -210,7 +213,7 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
body.push(toggleRow(relayOnly, () => setRelayOnly(!relayOnly), t('ice.relayTitle'),
t('ice.relayDesc'), C_GREEN, t('ice.relayBadge')));
if (relayOnly && useCustom && !hasTurn) {
body.push(h('p', { key: 'relaywarn', style: { margin: '-4px 0 10px', fontSize: '12.5px', color: '#e3c84e' } },
body.push(h('p', { key: 'relaywarn', style: { margin: '-4px 0 10px', fontSize: '12.5px', color: 'var(--sb-yellow)' } },
t('ice.relayWarning')));
}
body.push(toggleRow(persist, () => setPersist(!persist), t('ice.persist'),
@@ -220,38 +223,38 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
const footerBtns = [];
if (hasSaved) {
footerBtns.push(h('button', { key: 'forget', type: 'button', onClick: handleForget,
style: { marginInlineEnd: 'auto', padding: '11px 18px', borderRadius: '11px', border: '1px solid rgba(229,114,122,0.3)', background: 'transparent', color: '#e5727a', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 600, cursor: 'pointer' } }, t('ice.forget')));
style: { marginInlineEnd: 'auto', padding: '11px 18px', borderRadius: '11px', border: '1px solid rgba(var(--sb-red-rgb), 0.3)', background: 'transparent', color: 'var(--sb-red)', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 600, cursor: 'pointer' } }, t('ice.forget')));
}
footerBtns.push(h('button', { key: 'cancel', type: 'button', onClick: onClose,
style: { padding: '11px 18px', borderRadius: '11px', border: '1px solid rgba(255,255,255,0.1)', background: 'transparent', color: '#b3b3ba', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 600, cursor: 'pointer' } }, t('ice.cancel')));
style: { padding: '11px 18px', borderRadius: '11px', border: '1px solid rgba(var(--sb-ink), 0.1)', background: 'transparent', color: 'var(--sb-text-5)', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 600, cursor: 'pointer' } }, t('ice.cancel')));
footerBtns.push(h('button', { key: 'apply', type: 'button', onClick: handleApply, disabled: !canApply,
style: { display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '11px 20px', borderRadius: '11px', border: 'none', background: C_ORANGE, color: '#1a0f04', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 700, cursor: canApply ? 'pointer' : 'not-allowed', opacity: canApply ? 1 : 0.5, boxShadow: '0 6px 18px rgba(240,137,42,0.28)' } }, [
style: { display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '11px 20px', borderRadius: '11px', border: 'none', background: C_ORANGE_SOLID, color: 'var(--sb-on-accent)', fontFamily: 'inherit', fontSize: '13.5px', fontWeight: 700, cursor: canApply ? 'pointer' : 'not-allowed', opacity: canApply ? 1 : 0.5, boxShadow: '0 6px 18px rgba(var(--sb-orange-rgb), 0.28)' } }, [
h('i', { key: 'i', className: 'fas fa-check' }), t('ice.apply')
]));
// Embedded mode (default for the new design): fill the connection screen's
// right column and slide up over it. Fallback: a fixed right-side drawer.
const wrapperStyle = embedded
? { position: 'absolute', inset: 0, zIndex: 60, display: 'flex', flexDirection: 'column', background: '#0f0f11', animation: 'sbSlideUp .32s cubic-bezier(.2,.7,.3,1)' }
: { position: 'fixed', inset: 0, zIndex: 60, display: 'flex', flexDirection: 'column', alignItems: 'stretch', background: '#0f0f11', animation: 'sbSlideUp .32s cubic-bezier(.2,.7,.3,1)' };
? { position: 'absolute', inset: 0, zIndex: 60, display: 'flex', flexDirection: 'column', background: 'var(--sb-bg)', animation: 'sbSlideUp .32s cubic-bezier(.2,.7,.3,1)' }
: { position: 'fixed', inset: 0, zIndex: 60, display: 'flex', flexDirection: 'column', alignItems: 'stretch', background: 'var(--sb-bg)', animation: 'sbSlideUp .32s cubic-bezier(.2,.7,.3,1)' };
return h('div', { className: 'sb-ice-overlay', style: wrapperStyle }, [
h(React.Fragment, { key: 'panel' }, [
// header
h('div', { key: 'head', style: { display: 'flex', alignItems: 'center', gap: '12px', padding: '20px 24px', borderBottom: '1px solid rgba(255,255,255,0.06)' } }, [
h('div', { key: 'ic', style: { width: '38px', height: '38px', flex: 'none', display: 'grid', placeItems: 'center', borderRadius: '10px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' } },
h('i', { className: 'fas fa-sliders-h', style: { color: '#cfcfd4', fontSize: '15px' } })),
h('div', { key: 'head', style: { display: 'flex', alignItems: 'center', gap: '12px', padding: '20px 24px', borderBottom: '1px solid rgba(var(--sb-ink), 0.06)' } }, [
h('div', { key: 'ic', style: { width: '38px', height: '38px', flex: 'none', display: 'grid', placeItems: 'center', borderRadius: '10px', background: 'rgba(var(--sb-ink), 0.03)', border: '1px solid rgba(var(--sb-ink), 0.06)' } },
h('i', { className: 'fas fa-sliders-h', style: { color: 'var(--sb-text-4)', fontSize: '15px' } })),
h('div', { key: 'tx', style: { flex: 1, lineHeight: 1.25 } }, [
h('div', { key: 't', style: { fontSize: '16.5px', fontWeight: 800, letterSpacing: '-0.3px', color: '#f4f4f6' } }, t('ice.title')),
h('div', { key: 's', style: { fontSize: '12px', color: '#7b7b83' } }, t('ice.subtitle'))
h('div', { key: 't', style: { fontSize: '16.5px', fontWeight: 800, letterSpacing: '-0.3px', color: 'var(--sb-text-1)' } }, t('ice.title')),
h('div', { key: 's', style: { fontSize: '12px', color: 'var(--sb-text-8)' } }, t('ice.subtitle'))
]),
h('button', { key: 'x', type: 'button', onClick: onClose, style: { width: '32px', height: '32px', flex: 'none', display: 'grid', placeItems: 'center', borderRadius: '9px', border: 'none', background: 'rgba(255,255,255,0.04)', color: '#8a8a92', cursor: 'pointer' } },
h('button', { key: 'x', type: 'button', onClick: onClose, style: { width: '32px', height: '32px', flex: 'none', display: 'grid', placeItems: 'center', borderRadius: '9px', border: 'none', background: 'rgba(var(--sb-ink), 0.04)', color: 'var(--sb-text-7)', cursor: 'pointer' } },
h('i', { className: 'fas fa-times' }))
]),
// scroll body
h('div', { key: 'body', className: 'custom-scrollbar', style: { flex: 1, overflowY: 'auto', padding: '20px 24px' } }, body),
// footer
h('div', { key: 'foot', style: { display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '10px', padding: '16px 24px', borderTop: '1px solid rgba(255,255,255,0.06)', background: '#0e0e10', borderRadius: '0' } }, footerBtns)
h('div', { key: 'foot', style: { display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '10px', padding: '16px 24px', borderTop: '1px solid rgba(var(--sb-ink), 0.06)', background: 'var(--sb-bg-deep)', borderRadius: '0' } }, footerBtns)
])
]);
};
+7 -7
View File
@@ -72,7 +72,7 @@ const LanguageSuggestion = () => {
key: 'line',
lang: meta.htmlLang || target,
dir,
style: { fontSize: '13px', lineHeight: 1.45, color: '#cfcfd4', textAlign: 'start' },
style: { fontSize: '13px', lineHeight: 1.45, color: 'var(--sb-text-4)', textAlign: 'start' },
}, t('language.suggest.text', null, target));
const link = React.createElement('a', {
@@ -87,8 +87,8 @@ const LanguageSuggestion = () => {
style: {
display: 'inline-flex', alignItems: 'center', gap: '6px',
padding: '7px 12px', borderRadius: '9px',
border: '1px solid rgba(240,137,42,0.30)', background: 'rgba(240,137,42,0.12)',
color: '#f0892a', fontSize: '12.5px', fontWeight: 600,
border: '1px solid rgba(var(--sb-orange-rgb), 0.30)', background: 'rgba(var(--sb-orange-rgb), 0.12)',
color: 'var(--sb-orange)', fontSize: '12.5px', fontWeight: 600,
textDecoration: 'none', whiteSpace: 'nowrap',
},
}, t('language.suggest.cta', null, target));
@@ -101,8 +101,8 @@ const LanguageSuggestion = () => {
'aria-label': t('language.suggest.dismiss', null, target),
style: {
padding: '7px 10px', borderRadius: '9px',
border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)',
color: '#8a8a92', font: 'inherit', fontSize: '12.5px', fontWeight: 500,
border: '1px solid rgba(var(--sb-ink), 0.07)', background: 'rgba(var(--sb-ink), 0.02)',
color: 'var(--sb-text-7)', font: 'inherit', fontSize: '12.5px', fontWeight: 500,
cursor: 'pointer', whiteSpace: 'nowrap',
},
}, t('language.suggest.dismiss', null, target));
@@ -117,8 +117,8 @@ const LanguageSuggestion = () => {
maxWidth: 'min(340px, calc(100vw - 48px))',
display: 'flex', flexDirection: 'column', gap: '11px',
padding: '14px 16px', borderRadius: '14px',
border: '1px solid rgba(255,255,255,0.08)', background: '#161618',
boxShadow: '0 16px 40px rgba(0,0,0,0.5)',
border: '1px solid rgba(var(--sb-ink), 0.08)', background: 'var(--sb-surface)',
boxShadow: '0 16px 40px rgba(var(--sb-shadow-rgb), calc(0.5 * var(--sb-shadow-k)))',
opacity: shown ? 1 : 0,
transform: shown || still ? 'none' : 'translateY(10px)',
transition: still ? 'opacity .2s linear' : 'opacity .3s ease, transform .3s cubic-bezier(.2,.7,.3,1)',
+8 -8
View File
@@ -50,9 +50,9 @@ const LanguageSwitcher = () => {
style: {
display: 'flex', alignItems: 'center', gap: '6px',
padding: '7px 10px', borderRadius: '9px',
border: '1px solid rgba(255,255,255,0.07)',
background: open ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.02)',
color: '#cfcfd4', font: 'inherit', fontSize: '12.5px', fontWeight: 600,
border: '1px solid rgba(var(--sb-ink), 0.07)',
background: open ? 'rgba(var(--sb-ink), 0.06)' : 'rgba(var(--sb-ink), 0.02)',
color: 'var(--sb-text-4)', font: 'inherit', fontSize: '12.5px', fontWeight: 600,
cursor: 'pointer', transition: 'background .15s, color .15s',
},
}, [
@@ -77,8 +77,8 @@ const LanguageSwitcher = () => {
position: 'absolute', top: 'calc(100% + 6px)', insetInlineEnd: 0, zIndex: 60,
display: open ? 'block' : 'none',
minWidth: '170px', padding: '5px', borderRadius: '11px',
border: '1px solid rgba(255,255,255,0.08)', background: '#161618',
boxShadow: '0 14px 34px rgba(0,0,0,0.45)',
border: '1px solid rgba(var(--sb-ink), 0.08)', background: 'var(--sb-surface)',
boxShadow: '0 14px 34px rgba(var(--sb-shadow-rgb), calc(0.45 * var(--sb-shadow-k)))',
// Thirteen languages is taller than a phone in landscape. Cap it and scroll
// rather than let the list run off the bottom, where the last few entries
// would be unreachable.
@@ -104,14 +104,14 @@ const LanguageSwitcher = () => {
padding: '8px 10px', borderRadius: '8px',
fontSize: '13px',
fontWeight: link.isCurrent ? 600 : 500,
color: link.isCurrent ? '#e8e8eb' : '#9a9aa2',
background: link.isCurrent ? 'rgba(255,255,255,0.06)' : 'transparent',
color: link.isCurrent ? 'var(--sb-text-2)' : 'var(--sb-text-6)',
background: link.isCurrent ? 'rgba(var(--sb-ink), 0.06)' : 'transparent',
textDecoration: 'none', whiteSpace: 'nowrap',
},
}, [
React.createElement('span', {
key: 'a',
style: { fontSize: '11px', fontWeight: 700, letterSpacing: '0.4px', color: '#6b6b73', width: '22px', flex: 'none', textAlign: 'start' },
style: { fontSize: '11px', fontWeight: 700, letterSpacing: '0.4px', color: 'var(--sb-text-9)', width: '22px', flex: 'none', textAlign: 'start' },
}, link.abbr),
React.createElement('span', { key: 'n' }, link.label),
])));
+59 -41
View File
@@ -44,22 +44,32 @@ function Roadmap() {
features: tList(`roadmap.${d.k}.features`)
}));
// `rgb` is the same colour as `color`, published as bare channels. The pill draws
// its fill and border from it at low alpha, and there is no way to add an alpha to
// a colour that a custom property has already resolved hence the pair, which has
// to be kept in step.
// Three forms of the same status colour, because they are used three ways and only
// one of them is text. `color` is the word it darkens in the light theme so it can
// be read on white. `solid` is the dot, which is a mark rather than a label and
// stays the brand colour in both themes. `rgb` is the channels, for the pill's fill
// and border at low alpha. The three have to be kept in step.
const META = {
released: { word: t('roadmap.status.released'), color: "#3ecf8e", line: "rgba(62,207,142,0.32)" },
current: { word: t('roadmap.status.current'), color: "#f0892a", line: "rgba(240,137,42,0.32)" },
dev: { word: t('roadmap.status.dev'), color: "#e3b341", line: "rgba(255,255,255,0.08)" },
planned: { word: t('roadmap.status.planned'), color: "#8a8a92", line: "rgba(255,255,255,0.08)" },
research: { word: t('roadmap.status.research'), color: "#6b6b73", line: "rgba(255,255,255,0.08)" }
released: { word: t('roadmap.status.released'), color: "var(--sb-green)", solid: "var(--sb-green-solid)", rgb: "var(--sb-green-rgb)", line: "rgba(var(--sb-green-rgb), 0.32)" },
current: { word: t('roadmap.status.current'), color: "var(--sb-orange)", solid: "var(--sb-orange-solid)", rgb: "var(--sb-orange-rgb)", line: "rgba(var(--sb-orange-rgb), 0.32)" },
dev: { word: t('roadmap.status.dev'), color: "var(--sb-yellow-2)", solid: "var(--sb-yellow-2-solid)", rgb: "var(--sb-yellow-2-rgb)", line: "rgba(var(--sb-ink), 0.08)" },
planned: { word: t('roadmap.status.planned'), color: "var(--sb-text-7)", solid: "var(--sb-text-7)", rgb: "var(--sb-text-7-rgb)", line: "rgba(var(--sb-ink), 0.08)" },
research: { word: t('roadmap.status.research'), color: "var(--sb-text-9)", solid: "var(--sb-text-9)", rgb: "var(--sb-text-9-rgb)", line: "rgba(var(--sb-ink), 0.08)" }
};
const [open, setOpen] = React.useState({});
const isOpen = (i) => (open[i] === undefined ? DATA[i].status === 'current' : open[i]);
const toggle = (i) => setOpen((s) => ({ ...s, [i]: !isOpen(i) }));
const hexA = (hex, a) => {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
};
// Was hexA(), which took the literal hex out of META and split it into channels by
// hand. Those literals are custom properties now, so there is nothing to parse at
// runtime the browser does the substitution instead, and it does it again when
// the theme changes, which the old version could not.
const tint = (channels, a) => `rgba(${channels}, ${a})`;
const total = DATA.length;
const shipped = DATA.filter((d) => d.status === 'released' || d.status === 'current').length;
@@ -75,53 +85,53 @@ function Roadmap() {
const renderNode = (status) => {
if (status === 'released') {
return (
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(62,207,142,0.16),rgba(62,207,142,0.16)), #0f0f11', border: '1px solid rgba(62,207,142,0.4)', zIndex: 2 }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#3ecf8e" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4 10-11" /></svg>
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(var(--sb-green-rgb), 0.16),rgba(var(--sb-green-rgb), 0.16)), var(--sb-bg)', border: '1px solid rgba(var(--sb-green-rgb), 0.4)', zIndex: 2 }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--sb-green-solid)" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4 10-11" /></svg>
</div>
);
}
if (status === 'current') {
return (
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(240,137,42,0.2),rgba(240,137,42,0.2)), #0f0f11', border: '1px solid #f0892a', zIndex: 2, animation: 'rmPulse 2.4s ease-out infinite' }}>
<span style={{ width: '9px', height: '9px', borderRadius: '50%', background: '#f0892a' }} />
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(var(--sb-orange-rgb), 0.2),rgba(var(--sb-orange-rgb), 0.2)), var(--sb-bg)', border: '1px solid var(--sb-orange-solid)', zIndex: 2, animation: 'rmPulse 2.4s ease-out infinite' }}>
<span style={{ width: '9px', height: '9px', borderRadius: '50%', background: 'var(--sb-orange-solid)' }} />
</div>
);
}
if (status === 'dev') {
return (
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(227,179,65,0.15),rgba(227,179,65,0.15)), #0f0f11', border: '1px solid rgba(227,179,65,0.4)', zIndex: 2 }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#e3b341" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3a9 9 0 1 0 9 9" /></svg>
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'linear-gradient(rgba(var(--sb-yellow-2-rgb), 0.15),rgba(var(--sb-yellow-2-rgb), 0.15)), var(--sb-bg)', border: '1px solid rgba(var(--sb-yellow-2-rgb), 0.4)', zIndex: 2 }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--sb-yellow-2-solid)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3a9 9 0 1 0 9 9" /></svg>
</div>
);
}
// planned / research
return (
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: '#0f0f11', border: `1px ${status === 'research' ? 'dashed' : 'solid'} rgba(255,255,255,0.18)`, zIndex: 2 }}>
<span style={{ width: '7px', height: '7px', borderRadius: '50%', background: META[status].color }} />
<div style={{ position: 'absolute', insetInlineStart: '13px', top: '16px', width: '28px', height: '28px', borderRadius: '50%', display: 'grid', placeItems: 'center', background: 'var(--sb-bg)', border: `1px ${status === 'research' ? 'dashed' : 'solid'} rgba(var(--sb-ink), 0.18)`, zIndex: 2 }}>
<span style={{ width: '7px', height: '7px', borderRadius: '50%', background: META[status].solid }} />
</div>
);
};
return (
<section style={{ width: '100%', color: '#e8e8eb', fontFamily: SANS, padding: isMobile ? '48px 0' : '64px 0', background: 'radial-gradient(1200px 720px at 50% -8%, rgba(240,137,42,0.05), transparent 60%), #0f0f11' }}>
<style dangerouslySetInnerHTML={{ __html: '@keyframes rmExp{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}@keyframes rmPulse{0%,100%{box-shadow:0 0 0 0 rgba(240,137,42,0.18)}60%{box-shadow:0 0 0 9px rgba(240,137,42,0)}}' }} />
<section style={{ width: '100%', color: 'var(--sb-text-2)', fontFamily: SANS, padding: isMobile ? '48px 0' : '64px 0', background: 'radial-gradient(1200px 720px at 50% -8%, rgba(var(--sb-orange-rgb), 0.05), transparent 60%), var(--sb-bg)' }}>
<style dangerouslySetInnerHTML={{ __html: '@keyframes rmExp{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}@keyframes rmPulse{0%,100%{box-shadow:0 0 0 0 rgba(var(--sb-orange-rgb), 0.18)}60%{box-shadow:0 0 0 9px rgba(var(--sb-orange-rgb), 0)}}' }} />
<div style={{ maxWidth: '1040px', margin: '0 auto', padding: isMobile ? '0 18px' : '0 40px' }}>
{/* header */}
<div style={{ marginBottom: '30px' }}>
<div style={{ fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '1.6px', marginBottom: '13px' }}>{t('roadmap.eyebrow')}</div>
<h2 style={{ margin: '0 0 14px', fontSize: isMobile ? '27px' : '34px', fontWeight: 800, letterSpacing: '-1px', lineHeight: 1.08, color: '#f4f4f6' }}>{t('roadmap.heading')}</h2>
<p style={{ margin: 0, fontSize: '15.5px', lineHeight: 1.6, color: '#8a8a92', maxWidth: '660px' }}>{t('roadmap.subheading')}</p>
<div style={{ fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: 'var(--sb-text-9)', textTransform: 'uppercase', letterSpacing: '1.6px', marginBottom: '13px' }}>{t('roadmap.eyebrow')}</div>
<h2 style={{ margin: '0 0 14px', fontSize: isMobile ? '27px' : '34px', fontWeight: 800, letterSpacing: '-1px', lineHeight: 1.08, color: 'var(--sb-text-1)' }}>{t('roadmap.heading')}</h2>
<p style={{ margin: 0, fontSize: '15.5px', lineHeight: 1.6, color: 'var(--sb-text-7)', maxWidth: '660px' }}>{t('roadmap.subheading')}</p>
</div>
{/* progress */}
<div style={{ display: 'flex', alignItems: 'center', gap: '18px', flexWrap: 'wrap', padding: '18px 22px', borderRadius: '14px', background: '#141416', border: '1px solid rgba(255,255,255,0.06)', marginBottom: '36px' }}>
<div style={{ fontFamily: MONO, fontSize: '12px', fontWeight: 600, color: '#e8e8eb', whiteSpace: 'nowrap' }}>{progressBefore}<span style={{ color: '#3ecf8e' }}>{shipped}</span>{progressAfter}</div>
<div style={{ flex: '1 1 240px', minWidth: '200px', height: '8px', borderRadius: '99px', background: '#0c0c0e', border: '1px solid rgba(255,255,255,0.06)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: shippedPct, background: 'linear-gradient(90deg, #3ecf8e, #f0892a)' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: '18px', flexWrap: 'wrap', padding: '18px 22px', borderRadius: '14px', background: 'var(--sb-surface)', border: '1px solid rgba(var(--sb-ink), 0.06)', marginBottom: '36px' }}>
<div style={{ fontFamily: MONO, fontSize: '12px', fontWeight: 600, color: 'var(--sb-text-2)', whiteSpace: 'nowrap' }}>{progressBefore}<span style={{ color: 'var(--sb-green)' }}>{shipped}</span>{progressAfter}</div>
<div style={{ flex: '1 1 240px', minWidth: '200px', height: '8px', borderRadius: '99px', background: 'var(--sb-bg-deep)', border: '1px solid rgba(var(--sb-ink), 0.06)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: shippedPct, background: 'linear-gradient(90deg, var(--sb-green), var(--sb-orange))' }} />
</div>
<div style={{ fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '0.8px', whiteSpace: 'nowrap' }}>{t('roadmap.upcoming', { upcoming })}</div>
<div style={{ fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: 'var(--sb-text-9)', textTransform: 'uppercase', letterSpacing: '0.8px', whiteSpace: 'nowrap' }}>{t('roadmap.upcoming', { upcoming })}</div>
</div>
{/* timeline */}
@@ -139,37 +149,45 @@ function Roadmap() {
</div>
{/* card */}
<div style={{ borderRadius: '16px', background: '#141416', border: `1px solid ${d.status === 'current' ? 'rgba(240,137,42,0.28)' : 'rgba(255,255,255,0.06)'}`, overflow: 'hidden' }}>
<div style={{ borderRadius: '16px', background: 'var(--sb-surface)', border: `1px solid ${d.status === 'current' ? 'rgba(var(--sb-orange-rgb), 0.28)' : 'rgba(var(--sb-ink), 0.06)'}`, overflow: 'hidden' }}>
<div
onClick={() => toggle(i)}
style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '11px' : '16px', padding: isMobile ? '16px 16px' : '18px 22px', cursor: 'pointer', transition: 'background .18s ease' }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.018)'; }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(var(--sb-ink), 0.018)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<div style={{ flex: 'none', minWidth: '52px', textAlign: 'center', padding: '7px 10px', borderRadius: '9px', background: '#0c0c0e', border: '1px solid rgba(255,255,255,0.07)', fontFamily: MONO, fontSize: '13px', fontWeight: 700, color: d.status === 'current' ? '#f0892a' : '#cfcfd4' }}>{d.v}</div>
<div style={{ flex: 'none', minWidth: '52px', textAlign: 'center', padding: '7px 10px', borderRadius: '9px', background: 'var(--sb-bg-deep)', border: '1px solid rgba(var(--sb-ink), 0.07)', fontFamily: MONO, fontSize: '13px', fontWeight: 700, color: d.status === 'current' ? 'var(--sb-orange)' : 'var(--sb-text-4)' }}>{d.v}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: isMobile ? '15.5px' : '17px', fontWeight: 800, letterSpacing: '-0.4px', color: '#f4f4f6' }}>{d.title}</div>
{!isMobile && <div style={{ marginTop: '3px', fontSize: '13.5px', color: '#9a9aa2' }}>{d.sub}</div>}
<div style={{ fontSize: isMobile ? '15.5px' : '17px', fontWeight: 800, letterSpacing: '-0.4px', color: 'var(--sb-text-1)' }}>{d.title}</div>
{!isMobile && <div style={{ marginTop: '3px', fontSize: '13.5px', color: 'var(--sb-text-6)' }}>{d.sub}</div>}
</div>
<div style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '14px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '7px', padding: '6px 11px', borderRadius: '8px', background: hexA(meta.color, 0.1), border: `1px solid ${hexA(meta.color, 0.22)}`, fontFamily: MONO, fontSize: '10.5px', fontWeight: 600, color: meta.color, textTransform: 'uppercase', letterSpacing: '0.8px', whiteSpace: 'nowrap' }}>
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: meta.color }} />
{!isMobile && meta.word}
</span>
{!isMobile && <span style={{ fontFamily: MONO, fontSize: '12px', fontWeight: 500, color: '#8a8a92', whiteSpace: 'nowrap', minWidth: '74px', textAlign: 'end' }}>{d.date}</span>}
<span style={{ color: '#6b6b73', display: 'inline-flex', transition: 'transform .22s cubic-bezier(.2,.7,.3,1)', transform: opened ? 'rotate(180deg)' : 'rotate(0deg)' }}>
{/* The status pill is desktop-only. On a phone its
word does not fit, and what was left a bordered
box around a single coloured dot said nothing the
marker on the timeline was not already saying in
the same colour, while taking room from the title.
So the whole pill goes, not just its label. */}
{!isMobile && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '7px', padding: '6px 11px', borderRadius: '8px', background: tint(meta.rgb, 0.1), border: `1px solid ${tint(meta.rgb, 0.22)}`, fontFamily: MONO, fontSize: '10.5px', fontWeight: 600, color: meta.color, textTransform: 'uppercase', letterSpacing: '0.8px', whiteSpace: 'nowrap' }}>
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: meta.solid }} />
{meta.word}
</span>
)}
{!isMobile && <span style={{ fontFamily: MONO, fontSize: '12px', fontWeight: 500, color: 'var(--sb-text-7)', whiteSpace: 'nowrap', minWidth: '74px', textAlign: 'end' }}>{d.date}</span>}
<span style={{ color: 'var(--sb-text-9)', display: 'inline-flex', transition: 'transform .22s cubic-bezier(.2,.7,.3,1)', transform: opened ? 'rotate(180deg)' : 'rotate(0deg)' }}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6" /></svg>
</span>
</div>
</div>
{opened && (
<div style={{ padding: '4px 22px 22px 22px', animation: 'rmExp .24s cubic-bezier(.2,.7,.3,1)' }}>
<div style={{ fontFamily: MONO, fontSize: '10px', fontWeight: 600, color: '#56565e', textTransform: 'uppercase', letterSpacing: '1.2px', marginBottom: '14px', paddingTop: '14px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>{t('roadmap.keyFeatures')}</div>
<div style={{ fontFamily: MONO, fontSize: '10px', fontWeight: 600, color: 'var(--sb-text-faint)', textTransform: 'uppercase', letterSpacing: '1.2px', marginBottom: '14px', paddingTop: '14px', borderTop: '1px solid rgba(var(--sb-ink), 0.05)' }}>{t('roadmap.keyFeatures')}</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: '11px 28px' }}>
{d.features.map((f, fi) => (
<div key={fi} style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
<span style={{ flex: 'none', marginTop: '7px', width: '5px', height: '5px', borderRadius: '50%', background: meta.color }} />
<span style={{ fontSize: '13.5px', lineHeight: 1.5, color: '#cfcfd4' }}>{f}</span>
<span style={{ flex: 'none', marginTop: '7px', width: '5px', height: '5px', borderRadius: '50%', background: meta.solid }} />
<span style={{ fontSize: '13.5px', lineHeight: 1.5, color: 'var(--sb-text-4)' }}>{f}</span>
</div>
))}
</div>
+157
View File
@@ -0,0 +1,157 @@
// The three-state theme control, modelled on the language switcher next to it.
//
// Three states rather than a two-way toggle, because "follow the system" is a real
// preference and not the same as "dark": someone whose laptop turns light at sunrise
// wants the page to follow, and a two-way switch has nowhere to say so. The stored
// value is the *mode* ('system' | 'light' | 'dark'), never the colour it resolved to,
// so a page opened tomorrow in system mode answers the question again rather than
// replaying yesterday's answer.
//
// Nothing here owns the theme. src/scripts/theme-boot.js does it runs blocking in
// <head>, long before React exists, because the theme has to be right on the first
// paint. This component is a view onto it: it reads window.SecureBitTheme, calls set(),
// and re-renders from the subscription rather than from its own state, so the header
// stays correct when the OS theme changes under it or another tab switches.
//
// Landing page only, like the language switcher, though for a different reason: there
// is no room for it in the connected header, which already carries the security pill,
// the connection status and the network settings, and the preference persists across
// the transition into the chat anyway.
import { t } from '../../i18n/index.js';
// 24×24 line icons, drawn to match the chevron the language switcher uses.
const ICON = {
system: '<rect x="2.5" y="4" width="19" height="13" rx="2"/><path d="M8 20.5h8M12 17.5v3"/>',
light: '<circle cx="12" cy="12" r="4.2"/><path d="M12 2.6v2.4M12 19v2.4M2.6 12h2.4M19 12h2.4M5.3 5.3l1.7 1.7M17 17l1.7 1.7M18.7 5.3L17 7M7 17l-1.7 1.7"/>',
dark: '<path d="M20.5 14.3A8.6 8.6 0 0 1 9.7 3.5a8.6 8.6 0 1 0 10.8 10.8z"/>',
};
const svg = (path, size, width) => React.createElement('svg', {
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
stroke: 'currentColor', strokeWidth: width, strokeLinecap: 'round', strokeLinejoin: 'round',
'aria-hidden': 'true',
dangerouslySetInnerHTML: { __html: path },
});
const ThemeSwitcher = () => {
const api = typeof window !== 'undefined' ? window.SecureBitTheme : null;
// Without the boot script there is nothing to drive, and a control that cannot
// change anything is worse than no control.
if (!api) return null;
const [open, setOpen] = React.useState(false);
const [mode, setMode] = React.useState(() => api.get());
const [resolved, setResolved] = React.useState(() => api.resolved());
const rootRef = React.useRef(null);
// The mode can change without this component doing it the OS flipping while in
// system mode, or another tab writing the preference.
React.useEffect(() => api.subscribe((nextMode, nextResolved) => {
setMode(nextMode);
setResolved(nextResolved);
}), []);
React.useEffect(() => {
if (!open) return undefined;
const onDown = (e) => {
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
};
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('pointerdown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const OPTIONS = [
{ mode: 'system', icon: ICON.system, label: t('theme.system') },
{ mode: 'light', icon: ICON.light, label: t('theme.light') },
{ mode: 'dark', icon: ICON.dark, label: t('theme.dark') },
];
// The trigger shows what you are *getting*, not what you picked: in system mode the
// useful thing to see is whether the page is currently light or dark. Which mode is
// selected is the menu's job, and the check mark there says it.
const triggerIcon = mode === 'system' ? ICON.system : (resolved === 'light' ? ICON.light : ICON.dark);
const current = OPTIONS.find((o) => o.mode === mode) || OPTIONS[0];
const trigger = React.createElement('button', {
key: 'trigger',
type: 'button',
onClick: () => setOpen((v) => !v),
'aria-haspopup': 'menu',
'aria-expanded': open ? 'true' : 'false',
'aria-label': t('theme.label') + ': ' + current.label,
title: t('theme.label'),
style: {
display: 'flex', alignItems: 'center', gap: '6px',
padding: '8px 9px', borderRadius: '9px',
border: '1px solid rgba(var(--sb-ink), 0.07)',
background: open ? 'rgba(var(--sb-ink), 0.06)' : 'rgba(var(--sb-ink), 0.02)',
color: 'var(--sb-text-4)', font: 'inherit',
cursor: 'pointer', transition: 'background .15s, color .15s',
},
}, [
React.createElement('span', { key: 'i', style: { display: 'grid', placeItems: 'center' } }, svg(triggerIcon, 15, 1.9)),
React.createElement('svg', {
key: 'v', width: 11, height: 11, viewBox: '0 0 24 24', fill: 'none',
stroke: 'currentColor', strokeWidth: 2.4, strokeLinecap: 'round', strokeLinejoin: 'round',
style: { transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .18s' },
dangerouslySetInnerHTML: { __html: '<path d="M6 9l6 6 6-6"/>' },
}),
]);
const menu = React.createElement('div', {
key: 'menu',
role: 'menu',
style: {
position: 'absolute', top: 'calc(100% + 6px)', insetInlineEnd: 0, zIndex: 60,
display: open ? 'block' : 'none',
minWidth: '158px', padding: '5px', borderRadius: '11px',
border: '1px solid rgba(var(--sb-ink), 0.08)', background: 'var(--sb-surface)',
boxShadow: '0 14px 34px rgba(var(--sb-shadow-rgb), calc(0.45 * var(--sb-shadow-k)))',
},
}, OPTIONS.map((option) => {
const active = option.mode === mode;
return React.createElement('button', {
key: option.mode,
type: 'button',
role: 'menuitemradio',
'aria-checked': active ? 'true' : 'false',
onClick: () => { api.set(option.mode); setOpen(false); },
style: {
width: '100%', display: 'flex', alignItems: 'center', gap: '10px',
padding: '8px 10px', borderRadius: '8px', border: 'none',
fontSize: '13px', fontFamily: 'inherit',
fontWeight: active ? 600 : 500,
color: active ? 'var(--sb-text-2)' : 'var(--sb-text-6)',
background: active ? 'rgba(var(--sb-ink), 0.06)' : 'transparent',
cursor: 'pointer', textAlign: 'start', whiteSpace: 'nowrap',
},
}, [
React.createElement('span', {
key: 'i',
style: { flex: 'none', display: 'grid', placeItems: 'center', width: '16px', color: active ? 'var(--sb-orange)' : 'var(--sb-text-9)' },
}, svg(option.icon, 14, 1.9)),
React.createElement('span', { key: 'l', style: { flex: 1 } }, option.label),
// The check is the only thing that distinguishes "system, currently dark"
// from "dark" the trigger icon cannot, because both look dark.
active && React.createElement('span', {
key: 'c',
style: { flex: 'none', display: 'grid', placeItems: 'center', color: 'var(--sb-orange)' },
}, svg('<path d="M4.5 12.5l5 5 10-11"/>', 12, 2.6)),
]);
}));
return React.createElement('div', {
ref: rootRef,
style: { position: 'relative', display: 'inline-flex' },
}, [trigger, menu]);
};
window.ThemeSwitcher = ThemeSwitcher;
export { ThemeSwitcher };
+30 -27
View File
@@ -19,11 +19,14 @@ const UniqueFeatureSlider = () => {
};
}, []);
const ACCENT = '#f0892a';
const ACTIVE_BG = 'radial-gradient(130% 90% at 28% 0%, rgba(240,137,42,0.11), transparent 60%), #141416';
const ACTIVE_BD = 'rgba(240,137,42,0.3)';
const IDLE_BG = '#111113';
const IDLE_BD = 'rgba(255,255,255,0.06)';
const ACCENT = 'var(--sb-orange)';
// The icons are stroked with this, and a stroke is a mark, not a label brand
// colour in both themes. ACCENT above is for text and darkens on a light ground.
const ACCENT_SOLID = 'var(--sb-orange-solid)';
const ACTIVE_BG = 'radial-gradient(130% 90% at 28% 0%, rgba(var(--sb-orange-rgb), 0.11), transparent 60%), var(--sb-surface)';
const ACTIVE_BD = 'rgba(var(--sb-orange-rgb), 0.3)';
const IDLE_BG = 'var(--sb-bg)';
const IDLE_BD = 'rgba(var(--sb-ink), 0.06)';
const MONO = "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
const SANS = "'Manrope', system-ui, -apple-system, sans-serif";
@@ -85,12 +88,12 @@ const UniqueFeatureSlider = () => {
key, onClick, 'aria-label': key, className: 'sb-mirror-rtl',
style: {
width: '46px', height: '46px', display: 'grid', placeItems: 'center',
borderRadius: '50%', border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(255,255,255,0.025)', color: '#cfcfd4', cursor: 'pointer',
borderRadius: '50%', border: '1px solid rgba(var(--sb-ink), 0.1)',
background: 'rgba(var(--sb-ink), 0.025)', color: 'var(--sb-text-4)', cursor: 'pointer',
transition: 'all .2s cubic-bezier(.2,.7,.3,1)'
},
onMouseEnter: (e) => { e.currentTarget.style.borderColor = ACTIVE_BD; e.currentTarget.style.color = ACCENT; },
onMouseLeave: (e) => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; e.currentTarget.style.color = '#cfcfd4'; }
onMouseLeave: (e) => { e.currentTarget.style.borderColor = 'rgba(var(--sb-ink), 0.1)'; e.currentTarget.style.color = 'var(--sb-text-4)'; }
}, svg(path, 18, 'currentColor', 2.1));
const tag = (label) =>
@@ -98,12 +101,12 @@ const UniqueFeatureSlider = () => {
key: label,
style: {
display: 'inline-flex', alignItems: 'center', gap: '7px', padding: '7px 12px',
borderRadius: '9px', border: '1px solid rgba(255,255,255,0.07)',
background: 'rgba(255,255,255,0.025)', fontFamily: MONO,
fontSize: '11.5px', fontWeight: 500, color: '#9a9aa2'
borderRadius: '9px', border: '1px solid rgba(var(--sb-ink), 0.07)',
background: 'rgba(var(--sb-ink), 0.025)', fontFamily: MONO,
fontSize: '11.5px', fontWeight: 500, color: 'var(--sb-text-6)'
}
}, [
React.createElement('span', { key: 'dot', style: { width: '5px', height: '5px', borderRadius: '50%', background: '#3ecf8e' } }),
React.createElement('span', { key: 'dot', style: { width: '5px', height: '5px', borderRadius: '50%', background: 'var(--sb-green-solid)' } }),
label
]);
@@ -124,17 +127,17 @@ const UniqueFeatureSlider = () => {
key: 'ic',
style: {
width: '54px', height: '54px', borderRadius: '15px', display: 'grid', placeItems: 'center',
background: 'rgba(240,137,42,0.13)', border: '1px solid rgba(240,137,42,0.3)'
background: 'rgba(var(--sb-orange-rgb), 0.13)', border: '1px solid rgba(var(--sb-orange-rgb), 0.3)'
}
}, svg(s.icon, 26, ACCENT, 1.9)),
React.createElement('span', { key: 'n', style: { fontFamily: MONO, fontSize: '13px', fontWeight: 600, color: '#6b6b73' } }, s.num)
}, svg(s.icon, 26, ACCENT_SOLID, 1.9)),
React.createElement('span', { key: 'n', style: { fontFamily: MONO, fontSize: '13px', fontWeight: 600, color: 'var(--sb-text-9)' } }, s.num)
]),
React.createElement('div', { key: 'mid' }, [
React.createElement('h3', {
key: 'h', style: { margin: '0 0 12px', fontSize: isMobile ? '24px' : '30px', fontWeight: 800, letterSpacing: '-0.7px', lineHeight: 1.08, color: '#f4f4f6' }
key: 'h', style: { margin: '0 0 12px', fontSize: isMobile ? '24px' : '30px', fontWeight: 800, letterSpacing: '-0.7px', lineHeight: 1.08, color: 'var(--sb-text-1)' }
}, [s.title[0], React.createElement('br', { key: 'br' }), s.title[1]]),
React.createElement('p', {
key: 'p', style: { margin: 0, fontSize: '15px', lineHeight: 1.6, color: '#9a9aa2', maxWidth: '380px' }
key: 'p', style: { margin: 0, fontSize: '15px', lineHeight: 1.6, color: 'var(--sb-text-6)', maxWidth: '380px' }
}, s.desc)
]),
React.createElement('div', { key: 'tags', style: { display: 'flex', flexWrap: 'wrap', gap: '8px' } }, s.tags.map(tag))
@@ -145,19 +148,19 @@ const UniqueFeatureSlider = () => {
key: 'col',
style: { display: 'flex', alignItems: 'center', gap: '16px', padding: '20px 22px' }
}, [
React.createElement('span', { key: 'n', style: { fontFamily: MONO, fontSize: '12px', fontWeight: 600, color: '#56565e' } }, s.num),
React.createElement('span', { key: 'l', style: { fontSize: '16px', fontWeight: 800, letterSpacing: '-0.2px', color: '#cfcfd4' } }, s.collapsed)
React.createElement('span', { key: 'n', style: { fontFamily: MONO, fontSize: '12px', fontWeight: 600, color: 'var(--sb-text-faint)' } }, s.num),
React.createElement('span', { key: 'l', style: { fontSize: '16px', fontWeight: 800, letterSpacing: '-0.2px', color: 'var(--sb-text-4)' } }, s.collapsed)
])
: React.createElement('div', {
key: 'col',
style: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'space-between', padding: '24px 0' }
}, [
React.createElement('span', { key: 'n', style: { fontFamily: MONO, fontSize: '12px', fontWeight: 600, color: '#56565e' } }, s.num),
React.createElement('span', { key: 'n', style: { fontFamily: MONO, fontSize: '12px', fontWeight: 600, color: 'var(--sb-text-faint)' } }, s.num),
React.createElement('span', {
key: 'l',
style: { writingMode: 'vertical-rl', transform: 'rotate(180deg)', fontSize: '17px', fontWeight: 800, letterSpacing: '-0.2px', color: '#cfcfd4', whiteSpace: 'nowrap' }
style: { writingMode: 'vertical-rl', transform: 'rotate(180deg)', fontSize: '17px', fontWeight: 800, letterSpacing: '-0.2px', color: 'var(--sb-text-4)', whiteSpace: 'nowrap' }
}, s.collapsed),
svg(s.icon, 22, '#56565e', 1.8)
svg(s.icon, 22, 'var(--sb-text-faint)', 1.8)
]);
const panels = slides.map((s, i) => {
@@ -178,7 +181,7 @@ const UniqueFeatureSlider = () => {
cursor: 'pointer',
background: isActive ? ACTIVE_BG : IDLE_BG,
border: '1px solid ' + (isActive ? ACTIVE_BD : IDLE_BD),
color: '#8a8a92',
color: 'var(--sb-text-7)',
transition: 'flex .46s cubic-bezier(.2,.7,.3,1), background .3s ease, border-color .3s ease, filter .2s ease'
}
}, isActive ? expandedContent(s) : collapsedContent(s));
@@ -199,11 +202,11 @@ const UniqueFeatureSlider = () => {
React.createElement('div', { key: 'titles' }, [
React.createElement('div', {
key: 'eyebrow',
style: { fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#6b6b73', textTransform: 'uppercase', letterSpacing: '1.4px', marginBottom: '12px' }
style: { fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: 'var(--sb-text-9)', textTransform: 'uppercase', letterSpacing: '1.4px', marginBottom: '12px' }
}, t('unique.eyebrow')),
React.createElement('h2', {
key: 'h2',
style: { margin: 0, fontSize: isMobile ? '28px' : '38px', fontWeight: 800, letterSpacing: '-1.1px', lineHeight: 1.05, color: '#f4f4f6' }
style: { margin: 0, fontSize: isMobile ? '28px' : '38px', fontWeight: 800, letterSpacing: '-1.1px', lineHeight: 1.05, color: 'var(--sb-text-1)' }
}, t('unique.heading'))
]),
React.createElement('div', { key: 'nav', style: { display: 'flex', alignItems: 'center', gap: '10px', flex: 'none' } }, [
@@ -227,9 +230,9 @@ const UniqueFeatureSlider = () => {
// Full-bleed dark band with the radial accent glow matches the design mockup.
return React.createElement('section', {
style: {
width: '100%', color: '#e8e8eb', fontFamily: SANS,
width: '100%', color: 'var(--sb-text-2)', fontFamily: SANS,
padding: isMobile ? '44px 0' : '64px 0',
background: 'radial-gradient(1100px 700px at 18% 8%, rgba(240,137,42,0.05), transparent 60%), #0f0f11'
background: 'radial-gradient(1100px 700px at 18% 8%, rgba(var(--sb-orange-rgb), 0.05), transparent 60%), var(--sb-bg)'
}
}, [
React.createElement('style', { key: 'kf', dangerouslySetInnerHTML: { __html: '@keyframes wuUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}' } }),
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "هذه الصفحة متوفرة أيضًا بالعربية.",
"language.suggest.cta": "اقرأ بالعربية",
"language.suggest.dismiss": "إغلاق",
"theme.label": "المظهر",
"theme.system": "النظام",
"theme.light": "فاتح",
"theme.dark": "داكن",
"community.title": "انضمّ إلى مستقبل الخصوصية",
"community.description": "ينمو SecureBit بفضل مجتمعه. أفكاركم وملاحظاتكم هي ما يصوغ مستقبل التواصل الآمن - مبنيّ في العلن، مع تحقّق ASN.1 كامل من طرف إلى طرف.",
"community.github": "مستودع GitHub",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "أكّد لتثبيت SecureBit.chat",
"pwa.genericTitle": "تثبيت SecureBit",
"pwa.genericDesc": "لكلّ متصفّح طريقته في التثبيت. اختر الخطوات التي تناسب متصفّحك.",
"pwa.androidChromeHint": "اضغط على ⋮ في الزاوية العلوية، ثم «تثبيت التطبيق»",
"pwa.androidOtherHint": "افتح القائمة، ثم «إضافة إلى الشاشة الرئيسية»",
"pwa.gotIt": "فهمت",
"pwa.installedTitle": "تمّ تثبيت التطبيق!",
"pwa.installedIos": "تمّ تثبيت تطبيق iOS! افتحه من الشاشة الرئيسية.",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "Diese Seite gibt es auch auf Deutsch.",
"language.suggest.cta": "Auf Deutsch lesen",
"language.suggest.dismiss": "Schließen",
"theme.label": "Design",
"theme.system": "System",
"theme.light": "Hell",
"theme.dark": "Dunkel",
"community.title": "Gestalten Sie die Zukunft der Privatsphäre mit",
"community.description": "SecureBit wächst durch seine Community. Ihre Ideen und Ihr Feedback prägen die Zukunft sicherer Kommunikation - offen entwickelt, mit vollständiger ASN.1-Validierung von Ende zu Ende.",
"community.github": "GitHub-Repository",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "Bestätigen, um SecureBit.chat zu installieren",
"pwa.genericTitle": "SecureBit installieren",
"pwa.genericDesc": "Jeder Browser installiert auf seine eigene Weise. Wählen Sie die Schritte, die zu Ihrem passen.",
"pwa.androidChromeHint": "Tippe oben auf ⋮ und dann auf „App installieren“",
"pwa.androidOtherHint": "Öffne das Menü und wähle „Zum Startbildschirm hinzufügen“",
"pwa.gotIt": "Verstanden",
"pwa.installedTitle": "App installiert!",
"pwa.installedIos": "iOS-App installiert! Vom Home-Bildschirm öffnen.",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "This page is also available in English.",
"language.suggest.cta": "Read in English",
"language.suggest.dismiss": "Dismiss",
"theme.label": "Theme",
"theme.system": "System",
"theme.light": "Light",
"theme.dark": "Dark",
"community.title": "Join the future of privacy",
"community.description": "SecureBit grows thanks to its community. Your ideas and feedback shape the future of secure communication - built in the open, with complete ASN.1 validation end-to-end.",
"community.github": "GitHub Repository",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "Confirm to install SecureBit.chat",
"pwa.genericTitle": "Install SecureBit",
"pwa.genericDesc": "Your browser handles installs its own way. Pick the steps that match yours.",
"pwa.androidChromeHint": "Tap ⋮ in the top corner, then “Install app”",
"pwa.androidOtherHint": "Tap the menu, then “Add to Home screen”",
"pwa.gotIt": "Got it",
"pwa.installedTitle": "App Installed!",
"pwa.installedIos": "iOS App installed! Open from home screen.",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "Esta página también está disponible en español.",
"language.suggest.cta": "Leer en español",
"language.suggest.dismiss": "Cerrar",
"theme.label": "Tema",
"theme.system": "Sistema",
"theme.light": "Claro",
"theme.dark": "Oscuro",
"community.title": "Construye el futuro de la privacidad",
"community.description": "SecureBit crece gracias a su comunidad. Tus ideas y comentarios dan forma al futuro de la comunicación segura, desarrollada de forma abierta y con validación ASN.1 completa de extremo a extremo.",
"community.github": "Repositorio en GitHub",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "Confirma para instalar SecureBit.chat",
"pwa.genericTitle": "Instalar SecureBit",
"pwa.genericDesc": "Cada navegador instala a su manera. Elige los pasos que coincidan con el tuyo.",
"pwa.androidChromeHint": "Toca ⋮ en la esquina superior y elige «Instalar aplicación»",
"pwa.androidOtherHint": "Abre el menú y elige «Añadir a la pantalla de inicio»",
"pwa.gotIt": "Entendido",
"pwa.installedTitle": "¡App instalada!",
"pwa.installedIos": "¡App de iOS instalada! Ábrela desde la pantalla de inicio.",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "این صفحه به فارسی هم در دسترس است.",
"language.suggest.cta": "خواندن به فارسی",
"language.suggest.dismiss": "بستن",
"theme.label": "پوسته",
"theme.system": "سیستم",
"theme.light": "روشن",
"theme.dark": "تیره",
"community.title": "به آیندهٔ حریم خصوصی بپیوندید",
"community.description": "SecureBit به‌لطف جامعه‌اش رشد می‌کند. ایده‌ها و بازخوردهای شما آیندهٔ ارتباط امن را شکل می‌دهد - ساخته‌شده در فضای باز، با اعتبارسنجی کامل ASN.1 از یک سر تا سر دیگر.",
"community.github": "مخزن GitHub",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "برای نصب SecureBit.chat تأیید کنید",
"pwa.genericTitle": "نصب SecureBit",
"pwa.genericDesc": "هر مرورگر روش نصب خودش را دارد. گام‌هایی را برگزینید که با مرورگر شما می‌خواند.",
"pwa.androidChromeHint": "روی ⋮ در گوشهٔ بالا بزنید و «نصب برنامه» را بزنید",
"pwa.androidOtherHint": "منو را باز کنید و «افزودن به صفحهٔ اصلی» را بزنید",
"pwa.gotIt": "متوجه شدم",
"pwa.installedTitle": "برنامه نصب شد!",
"pwa.installedIos": "برنامهٔ iOS نصب شد! از صفحهٔ اصلی بازش کنید.",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "Cette page est aussi disponible en français.",
"language.suggest.cta": "Lire en français",
"language.suggest.dismiss": "Fermer",
"theme.label": "Thème",
"theme.system": "Système",
"theme.light": "Clair",
"theme.dark": "Sombre",
"community.title": "Construisez l'avenir de la vie privée",
"community.description": "SecureBit grandit grâce à sa communauté. Vos idées et vos retours façonnent l'avenir des communications sécurisées, développées à ciel ouvert, avec une validation ASN.1 complète de bout en bout.",
"community.github": "Dépôt GitHub",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "Confirmez pour installer SecureBit.chat",
"pwa.genericTitle": "Installer SecureBit",
"pwa.genericDesc": "Chaque navigateur installe à sa manière. Choisissez les étapes qui correspondent au vôtre.",
"pwa.androidChromeHint": "Appuyez sur ⋮ en haut, puis sur « Installer lapplication »",
"pwa.androidOtherHint": "Ouvrez le menu, puis « Ajouter à l’écran daccueil »",
"pwa.gotIt": "Compris",
"pwa.installedTitle": "Application installée !",
"pwa.installedIos": "Application iOS installée ! Ouvrez-la depuis l'écran d'accueil.",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "הדף הזה זמין גם בעברית.",
"language.suggest.cta": "לקריאה בעברית",
"language.suggest.dismiss": "סגירה",
"theme.label": "ערכת נושא",
"theme.system": "מערכת",
"theme.light": "בהיר",
"theme.dark": "כהה",
"community.title": "הצטרפו לעתיד של הפרטיות",
"community.description": "SecureBit גדל בזכות הקהילה שלו. הרעיונות והמשוב שלכם מעצבים את עתיד התקשורת המאובטחת - נבנה בגלוי, עם אימות ASN.1 מלא מקצה לקצה.",
"community.github": "מאגר GitHub",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "אשרו כדי להתקין את SecureBit.chat",
"pwa.genericTitle": "התקנת SecureBit",
"pwa.genericDesc": "לכל דפדפן דרך משלו להתקין. בחרו את השלבים שמתאימים לשלכם.",
"pwa.androidChromeHint": "הקישו על ⋮ בפינה העליונה ובחרו ב־”התקנת אפליקציה”",
"pwa.androidOtherHint": "פתחו את התפריט ובחרו ב־”הוספה למסך הבית”",
"pwa.gotIt": "הבנתי",
"pwa.installedTitle": "האפליקציה הותקנה!",
"pwa.installedIos": "אפליקציית iOS הותקנה! פתחו ממסך הבית.",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "यह पृष्ठ हिन्दी में भी उपलब्ध है।",
"language.suggest.cta": "हिन्दी में पढ़ें",
"language.suggest.dismiss": "बंद करें",
"theme.label": "थीम",
"theme.system": "सिस्टम",
"theme.light": "हल्की",
"theme.dark": "गहरी",
"community.title": "निजता के भविष्य में शामिल हों",
"community.description": "SecureBit अपने समुदाय से बढ़ता है। आपके विचार और सुझाव सुरक्षित संवाद का भविष्य गढ़ते हैं - खुले में बना, और पूरे रास्ते पूरी ASN.1 जाँच के साथ।",
"community.github": "GitHub रिपॉज़िटरी",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "पुष्टि कीजिए, SecureBit.chat इंस्टॉल हो जाएगा",
"pwa.genericTitle": "SecureBit इंस्टॉल करें",
"pwa.genericDesc": "हर ब्राउज़र अपने तरीक़े से इंस्टॉल करता है। अपने ब्राउज़र से मेल खाते चरण चुनिए।",
"pwa.androidChromeHint": "ऊपर के कोने में ⋮ दबाएँ, फिर “ऐप इंस्टॉल करें” चुनें",
"pwa.androidOtherHint": "मेनू खोलें और “होम स्क्रीन पर जोड़ें” चुनें",
"pwa.gotIt": "समझ गया",
"pwa.installedTitle": "ऐप इंस्टॉल हो गया!",
"pwa.installedIos": "iOS ऐप इंस्टॉल हो गया! होम स्क्रीन से खोलिए।",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "이 페이지는 한국어로도 볼 수 있습니다.",
"language.suggest.cta": "한국어로 보기",
"language.suggest.dismiss": "닫기",
"theme.label": "테마",
"theme.system": "시스템",
"theme.light": "밝게",
"theme.dark": "어둡게",
"community.title": "프라이버시의 미래를 함께 만듭니다",
"community.description": "SecureBit은 커뮤니티 덕분에 자랍니다. 여러분의 아이디어와 의견이 안전한 소통의 미래를 만듭니다 - 모든 과정이 공개되어 있고, 전 구간에서 ASN.1 검증을 수행합니다.",
"community.github": "GitHub 저장소",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "확인하면 SecureBit.chat이 설치됩니다",
"pwa.genericTitle": "SecureBit 설치",
"pwa.genericDesc": "브라우저마다 설치 방법이 다릅니다. 쓰시는 브라우저에 맞는 순서를 고르세요.",
"pwa.androidChromeHint": "오른쪽 위 ⋮ 를 누른 뒤 “앱 설치”를 선택하세요",
"pwa.androidOtherHint": "메뉴를 열고 “홈 화면에 추가”를 선택하세요",
"pwa.gotIt": "알겠습니다",
"pwa.installedTitle": "앱이 설치되었습니다",
"pwa.installedIos": "iOS 앱이 설치되었습니다. 홈 화면에서 열어보세요.",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "Эта страница также доступна на русском.",
"language.suggest.cta": "Читать по-русски",
"language.suggest.dismiss": "Закрыть",
"theme.label": "Тема",
"theme.system": "Системная",
"theme.light": "Светлая",
"theme.dark": "Тёмная",
"community.title": "Присоединяйтесь к будущему приватности",
"community.description": "SecureBit растёт благодаря сообществу. Ваши идеи и отзывы формируют будущее защищённого общения - разработка ведётся открыто, с полной проверкой ASN.1 на всём пути.",
"community.github": "Репозиторий на GitHub",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "Подтвердите, чтобы установить SecureBit.chat",
"pwa.genericTitle": "Установить SecureBit",
"pwa.genericDesc": "Каждый браузер устанавливает по-своему. Выберите шаги, подходящие вашему.",
"pwa.androidChromeHint": "Нажмите ⋮ в верхнем углу и выберите «Установить приложение»",
"pwa.androidOtherHint": "Откройте меню и выберите «Добавить на главный экран»",
"pwa.gotIt": "Понятно",
"pwa.installedTitle": "Приложение установлено!",
"pwa.installedIos": "Приложение для iOS установлено! Откройте его с экрана «Домой».",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "Ця сторінка також доступна українською.",
"language.suggest.cta": "Читати українською",
"language.suggest.dismiss": "Закрити",
"theme.label": "Тема",
"theme.system": "Системна",
"theme.light": "Світла",
"theme.dark": "Темна",
"community.title": "Долучайтеся до майбутнього приватності",
"community.description": "SecureBit росте завдяки своїй спільноті. Ваші ідеї та відгуки формують майбутнє захищеного спілкування - розробка ведеться відкрито, з повною валідацією ASN.1 на всьому шляху.",
"community.github": "Репозиторій на GitHub",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "Підтвердіть, щоб встановити SecureBit.chat",
"pwa.genericTitle": "Встановити SecureBit",
"pwa.genericDesc": "Кожен браузер встановлює по-своєму. Оберіть кроки, які відповідають вашому.",
"pwa.androidChromeHint": "Натисніть ⋮ у верхньому кутку та оберіть «Встановити застосунок»",
"pwa.androidOtherHint": "Відкрийте меню та оберіть «Додати на головний екран»",
"pwa.gotIt": "Зрозуміло",
"pwa.installedTitle": "Застосунок встановлено!",
"pwa.installedIos": "Застосунок для iOS встановлено! Відкрийте його з екрана «Домів».",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "یہ صفحہ اردو میں بھی دستیاب ہے۔",
"language.suggest.cta": "اردو میں پڑھیں",
"language.suggest.dismiss": "بند کریں",
"theme.label": "تھیم",
"theme.system": "سسٹم",
"theme.light": "روشن",
"theme.dark": "گہرا",
"community.title": "نجیت کے مستقبل میں شامل ہوں",
"community.description": "SecureBit اپنی برادری کی بدولت بڑھتا ہے۔ آپ کے خیالات اور آراء محفوظ رابطے کا مستقبل تشکیل دیتے ہیں - کھلے عام بنایا گیا، سرے سے سرے تک مکمل ASN.1 توثیق کے ساتھ۔",
"community.github": "GitHub مخزن",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "SecureBit.chat نصب کرنے کی تصدیق کریں",
"pwa.genericTitle": "SecureBit نصب کریں",
"pwa.genericDesc": "ہر براؤزر اپنے انداز میں نصب کرتا ہے۔ وہ مراحل چنیں جو آپ کے براؤزر سے میل کھاتے ہوں۔",
"pwa.androidChromeHint": "اوپر کے کونے میں ⋮ دبائیں، پھر «ایپ انسٹال کریں»",
"pwa.androidOtherHint": "مینیو کھولیں اور «ہوم اسکرین پر شامل کریں» منتخب کریں",
"pwa.gotIt": "سمجھ گیا",
"pwa.installedTitle": "ایپ نصب ہو گئی!",
"pwa.installedIos": "‏iOS ایپ نصب ہو گئی! ہوم سکرین سے کھولیں۔",
+6
View File
@@ -6,6 +6,10 @@ export const DICTIONARY = {
"language.suggest.text": "本页面也有简体中文版本。",
"language.suggest.cta": "阅读简体中文",
"language.suggest.dismiss": "关闭",
"theme.label": "主题",
"theme.system": "跟随系统",
"theme.light": "浅色",
"theme.dark": "深色",
"community.title": "一起构筑隐私的未来",
"community.description": "SecureBit 因社区而成长。你的想法与反馈塑造着安全通讯的未来 —— 开放开发,全程完整的 ASN.1 校验。",
"community.github": "GitHub 仓库",
@@ -290,6 +294,8 @@ export const DICTIONARY = {
"pwa.iosStep3Hint": "确认以安装 SecureBit.chat",
"pwa.genericTitle": "安装 SecureBit",
"pwa.genericDesc": "每种浏览器的安装方式都不一样。请选择与你的浏览器相符的步骤。",
"pwa.androidChromeHint": "点击右上角的 ⋮ ,选择“安装应用”",
"pwa.androidOtherHint": "打开菜单,选择“添加到主屏幕”",
"pwa.gotIt": "知道了",
"pwa.installedTitle": "应用已安装!",
"pwa.installedIos": "iOS 应用已安装!请从主屏幕打开。",
+103 -69
View File
@@ -100,18 +100,33 @@ class PWAInstallPrompt {
});
}
// Called both by our own listener and by pwa-install-capture.js, which owns
// the event whenever it fired before this module was evaluated.
adoptInstallEvent(event) {
this.deferredPrompt = event;
if (this.checkInstallationStatus()) {
return;
}
if (!this.isInstalled && this.shouldShowPrompt()) {
setTimeout(() => this.showInstallOptions(), 1000);
}
}
setupEventListeners() {
// If the capture script already caught it, take it now — the event does
// not fire a second time for this page load.
if (window.__pwaInstallEvent) {
this.adoptInstallEvent(window.__pwaInstallEvent);
}
window.addEventListener('beforeinstallprompt', (event) => {
// Don't prevent default - let browser show its own banner
this.deferredPrompt = event;
if (this.checkInstallationStatus()) {
return;
}
if (!this.isInstalled && this.shouldShowPrompt()) {
setTimeout(() => this.showInstallOptions(), 1000);
}
// preventDefault() is what keeps the event usable. Let Chrome run its
// default action and the stashed event is spent: prompt() rejects and
// the install button silently degrades to a manual how-to.
event.preventDefault();
this.adoptInstallEvent(event);
});
window.addEventListener('appinstalled', () => {
@@ -166,10 +181,10 @@ class PWAInstallPrompt {
this.installButton.innerHTML = `
<div style="position:relative; display:inline-flex;">
<button class="close-btn" type="button" title="${t('pwa.dismiss')}" aria-label="${t('pwa.dismiss')}" style="position:absolute; top:-11px; inset-inline-end:-11px; z-index:3; width:28px; height:28px; padding:0; border-radius:50%; display:grid; place-items:center; border:1px solid rgba(255,255,255,0.1); background:#1a1a1d; color:#9a9aa2; cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
<button class="close-btn" type="button" title="${t('pwa.dismiss')}" aria-label="${t('pwa.dismiss')}" style="position:absolute; top:-11px; inset-inline-end:-11px; z-index:3; width:28px; height:28px; padding:0; border-radius:50%; display:grid; place-items:center; border:1px solid rgba(var(--sb-ink), 0.1); background:var(--sb-surface-2); color:var(--sb-text-6); cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
<button class="install-pill" type="button" style="display:inline-flex; align-items:center; gap:11px; padding-block:15px; padding-inline-start:22px; padding-inline-end:26px; border-radius:15px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:16px; font-weight:700; letter-spacing:-0.2px; cursor:pointer; box-shadow:0 10px 30px rgba(240,137,42,0.32); transition:all .2s cubic-bezier(.2,.7,.3,1);">
<button class="install-pill" type="button" style="display:inline-flex; align-items:center; gap:11px; padding-block:15px; padding-inline-start:22px; padding-inline-end:26px; border-radius:15px; border:none; background:var(--sb-orange-solid); color:var(--sb-on-accent); font-family:inherit; font-size:16px; font-weight:700; letter-spacing:-0.2px; cursor:pointer; box-shadow:0 10px 30px rgba(var(--sb-orange-rgb), 0.32); transition:all .2s cubic-bezier(.2,.7,.3,1);">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M12 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/></svg>
${t('pwa.installApp')}
</button>
@@ -177,12 +192,12 @@ class PWAInstallPrompt {
`;
const pill = this.installButton.querySelector('.install-pill');
pill.addEventListener('mouseenter', () => { pill.style.background = '#ff9637'; pill.style.transform = 'translateY(-2px)'; });
pill.addEventListener('mouseleave', () => { pill.style.background = '#f0892a'; pill.style.transform = 'none'; });
pill.addEventListener('mouseenter', () => { pill.style.background = 'var(--sb-orange-hi)'; pill.style.transform = 'translateY(-2px)'; });
pill.addEventListener('mouseleave', () => { pill.style.background = 'var(--sb-orange)'; pill.style.transform = 'none'; });
const closeBtn = this.installButton.querySelector('.close-btn');
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.color = '#e5727a'; closeBtn.style.borderColor = 'rgba(229,114,122,0.4)'; closeBtn.style.background = '#201416'; });
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.color = '#9a9aa2'; closeBtn.style.borderColor = 'rgba(255,255,255,0.1)'; closeBtn.style.background = '#1a1a1d'; });
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.color = 'var(--sb-red)'; closeBtn.style.borderColor = 'rgba(var(--sb-red-rgb), 0.4)'; closeBtn.style.background = 'var(--sb-surface-warm)'; });
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.color = 'var(--sb-text-6)'; closeBtn.style.borderColor = 'rgba(var(--sb-ink), 0.1)'; closeBtn.style.background = 'var(--sb-surface-2)'; });
this.installButton.addEventListener('click', (e) => {
if (!e.target.closest('.close-btn')) {
@@ -221,19 +236,19 @@ class PWAInstallPrompt {
: "transition:transform .44s cubic-bezier(.2,.7,.3,1), opacity .3s ease;");
this.installBanner.innerHTML = `
<div style="max-width:520px; margin-inline:auto; border-radius:20px; background:#121214; border:1px solid rgba(255,255,255,0.08); padding:18px; box-shadow:0 20px 50px rgba(0,0,0,0.55);">
<div style="max-width:520px; margin-inline:auto; border-radius:20px; background:var(--sb-bg); border:1px solid rgba(var(--sb-ink), 0.08); padding:18px; box-shadow:0 20px 50px rgba(var(--sb-shadow-rgb), calc(0.55 * var(--sb-shadow-k)));">
<div style="display:flex; align-items:center; gap:14px; margin-bottom:16px;">
<div style="flex:none; width:44px; height:44px; border-radius:12px; display:grid; place-items:center; background:rgba(240,137,42,0.12); border:1px solid rgba(240,137,42,0.28);">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="#f0892a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/></svg>
<div style="flex:none; width:44px; height:44px; border-radius:12px; display:grid; place-items:center; background:rgba(var(--sb-orange-rgb), 0.12); border:1px solid rgba(var(--sb-orange-rgb), 0.28);">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="var(--sb-orange-solid)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/></svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:15.5px; font-weight:800; letter-spacing:-0.3px; color:#f4f4f6; margin-bottom:3px;">${t('pwa.bannerTitle')}</div>
<div style="font-size:13px; line-height:1.45; color:#8a8a92;">${t('pwa.bannerDesc')}</div>
<div style="font-size:15.5px; font-weight:800; letter-spacing:-0.3px; color:var(--sb-text-1); margin-bottom:3px;">${t('pwa.bannerTitle')}</div>
<div style="font-size:13px; line-height:1.45; color:var(--sb-text-7);">${t('pwa.bannerDesc')}</div>
</div>
</div>
<div style="display:flex; gap:10px;">
<button class="install-btn" type="button" data-action="install" style="flex:1; padding:13px 20px; border-radius:13px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:15px; font-weight:700; letter-spacing:-0.2px; cursor:pointer; transition:background .2s cubic-bezier(.2,.7,.3,1);">${t('pwa.install')}</button>
<button class="close-btn" type="button" data-action="close" style="flex:none; padding:13px 18px; border-radius:13px; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.03); color:#9a9aa2; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:all .2s cubic-bezier(.2,.7,.3,1);">${t('pwa.dismiss')}</button>
<button class="install-btn" type="button" data-action="install" style="flex:1; padding:13px 20px; border-radius:13px; border:none; background:var(--sb-orange-solid); color:var(--sb-on-accent); font-family:inherit; font-size:15px; font-weight:700; letter-spacing:-0.2px; cursor:pointer; transition:background .2s cubic-bezier(.2,.7,.3,1);">${t('pwa.install')}</button>
<button class="close-btn" type="button" data-action="close" style="flex:none; padding:13px 18px; border-radius:13px; border:1px solid rgba(var(--sb-ink), 0.1); background:rgba(var(--sb-ink), 0.03); color:var(--sb-text-6); font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:all .2s cubic-bezier(.2,.7,.3,1);">${t('pwa.dismiss')}</button>
</div>
</div>
`;
@@ -339,23 +354,34 @@ class PWAInstallPrompt {
return;
}
try {
const result = await this.deferredPrompt.prompt();
const prompt = this.deferredPrompt;
// Clear first: prompt() may only be called once per event, and a second
// click while the sheet is open would otherwise throw.
this.deferredPrompt = null;
window.__pwaInstallEvent = null;
if (result.outcome === 'accepted') {
this.isInstalled = true;
try {
// Chrome resolves prompt() with the choice; older implementations
// resolve it with nothing and expose userChoice instead.
const result = (await prompt.prompt()) || (await prompt.userChoice);
const outcome = result && result.outcome;
if (outcome === 'accepted') {
this.isInstalled = true;
this.hideInstallPrompts();
this.saveInstallPreference('accepted', true);
this.saveInstallPreference('installed', true);
} else {
} else if (outcome === 'dismissed') {
this.handleInstallDismissal();
}
this.deferredPrompt = null;
} catch (error) {
console.error('❌ Install prompt failed:', error);
// A rejected prompt() has not been consumed — Chrome rejects it for a
// missing user gesture too — so put the event back rather than
// stranding the button on the manual guide for the rest of the visit.
this.deferredPrompt = prompt;
window.__pwaInstallEvent = prompt;
this.showFallbackInstructions();
}
}
@@ -366,28 +392,28 @@ class PWAInstallPrompt {
// strings baked in English, which a thirteen-locale site cannot ship.
const modal = document.createElement('div');
modal.id = 'pwa-ios-install';
modal.style.cssText = "position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(8,8,10,0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); animation:igFade .3s ease; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
modal.style.cssText = "position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(var(--sb-scrim-rgb), 0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); animation:igFade .3s ease; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
const step = (n, title, hint, delay) => `
<div style="display:flex; align-items:flex-start; gap:14px; padding:14px 16px; border-radius:13px; background:#161618; border:1px solid rgba(255,255,255,0.06); animation:igRow ${delay} cubic-bezier(.2,.7,.3,1);">
<div style="flex:none; width:26px; height:26px; border-radius:9px; display:grid; place-items:center; background:rgba(240,137,42,0.12); border:1px solid rgba(240,137,42,0.28); font-size:13px; font-weight:800; color:#f0892a;">${n}</div>
<div style="display:flex; align-items:flex-start; gap:14px; padding:14px 16px; border-radius:13px; background:var(--sb-surface); border:1px solid rgba(var(--sb-ink), 0.06); animation:igRow ${delay} cubic-bezier(.2,.7,.3,1);">
<div style="flex:none; width:26px; height:26px; border-radius:9px; display:grid; place-items:center; background:rgba(var(--sb-orange-rgb), 0.12); border:1px solid rgba(var(--sb-orange-rgb), 0.28); font-size:13px; font-weight:800; color:var(--sb-orange);">${n}</div>
<div style="flex:1; min-width:0;">
<div style="font-size:14.5px; font-weight:700; color:#f4f4f6; margin-bottom:2px;">${title}</div>
<div style="font-size:13px; line-height:1.45; color:#8a8a92;">${hint}</div>
<div style="font-size:14.5px; font-weight:700; color:var(--sb-text-1); margin-bottom:2px;">${title}</div>
<div style="font-size:13px; line-height:1.45; color:var(--sb-text-7);">${hint}</div>
</div>
</div>`;
modal.innerHTML = `
<div style="position:relative; z-index:2; width:440px; max-width:calc(100vw - 48px); border-radius:22px; background:#121214; border:1px solid rgba(255,255,255,0.08); padding:34px 30px 26px; box-shadow:0 30px 70px rgba(0,0,0,0.6); animation:igPop .32s cubic-bezier(.2,.7,.3,1);">
<button class="close-x" type="button" title="${t('pwa.close')}" aria-label="${t('pwa.close')}" style="position:absolute; top:18px; inset-inline-end:18px; width:30px; height:30px; padding:0; border-radius:9px; display:grid; place-items:center; border:1px solid rgba(255,255,255,0.08); background:rgba(255,255,255,0.02); color:#8a8a92; cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
<div style="position:relative; z-index:2; width:440px; max-width:calc(100vw - 48px); border-radius:22px; background:var(--sb-bg); border:1px solid rgba(var(--sb-ink), 0.08); padding:34px 30px 26px; box-shadow:0 30px 70px rgba(var(--sb-shadow-rgb), calc(0.6 * var(--sb-shadow-k))); animation:igPop .32s cubic-bezier(.2,.7,.3,1);">
<button class="close-x" type="button" title="${t('pwa.close')}" aria-label="${t('pwa.close')}" style="position:absolute; top:18px; inset-inline-end:18px; width:30px; height:30px; padding:0; border-radius:9px; display:grid; place-items:center; border:1px solid rgba(var(--sb-ink), 0.08); background:rgba(var(--sb-ink), 0.02); color:var(--sb-text-7); cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
<div style="text-align:center; margin-bottom:22px;">
<div style="display:inline-flex; width:60px; height:60px; border-radius:16px; align-items:center; justify-content:center; background:rgba(240,137,42,0.12); border:1px solid rgba(240,137,42,0.3); margin-bottom:18px;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f0892a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V4M8.5 7.5L12 4l3.5 3.5"/><path d="M6 11H5a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1h-1"/></svg>
<div style="display:inline-flex; width:60px; height:60px; border-radius:16px; align-items:center; justify-content:center; background:rgba(var(--sb-orange-rgb), 0.12); border:1px solid rgba(var(--sb-orange-rgb), 0.3); margin-bottom:18px;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="var(--sb-orange-solid)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V4M8.5 7.5L12 4l3.5 3.5"/><path d="M6 11H5a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1h-1"/></svg>
</div>
<h3 style="margin:0; font-size:23px; font-weight:800; letter-spacing:-0.6px; color:#f4f4f6;">${t('pwa.iosTitle')}</h3>
<h3 style="margin:0; font-size:23px; font-weight:800; letter-spacing:-0.6px; color:var(--sb-text-1);">${t('pwa.iosTitle')}</h3>
</div>
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:22px;">
@@ -396,13 +422,13 @@ class PWAInstallPrompt {
${step(3, t('pwa.iosStep3'), t('pwa.iosStep3Hint'), '.5s')}
</div>
<button class="got-it" type="button" style="width:100%; padding:14px 20px; border-radius:13px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .2s cubic-bezier(.2,.7,.3,1);">${t('pwa.gotIt')}</button>
<button class="got-it" type="button" style="width:100%; padding:14px 20px; border-radius:13px; border:none; background:var(--sb-orange-solid); color:var(--sb-on-accent); font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .2s cubic-bezier(.2,.7,.3,1);">${t('pwa.gotIt')}</button>
</div>
`;
const closeX = modal.querySelector('.close-x');
closeX.addEventListener('mouseenter', () => { closeX.style.color = '#e5727a'; closeX.style.borderColor = 'rgba(229,114,122,0.4)'; });
closeX.addEventListener('mouseleave', () => { closeX.style.color = '#8a8a92'; closeX.style.borderColor = 'rgba(255,255,255,0.08)'; });
closeX.addEventListener('mouseenter', () => { closeX.style.color = 'var(--sb-red)'; closeX.style.borderColor = 'rgba(var(--sb-red-rgb), 0.4)'; });
closeX.addEventListener('mouseleave', () => { closeX.style.color = 'var(--sb-text-7)'; closeX.style.borderColor = 'rgba(var(--sb-ink), 0.08)'; });
const gotIt = modal.querySelector('.got-it');
@@ -441,7 +467,7 @@ class PWAInstallPrompt {
// (Install Guide.dc.html). Styling is inline so it tracks the design.
const modal = document.createElement('div');
modal.id = 'pwa-install-guide';
modal.style.cssText = "position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(8,8,10,0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); animation:igFade .3s ease; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
modal.style.cssText = "position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(var(--sb-scrim-rgb), 0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); animation:igFade .3s ease; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
const rowIcon = {
chromeEdge: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 9h18"/><path d="M12 12v4M10 14l2 2 2-2"/>',
@@ -450,47 +476,55 @@ class PWAInstallPrompt {
};
const row = (icon, title, desc, delay, nowrap) => `
<div style="display:flex; align-items:center; gap:14px; padding:14px 16px; border-radius:13px; background:#161618; border:1px solid rgba(255,255,255,0.06); animation:igRow ${delay} cubic-bezier(.2,.7,.3,1);">
<div style="flex:none; width:40px; height:40px; border-radius:11px; display:grid; place-items:center; background:rgba(240,137,42,0.1); border:1px solid rgba(240,137,42,0.22);">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f0892a" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${icon}</svg>
<div style="display:flex; align-items:center; gap:14px; padding:14px 16px; border-radius:13px; background:var(--sb-surface); border:1px solid rgba(var(--sb-ink), 0.06); animation:igRow ${delay} cubic-bezier(.2,.7,.3,1);">
<div style="flex:none; width:40px; height:40px; border-radius:11px; display:grid; place-items:center; background:rgba(var(--sb-orange-rgb), 0.1); border:1px solid rgba(var(--sb-orange-rgb), 0.22);">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--sb-orange-solid)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${icon}</svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:14.5px; font-weight:700; color:#f4f4f6; margin-bottom:2px;">${title}</div>
<div style="font-size:13px; color:#8a8a92;${nowrap ? ' white-space:nowrap;' : ''}">${desc}</div>
<div style="font-size:14.5px; font-weight:700; color:var(--sb-text-1); margin-bottom:2px;">${title}</div>
<div style="font-size:13px; color:var(--sb-text-7);${nowrap ? ' white-space:nowrap;' : ''}">${desc}</div>
</div>
</div>`;
const isAndroid = /Android/i.test(navigator.userAgent);
modal.innerHTML = `
<div style="position:relative; z-index:2; width:480px; max-width:calc(100vw - 48px); border-radius:22px; background:#121214; border:1px solid rgba(255,255,255,0.08); padding:34px 30px 26px; box-shadow:0 30px 70px rgba(0,0,0,0.6); animation:igPop .32s cubic-bezier(.2,.7,.3,1);">
<button class="close-x" type="button" title="${t('pwa.close')}" aria-label="${t('pwa.close')}" style="position:absolute; top:18px; inset-inline-end:18px; width:30px; height:30px; padding:0; border-radius:9px; display:grid; place-items:center; border:1px solid rgba(255,255,255,0.08); background:rgba(255,255,255,0.02); color:#8a8a92; cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
<div style="position:relative; z-index:2; width:480px; max-width:calc(100vw - 48px); border-radius:22px; background:var(--sb-bg); border:1px solid rgba(var(--sb-ink), 0.08); padding:34px 30px 26px; box-shadow:0 30px 70px rgba(var(--sb-shadow-rgb), calc(0.6 * var(--sb-shadow-k))); animation:igPop .32s cubic-bezier(.2,.7,.3,1);">
<button class="close-x" type="button" title="${t('pwa.close')}" aria-label="${t('pwa.close')}" style="position:absolute; top:18px; inset-inline-end:18px; width:30px; height:30px; padding:0; border-radius:9px; display:grid; place-items:center; border:1px solid rgba(var(--sb-ink), 0.08); background:rgba(var(--sb-ink), 0.02); color:var(--sb-text-7); cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
<div style="text-align:center; margin-bottom:24px;">
<div style="display:inline-flex; width:60px; height:60px; border-radius:16px; align-items:center; justify-content:center; background:rgba(240,137,42,0.12); border:1px solid rgba(240,137,42,0.3); margin-bottom:18px;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#f0892a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/></svg>
<div style="display:inline-flex; width:60px; height:60px; border-radius:16px; align-items:center; justify-content:center; background:rgba(var(--sb-orange-rgb), 0.12); border:1px solid rgba(var(--sb-orange-rgb), 0.3); margin-bottom:18px;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="var(--sb-orange-solid)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/></svg>
</div>
<h3 style="margin:0 0 10px; font-size:24px; font-weight:800; letter-spacing:-0.6px; color:#f4f4f6;">${t('pwa.genericTitle')}</h3>
<p style="margin:0 auto; max-width:380px; font-size:14px; line-height:1.55; color:#9a9aa2;">${t('pwa.genericDesc')}</p>
<h3 style="margin:0 0 10px; font-size:24px; font-weight:800; letter-spacing:-0.6px; color:var(--sb-text-1);">${t('pwa.genericTitle')}</h3>
<p style="margin:0 auto; max-width:380px; font-size:14px; line-height:1.55; color:var(--sb-text-6);">${t('pwa.genericDesc')}</p>
</div>
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:22px;">
${row(rowIcon.chromeEdge, 'Chrome / Edge', 'Click the install icon in the address bar', '.34s', false)}
${row(rowIcon.firefox, 'Firefox', 'Add a bookmark to your home screen', '.42s', false)}
${row(rowIcon.safari, 'Safari', 'Share &rarr; Add to Home Screen', '.5s', true)}
${isAndroid
// A phone has no address bar to click an install icon in, and
// telling an Android user to "add a bookmark" is what got them
// a browser shortcut instead of an app in the first place.
? row(rowIcon.chromeEdge, 'Chrome', t('pwa.androidChromeHint'), '.34s', false) +
row(rowIcon.firefox, 'Firefox / Samsung Internet', t('pwa.androidOtherHint'), '.42s', false)
: row(rowIcon.chromeEdge, 'Chrome / Edge', 'Click the install icon in the address bar', '.34s', false) +
row(rowIcon.firefox, 'Firefox', 'Add a bookmark to your home screen', '.42s', false) +
row(rowIcon.safari, 'Safari', 'Share &rarr; Add to Home Screen', '.5s', true)}
</div>
<button class="got-it" type="button" style="width:100%; padding:14px 20px; border-radius:13px; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.03); color:#e8e8eb; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:all .2s cubic-bezier(.2,.7,.3,1);">${t('pwa.gotIt')}</button>
<button class="got-it" type="button" style="width:100%; padding:14px 20px; border-radius:13px; border:1px solid rgba(var(--sb-ink), 0.1); background:rgba(var(--sb-ink), 0.03); color:var(--sb-text-2); font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:all .2s cubic-bezier(.2,.7,.3,1);">${t('pwa.gotIt')}</button>
</div>
`;
const closeX = modal.querySelector('.close-x');
closeX.addEventListener('mouseenter', () => { closeX.style.color = '#e5727a'; closeX.style.borderColor = 'rgba(229,114,122,0.4)'; });
closeX.addEventListener('mouseleave', () => { closeX.style.color = '#8a8a92'; closeX.style.borderColor = 'rgba(255,255,255,0.08)'; });
closeX.addEventListener('mouseenter', () => { closeX.style.color = 'var(--sb-red)'; closeX.style.borderColor = 'rgba(var(--sb-red-rgb), 0.4)'; });
closeX.addEventListener('mouseleave', () => { closeX.style.color = 'var(--sb-text-7)'; closeX.style.borderColor = 'rgba(var(--sb-ink), 0.08)'; });
const gotIt = modal.querySelector('.got-it');
gotIt.addEventListener('mouseenter', () => { gotIt.style.borderColor = 'rgba(255,255,255,0.22)'; gotIt.style.background = 'rgba(255,255,255,0.06)'; });
gotIt.addEventListener('mouseleave', () => { gotIt.style.borderColor = 'rgba(255,255,255,0.1)'; gotIt.style.background = 'rgba(255,255,255,0.03)'; });
gotIt.addEventListener('mouseenter', () => { gotIt.style.borderColor = 'rgba(var(--sb-ink), 0.22)'; gotIt.style.background = 'rgba(var(--sb-ink), 0.06)'; });
gotIt.addEventListener('mouseleave', () => { gotIt.style.borderColor = 'rgba(var(--sb-ink), 0.1)'; gotIt.style.background = 'rgba(var(--sb-ink), 0.03)'; });
const close = () => modal.remove();
closeX.addEventListener('click', close);
@@ -514,7 +548,7 @@ class PWAInstallPrompt {
notification.style.cssText =
"position:fixed; top:calc(16px + var(--sb-safe-top, 0px)); inset-inline-end:16px; inset-inline-start:auto; max-width:min(360px, calc(100vw - 32px)); z-index:9999; " +
"font-family:'Manrope',system-ui,-apple-system,sans-serif; " +
"border-radius:15px; background:#121214; border:1px solid rgba(62,207,142,0.28); padding:15px 17px; box-shadow:0 20px 50px rgba(0,0,0,0.55); " +
"border-radius:15px; background:var(--sb-bg); border:1px solid rgba(var(--sb-green-rgb), 0.28); padding:15px 17px; box-shadow:0 20px 50px rgba(var(--sb-shadow-rgb), calc(0.55 * var(--sb-shadow-k))); " +
"transform:translateX(calc(100% + 24px)); opacity:0; " +
(prefersReducedMotion()
? "transition:opacity .2s linear;"
@@ -522,12 +556,12 @@ class PWAInstallPrompt {
notification.innerHTML = `
<div style="display:flex; align-items:center; gap:13px;">
<div style="flex:none; width:36px; height:36px; border-radius:11px; display:grid; place-items:center; background:rgba(62,207,142,0.12); border:1px solid rgba(62,207,142,0.28);">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3ecf8e" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
<div style="flex:none; width:36px; height:36px; border-radius:11px; display:grid; place-items:center; background:rgba(var(--sb-green-rgb), 0.12); border:1px solid rgba(var(--sb-green-rgb), 0.28);">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-green-solid)" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:14.5px; font-weight:800; letter-spacing:-0.2px; color:#f4f4f6; margin-bottom:2px;">${t('pwa.installedTitle')}</div>
<div style="font-size:12.5px; line-height:1.4; color:#8a8a92;">${successText}</div>
<div style="font-size:14.5px; font-weight:800; letter-spacing:-0.2px; color:var(--sb-text-1); margin-bottom:2px;">${t('pwa.installedTitle')}</div>
<div style="font-size:12.5px; line-height:1.4; color:var(--sb-text-7);">${successText}</div>
</div>
</div>
`;
+41 -41
View File
@@ -248,12 +248,12 @@ class PWAOfflineManager {
// Clean pill matching the app's design language (no emoji, no FontAwesome,
// proper SVG close wired via a real listener — the old inline onclick was
// blocked by the CSP anyway).
const PILL = "display:inline-flex; align-items:center; gap:10px; padding:9px 14px; border-radius:11px; background:#161618; box-shadow:0 12px 30px rgba(0,0,0,0.45); font-family:'Manrope',system-ui,-apple-system,sans-serif; font-size:13px; font-weight:600; color:#e8e8eb;";
const PILL = "display:inline-flex; align-items:center; gap:10px; padding:9px 14px; border-radius:11px; background:var(--sb-surface); box-shadow:0 12px 30px rgba(var(--sb-shadow-rgb), calc(0.45 * var(--sb-shadow-k))); font-family:'Manrope',system-ui,-apple-system,sans-serif; font-size:13px; font-weight:600; color:var(--sb-text-2);";
if (isOnline) {
this.offlineIndicator.innerHTML =
`<div style="${PILL} border:1px solid rgba(62,207,142,0.3);">
<span style="width:8px; height:8px; border-radius:50%; background:#3ecf8e; box-shadow:0 0 8px rgba(62,207,142,0.6);"></span>
`<div style="${PILL} border:1px solid rgba(var(--sb-green-rgb), 0.3);">
<span style="width:8px; height:8px; border-radius:50%; background:var(--sb-green-solid); box-shadow:0 0 8px rgba(var(--sb-green-rgb), 0.6);"></span>
<span>${t('offline.backOnline')}</span>
</div>`;
this.offlineIndicator.classList.remove('hidden');
@@ -264,18 +264,18 @@ class PWAOfflineManager {
}, 3000);
} else {
this.offlineIndicator.innerHTML =
`<div style="${PILL} border:1px solid rgba(227,179,65,0.32);">
<span style="width:8px; height:8px; border-radius:50%; background:#e3b341;"></span>
`<div style="${PILL} border:1px solid rgba(var(--sb-yellow-2-rgb), 0.32);">
<span style="width:8px; height:8px; border-radius:50%; background:var(--sb-yellow-2-solid);"></span>
<span>${t('offline.mode')}</span>
<button class="oi-close" type="button" aria-label="${t('offline.dismiss')}" style="margin-inline-start:4px; width:22px; height:22px; padding:0; display:grid; place-items:center; border:none; background:transparent; color:#8a8a92; cursor:pointer; border-radius:6px; transition:color .15s ease;">
<button class="oi-close" type="button" aria-label="${t('offline.dismiss')}" style="margin-inline-start:4px; width:22px; height:22px; padding:0; display:grid; place-items:center; border:none; background:transparent; color:var(--sb-text-7); cursor:pointer; border-radius:6px; transition:color .15s ease;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>`;
this.offlineIndicator.classList.remove('hidden');
const closeBtn = this.offlineIndicator.querySelector('.oi-close');
if (closeBtn) {
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.color = '#e8e8eb'; });
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.color = '#8a8a92'; });
closeBtn.addEventListener('mouseenter', () => { closeBtn.style.color = 'var(--sb-text-2)'; });
closeBtn.addEventListener('mouseleave', () => { closeBtn.style.color = 'var(--sb-text-7)'; });
closeBtn.addEventListener('click', () => this.offlineIndicator.classList.add('hidden'));
}
}
@@ -361,46 +361,46 @@ class PWAOfflineManager {
const guidance = document.createElement('div');
guidance.id = 'pwa-offline-modal';
guidance.style.cssText = "position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(8,8,10,0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); animation:omFade .3s ease; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
guidance.style.cssText = "position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(var(--sb-scrim-rgb), 0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); animation:omFade .3s ease; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
const feature = (bg, bd, stroke, sw, icon, text) => `
<div style="display:flex; align-items:center; gap:13px;">
<span style="flex:none; width:34px; height:34px; border-radius:9px; display:grid; place-items:center; background:${bg}; border:1px solid ${bd};"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="${stroke}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round">${icon}</svg></span>
<span style="font-size:14.5px; color:#e8e8eb;">${text}</span>
<span style="font-size:14.5px; color:var(--sb-text-2);">${text}</span>
</div>`;
const card = (bg, bd, stroke, icon, title, desc) => `
<div style="display:flex; align-items:flex-start; gap:13px; padding:14px 16px; border-radius:13px; background:#161618; border:1px solid rgba(255,255,255,0.06);">
<div style="display:flex; align-items:flex-start; gap:13px; padding:14px 16px; border-radius:13px; background:var(--sb-surface); border:1px solid rgba(var(--sb-ink), 0.06);">
<span style="flex:none; width:36px; height:36px; border-radius:10px; display:grid; place-items:center; background:${bg}; border:1px solid ${bd};"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="${stroke}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${icon}</svg></span>
<div><div style="font-size:14.5px; font-weight:700; color:#f4f4f6; margin-bottom:2px;">${title}</div><div style="font-size:13px; line-height:1.5; color:#8a8a92;">${desc}</div></div>
<div><div style="font-size:14.5px; font-weight:700; color:var(--sb-text-1); margin-bottom:2px;">${title}</div><div style="font-size:13px; line-height:1.5; color:var(--sb-text-7);">${desc}</div></div>
</div>`;
const GREEN_BG = 'rgba(62,207,142,0.12)', GREEN_BD = 'rgba(62,207,142,0.24)';
const ORANGE_BG = 'rgba(240,137,42,0.12)', ORANGE_BD = 'rgba(240,137,42,0.24)';
const GREEN_BG = 'rgba(var(--sb-green-rgb), 0.12)', GREEN_BD = 'rgba(var(--sb-green-rgb), 0.24)';
const ORANGE_BG = 'rgba(var(--sb-orange-rgb), 0.12)', ORANGE_BD = 'rgba(var(--sb-orange-rgb), 0.24)';
const mainHTML = `
<div style="animation:omSwap .26s cubic-bezier(.2,.7,.3,1);">
<div style="text-align:center; margin-bottom:22px;">
<div style="display:inline-flex; width:64px; height:64px; border-radius:50%; align-items:center; justify-content:center; background:rgba(227,179,65,0.12); border:1px solid rgba(227,179,65,0.3); margin-bottom:18px;">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#e3b341" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8.8a15 15 0 0 1 20 0"/><path d="M5 12.5a11 11 0 0 1 14 0"/><path d="M8.5 16.3a6 6 0 0 1 7 0"/><path d="M12 20h.01"/><path d="M2 2l20 20"/></svg>
<div style="display:inline-flex; width:64px; height:64px; border-radius:50%; align-items:center; justify-content:center; background:rgba(var(--sb-yellow-2-rgb), 0.12); border:1px solid rgba(var(--sb-yellow-2-rgb), 0.3); margin-bottom:18px;">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--sb-yellow-2-solid)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8.8a15 15 0 0 1 20 0"/><path d="M5 12.5a11 11 0 0 1 14 0"/><path d="M8.5 16.3a6 6 0 0 1 7 0"/><path d="M12 20h.01"/><path d="M2 2l20 20"/></svg>
</div>
<h3 style="margin:0 0 10px; font-size:24px; font-weight:800; letter-spacing:-0.6px; color:#f4f4f6;">${t('offline.lostTitle')}</h3>
<p style="margin:0 auto; max-width:380px; font-size:14px; line-height:1.55; color:#9a9aa2;">${t('offline.lostDesc')}</p>
<h3 style="margin:0 0 10px; font-size:24px; font-weight:800; letter-spacing:-0.6px; color:var(--sb-text-1);">${t('offline.lostTitle')}</h3>
<p style="margin:0 auto; max-width:380px; font-size:14px; line-height:1.55; color:var(--sb-text-6);">${t('offline.lostDesc')}</p>
</div>
<div style="display:flex; flex-direction:column; gap:14px; margin-bottom:24px; padding:0 6px;">
${feature(GREEN_BG, GREEN_BD, '#3ecf8e', '2.3', '<path d="M5 13l4 4 10-11"/>', t('offline.point1'))}
${feature(GREEN_BG, GREEN_BD, '#3ecf8e', '1.9', '<path d="M12 3l8 4v5c0 4.5-3.2 7.8-8 9-4.8-1.2-8-4.5-8-9V7l8-4z"/>', t('offline.point2'))}
${feature(ORANGE_BG, ORANGE_BD, '#f0892a', '1.9', '<path d="M21 8a8.5 8.5 0 0 0-15.6-2.5M3 4v4h4"/><path d="M3 16a8.5 8.5 0 0 0 15.6 2.5M21 20v-4h-4"/>', t('offline.point3'))}
${feature(GREEN_BG, GREEN_BD, 'var(--sb-green)', '2.3', '<path d="M5 13l4 4 10-11"/>', t('offline.point1'))}
${feature(GREEN_BG, GREEN_BD, 'var(--sb-green)', '1.9', '<path d="M12 3l8 4v5c0 4.5-3.2 7.8-8 9-4.8-1.2-8-4.5-8-9V7l8-4z"/>', t('offline.point2'))}
${feature(ORANGE_BG, ORANGE_BD, 'var(--sb-orange)', '1.9', '<path d="M21 8a8.5 8.5 0 0 0-15.6-2.5M3 4v4h4"/><path d="M3 16a8.5 8.5 0 0 0 15.6 2.5M21 20v-4h-4"/>', t('offline.point3'))}
</div>
<div style="display:flex; flex-direction:column; gap:11px;">
<div style="display:flex; gap:12px;">
<button class="om-continue" type="button" style="flex:1; padding:14px 18px; border-radius:13px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; box-shadow:0 8px 24px rgba(240,137,42,0.28); transition:all .2s cubic-bezier(.2,.7,.3,1);">${t('offline.continue')}</button>
<button class="om-disconnect" type="button" style="flex:1; display:inline-flex; align-items:center; justify-content:center; gap:9px; padding:14px 18px; border-radius:13px; border:1px solid rgba(229,114,122,0.3); background:rgba(229,114,122,0.08); color:#e5727a; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:all .2s cubic-bezier(.2,.7,.3,1);">
<button class="om-continue" type="button" style="flex:1; padding:14px 18px; border-radius:13px; border:none; background:var(--sb-orange-solid); color:var(--sb-on-accent); font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; box-shadow:0 8px 24px rgba(var(--sb-orange-rgb), 0.28); transition:all .2s cubic-bezier(.2,.7,.3,1);">${t('offline.continue')}</button>
<button class="om-disconnect" type="button" style="flex:1; display:inline-flex; align-items:center; justify-content:center; gap:9px; padding:14px 18px; border-radius:13px; border:1px solid rgba(var(--sb-red-rgb), 0.3); background:rgba(var(--sb-red-rgb), 0.08); color:var(--sb-red); font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:all .2s cubic-bezier(.2,.7,.3,1);">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M12 4v8"/><path d="M7 7a8 8 0 1 0 10 0"/></svg>
${t('offline.disconnect')}
</button>
</div>
<button class="om-learn" type="button" style="width:100%; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:12px 18px; border-radius:13px; border:none; background:transparent; color:#9a9aa2; font-family:inherit; font-size:14px; font-weight:600; cursor:pointer; transition:color .18s cubic-bezier(.2,.7,.3,1);">
<button class="om-learn" type="button" style="width:100%; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:12px 18px; border-radius:13px; border:none; background:transparent; color:var(--sb-text-6); font-family:inherit; font-size:14px; font-weight:600; cursor:pointer; transition:color .18s cubic-bezier(.2,.7,.3,1);">
${t('offline.learnMore')}
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" class="sb-mirror-rtl" style="pointer-events:none;"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</button>
@@ -410,28 +410,28 @@ class PWAOfflineManager {
const detailsHTML = `
<div style="animation:omSwap .26s cubic-bezier(.2,.7,.3,1);">
<div style="display:flex; align-items:center; gap:12px; margin-bottom:18px;">
<button class="om-back" type="button" title="Back" style="flex:none; width:34px; height:34px; border-radius:10px; display:grid; place-items:center; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.025); color:#cfcfd4; cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
<button class="om-back" type="button" title="Back" style="flex:none; width:34px; height:34px; border-radius:10px; display:grid; place-items:center; border:1px solid rgba(var(--sb-ink), 0.1); background:rgba(var(--sb-ink), 0.025); color:var(--sb-text-4); cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" class="sb-mirror-rtl" style="pointer-events:none;"><path d="M15 6l-6 6 6 6"/></svg>
</button>
<h3 style="margin:0; font-size:20px; font-weight:800; letter-spacing:-0.5px; color:#f4f4f6;">${t('offline.restoredTitle')}</h3>
<h3 style="margin:0; font-size:20px; font-weight:800; letter-spacing:-0.5px; color:var(--sb-text-1);">${t('offline.restoredTitle')}</h3>
</div>
<p style="margin:0 0 20px; font-size:14px; line-height:1.6; color:#9a9aa2;">${t('offline.restoredDesc')}</p>
<p style="margin:0 0 20px; font-size:14px; line-height:1.6; color:var(--sb-text-6);">${t('offline.restoredDesc')}</p>
<div style="display:flex; flex-direction:column; gap:11px; margin-bottom:22px;">
${card(GREEN_BG, GREEN_BD, '#3ecf8e', '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/>', t('offline.r1Title'), t('offline.r1Desc'))}
${card(GREEN_BG, GREEN_BD, '#3ecf8e', '<path d="M14 3v5h5"/><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M12 18v-6M9.5 14.5L12 12l2.5 2.5"/>', t('offline.r2Title'), t('offline.r2Desc'))}
${card(GREEN_BG, GREEN_BD, '#3ecf8e', '<path d="M12 3v12"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/>', t('offline.r3Title'), t('offline.r3Desc'))}
${card(ORANGE_BG, ORANGE_BD, '#f0892a', '<path d="M12 3l8 4v5c0 4.5-3.2 7.8-8 9-4.8-1.2-8-4.5-8-9V7l8-4z"/><path d="M9.2 12.2l2 2 3.6-3.8"/>', t('offline.r4Title'), t('offline.r4Desc'))}
${card(GREEN_BG, GREEN_BD, 'var(--sb-green)', '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/>', t('offline.r1Title'), t('offline.r1Desc'))}
${card(GREEN_BG, GREEN_BD, 'var(--sb-green)', '<path d="M14 3v5h5"/><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M12 18v-6M9.5 14.5L12 12l2.5 2.5"/>', t('offline.r2Title'), t('offline.r2Desc'))}
${card(GREEN_BG, GREEN_BD, 'var(--sb-green)', '<path d="M12 3v12"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/>', t('offline.r3Title'), t('offline.r3Desc'))}
${card(ORANGE_BG, ORANGE_BD, 'var(--sb-orange)', '<path d="M12 3l8 4v5c0 4.5-3.2 7.8-8 9-4.8-1.2-8-4.5-8-9V7l8-4z"/><path d="M9.2 12.2l2 2 3.6-3.8"/>', t('offline.r4Title'), t('offline.r4Desc'))}
</div>
<button class="om-gotit" type="button" style="width:100%; padding:14px 18px; border-radius:13px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; box-shadow:0 8px 24px rgba(240,137,42,0.28); transition:all .2s cubic-bezier(.2,.7,.3,1);">${t('offline.gotIt')}</button>
<button class="om-gotit" type="button" style="width:100%; padding:14px 18px; border-radius:13px; border:none; background:var(--sb-orange-solid); color:var(--sb-on-accent); font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; box-shadow:0 8px 24px rgba(var(--sb-orange-rgb), 0.28); transition:all .2s cubic-bezier(.2,.7,.3,1);">${t('offline.gotIt')}</button>
</div>`;
const cardWrap = document.createElement('div');
cardWrap.style.cssText = "position:relative; z-index:2; width:470px; max-width:calc(100vw - 48px); border-radius:22px; background:#121214; border:1px solid rgba(255,255,255,0.08); padding:34px 30px 26px; box-shadow:0 30px 70px rgba(0,0,0,0.6); animation:omPop .32s cubic-bezier(.2,.7,.3,1);";
cardWrap.style.cssText = "position:relative; z-index:2; width:470px; max-width:calc(100vw - 48px); border-radius:22px; background:var(--sb-bg); border:1px solid rgba(var(--sb-ink), 0.08); padding:34px 30px 26px; box-shadow:0 30px 70px rgba(var(--sb-shadow-rgb), calc(0.6 * var(--sb-shadow-k))); animation:omPop .32s cubic-bezier(.2,.7,.3,1);";
guidance.appendChild(cardWrap);
const hoverLift = (btn) => {
btn.addEventListener('mouseenter', () => { btn.style.background = '#ff9637'; btn.style.transform = 'translateY(-2px)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = '#f0892a'; btn.style.transform = 'none'; });
btn.addEventListener('mouseenter', () => { btn.style.background = 'var(--sb-orange-hi)'; btn.style.transform = 'translateY(-2px)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'var(--sb-orange)'; btn.style.transform = 'none'; });
};
const close = () => guidance.remove();
@@ -442,8 +442,8 @@ class PWAOfflineManager {
cont.addEventListener('click', close);
const disc = cardWrap.querySelector('.om-disconnect');
disc.addEventListener('mouseenter', () => { disc.style.background = 'rgba(229,114,122,0.14)'; disc.style.borderColor = 'rgba(229,114,122,0.5)'; });
disc.addEventListener('mouseleave', () => { disc.style.background = 'rgba(229,114,122,0.08)'; disc.style.borderColor = 'rgba(229,114,122,0.3)'; });
disc.addEventListener('mouseenter', () => { disc.style.background = 'rgba(var(--sb-red-rgb), 0.14)'; disc.style.borderColor = 'rgba(var(--sb-red-rgb), 0.5)'; });
disc.addEventListener('mouseleave', () => { disc.style.background = 'rgba(var(--sb-red-rgb), 0.08)'; disc.style.borderColor = 'rgba(var(--sb-red-rgb), 0.3)'; });
disc.addEventListener('click', () => {
try {
if (window.webrtcManager && typeof window.webrtcManager.disconnect === 'function') {
@@ -454,16 +454,16 @@ class PWAOfflineManager {
});
const learn = cardWrap.querySelector('.om-learn');
learn.addEventListener('mouseenter', () => { learn.style.color = '#f0892a'; });
learn.addEventListener('mouseleave', () => { learn.style.color = '#9a9aa2'; });
learn.addEventListener('mouseenter', () => { learn.style.color = 'var(--sb-orange)'; });
learn.addEventListener('mouseleave', () => { learn.style.color = 'var(--sb-text-6)'; });
learn.addEventListener('click', renderDetails);
};
const renderDetails = () => {
cardWrap.innerHTML = detailsHTML;
const back = cardWrap.querySelector('.om-back');
back.addEventListener('mouseenter', () => { back.style.color = '#f0892a'; back.style.borderColor = 'rgba(240,137,42,0.45)'; });
back.addEventListener('mouseleave', () => { back.style.color = '#cfcfd4'; back.style.borderColor = 'rgba(255,255,255,0.1)'; });
back.addEventListener('mouseenter', () => { back.style.color = 'var(--sb-orange)'; back.style.borderColor = 'rgba(var(--sb-orange-rgb), 0.45)'; });
back.addEventListener('mouseleave', () => { back.style.color = 'var(--sb-text-4)'; back.style.borderColor = 'rgba(var(--sb-ink), 0.1)'; });
back.addEventListener('click', renderMain);
const gotit = cardWrap.querySelector('.om-gotit');
+18 -18
View File
@@ -60,47 +60,47 @@ window.showUpdateNotification = function showUpdateNotification() {
const modal = document.createElement('div');
modal.id = 'pwa-update-modal';
modal.style.cssText = "position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(8,8,10,0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); animation:unFade .3s ease; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
modal.style.cssText = "position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(var(--sb-scrim-rgb), 0.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); animation:unFade .3s ease; font-family:'Manrope',system-ui,-apple-system,sans-serif;";
modal.innerHTML = `
<div style="position:relative; width:440px; max-width:calc(100vw - 48px); border-radius:22px; background:#121214; border:1px solid rgba(255,255,255,0.08); padding:36px 32px 28px; text-align:center; box-shadow:0 30px 70px rgba(0,0,0,0.6); animation:unPop .32s cubic-bezier(.2,.7,.3,1);">
<div style="display:inline-flex; width:64px; height:64px; border-radius:50%; align-items:center; justify-content:center; background:rgba(240,137,42,0.12); border:1px solid rgba(240,137,42,0.3); margin-bottom:20px;">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#f0892a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation:unSpin 6s linear infinite;"><path d="M21 8a8.5 8.5 0 0 0-15.6-2.5M3 4v4h4"/><path d="M3 16a8.5 8.5 0 0 0 15.6 2.5M21 20v-4h-4"/></svg>
<div style="position:relative; width:440px; max-width:calc(100vw - 48px); border-radius:22px; background:var(--sb-bg); border:1px solid rgba(var(--sb-ink), 0.08); padding:36px 32px 28px; text-align:center; box-shadow:0 30px 70px rgba(var(--sb-shadow-rgb), calc(0.6 * var(--sb-shadow-k))); animation:unPop .32s cubic-bezier(.2,.7,.3,1);">
<div style="display:inline-flex; width:64px; height:64px; border-radius:50%; align-items:center; justify-content:center; background:rgba(var(--sb-orange-rgb), 0.12); border:1px solid rgba(var(--sb-orange-rgb), 0.3); margin-bottom:20px;">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--sb-orange-solid)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation:unSpin 6s linear infinite;"><path d="M21 8a8.5 8.5 0 0 0-15.6-2.5M3 4v4h4"/><path d="M3 16a8.5 8.5 0 0 0 15.6 2.5M21 20v-4h-4"/></svg>
</div>
<h2 style="margin:0 0 9px; font-size:26px; font-weight:800; letter-spacing:-0.7px; color:#f4f4f6;">Update available</h2>
<p style="margin:0 0 24px; font-size:14.5px; line-height:1.55; color:#9a9aa2;">A newer version of SecureBit has been detected.</p>
<h2 style="margin:0 0 9px; font-size:26px; font-weight:800; letter-spacing:-0.7px; color:var(--sb-text-1);">Update available</h2>
<p style="margin:0 0 24px; font-size:14.5px; line-height:1.55; color:var(--sb-text-6);">A newer version of SecureBit has been detected.</p>
<div style="border-radius:14px; background:#0c0c0e; border:1px solid rgba(255,255,255,0.06); padding:16px 18px; margin-bottom:24px; text-align:start;">
<div style="border-radius:14px; background:var(--sb-bg-deep); border:1px solid rgba(var(--sb-ink), 0.06); padding:16px 18px; margin-bottom:24px; text-align:start;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:14px; padding:5px 0;">
<span style="font-size:13.5px; font-weight:500; color:#8a8a92;">Current version</span>
<span class="cur-ver" style="font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; font-size:13px; font-weight:500; color:#9a9aa2; white-space:nowrap;">${currentStr}</span>
<span style="font-size:13.5px; font-weight:500; color:var(--sb-text-7);">Current version</span>
<span class="cur-ver" style="font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; font-size:13px; font-weight:500; color:var(--sb-text-6); white-space:nowrap;">${currentStr}</span>
</div>
<div style="height:1px; background:rgba(255,255,255,0.05); margin:4px 0;"></div>
<div style="height:1px; background:rgba(var(--sb-ink), 0.05); margin:4px 0;"></div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:14px; padding:5px 0;">
<span style="display:inline-flex; align-items:center; gap:8px; font-size:13.5px; font-weight:600; color:#e8e8eb;"><span style="width:6px; height:6px; border-radius:50%; background:#f0892a;"></span>New version</span>
<span class="new-ver" style="font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; font-size:13px; font-weight:700; color:#f0892a; white-space:nowrap;">Latest</span>
<span style="display:inline-flex; align-items:center; gap:8px; font-size:13.5px; font-weight:600; color:var(--sb-text-2);"><span style="width:6px; height:6px; border-radius:50%; background:var(--sb-orange-solid);"></span>New version</span>
<span class="new-ver" style="font-family:'JetBrains Mono',ui-monospace,Menlo,monospace; font-size:13px; font-weight:700; color:var(--sb-orange); white-space:nowrap;">Latest</span>
</div>
</div>
<div style="display:flex; align-items:center; gap:12px;">
<button class="upd-now" type="button" style="flex:1; display:inline-flex; align-items:center; justify-content:center; gap:10px; padding:15px 20px; border-radius:13px; border:none; background:#f0892a; color:#1a0f04; font-family:inherit; font-size:15.5px; font-weight:700; letter-spacing:-0.2px; cursor:pointer; box-shadow:0 8px 24px rgba(240,137,42,0.28); transition:all .2s cubic-bezier(.2,.7,.3,1);">
<button class="upd-now" type="button" style="flex:1; display:inline-flex; align-items:center; justify-content:center; gap:10px; padding:15px 20px; border-radius:13px; border:none; background:var(--sb-orange-solid); color:var(--sb-on-accent); font-family:inherit; font-size:15.5px; font-weight:700; letter-spacing:-0.2px; cursor:pointer; box-shadow:0 8px 24px rgba(var(--sb-orange-rgb), 0.28); transition:all .2s cubic-bezier(.2,.7,.3,1);">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M12 3v11"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/></svg>
Update now
</button>
<button class="upd-later" type="button" title="Later" style="flex:none; width:50px; height:50px; border-radius:13px; display:grid; place-items:center; border:1px solid rgba(255,255,255,0.1); background:rgba(255,255,255,0.025); color:#9a9aa2; cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
<button class="upd-later" type="button" title="Later" style="flex:none; width:50px; height:50px; border-radius:13px; display:grid; place-items:center; border:1px solid rgba(var(--sb-ink), 0.1); background:rgba(var(--sb-ink), 0.025); color:var(--sb-text-6); cursor:pointer; transition:all .18s cubic-bezier(.2,.7,.3,1);">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
</div>`;
const updNow = modal.querySelector('.upd-now');
updNow.addEventListener('mouseenter', () => { updNow.style.background = '#ff9637'; updNow.style.transform = 'translateY(-2px)'; });
updNow.addEventListener('mouseleave', () => { updNow.style.background = '#f0892a'; updNow.style.transform = 'none'; });
updNow.addEventListener('mouseenter', () => { updNow.style.background = 'var(--sb-orange-hi)'; updNow.style.transform = 'translateY(-2px)'; });
updNow.addEventListener('mouseleave', () => { updNow.style.background = 'var(--sb-orange)'; updNow.style.transform = 'none'; });
updNow.addEventListener('click', () => window.location.reload());
const updLater = modal.querySelector('.upd-later');
updLater.addEventListener('mouseenter', () => { updLater.style.color = '#e5727a'; updLater.style.borderColor = 'rgba(229,114,122,0.4)'; });
updLater.addEventListener('mouseleave', () => { updLater.style.color = '#9a9aa2'; updLater.style.borderColor = 'rgba(255,255,255,0.1)'; });
updLater.addEventListener('mouseenter', () => { updLater.style.color = 'var(--sb-red)'; updLater.style.borderColor = 'rgba(var(--sb-red-rgb), 0.4)'; });
updLater.addEventListener('mouseleave', () => { updLater.style.color = 'var(--sb-text-6)'; updLater.style.borderColor = 'rgba(var(--sb-ink), 0.1)'; });
updLater.addEventListener('click', () => modal.remove());
document.body.appendChild(modal);
+39
View File
@@ -0,0 +1,39 @@
// beforeinstallprompt lands once, early, and never comes back.
//
// Chrome fires it as soon as it has decided the page is installable — which on
// Android is usually before install-prompt.js (a module, so deferred) has even
// been evaluated. A listener registered later never sees it, and the event is
// not replayed. Worse, without preventDefault() Chrome runs its own default
// action for the event, and the object we stashed can no longer be prompted
// with: prompt() rejects, handleInstallClick() falls through to the manual
// guide, and the user ends up using "Add to Home screen" — which mints a
// browser shortcut, not a WebAPK. That is the whole bug: the app looked
// installable, the button did nothing real, and the icon that landed on the
// home screen opened a Chrome tab.
//
// So this file is a classic, blocking <script> in <head>: it is the first
// listener on the page, it takes the event out of Chrome's hands, and it holds
// it until PWAInstallPrompt is ready to adopt it.
(function () {
if (window.__pwaInstallCapture) return;
window.__pwaInstallCapture = true;
window.__pwaInstallEvent = null;
window.__pwaAppInstalled = false;
window.addEventListener('beforeinstallprompt', function (event) {
event.preventDefault();
window.__pwaInstallEvent = event;
// If the prompt UI is already up, hand it over now; otherwise its
// constructor picks the event up from window.__pwaInstallEvent.
if (window.pwaInstallPrompt && typeof window.pwaInstallPrompt.adoptInstallEvent === 'function') {
window.pwaInstallPrompt.adoptInstallEvent(event);
}
});
window.addEventListener('appinstalled', function () {
window.__pwaInstallEvent = null;
window.__pwaAppInstalled = true;
});
})();
+162
View File
@@ -0,0 +1,162 @@
// The theme has to be decided before the first pixel, not after React mounts.
//
// A stylesheet cannot read localStorage, so the stored preference can only be applied
// by script — and if that script runs late, the page paints in the default (dark) and
// then repaints, which is the white/black flash every themed site is judged on. So this
// is a classic, blocking <script> in <head>, ahead of the stylesheet: it stamps
// data-theme on <html> while the parser is still inside <head>, and the CSS that
// arrives afterwards is matched against the right selector on the very first paint.
//
// It cannot be inline — the page's CSP is `script-src 'self'` with no hash or nonce —
// and it deliberately does not import anything, because a module is deferred and
// deferred is too late. Everything it needs is in this file.
//
// Three settings, not two. "system" is the default and means: follow the OS, and keep
// following it if the OS changes while the page is open. "light" and "dark" are the
// user overriding that, which is why the resolved value is stamped as data-theme rather
// than leaving CSS to answer prefers-color-scheme itself — a media query cannot be
// overridden by a preference, and someone who wants a light page on a dark laptop is
// exactly who a theme switch is for.
(function () {
if (window.SecureBitTheme) return;
var STORAGE_KEY = 'securebit-theme';
var MODES = ['system', 'light', 'dark'];
// Kept in step with --sb-bg in src/styles/theme.css. This paints the browser UI
// around the page (Android's address bar, the iOS status bar area in a PWA), and a
// dark strip above a light page is more obviously wrong than a slightly-off shade.
var BAR_COLOR = { dark: '#0f0f11', light: '#f6f7f8' };
var root = document.documentElement;
var listeners = [];
var query = null;
try {
query = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
} catch (e) {
query = null;
}
// Storage throws rather than returning null in a few real situations — Safari in
// private browsing, and any browser set to block site data — and a theme is not
// worth breaking the page's first paint over.
function readStored() {
try {
var value = window.localStorage.getItem(STORAGE_KEY);
return MODES.indexOf(value) === -1 ? 'system' : value;
} catch (e) {
return 'system';
}
}
function writeStored(mode) {
try {
window.localStorage.setItem(STORAGE_KEY, mode);
} catch (e) {
/* preference is not persisted; the session still honours it */
}
}
var mode = readStored();
function resolve(m) {
if (m === 'light' || m === 'dark') return m;
return query && query.matches ? 'light' : 'dark';
}
function paint(resolved) {
root.setAttribute('data-theme', resolved);
// The document ships two theme-color tags carrying prefers-color-scheme media
// queries, so the browser has an answer before this script exists and a client
// with no script keeps one. From here on the answer is ours: the media
// attributes come off — a media query cannot express "light on a dark system",
// which is the whole point of an explicit choice — and both tags are set to the
// resolved colour. Setting content while leaving media in place would do
// nothing on the tag whose query does not match.
//
// The tags may not exist yet on the very first run: this script sits above them
// in <head> so that data-theme is stamped before the stylesheet lands. The
// DOMContentLoaded pass below catches them, and every later switch finds them.
var metas = document.querySelectorAll('meta[name="theme-color"]');
for (var i = 0; i < metas.length; i++) {
metas[i].removeAttribute('media');
metas[i].setAttribute('content', BAR_COLOR[resolved] || BAR_COLOR.dark);
}
}
function apply(animated) {
var resolved = resolve(mode);
if (animated && root.getAttribute('data-theme') !== resolved) {
// The cross-fade is opt-in per switch rather than a standing rule, so a
// page load never animates and a click always does. See theme.css.
root.classList.add('sb-theme-switching');
window.setTimeout(function () {
root.classList.remove('sb-theme-switching');
}, 260);
}
paint(resolved);
for (var i = 0; i < listeners.length; i++) {
try {
listeners[i](mode, resolved);
} catch (e) {
/* one bad subscriber must not stop the rest */
}
}
return resolved;
}
// First paint. Runs while <head> is still parsing, before the stylesheet lands.
apply(false);
// The OS theme changing mid-session only matters in "system" mode; in the other two
// the user has already said what they want. The listener stays attached either way
// so switching back to "system" picks up the current OS value with no extra wiring.
if (query) {
var onSystemChange = function () {
if (mode === 'system') apply(true);
};
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', onSystemChange);
} else if (typeof query.addListener === 'function') {
query.addListener(onSystemChange); // Safari < 14
}
}
// A second document in the same browser — another tab, or the PWA alongside a tab —
// should not disagree with this one about the theme.
window.addEventListener('storage', function (event) {
if (event.key !== STORAGE_KEY) return;
var next = MODES.indexOf(event.newValue) === -1 ? 'system' : event.newValue;
if (next === mode) return;
mode = next;
apply(true);
});
// theme-color is written again once the parser has passed the <meta> tags.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { paint(resolve(mode)); });
}
window.SecureBitTheme = {
MODES: MODES.slice(),
/** 'system' | 'light' | 'dark' — what the user chose. */
get: function () { return mode; },
/** 'light' | 'dark' — what that currently resolves to. */
resolved: function () { return resolve(mode); },
set: function (next) {
if (MODES.indexOf(next) === -1) return resolve(mode);
mode = next;
writeStored(next);
return apply(true);
},
/** Returns an unsubscribe function. Called with (mode, resolved). */
subscribe: function (fn) {
if (typeof fn !== 'function') return function () {};
listeners.push(fn);
return function () {
var i = listeners.indexOf(fn);
if (i !== -1) listeners.splice(i, 1);
};
}
};
})();
+3 -3
View File
@@ -321,11 +321,11 @@ export function groupSub(group) {
export function groupDot(group) {
switch (group.phase) {
case GROUP_PHASE.READY:
return linkedCount(group) < group.members.length ? '#e3b341' : '#3ecf8e';
return linkedCount(group) < group.members.length ? 'var(--sb-yellow-2-solid)' : 'var(--sb-green-solid)';
case GROUP_PHASE.FAILED:
return '#e5727a';
return 'var(--sb-red-solid)';
default:
return '#e3b341';
return 'var(--sb-yellow-2-solid)';
}
}
+11 -11
View File
@@ -34,14 +34,14 @@ export const SESSION_ACTIONS = Object.freeze({
// Availability presence the PEER advertises to us (sent E2E over the data channel, never
// stored on a server). 'invisible' is sent on the wire as 'offline' so peers can't tell.
export const PRESENCE_DOT = { available: '#3ecf8e', away: '#e3b341', busy: '#e5727a', offline: '#6b6b73' };
export const PRESENCE_DOT = { available: 'var(--sb-green-solid)', away: 'var(--sb-yellow-2-solid)', busy: 'var(--sb-red-solid)', offline: 'var(--sb-text-9)' };
export const PRESENCE_WORD = { available: t('presence.available'), away: t('presence.away'), busy: t('presence.busy'), offline: t('presence.offline') };
// The statuses the local user can pick for themselves (design: Set your status).
export const MY_STATUS_OPTIONS = [
{ key: 'available', word: t('presence.available'), desc: t('presence.availableDesc'), dot: '#3ecf8e' },
{ key: 'away', word: t('presence.away'), desc: t('presence.awayDesc'), dot: '#e3b341' },
{ key: 'busy', word: t('presence.busy'), desc: t('presence.busyDesc'), dot: '#e5727a' },
{ key: 'invisible', word: t('presence.invisible'), desc: t('presence.invisibleDesc'), dot: '#6b6b73' }
{ key: 'available', word: t('presence.available'), desc: t('presence.availableDesc'), dot: 'var(--sb-green-solid)' },
{ key: 'away', word: t('presence.away'), desc: t('presence.awayDesc'), dot: 'var(--sb-yellow-2-solid)' },
{ key: 'busy', word: t('presence.busy'), desc: t('presence.busyDesc'), dot: 'var(--sb-red-solid)' },
{ key: 'invisible', word: t('presence.invisible'), desc: t('presence.invisibleDesc'), dot: 'var(--sb-text-9)' }
];
// Short, human-friendly default label derived from the local sessionId. Never the peer's
@@ -64,13 +64,13 @@ export function statusDot(status) {
switch (status) {
case 'connected':
case 'verified':
return '#3ecf8e';
return 'var(--sb-green-solid)';
case 'connecting':
case 'verifying':
case 'new':
return '#e3b341';
return 'var(--sb-yellow-2-solid)';
default:
return '#e5727a'; // disconnected / peer_disconnected / lost
return 'var(--sb-red-solid)'; // disconnected / peer_disconnected / lost
}
}
@@ -349,13 +349,13 @@ export function decorateSession(session, activeSessionId) {
// otherwise reflect the connection state (amber = connecting, red = dropped).
let dot, headerSub;
if (isPending) {
dot = '#e3b341';
dot = 'var(--sb-yellow-2-solid)';
headerSub = statusSub(s);
} else if (isUp) {
dot = session.peerPresence ? (PRESENCE_DOT[session.peerPresence] || '#6b6b73') : '#3ecf8e';
dot = session.peerPresence ? (PRESENCE_DOT[session.peerPresence] || 'var(--sb-text-9)') : 'var(--sb-green-solid)';
headerSub = session.peerPresence ? (PRESENCE_WORD[session.peerPresence] || t('presence.online')) : t('conn.p2p');
} else {
dot = '#e5727a';
dot = 'var(--sb-red-solid)';
headerSub = statusSub(s);
}
const preview = lastMessage ? (lastMessage.voice ? '🎙 Voice message' : lastMessage.message) : headerSub;
+9 -9
View File
@@ -90,7 +90,7 @@ button:disabled,
/* Keyboard users get the same clarity as pointer users. */
:focus-visible {
outline: 2px solid rgba(249, 115, 22, 0.75);
outline: 2px solid rgba(var(--sb-legacy-orange-500-rgb), 0.75);
outline-offset: 2px;
}
@@ -101,7 +101,7 @@ button:disabled,
bright top edge light catching the near face of the glass, which is what
separates a translucent bar from a washed-out rectangle. */
.header-minimal {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
box-shadow: inset 0 1px 0 rgba(var(--sb-ink), 0.05);
}
/* Cards are a smaller surface than chrome, so they read thinner: less blur,
@@ -139,7 +139,7 @@ h3, .text-3xl { letter-spacing: -0.018em; line-height: 1.15; }
.header-minimal .text-secondary,
.chat-input-area .text-secondary,
.card-minimal .text-muted {
color: #c3c3c9;
color: var(--sb-motion-text-3);
letter-spacing: 0.01em;
}
@@ -244,8 +244,8 @@ h3, .text-3xl { letter-spacing: -0.018em; line-height: 1.15; }
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
.header-minimal { background: #18181a !important; }
.card-minimal { background: #202022 !important; }
.header-minimal { background: var(--sb-motion-surface) !important; }
.card-minimal { background: var(--sb-motion-raise) !important; }
}
/* ── 6. Increased contrast ───────────────────────────────────────────────── */
@@ -255,10 +255,10 @@ h3, .text-3xl { letter-spacing: -0.018em; line-height: 1.15; }
.icon-container,
.verification-code,
.security-shield {
background: #141416 !important;
border-color: rgba(255, 255, 255, 0.42) !important;
background: var(--sb-surface) !important;
border-color: rgba(var(--sb-ink), 0.42) !important;
}
.text-secondary { color: #e4e4e8 !important; }
.text-muted { color: #c8c8cf !important; }
.text-secondary { color: var(--sb-motion-text) !important; }
.text-muted { color: var(--sb-motion-text-2) !important; }
:focus-visible { outline-width: 3px; }
}
+94 -94
View File
@@ -53,23 +53,23 @@
}
.file-drop-zone {
border: 2px dashed #4b5563;
border: 2px dashed rgba(var(--sb-legacy-border-rgb), 1);
border-radius: 12px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(55, 65, 81, 0.1);
background: rgba(var(--sb-legacy-slate-rgb), 0.1);
}
.file-drop-zone:hover {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
border-color: var(--sb-legacy-blue-500);
background: rgba(var(--sb-legacy-blue-rgb), 0.1);
}
.file-drop-zone.drag-over {
border-color: #10b981;
background: rgba(16, 185, 129, 0.1);
border-color: var(--sb-legacy-emerald-500);
background: rgba(var(--sb-legacy-emerald-rgb), 0.1);
transform: scale(1.02);
}
@@ -93,7 +93,7 @@
.progress-bar {
position: relative;
height: 6px;
background: rgba(75, 85, 99, 0.3);
background: rgba(var(--sb-legacy-border-rgb), 0.3);
border-radius: 3px;
overflow: hidden;
}
@@ -108,11 +108,11 @@
position: absolute;
top: -20px;
inset-inline-end: 0;
color: #9ca3af;
color: var(--sb-legacy-gray);
}
.file-transfer-section {
border-top: 1px solid rgba(75, 85, 99, 0.1);
border-top: 1px solid rgba(var(--sb-legacy-border-rgb), 0.1);
}
@media (max-width: 640px) {
@@ -130,7 +130,7 @@
}
.header-minimal {
background: rgb(35 36 35 / 13%);
background: rgba(var(--sb-legacy-bar-rgb), 0.13);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
flex-shrink: 0;
@@ -169,9 +169,9 @@
flex-shrink: 0;
position: sticky;
bottom: 0;
background: rgba(42, 43, 42, 0.95);
background: rgba(var(--sb-legacy-fill-rgb), 0.95);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(75, 85, 99, 0.2);
border-top: 1px solid rgba(var(--sb-legacy-border-rgb), 0.2);
}
/* The message container must occupy the entire available height. */
@@ -201,19 +201,19 @@ main {
}
.card-minimal {
background: rgba(42, 43, 42, 0.8);
background: rgba(var(--sb-legacy-fill-rgb), 0.8);
backdrop-filter: blur(16px);
border: 1px solid rgba(75, 85, 99, 0.2);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(var(--sb-legacy-border-rgb), 0.2);
box-shadow: 0 4px 16px rgba(var(--sb-shadow-rgb), calc(0.1 * var(--sb-shadow-k)));
}
/* Selected state override (higher precedence than Tailwind bg-*) */
.card-minimal--selected {
background: rgba(249, 115, 22, 0.15) !important; /* orange-500 @ 0.15 */
border-color: rgba(249, 115, 22, 1) !important; /* border-orange-500 */
box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.3) !important; /* soft ring */
background: rgba(var(--sb-legacy-orange-500-rgb), 0.15) !important; /* orange-500 @ 0.15 */
border-color: rgba(var(--sb-legacy-orange-500-rgb), 1) !important; /* border-orange-500 */
box-shadow: 0 0 0 2px rgba(var(--sb-legacy-orange-500-rgb), 0.3) !important; /* soft ring */
}
/* .card-minimal:hover {
border-color: rgba(249, 115, 22, 0.3);
border-color: rgba(var(--sb-legacy-orange-500-rgb), 0.3);
transform: translateY(-1px);
transition: all 0.2s ease;
} */
@@ -225,21 +225,21 @@ main {
display: inline-block;
margin-inline-end: 8px;
}
.status-connected { background: #10b981; }
.status-connecting { background: #6b7280; }
.status-failed { background: #ef4444; }
.status-disconnected { background: #6b7280; }
.status-verifying { background: #9ca3af; }
.status-connected { background: var(--sb-legacy-emerald-500); }
.status-connecting { background: var(--sb-legacy-gray-500); }
.status-failed { background: var(--sb-legacy-red-500); }
.status-disconnected { background: var(--sb-legacy-gray-500); }
.status-verifying { background: var(--sb-legacy-gray); }
.security-shield {
background: linear-gradient(135deg, #2A2B2A 0%, #3a3b3a 100%);
border: 1px solid rgba(75, 85, 99, 0.3);
background: linear-gradient(135deg, var(--sb-legacy-body) 0%, var(--sb-legacy-raise-1) 100%);
border: 1px solid rgba(var(--sb-legacy-border-rgb), 0.3);
}
.verification-code {
background: rgba(42, 43, 42, 0.8);
border: 1px solid rgba(75, 85, 99, 0.3);
color: #f1f5f9;
background: rgba(var(--sb-legacy-fill-rgb), 0.8);
border: 1px solid rgba(var(--sb-legacy-border-rgb), 0.3);
color: var(--sb-legacy-text);
font-family: 'Monaco', 'Menlo', monospace;
letter-spacing: 0.1em;
font-size: 1.2em;
@@ -251,8 +251,8 @@ main {
.icon-container {
width: 40px;
height: 40px;
background: rgba(42, 43, 42, 0.8);
border: 1px solid rgba(75, 85, 99, 0.3);
background: rgba(var(--sb-legacy-fill-rgb), 0.8);
border: 1px solid rgba(var(--sb-legacy-border-rgb), 0.3);
border-radius: 8px;
display: flex;
align-items: center;
@@ -272,7 +272,7 @@ main {
.step-number {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #f97316, #ea580c);
background: linear-gradient(135deg, var(--sb-legacy-orange-500), var(--sb-legacy-orange-600));
color: white;
border-radius: 8px;
display: flex;
@@ -356,39 +356,39 @@ button i {
/* Lightning button */
.lightning-button {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
border: 1px solid rgba(245, 158, 11, 0.3);
background: linear-gradient(135deg, var(--sb-legacy-amber-500) 0%, var(--sb-legacy-amber-600) 100%);
border: 1px solid rgba(var(--sb-legacy-amber-rgb), 0.3);
transition: all 0.3s ease;
}
.lightning-button:hover {
background: linear-gradient(135deg, #d97706 0%, #b45309 100%);
background: linear-gradient(135deg, var(--sb-legacy-amber-600) 0%, var(--sb-legacy-amber-700) 100%);
transform: translateY(-2px);
}
.btn-primary {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
border: 1px solid rgba(249, 115, 22, 0.3);
background: linear-gradient(135deg, var(--sb-legacy-orange-500) 0%, var(--sb-legacy-orange-600) 100%);
border: 1px solid rgba(var(--sb-legacy-orange-500-rgb), 0.3);
}
.btn-primary:hover {
background: linear-gradient(135deg, #ea580c 0%, #dc2626 100%);
background: linear-gradient(135deg, var(--sb-legacy-orange-600) 0%, var(--sb-legacy-red-600) 100%);
transform: translateY(-1px);
}
.btn-secondary {
background: linear-gradient(135deg, #2A2B2A 0%, #3a3b3a 100%);
border: 1px solid rgba(75, 85, 99, 0.3);
background: linear-gradient(135deg, var(--sb-legacy-body) 0%, var(--sb-legacy-raise-1) 100%);
border: 1px solid rgba(var(--sb-legacy-border-rgb), 0.3);
}
.btn-secondary:hover {
background: linear-gradient(135deg, #3a3b3a 0%, #4a4b4a 100%);
background: linear-gradient(135deg, var(--sb-legacy-raise-1) 0%, var(--sb-legacy-raise-2) 100%);
transform: translateY(-1px);
}
.btn-verify {
background: linear-gradient(135deg, #2A2B2A 0%, #3a3b3a 100%);
border: 1px solid rgba(75, 85, 99, 0.3);
background: linear-gradient(135deg, var(--sb-legacy-body) 0%, var(--sb-legacy-raise-1) 100%);
border: 1px solid rgba(var(--sb-legacy-border-rgb), 0.3);
}
.btn-verify:hover {
background: linear-gradient(135deg, #3a3b3a 0%, #4a4b4a 100%);
background: linear-gradient(135deg, var(--sb-legacy-raise-1) 0%, var(--sb-legacy-raise-2) 100%);
transform: translateY(-1px);
}
@@ -417,20 +417,20 @@ button i {
justify-content: center;
width: 100px;
height: 48px;
background: rgba(42, 43, 42, 0.8);
border: 1px solid rgba(75, 85, 99, 0.3);
background: rgba(var(--sb-legacy-fill-rgb), 0.8);
border: 1px solid rgba(var(--sb-legacy-border-rgb), 0.3);
border-radius: 8px;
font-size: 14px;
font-weight: 600;
color: #f1f5f9;
color: var(--sb-legacy-text);
flex-shrink: 0;
transition: all 0.3s ease;
}
.wallet-logo:hover {
border-color: rgba(249, 115, 22, 0.3);
background: rgba(249, 115, 22, 0.1);
border-color: rgba(var(--sb-legacy-orange-500-rgb), 0.3);
background: rgba(var(--sb-legacy-orange-500-rgb), 0.1);
transform: translateY(-2px) scale(1.05);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
box-shadow: 0 4px 10px rgba(var(--sb-shadow-rgb), calc(0.2 * var(--sb-shadow-k)));
}
.wallet-logo.bitcoin-lightning { background: transparent; padding: 4px; }
@@ -536,10 +536,10 @@ button i {
/* Modern animated button */
.btn-partner-modern {
position: relative;
background: linear-gradient(135deg, #f97316 0%, #ea580c 50%, #dc2626 100%);
background: linear-gradient(135deg, var(--sb-legacy-orange-500) 0%, var(--sb-legacy-orange-600) 50%, var(--sb-legacy-red-600) 100%);
background-size: 200% 200%;
background-position: 0% 0;
box-shadow: 0 4px 15px rgba(249, 115, 22, 0.3);
box-shadow: 0 4px 15px rgba(var(--sb-legacy-orange-500-rgb), 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
@@ -558,7 +558,7 @@ button i {
.btn-partner-modern:hover {
background-position: 100% 0;
transform: translateY(-2px) scale(1.02);
box-shadow: 0 8px 25px rgba(249, 115, 22, 0.5), 0 0 30px rgba(249, 115, 22, 0.3);
box-shadow: 0 8px 25px rgba(var(--sb-legacy-orange-500-rgb), 0.5), 0 0 30px rgba(var(--sb-legacy-orange-500-rgb), 0.3);
}
.btn-partner-modern:hover::before {
@@ -568,14 +568,14 @@ button i {
.btn-partner-modern:active {
transform: translateY(0) scale(0.98);
box-shadow: 0 2px 10px rgba(249, 115, 22, 0.4);
box-shadow: 0 2px 10px rgba(var(--sb-legacy-orange-500-rgb), 0.4);
}
/* Custom scrollbar */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
scrollbar-color: rgba(var(--sb-legacy-scroll-rgb), 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
@@ -587,22 +587,22 @@ button i {
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
background-color: rgba(var(--sb-legacy-scroll-rgb), 0.5);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
background-color: rgba(var(--sb-legacy-scroll-rgb), 0.7);
}
.accent-orange { color: #fb923c !important; }
.accent-green { color: #34d399 !important; }
.accent-red { color: #f87171 !important; }
.accent-yellow { color: #fbbf24 !important; }
.accent-purple { color: #a78bfa !important; }
.accent-gray { color: #9ca3af !important; }
.accent-cyan { color: #22d3ee !important; }
.accent-blue { color: #60a5fa !important; }
.accent-orange { color: var(--sb-legacy-orange) !important; }
.accent-green { color: var(--sb-legacy-green) !important; }
.accent-red { color: var(--sb-legacy-red) !important; }
.accent-yellow { color: var(--sb-legacy-yellow) !important; }
.accent-purple { color: var(--sb-legacy-purple) !important; }
.accent-gray { color: var(--sb-legacy-gray) !important; }
.accent-cyan { color: var(--sb-legacy-cyan) !important; }
.accent-blue { color: var(--sb-legacy-blue) !important; }
/* Ensure icons visible in dark backgrounds */
.text-secondary i {
@@ -626,8 +626,8 @@ button i {
); /* Transparent glass gradient */
box-shadow:
inset 0 1px 2px rgba(255, 255, 255, 0.4),
/* Top highlight */ inset 0 -1px 2px rgba(0, 0, 0, 0.2),
/* Bottom shadow */ 0 4px 8px rgba(0, 0, 0, 0.2),
/* Top highlight */ inset 0 -1px 2px rgba(var(--sb-shadow-rgb), calc(0.2 * var(--sb-shadow-k))),
/* Bottom shadow */ 0 4px 8px rgba(var(--sb-shadow-rgb), calc(0.2 * var(--sb-shadow-k))),
/* Drop shadow */ 0 0 20px rgba(255, 255, 255, 0.1); /* Subtle glow */
transform: rotateX(15deg) translateZ(0); /* Initial 3D tilt */
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.27, 1.55); /* Springy transition */
@@ -671,7 +671,7 @@ button i {
height: 10px;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.3) 0%,
rgba(var(--sb-shadow-rgb), calc(0.3 * var(--sb-shadow-k))) 0%,
transparent 70%
); /* Soft shadow */
z-index: -1;
@@ -684,7 +684,7 @@ button i {
color: white;
font-size: 18px;
font-family: Arial, sans-serif;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); /* Depth for text */
text-shadow: 0 1px 2px rgba(var(--sb-shadow-rgb), calc(0.5 * var(--sb-shadow-k))); /* Depth for text */
display: block;
line-height: 60px; /* Center vertically */
text-align: center;
@@ -695,8 +695,8 @@ button i {
transform: rotateX(0deg) translateZ(15px) scale(1.05); /* Lift and scale */
box-shadow:
inset 0 1px 2px rgba(255, 255, 255, 0.4),
inset 0 -1px 2px rgba(0, 0, 0, 0.2),
0 8px 16px rgba(0, 0, 0, 0.3),
inset 0 -1px 2px rgba(var(--sb-shadow-rgb), calc(0.2 * var(--sb-shadow-k))),
0 8px 16px rgba(var(--sb-shadow-rgb), calc(0.3 * var(--sb-shadow-k))),
/* Stronger shadow */ 0 0 40px rgba(255, 255, 255, 0.25); /* Brighter glow */
}
@@ -705,8 +705,8 @@ button i {
transform: rotateX(0deg) translateZ(-5px) scale(0.95); /* Push down */
box-shadow:
inset 0 1px 2px rgba(255, 255, 255, 0.4),
inset 0 -1px 2px rgba(0, 0, 0, 0.2),
0 2px 4px rgba(0, 0, 0, 0.2),
inset 0 -1px 2px rgba(var(--sb-shadow-rgb), calc(0.2 * var(--sb-shadow-k))),
0 2px 4px rgba(var(--sb-shadow-rgb), calc(0.2 * var(--sb-shadow-k))),
/* Reduced shadow */ 0 0 10px rgba(255, 255, 255, 0.1); /* Dimmed glow */
}
@@ -716,16 +716,16 @@ button i {
100% {
box-shadow:
inset 0 1px 2px rgba(255, 255, 255, 0.4),
inset 0 -1px 2px rgba(0, 0, 0, 0.2),
0 4px 8px rgba(0, 0, 0, 0.2),
inset 0 -1px 2px rgba(var(--sb-shadow-rgb), calc(0.2 * var(--sb-shadow-k))),
0 4px 8px rgba(var(--sb-shadow-rgb), calc(0.2 * var(--sb-shadow-k))),
0 0 20px rgba(255, 255, 255, 0.1);
}
50% {
box-shadow:
inset 0 1px 2px rgba(255, 255, 255, 0.4),
inset 0 -1px 2px rgba(0, 0, 0, 0.2),
0 4px 8px rgba(0, 0, 0, 0.2),
0 0 30px rgba(255, 255, 255, 0.2); /* Peak glow */
inset 0 1px 2px rgba(var(--sb-ink), 0.4),
inset 0 -1px 2px rgba(var(--sb-shadow-rgb), calc(0.2 * var(--sb-shadow-k))),
0 4px 8px rgba(var(--sb-shadow-rgb), calc(0.2 * var(--sb-shadow-k))),
0 0 30px rgba(var(--sb-ink), 0.2); /* Peak glow */
}
}
@@ -747,28 +747,28 @@ button i {
.sb-scroll {
scrollbar-gutter: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.13) transparent;
scrollbar-color: rgba(var(--sb-ink), 0.13) transparent;
}
.sb-scroll::-webkit-scrollbar { width: 9px; }
.sb-scroll::-webkit-scrollbar-track { background: transparent; }
.sb-scroll::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.07);
background: rgba(var(--sb-ink), 0.07);
border-radius: 99px;
border: 2px solid transparent;
background-clip: padding-box;
}
.sb-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.13);
background: rgba(var(--sb-ink), 0.13);
background-clip: padding-box;
}
.sb-textarea::placeholder { color: #56565e; }
.sb-chip:hover { border-color: rgba(255,255,255,0.16) !important; color: #e8e8eb !important; }
.sb-textarea::placeholder { color: var(--sb-text-faint); }
.sb-chip:hover { border-color: rgba(var(--sb-ink), 0.16) !important; color: var(--sb-text-2) !important; }
.sb-send:hover:not(:disabled) { filter: brightness(1.06); }
.sb-unsend:hover { color: #e5727a !important; }
.sb-link:hover { color: #e8e8eb !important; }
.sb-disconnect:hover { border-color: rgba(229,114,122,0.4) !important; color: #e5727a !important; background: rgba(229,114,122,0.06) !important; }
.sb-unsend:hover { color: var(--sb-red) !important; }
.sb-link:hover { color: var(--sb-text-2) !important; }
.sb-disconnect:hover { border-color: rgba(var(--sb-red-rgb), 0.4) !important; color: var(--sb-red) !important; background: rgba(var(--sb-red-rgb), 0.06) !important; }
@media (max-width: 560px) { .sb-hide-sm { display: none; } }
.sb-secpill:hover { border-color: rgba(255,255,255,0.16) !important; background: rgba(255,255,255,0.05) !important; }
.sb-secpill:hover { border-color: rgba(var(--sb-ink), 0.16) !important; background: rgba(var(--sb-ink), 0.05) !important; }
/* ── Start Secure — new connection screen (design import) ───────────────── */
@keyframes sbFlowR { 0% { left: 4%; opacity: 0; } 12% { opacity: 1; } 88% { opacity: 1; } 100% { left: 96%; opacity: 0; } }
@@ -776,7 +776,7 @@ button i {
@keyframes sbPulse { 0%,100% { transform: translate(-50%,-50%) scale(1); opacity: 0.5; } 50% { transform: translate(-50%,-50%) scale(1.5); opacity: 0; } }
@keyframes sbSpin { to { transform: rotate(360deg); } }
@keyframes sbUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes sbNode { 0%,100% { box-shadow: 0 0 0 0 rgba(62,207,142,0.0); } 50% { box-shadow: 0 0 0 6px rgba(62,207,142,0.06); } }
@keyframes sbNode { 0%,100% { box-shadow: 0 0 0 0 rgba(var(--sb-green-rgb), 0.0); } 50% { box-shadow: 0 0 0 6px rgba(var(--sb-green-rgb), 0.06); } }
@keyframes sbScan { 0% { top: 8%; } 100% { top: 88%; } }
@keyframes sbSlideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@@ -793,14 +793,14 @@ button i {
.sb-conn-footer > button { width: 100% !important; justify-content: center !important; }
.sb-conn-footer > .sb-platforms-menu { width: 100% !important; }
}
.sb-start textarea::placeholder, .sb-start input::placeholder { color: #56565e; }
.sb-start textarea::placeholder, .sb-start input::placeholder { color: var(--sb-text-faint); }
.sb-start .sb-sc::-webkit-scrollbar { width: 8px; }
.sb-start .sb-sc::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 99px; }
.sb-start .sb-sc::-webkit-scrollbar-thumb { background: rgba(var(--sb-ink), 0.1); border-radius: 99px; }
.sb-seg-btn { transition: color .2s; }
.sb-soft-btn { transition: all .15s; }
.sb-soft-btn:hover { border-color: rgba(255,255,255,0.18) !important; color: #e8e8eb !important; }
.sb-scan-btn:hover { border-color: rgba(62,207,142,0.5) !important; background: rgba(62,207,142,0.1) !important; }
.sb-gen-btn:hover { background: #ff9637 !important; }
.sb-soft-btn:hover { border-color: rgba(var(--sb-ink), 0.18) !important; color: var(--sb-text-2) !important; }
.sb-scan-btn:hover { border-color: rgba(var(--sb-green-rgb), 0.5) !important; background: rgba(var(--sb-green-rgb), 0.1) !important; }
.sb-gen-btn:hover { background: var(--sb-orange-hi) !important; }
.sb-start-card { transition: transform .26s cubic-bezier(.3,.8,.3,1); }
@media (max-width: 900px) { .sb-start-left { border-inline-end: none !important; } }
@media (max-width: 560px) { .sb-start-left { padding: 30px 22px !important; } }
+31 -31
View File
@@ -4,69 +4,69 @@
}
body {
background: #2A2B2A;
background: var(--sb-legacy-body);
overflow-x: hidden;
}
/* Basic backgrounds */
.bg-custom-bg {
background-color: rgb(37 38 37) !important;
background-color: var(--sb-legacy-body-2) !important;
}
.bg-my{
background-color: rgb(26 26 26);
background-color: var(--sb-legacy-panel);
}
.bg-header {
background-color: rgb(35 35 35) !important;
background-color: var(--sb-legacy-header) !important;
}
.minimal-bg {
background: linear-gradient(135deg, #1a1a1a 0%, #2A2B2A 100%);
background: linear-gradient(135deg, var(--sb-legacy-panel) 0%, var(--sb-legacy-body) 100%);
position: relative;
min-height: 100vh;
}
.card-minimal.rounded-xl.p-6.cursor-pointer.group.flex-1.create {
background-color: #60a5fa08;
background-color: rgba(var(--sb-blue-rgb), 0.03);
}
.card-minimal.rounded-xl.p-6.cursor-pointer.group.flex-1.join {
background-color: #34d39908;
background-color: rgba(var(--sb-legacy-green-rgb), 0.03);
}
tr.bg-black-table {
background-color: #1c1c1c;
background-color: var(--sb-legacy-table);
}
/* Text styles */
.text-primary { color: #f1f5f9; }
.text-secondary { color: #b6b6b6; }
.text-muted { color: #818080; }
.text-primary { color: var(--sb-legacy-text); }
.text-secondary { color: var(--sb-legacy-text-2); }
.text-muted { color: var(--sb-legacy-text-3); }
/* Accent colors */
.accent-orange { color: #fb923c; }
.accent-green { color: #34d399; }
.accent-red { color: #f87171; }
.accent-yellow { color: #fbbf24; }
.accent-purple { color: #a78bfa; }
.accent-blue { color: #60a5fa; }
.accent-gray { color: #9ca3af; }
.accent-cyan { color: #22d3ee; }
.accent-orange { color: var(--sb-legacy-orange); }
.accent-green { color: var(--sb-legacy-green); }
.accent-red { color: var(--sb-legacy-red); }
.accent-yellow { color: var(--sb-legacy-yellow); }
.accent-purple { color: var(--sb-legacy-purple); }
.accent-blue { color: var(--sb-legacy-blue); }
.accent-gray { color: var(--sb-legacy-gray); }
.accent-cyan { color: var(--sb-legacy-cyan); }
/* Custom scroll */
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(42, 43, 42, 0.3);
background: rgba(var(--sb-legacy-fill-rgb), 0.3);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(75, 85, 99, 0.5);
background: rgba(var(--sb-legacy-border-rgb), 0.5);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(75, 85, 99, 0.7);
background: rgba(var(--sb-legacy-border-rgb), 0.7);
}
/* Smooth scrolling */
@@ -171,7 +171,7 @@ tr.bg-black-table {
--speed: 0.55s cubic-bezier(0.25, 0.46, 0.45, 0.94);
--closed: 5rem;
--open: 30rem;
--accent: #fb923c;
--accent: var(--sb-legacy-orange);
}
* {
margin: 0;
@@ -180,8 +180,8 @@ tr.bg-black-table {
}
body {
font-family: Inter, sans-serif;
background: #07090d;
color: #c5c7ce;
background: var(--sb-legacy-ground);
color: var(--sb-legacy-body-text);
}
.head {
@@ -200,7 +200,7 @@ body {
height: 2.5rem;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
background: rgba(var(--sb-ink), 0.12);
color: #fff;
font-size: 1.5rem;
display: flex;
@@ -253,7 +253,7 @@ body {
.project-card[active] {
flex-basis: var(--open);
transform: translateY(-6px);
box-shadow: 0 15px 12px rgba(0, 0, 0, 0.25);
box-shadow: 0 15px 12px rgba(var(--sb-shadow-rgb), calc(0.25 * var(--sb-shadow-k)));
}
.project-card__bg {
position: absolute;
@@ -278,7 +278,7 @@ body {
align-items: center;
gap: 0.7rem;
padding: 0;
background: linear-gradient(transparent 40%, rgba(0, 0, 0, 0.35) 100%);
background: linear-gradient(transparent 40%, rgba(var(--sb-shadow-rgb), calc(0.35 * var(--sb-shadow-k))) 100%);
z-index: 2;
}
.project-card__title {
@@ -316,10 +316,10 @@ body {
height: 269px;
border-radius: 0.45rem;
object-fit: cover;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
box-shadow: 0 4px 10px rgba(var(--sb-shadow-rgb), calc(0.4 * var(--sb-shadow-k)));
}
.project-card__desc {
color: #b6b6b6;
color: var(--sb-legacy-text-2);
font-size: 1rem;
line-height: 1.4;
max-width: 16rem;
@@ -393,7 +393,7 @@ body {
.project-card[active] {
min-height: 300px;
transform: none;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
box-shadow: 0 8px 25px rgba(var(--sb-shadow-rgb), calc(0.3 * var(--sb-shadow-k)));
}
.project-card__content {
+19 -12
View File
@@ -7,7 +7,7 @@
/* PWA Update Banner */
#pwa-update-banner {
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(var(--sb-ink), 0.1);
animation: slideDown 0.3s ease-out;
}
@@ -25,18 +25,25 @@
/* PWA Notifications */
.pwa-notification {
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid rgba(var(--sb-ink), 0.1);
}
/* Status-bar inset for the installed app.
*
* Installed on iOS, the web view runs edge-to-edge: `viewport-fit=cover` plus
* `apple-mobile-web-app-status-bar-style: black-translucent` put our own pixels
* under the clock and the notch. Every bar pinned to the top of the screen has
* to gutter itself out of that strip, or it reads as if it is sliding under the
* status bar. In a browser tab the inset is 0 the browser chrome already
* reserves the space so one variable covers both cases and the bars can use
* it unconditionally.
* This was written for `apple-mobile-web-app-status-bar-style: black-translucent`,
* which ran the web view edge-to-edge and put our own pixels under the clock and the
* notch, so every bar pinned to the top had to gutter itself out of that strip or it
* read as sliding under the status bar. The status bar style is now `default` under
* black-translucent iOS always drew the clock in white, which is unreadable once the
* page can be light, and the meta is read once at launch so nothing could fix it after
* the fact. See the comment on that tag in templates/index.template.html.
*
* None of the machinery below changes as a result, and that is the point of deriving it
* from env() rather than from a constant: with `default` the system reserves the strip
* itself, so env(safe-area-inset-top) reports 0 in standalone and --sb-safe-top
* collapses to 0 the same value a browser tab already gave it. If the style is ever
* put back, the insets come back with it and the bars gutter themselves again.
* The bottom inset (the home indicator) is unaffected and still needs viewport-fit=cover.
*
* The previous version of this file padded `body` instead. That is the wrong
* element twice over: the landing header is `position: fixed`, which ignores an
@@ -124,7 +131,7 @@ body.pwa-installed .header-minimal { height: auto; }
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
background: linear-gradient(135deg, var(--sb-legacy-panel) 0%, var(--sb-legacy-splash) 100%);
display: flex;
flex-direction: column;
align-items: center;
@@ -174,7 +181,7 @@ body.pwa-installed .header-minimal { height: auto; }
}
.pwa-splash .subtitle {
color: #9ca3af;
color: var(--sb-legacy-gray);
font-size: 14px;
margin-bottom: 32px;
}
@@ -264,7 +271,7 @@ body.pwa-installed .header-minimal { height: auto; }
/* PWA Dark Mode Support */
@media (prefers-color-scheme: dark) {
.pwa-notification {
border-color: rgba(255, 255, 255, 0.1);
border-color: rgba(var(--sb-ink), 0.1);
}
}
+484
View File
@@ -0,0 +1,484 @@
/**
* theme.css the one place every colour in the interface is decided.
*
* Until this file existed the palette lived as ~620 literal hex values inside inline
* styles in the JSX plus another few hundred in the hand-written sheets, which is why
* the app could only ever be dark: there was no single thing to change. Every one of
* those literals now reads a custom property declared here, so a theme is a matter of
* redeclaring the block rather than touching a component.
*
* The dark theme is unchanged by this. Every colour declaration the previous build
* produced comes out of this one identically once the properties are resolved that
* was checked declaration by declaration against the last release's assets/app.css, and
* it is the reason there are tokens here that look like duplicates of each other
* (--sb-orange next to --sb-legacy-orange-500, --sb-surface next to --sb-motion-surface).
* They were duplicates in the old stylesheets too, a step apart on Tailwind's ramp
* versus the redesign's; collapsing them would have been tidier and would also have
* quietly restyled the dark theme, which is not what adding a light one is for.
* The only values folded together are greys that differed by one or two counts out of
* 255 (#0d0d0f into #0c0c0e, #121214 into #0f0f11, #141417 into #141416) below what
* a display resolves, and not worth two light values that must never drift apart.
*
* Two palettes, plus two fallbacks:
* :root dark, and the default.
* :root[data-theme="light"] light, selected by src/scripts/theme-boot.js from
* localStorage before the first paint.
* "Follow the system" is not a third palette. theme-boot.js resolves the media query
* itself and stamps data-theme with the answer, so the cascade only ever sees an
* explicit value. Answering prefers-color-scheme in CSS instead would get the first
* paint right but could never be overridden and someone who wants a light page on a
* dark laptop is precisely who a theme switch is for. The one place the media query
* *is* used is further down, for a client running no JavaScript at all, where there is
* no stored preference to honour and nothing to stamp the attribute.
*
* The last block re-points a handful of Tailwind utility classes that only work on a
* dark ground; see the comment above it for why they are overridden rather than
* rewritten at the call sites.
*
* Naming: --sb-bg-* are page and container grounds, --sb-surface-* are things that sit
* on them, --sb-text-* is a ramp from loudest (1) to faintest, and the accents keep
* their colour name. --sb-ink is the pair of RGB channels used for translucent overlay
* tints: white-on-dark inverts to black-on-light, so `rgba(var(--sb-ink), 0.06)`
* is a hairline border in either theme without the call site knowing which.
*/
:root {
color-scheme: dark;
/* Overlay tint base. Everything that used to be rgba(255,255,255,a) reads this. */
--sb-ink: 255, 255, 255;
/* Grounds */
--sb-bg-deepest: #0a0a0c;
--sb-bg-deep: #0c0c0e;
--sb-bg: #0f0f11;
/* Surfaces, in rising order of elevation */
--sb-surface: #141416;
--sb-surface-2: #1a1a1f;
--sb-surface-warm: #201416;
--sb-surface-3: #26262b;
--sb-surface-4: #2a2a30;
--sb-surface-5: #3f3f47;
/* Three decorative gradients read as one warm sweep; kept apart so it survives. */
--sb-grad-1: #15151b;
--sb-grad-2: #1d1a24;
--sb-grad-3: #161620;
/* Text, loudest to faintest */
--sb-text-1: #f4f4f6;
--sb-text-2: #e8e8eb;
--sb-text-3: #d7d7db;
--sb-text-4: #cfcfd4;
--sb-text-5: #b3b3ba;
--sb-text-5b: #a7a7b0;
--sb-text-6: #9a9aa2;
--sb-text-7: #8a8a92;
--sb-text-8: #7b7b83;
--sb-text-9: #6b6b73;
--sb-text-faint: #56565e;
--sb-text-code: #c9ccd8;
/* Accents */
--sb-green: #3ecf8e;
--sb-green-soft: #8fe0bb;
--sb-green-muted: #a8b8ae;
--sb-orange: #f0892a;
--sb-orange-hi: #ff9637;
--sb-orange-2: #f0a455;
--sb-orange-3: #f0b072;
--sb-orange-4: #e8b27a;
--sb-red: #e5727a;
--sb-red-soft: #f0a6ab;
--sb-yellow: #e3c84e;
--sb-yellow-2: #e3b341;
/* Text that sits *on* a filled accent. Dark ink on a bright fill reads better than
white does at these saturations but only while the fill stays bright, which is
why the light theme darkens the fills and flips these to white. */
--sb-on-accent: #1a0f04;
--sb-on-green: #06231a;
--sb-on-green-2: #08160e;
/* Warm neutrals used by the hand-drawn SVG diagrams on the landing page. */
--sb-diag-text: #d8cfc1;
--sb-diag-label: #8f8b84;
--sb-diag-stroke: #6b6760;
--sb-diag-dim: #85817b;
/* Legacy surfaces from the pre-redesign screens that are still on the page. */
--sb-legacy-1: #1b1c1b;
--sb-legacy-2: #222322;
--sb-legacy-3: #1f201f;
--sb-legacy-panel: #1a1a1a;
--sb-legacy-body: #2a2b2a;
--sb-legacy-body-2: #252625;
/* .header-minimal's own translucent ground, and the drop zone's fill. */
--sb-legacy-bar-rgb: 35, 36, 35;
--sb-legacy-slate-rgb: 55, 65, 81;
--sb-legacy-green-rgb: 52, 211, 153;
--sb-legacy-header: #232323;
--sb-legacy-table: #1c1c1c;
/* An accent has two jobs and they pull in opposite directions once the page can be
light. As a FILL a button, a chip, a status dot it should be the brand colour
and nothing else, and it stays readable because the text on it is near-black
(--sb-on-accent, 7.5:1 on this orange). As TEXT on the page itself it has to
clear 4.5:1 against white, and the brand orange manages 2.5:1 there.
So: --sb-*-solid is the brand, identical in both themes, and --sb-orange /
--sb-green above are the text-safe versions that darken in the light theme.
Getting this wrong in the other direction is what made the light theme's buttons
brown. */
--sb-orange-solid: #f0892a;
--sb-green-solid: #3ecf8e;
--sb-red-solid: #e5727a;
--sb-red-strong-solid: #e5484d;
--sb-yellow-solid: #e3c84e;
--sb-yellow-2-solid: #e3b341;
/* Channel triples for the same accents. A translucent tint of an accent
(`rgba(var(--sb-orange-rgb), 0.14)`) cannot be built from the hex token, because
CSS has no way to add an alpha to a colour a custom property already resolved
so the channels are published separately and the two must be kept in step. */
--sb-green-rgb: 62, 207, 142;
--sb-orange-rgb: 240, 137, 42;
--sb-red-rgb: 229, 114, 122;
--sb-red-strong-rgb: 229, 72, 77;
--sb-yellow-rgb: 227, 200, 78;
--sb-yellow-2-rgb: 227, 179, 65;
--sb-blue-rgb: 96, 165, 250;
--sb-bg-rgb: 18, 18, 20;
--sb-surface-rgb: 20, 20, 22;
--sb-text-7-rgb: 138, 138, 146;
--sb-text-9-rgb: 107, 107, 115;
/* What a modal dims the page behind it with. Stays dark in both themes a light
scrim over a light page dims nothing but lighter and bluer on the light one so
it reads as depth rather than as a hole cut in the page. */
--sb-scrim-rgb: 6, 6, 8;
/* The .accent-* and .text-* utility classes predate the redesign and use a slightly
different, brighter set than the sb- palette above. They are still on the page, so
they keep their own values rather than being snapped to the newer accents the
point here is to give them a light counterpart, not to restyle the dark theme. */
--sb-legacy-orange: #fb923c;
--sb-legacy-green: #34d399;
--sb-legacy-red: #f87171;
--sb-legacy-yellow: #fbbf24;
--sb-legacy-purple: #a78bfa;
--sb-legacy-blue: #60a5fa;
--sb-legacy-gray: #9ca3af;
--sb-legacy-cyan: #22d3ee;
--sb-legacy-text: #f1f5f9;
--sb-legacy-text-2: #b6b6b6;
--sb-legacy-text-3: #818080;
--sb-legacy-body-text: #c5c7ce;
/* Borders and fills the older components draw with (Tailwind's gray-600 at low
alpha, mostly). Published as channels so their many alphas still work. */
--sb-legacy-border-rgb: 75, 85, 99;
--sb-legacy-fill-rgb: 42, 43, 42;
--sb-legacy-scroll-rgb: 156, 163, 175;
/* The pre-redesign component set (btn-primary, the status dots, the drop zone, the
splash) was built on Tailwind's own palette, one step off the sb- accents above.
Snapping them together would have been tidier and would also have restyled the
dark theme, which is not what adding a light theme is for so each keeps its
exact value here and gets a light counterpart of its own. */
--sb-legacy-orange-500: #f97316;
--sb-legacy-orange-500-rgb: 249, 115, 22;
--sb-legacy-orange-600: #ea580c;
--sb-legacy-amber-500: #f59e0b;
--sb-legacy-amber-600: #d97706;
--sb-legacy-amber-700: #b45309;
--sb-legacy-red-500: #ef4444;
--sb-legacy-red-600: #dc2626;
--sb-legacy-blue-500: #3b82f6;
--sb-legacy-emerald-500: #10b981;
--sb-legacy-gray-500: #6b7280;
--sb-legacy-blue-rgb: 59, 130, 246;
--sb-legacy-emerald-rgb: 16, 185, 129;
--sb-legacy-amber-rgb: 245, 158, 11;
/* The two greys the older gradients step up to. */
--sb-legacy-raise-1: #3a3b3a;
--sb-legacy-raise-2: #4a4b4a;
--sb-legacy-splash: #2d2d2d;
/* apple-motion.css draws its own pressed/hover surfaces. */
--sb-motion-surface: #18181a;
--sb-motion-raise: #202022;
--sb-motion-text: #e4e4e8;
--sb-motion-text-2: #c8c8cf;
--sb-motion-text-3: #c3c3c9;
/* The page ground the legacy body rule paints, a shade below --sb-bg-deepest. */
--sb-legacy-ground: #07090d;
/* Shadows. On a dark ground a shadow is nearly invisible and the alphas can be
generous; the same alphas on a light page produce grey smears under everything.
Rather than restating every shadow in the light block, the call sites keep their
own alpha and multiply it by --sb-shadow-k, which the light theme turns down
so one number retunes every shadow in the app at once. */
--sb-shadow-rgb: 0, 0, 0;
--sb-shadow-k: 1;
}
:root[data-theme="light"] {
color-scheme: light;
--sb-ink: 0, 0, 0;
/* The grounds invert but do not go to pure white: an all-#fff page under a full-
bleed layout is glare, and the cards need something to sit on top of. */
--sb-bg-deepest: #e7e7ea;
--sb-bg-deep: #eff0f2;
--sb-bg: #f6f7f8;
--sb-surface: #ffffff;
--sb-surface-2: #f2f3f5;
--sb-surface-warm: #fdeced;
--sb-surface-3: #e7e8ec;
--sb-surface-4: #e0e1e6;
--sb-surface-5: #cfd0d7;
--sb-grad-1: #f1f1f5;
--sb-grad-2: #eae7f0;
--sb-grad-3: #eeeef4;
/* The ramp mirrors rather than inverts: the darkest text stops short of black,
and the faint end stops at roughly 4:1 on white so hints stay legible. */
--sb-text-1: #0e0e12;
--sb-text-2: #1b1b20;
--sb-text-3: #26262c;
--sb-text-4: #333339;
--sb-text-5: #4a4a52;
--sb-text-5b: #52525b;
--sb-text-6: #56565f;
--sb-text-7: #63636c;
--sb-text-8: #6f6f78;
--sb-text-9: #7c7c85;
--sb-text-faint: #85858f;
--sb-text-code: #2f3340;
/* Every accent is darkened until it clears 4.5:1 against white the dark theme's
versions are tuned for a black ground and turn to pastel on a white one. */
--sb-green: #0a8657;
--sb-green-soft: #0e7a52;
--sb-green-muted: #4f6259;
--sb-orange: #b05c08;
--sb-orange-hi: #ff9637;
--sb-orange-2: #f0a455;
--sb-orange-3: #96550f;
--sb-orange-4: #8d5417;
--sb-red: #c62f38;
--sb-red-soft: #b8434c;
--sb-yellow: #7d6400;
--sb-yellow-2: #8a6c00;
/* The fills above are now dark, so their text flips to white. */
--sb-on-accent: #1a0f04;
--sb-on-green: #06231a;
--sb-on-green-2: #08160e;
--sb-diag-text: #35302a;
--sb-diag-label: #6a665f;
--sb-diag-stroke: #a9a49b;
--sb-diag-dim: #75716b;
--sb-legacy-1: #ffffff;
--sb-legacy-2: #f4f5f7;
--sb-legacy-3: #ffffff;
--sb-legacy-panel: #ffffff;
--sb-legacy-body: #f6f7f8;
--sb-legacy-body-2: #f1f2f4;
--sb-legacy-bar-rgb: 250, 250, 251;
--sb-legacy-slate-rgb: 226, 228, 233;
--sb-legacy-green-rgb: 52, 211, 153;
--sb-legacy-header: #ffffff;
--sb-legacy-table: #f4f5f7;
/* Identical to the dark theme on purpose see the note there. A filled accent is
the brand mark; only accent *text* moves. */
--sb-orange-solid: #f0892a;
--sb-green-solid: #3ecf8e;
--sb-red-solid: #e5727a;
--sb-red-strong-solid: #e5484d;
--sb-yellow-solid: #e3c84e;
--sb-yellow-2-solid: #e3b341;
--sb-green-rgb: 62, 207, 142;
--sb-orange-rgb: 240, 137, 42;
--sb-red-rgb: 229, 114, 122;
--sb-red-strong-rgb: 229, 72, 77;
--sb-yellow-rgb: 227, 200, 78;
--sb-yellow-2-rgb: 227, 179, 65;
--sb-blue-rgb: 29, 100, 200;
--sb-bg-rgb: 246, 247, 248;
--sb-surface-rgb: 255, 255, 255;
--sb-text-7-rgb: 99, 99, 108;
--sb-text-9-rgb: 124, 124, 133;
--sb-scrim-rgb: 24, 26, 34;
--sb-legacy-orange: #b05c08;
--sb-legacy-green: #0a8657;
--sb-legacy-red: #c62f38;
--sb-legacy-yellow: #8a6c00;
--sb-legacy-purple: #6d45d9;
--sb-legacy-blue: #1d64c8;
--sb-legacy-gray: #63636c;
--sb-legacy-cyan: #0e7490;
--sb-legacy-text: #0e0e12;
--sb-legacy-text-2: #4a4a52;
--sb-legacy-text-3: #6f6f78;
--sb-legacy-body-text: #333339;
--sb-legacy-border-rgb: 148, 155, 168;
--sb-legacy-fill-rgb: 244, 245, 247;
--sb-legacy-scroll-rgb: 120, 126, 138;
--sb-legacy-orange-500: #b05c08;
--sb-legacy-orange-500-rgb: 249, 115, 22;
--sb-legacy-orange-600: #9c5007;
--sb-legacy-amber-500: #8a6c00;
--sb-legacy-amber-600: #7a5f00;
--sb-legacy-amber-700: #6a5200;
--sb-legacy-red-500: #c62f38;
--sb-legacy-red-600: #b0212a;
--sb-legacy-blue-500: #1d64c8;
--sb-legacy-emerald-500: #0a8657;
--sb-legacy-gray-500: #63636c;
--sb-legacy-blue-rgb: 59, 130, 246;
--sb-legacy-emerald-rgb: 16, 185, 129;
--sb-legacy-amber-rgb: 245, 158, 11;
/* Gradients that stepped *up* from the ground in the dark theme have to step
*down* from white here, or the raised end reads as a hole. */
--sb-legacy-raise-1: #eceef1;
--sb-legacy-raise-2: #e0e2e7;
--sb-legacy-splash: #e7e8ec;
--sb-motion-surface: #ffffff;
--sb-motion-raise: #f2f3f5;
--sb-motion-text: #1b1b20;
--sb-motion-text-2: #333339;
--sb-motion-text-3: #3a3a41;
--sb-legacy-ground: #eef0f2;
--sb-shadow-rgb: 16, 18, 27;
--sb-shadow-k: 0.26;
}
/* The one case theme-boot.js cannot answer: a client that does not run scripts, where
no data-theme is ever stamped. It gets the system preference, which is the closest
thing to a right answer available without storage. Guarded on the attribute being
absent so it can never fight the explicit choice the moment JS runs, data-theme
exists and this block stops matching. */
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
color-scheme: light;
--sb-ink: 0, 0, 0;
--sb-bg-deepest: #e7e7ea;
--sb-bg-deep: #eff0f2;
--sb-bg: #f6f7f8;
--sb-surface: #ffffff;
--sb-surface-2: #f2f3f5;
--sb-surface-3: #e7e8ec;
--sb-surface-4: #e0e1e6;
--sb-surface-5: #cfd0d7;
--sb-text-1: #0e0e12;
--sb-text-2: #1b1b20;
--sb-text-3: #26262c;
--sb-text-4: #333339;
--sb-text-5: #4a4a52;
--sb-text-6: #56565f;
--sb-text-7: #63636c;
--sb-text-8: #6f6f78;
--sb-text-9: #7c7c85;
--sb-text-faint: #85858f;
--sb-green: #0a8657;
--sb-green-rgb: 10, 134, 87;
--sb-orange: #b05c08;
--sb-orange-rgb: 176, 92, 8;
}
}
/* Tailwind utilities that assume a dark ground
*
* A handful of screens that predate the redesign the SAS verification panel, the
* password modal, the download cards are styled with Tailwind classes rather than
* inline styles, and Tailwind's palette has no idea a second theme exists. The classes
* below are the ones that only work on a dark background: light text (white, gray-200,
* gray-400), dark fills (gray-700, gray-900, black at low alpha), and the -300/-400
* accent shades, which are picked to glow on black and turn to pastel on white.
*
* Overriding here rather than rewriting the call sites is deliberate: the alternative is
* ~50 edits across five files to swap one palette for another, in screens that are
* already scheduled to be replaced. The selector carries the data-theme attribute, so it
* outranks Tailwind's single class without needing !important, and it costs nothing in
* the dark theme, where none of it matches.
*
* Not listed, on purpose: bg-black\/50, which is the password modal's backdrop. A scrim
* is supposed to be dark in both themes a light scrim over a light page dims nothing.
* The -500/10 fills and -500/20 borders are also left alone; a mid-tone at one tenth
* alpha reads correctly against either ground.
*/
:root[data-theme="light"] .text-white { color: #1b1b20; }
:root[data-theme="light"] .text-gray-200 { color: #333339; }
:root[data-theme="light"] .text-gray-400 { color: #63636c; }
:root[data-theme="light"] .text-gray-500 { color: #6f6f78; }
:root[data-theme="light"] .placeholder-gray-500::placeholder { color: #85858f; }
/* A watermark, not text: the huge icon behind each download card. */
:root[data-theme="light"] .text-white\/10 { color: rgba(0, 0, 0, 0.08); }
:root[data-theme="light"] .bg-gray-700\/40 { background-color: rgba(0, 0, 0, 0.04); }
:root[data-theme="light"] .bg-gray-700\/50 { background-color: rgba(0, 0, 0, 0.05); }
:root[data-theme="light"] .bg-gray-700\/60 { background-color: rgba(0, 0, 0, 0.06); }
:root[data-theme="light"] .bg-gray-900\/30 { background-color: rgba(0, 0, 0, 0.04); }
:root[data-theme="light"] .bg-black\/20 { background-color: rgba(0, 0, 0, 0.04); }
/* The hover overlay on a download card. Its text is --sb-text-*, which is dark in this
theme, so the overlay has to be light or the label disappears into it. */
:root[data-theme="light"] .bg-black\/30 { background-color: rgba(255, 255, 255, 0.82); }
/* The -300 and -400 accent shades, darkened to the same targets as the sb- accents. */
:root[data-theme="light"] .text-green-300,
:root[data-theme="light"] .text-green-400,
:root[data-theme="light"] .text-green-500 { color: #0a8657; }
:root[data-theme="light"] .text-red-300,
:root[data-theme="light"] .text-red-400,
:root[data-theme="light"] .text-red-500 { color: #c62f38; }
:root[data-theme="light"] .text-yellow-400 { color: #7d6400; }
:root[data-theme="light"] .text-orange-400,
:root[data-theme="light"] .text-orange-500 { color: #b05c08; }
:root[data-theme="light"] .text-purple-300,
:root[data-theme="light"] .text-purple-400 { color: #6d45d9; }
:root[data-theme="light"] .text-blue-400,
:root[data-theme="light"] .text-blue-500 { color: #1d64c8; }
:root[data-theme="light"] .text-sky-300 { color: #0b6fa8; }
:root[data-theme="light"] .text-amber-300 { color: #8a6c00; }
:root[data-theme="light"] .border-purple-400 { border-color: #6d45d9; }
/* Switching themes repaints the whole page at once. Without this it happens in one
frame and reads as a flash; with it the two palettes cross-fade. Only the two
properties that actually carry the theme are transitioned putting `all` here made
every hover and layout animation on the page inherit a 200ms delay.
The class is added by theme-boot.js for the duration of a switch only, so a page
*load* in either theme still paints instantly with no fade. */
:root.sb-theme-switching,
:root.sb-theme-switching * {
transition: background-color .2s ease, border-color .2s ease, color .2s ease !important;
}
@media (prefers-reduced-motion: reduce) {
:root.sb-theme-switching,
:root.sb-theme-switching * {
transition: none !important;
}
}