feat(i18n): Arabic, Hebrew, Persian and Urdu, and a layout that mirrors; release v6.4.0
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

Four right-to-left languages at /ar/, /he/, /fa/ and /ur/ — thirteen in all.

The layout no longer has a left and a right, it has a start and an end: the
margins, insets and corners are CSS logical properties now, so they follow
dir on <html>. Directional glyphs flip; keys, safety codes and session
descriptors are pinned left-to-right so bidi cannot reorder what two people
compare against each other's screens.

Also fixed: the Service Worker was registered as './sw.js', which 404s from
every locale subdirectory, so twelve of the thirteen pages had no worker at
all. And the bundler ran before the dictionaries were generated, so a newly
added language could reach the page ahead of the app that renders it.
This commit is contained in:
lockbitchat
2026-08-29 18:05:20 -04:00
parent 98d42ac2fb
commit 5e32f547b9
68 changed files with 15147 additions and 500 deletions
+1 -1
View File
@@ -461,7 +461,7 @@ const QRScanner = ({ onScan, onClose, isVisible, continuous = false }) => {
}, [
React.createElement('i', {
key: 'tips-icon',
className: 'fas fa-lightbulb mr-2'
className: 'fas fa-lightbulb me-2'
}),
'Tips for better scanning:'
]),
+1 -1
View File
@@ -183,7 +183,7 @@ const UpdateChecker = ({ children, onUpdateAvailable, debug = false }) => {
// 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: 'left' }
style: { borderRadius: '14px', background: '#0c0c0e', border: '1px solid rgba(255,255,255,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')),
+3 -3
View File
@@ -47,9 +47,9 @@ const BecomePartner = () => {
}
];
const svg = (inner, size, stroke, sw) =>
const svg = (inner, size, stroke, sw, className) =>
React.createElement('svg', {
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
className, width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
stroke, strokeWidth: sw, strokeLinecap: 'round', strokeLinejoin: 'round',
dangerouslySetInnerHTML: { __html: inner }
});
@@ -123,7 +123,7 @@ const BecomePartner = () => {
}
}, [
t('partners.inviteCta'),
svg('<path d="M5 12h14M13 6l6 6-6 6"/>', 17, 'currentColor', 2.2)
svg('<path d="M5 12h14M13 6l6 6-6 6"/>', 17, 'currentColor', 2.2, 'sb-mirror-rtl')
])
]);
+3 -3
View File
@@ -177,12 +177,12 @@ const CallUIComponent = ({ webrtcManager, peerTitle }) => {
// ── MINIMIZED widget ─────────────────────────────────────────────────────
if (minimized) {
return h('div', { style: { position: 'absolute', bottom: '18px', right: '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: '#161618', border: '1px solid rgba(255,255,255,0.1)', boxShadow: '0 18px 44px rgba(0,0,0,0.55)', 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)),
h('span', { key: 's', style: { position: 'absolute', top: '8px', left: '9px', fontFamily: MONO, fontSize: '11px', fontWeight: 600, color: '#fff', padding: '3px 7px', borderRadius: '6px', background: 'rgba(0,0,0,0.5)' } }, callStatus)
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)),
@@ -222,7 +222,7 @@ 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', right: '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(255,255,255,0.16)', boxShadow: '0 12px 30px rgba(0,0,0,0.5)', 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' } }, [
svg(ICON.camOff, 24, 1.8),
+6 -6
View File
@@ -180,7 +180,7 @@
{/* Mobile Alert */}
<div className="md:hidden p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg mb-4">
<p className="text-yellow-400 text-sm text-center">
<i className="fas fa-lightbulb mr-2"></i>
<i className="fas fa-lightbulb me-2"></i>
Rotate your device horizontally for better viewing
</p>
</div>
@@ -194,7 +194,7 @@
{/* Table Header */}
<thead>
<tr className="bg-black-table">
<th className="text-left p-4 border-b border-gray-600 text-white font-bold min-w-[240px]">
<th className="text-start p-4 border-b border-gray-600 text-white font-bold min-w-[240px]">
Security Criterion
</th>
{messengers.map((messenger, index) => (
@@ -282,20 +282,20 @@
{/* Legend */}
<div className="mt-8 grid grid-cols-2 md:grid-cols-4 gap-4 max-w-5xl mx-auto">
<div className="flex items-center justify-center p-4 bg-orange-500/10 rounded-xl hover:bg-orange-500/40 transition-colors">
<i className="fas fa-trophy text-orange-400 mr-2 text-xl"></i>
<i className="fas fa-trophy text-orange-400 me-2 text-xl"></i>
<span className="text-orange-300 text-sm font-bold">Category Leader</span>
</div>
<div className="flex items-center justify-center p-4 bg-green-500/10 rounded-xl hover:bg-green-600/40 transition-colors">
<i className="fas fa-check text-green-300 mr-2 text-xl"></i>
<i className="fas fa-check text-green-300 me-2 text-xl"></i>
<span className="text-green-200 text-sm font-bold">Excellent</span>
</div>
<div className="flex items-center justify-center p-4 bg-yellow-500/10 rounded-xl hover:bg-yellow-600/40 transition-colors">
<i className="fas fa-exclamation-triangle text-yellow-300 mr-2 text-xl"></i>
<i className="fas fa-exclamation-triangle text-yellow-300 me-2 text-xl"></i>
<span className="text-yellow-200 text-sm font-bold">Partial/Limited</span>
</div>
<div className="flex items-center justify-center p-4 bg-red-500/10 rounded-xl hover:bg-red-600/40 transition-colors">
<i className="fas fa-times text-red-300 mr-2 text-xl"></i>
<i className="fas fa-times text-red-300 me-2 text-xl"></i>
<span className="text-red-200 text-sm font-bold">Not Available</span>
</div>
</div>
+6 -6
View File
@@ -203,7 +203,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
}, [
React.createElement('i', {
key: 'icon',
className: 'fas fa-exclamation-triangle mr-2'
className: 'fas fa-exclamation-triangle me-2'
}),
'Соединение устанавливается... Передача файлов будет доступна после завершения установки.'
]);
@@ -333,7 +333,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
React.createElement('i', {
key: 'icon',
className: 'fas fa-arrow-up',
style: { color: '#f0892a', fontSize: '13px', marginRight: '8px' }
style: { color: '#f0892a', fontSize: '13px', marginInlineEnd: '8px' }
}),
React.createElement('span', {
key: 'name',
@@ -342,7 +342,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
}, transfer.fileName),
React.createElement('span', {
key: 'size',
className: "text-xs ml-2",
className: "text-xs ms-2",
style: { color: '#7b7b83' }
}, formatFileSize(transfer.fileSize))
]),
@@ -377,7 +377,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
React.createElement('i', {
key: 'icon',
className: 'fas fa-arrow-down',
style: { color: '#3ecf8e', fontSize: '13px', marginRight: '8px' }
style: { color: '#3ecf8e', fontSize: '13px', marginInlineEnd: '8px' }
}),
React.createElement('span', {
key: 'name',
@@ -386,7 +386,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
}, transfer.fileName),
React.createElement('span', {
key: 'size',
className: "text-xs ml-2",
className: "text-xs ms-2",
style: { color: '#7b7b83' }
}, formatFileSize(transfer.fileSize))
]),
@@ -408,7 +408,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFile
}
}
}, [
React.createElement('i', { key: 'i', className: 'fas fa-download mr-1' }),
React.createElement('i', { key: 'i', className: 'fas fa-download me-1' }),
t('file.download')
]) : null,
React.createElement('button', {
+10 -5
View File
@@ -167,9 +167,12 @@ export function GroupSasModal({ group, onConfirm, onCancel }) {
background: 'rgba(240,137,42,0.09)', border: '1px solid rgba(240,137,42,0.3)',
},
}, h('span', {
// Everyone in the group compares these digits against each other's
// screens, so their order must not depend on the reader's language.
dir: 'ltr',
style: {
fontFamily: C.mono, fontSize: 'clamp(30px, 9vw, 42px)', fontWeight: 700,
letterSpacing: '9px', color: C.accent,
letterSpacing: '9px', color: C.accent, unicodeBidi: 'isolate',
},
}, group.sasCode)),
@@ -277,7 +280,7 @@ export function CreateGroupModal({ candidates, relayOnly, onCreate, onCancel })
disabled: full,
style: {
display: 'flex', alignItems: 'center', gap: '11px', padding: '10px 12px',
borderRadius: '10px', cursor: full ? 'not-allowed' : 'pointer', textAlign: 'left',
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}`,
opacity: full ? 0.4 : 1, fontFamily: 'inherit',
@@ -405,7 +408,7 @@ export function AddMembersModal({ candidates, remaining, onAdd, onCancel }) {
disabled: full,
style: {
display: 'flex', alignItems: 'center', gap: '11px', padding: '10px 12px',
borderRadius: '10px', cursor: full ? 'not-allowed' : 'pointer', textAlign: 'left',
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}`,
opacity: full ? 0.4 : 1, fontFamily: 'inherit',
@@ -558,7 +561,7 @@ function Bubble({ msg }) {
}, [
!mine && h('span', {
key: 'who',
style: { fontSize: '11.5px', fontWeight: 600, color: C.accent, paddingLeft: '3px' },
style: { fontSize: '11.5px', fontWeight: 600, color: C.accent, paddingInlineStart: '3px' },
}, msg.senderName || t('group.member')),
h('div', {
key: 'b',
@@ -626,7 +629,9 @@ export function GroupChatView({
}, [
svg(ICON.users, { key: 'i', width: '13px', height: '13px' }),
t('group.membersCount', { count: group.members.length }),
ready && group.sasCode ? t('group.codeSuffix', { code: group.sasCode }) : '',
ready && group.sasCode
? h('span', { key: 'code', dir: 'ltr', style: { unicodeBidi: 'isolate' } }, t('group.codeSuffix', { code: group.sasCode }))
: '',
]),
]),
(isAdmin && onAddMembers && group.members.length < GROUP_LIMITS.MAX_MEMBERS) && h('button', {
+8 -7
View File
@@ -1,4 +1,4 @@
import { t } from '../../i18n/index.js';
import { t, direction } from '../../i18n/index.js';
// Advanced network settings: lets a user supply their own STUN/TURN servers
// instead of the bundled public defaults, and toggle relay-only privacy mode.
// Free / power-user feature, hidden behind an explicit "Advanced" entry point.
@@ -115,7 +115,7 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
const radioCard = (selected, onClick, title, desc, extraStyle) => h('button', {
type: 'button', onClick,
style: Object.assign({
width: '100%', textAlign: 'left', display: 'flex', alignItems: 'flex-start', gap: '12px',
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',
@@ -134,7 +134,7 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
const toggleRow = (on, onClick, title, desc, accent, badge) => h('button', {
type: 'button', onClick,
style: {
width: '100%', textAlign: 'left', display: 'flex', alignItems: 'flex-start', gap: '12px',
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',
@@ -149,7 +149,7 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
h('span', { key: 'd', style: { display: 'block', fontSize: '12.5px', lineHeight: 1.5, color: '#8a8a92', 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', { style: { position: 'absolute', top: '2px', left: '2px', width: '18px', height: '18px', borderRadius: '50%', background: '#fff', transform: on ? 'translateX(18px)' : 'translateX(0)', transition: 'transform .18s' } }))
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 ──
@@ -163,16 +163,17 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
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' } },
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' }
})));
if (parsed.errors.length > 0) {
custom.push(h('ul', { key: 'err', style: { margin: '0 0 10px', paddingLeft: '18px', color: '#e5727a', fontSize: '12.5px' } },
custom.push(h('ul', { key: 'err', style: { margin: '0 0 10px', paddingInlineStart: '18px', color: '#e5727a', 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', paddingLeft: '18px', color: '#e3c84e', fontSize: '12.5px' } },
custom.push(h('ul', { key: 'warn', style: { margin: '0 0 10px', paddingInlineStart: '18px', color: '#e3c84e', 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) {
@@ -219,7 +220,7 @@ const IceServerSettings = ({ isOpen, onClose, initial, hasSaved, onApply, onForg
const footerBtns = [];
if (hasSaved) {
footerBtns.push(h('button', { key: 'forget', type: 'button', onClick: handleForget,
style: { marginRight: '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(229,114,122,0.3)', background: 'transparent', color: '#e5727a', 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')));
+12 -2
View File
@@ -68,20 +68,30 @@ const LanguageSwitcher = () => {
const menu = React.createElement('div', {
key: 'menu',
role: 'menu',
// The same scrollbar the chat uses, rather than the browser's default slab —
// a 170px menu is exactly where a 15px stock scrollbar looks like a mistake.
className: 'sb-scroll',
// Kept in the DOM when shut: the links stay followable and the menu costs nothing
// to reopen.
style: {
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 60,
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)',
// 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.
maxHeight: 'min(62vh, 420px)', overflowY: 'auto', overscrollBehavior: 'contain',
},
}, links.map((link) => React.createElement('a', {
key: link.code,
href: link.href,
hrefLang: link.hrefLang,
lang: link.hrefLang,
// Each row is a word in its own script, so it gets its own direction — the menu
// around it stays in the direction of the page being read.
dir: link.dir,
role: 'menuitem',
// aria-current names the page you are on for a screen reader; the weight and
// contrast below say the same thing to everyone else.
@@ -101,7 +111,7 @@ const LanguageSwitcher = () => {
}, [
React.createElement('span', {
key: 'a',
style: { fontSize: '11px', fontWeight: 700, letterSpacing: '0.4px', color: '#6b6b73', width: '22px' },
style: { fontSize: '11px', fontWeight: 700, letterSpacing: '0.4px', color: '#6b6b73', width: '22px', flex: 'none', textAlign: 'start' },
}, link.abbr),
React.createElement('span', { key: 'n' }, link.label),
])));
+3 -3
View File
@@ -29,7 +29,7 @@ const PasswordModal = ({ isOpen, onClose, onSubmit, action, password, setPasswor
}, [
React.createElement('div', {
key: 'icon',
className: 'w-10 h-10 bg-purple-500/10 border border-purple-500/20 rounded-lg flex items-center justify-center mr-3'
className: 'w-10 h-10 bg-purple-500/10 border border-purple-500/20 rounded-lg flex items-center justify-center me-3'
}, [
React.createElement('i', {
className: 'fas fa-key accent-purple'
@@ -68,7 +68,7 @@ const PasswordModal = ({ isOpen, onClose, onSubmit, action, password, setPasswor
className: 'flex-1 btn-primary text-white py-3 px-4 rounded-lg font-medium transition-all duration-200'
}, [
React.createElement('i', {
className: 'fas fa-unlock-alt mr-2'
className: 'fas fa-unlock-alt me-2'
}),
t('pw.decrypt')
]),
@@ -79,7 +79,7 @@ const PasswordModal = ({ isOpen, onClose, onSubmit, action, password, setPasswor
className: 'flex-1 btn-secondary text-white py-3 px-4 rounded-lg font-medium transition-all duration-200'
}, [
React.createElement('i', {
className: 'fas fa-times mr-2'
className: 'fas fa-times me-2'
}),
t('pw.cancel')
])
+6 -6
View File
@@ -75,28 +75,28 @@ function Roadmap() {
const renderNode = (status) => {
if (status === 'released') {
return (
<div style={{ position: 'absolute', left: '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 }}>
<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>
);
}
if (status === 'current') {
return (
<div style={{ position: 'absolute', left: '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' }}>
<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>
);
}
if (status === 'dev') {
return (
<div style={{ position: 'absolute', left: '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 }}>
<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>
);
}
// planned / research
return (
<div style={{ position: 'absolute', left: '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 }}>
<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>
);
@@ -134,7 +134,7 @@ function Roadmap() {
{/* spine */}
<div style={{ position: 'relative' }}>
{notLast && <div style={{ position: 'absolute', left: '26px', top: '30px', height: 'calc(100% + 16px)', width: '2px', background: meta.line }} />}
{notLast && <div style={{ position: 'absolute', insetInlineStart: '26px', top: '30px', height: 'calc(100% + 16px)', width: '2px', background: meta.line }} />}
{renderNode(d.status)}
</div>
@@ -156,7 +156,7 @@ function Roadmap() {
<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: 'right' }}>{d.date}</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)' }}>
<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>
+3 -3
View File
@@ -84,7 +84,7 @@ const Testimonials = () => {
<div key={`${t.id}-${index}`} className="card bg-neutral-900 rounded-xl p-5 shadow-md w-72 text-sm text-white flex-shrink-0">
<div className="flex items-center mb-2 text-yellow-400">
{"★".repeat(Math.floor(t.rating))}
<span className="ml-2 text-secondary">{t.rating.toFixed(1)}</span>
<span className="ms-2 text-secondary">{t.rating.toFixed(1)}</span>
</div>
<p className="text-secondary mb-3">{t.text}</p>
</div>
@@ -104,8 +104,8 @@ const Testimonials = () => {
</div>
<div className="lg:col-span-3 testimonials-wrapper flex gap-6 overflow-hidden relative h-[420px]">
<div className="pointer-events-none absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-[#1f1f1f]/90 to-transparent z-20"></div>
<div className="pointer-events-none absolute bottom-0 left-0 w-full h-16 bg-gradient-to-t from-[#1f1f1f]/90 to-transparent z-20"></div>
<div className="pointer-events-none absolute top-0 start-0 w-full h-16 bg-gradient-to-b from-[#1f1f1f]/90 to-transparent z-20"></div>
<div className="pointer-events-none absolute bottom-0 start-0 w-full h-16 bg-gradient-to-t from-[#1f1f1f]/90 to-transparent z-20"></div>
<div className="col-up flex flex-col gap-6">
{testimonials.map((t, i) => renderCard(t, i))}
+1 -1
View File
@@ -82,7 +82,7 @@ const UniqueFeatureSlider = () => {
const navBtn = (key, onClick, path) =>
React.createElement('button', {
key, onClick, 'aria-label': key,
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)',