source for getorbyt.com getorbyt.com/
client bsky orbytapp app orbyt bluesky getorbyt orbytvideo atproto video
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement dark mode support and improve color scheme handling

- Update HTML structure to include a dynamic color scheme based on the user's profile settings.
- Introduce a new DocumentColorScheme component for consistent theme color application across pages.
- Refactor CSS to utilize CSS variables for background and text colors, enhancing visual consistency.
- Adjust various pages to support dark mode, ensuring a cohesive user experience.

+243 -277
+4 -1
public/callback.html
··· 1 1 <!DOCTYPE html> 2 - <html lang="en"> 2 + <html lang="en" class="orbyt-doc-dark"> 3 3 <head> 4 4 <meta charset="UTF-8"> 5 + <meta name="color-scheme" content="dark" /> 6 + <meta name="theme-color" content="#05070a" /> 5 7 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 8 <title>Orbyt OAuth Callback</title> 9 + <link rel="stylesheet" href="/css/tokens.css" /> 7 10 <link rel="stylesheet" href="/css/fonts.css"> 8 11 <style> 9 12 body {
+35 -12
public/css/profile.css
··· 3 3 } 4 4 body { 5 5 width: 100%; 6 - /* Fallback when --orbyt-chrome is unset; matches chrome for default profile bg #0e141b */ 7 - background-color: var(--orbyt-chrome, #0c1117); 8 - color: #ccd7e9; 6 + background-color: var(--orbyt-chrome, var(--orbyt-chrome-fallback)); 7 + color: var(--orbyt-profile-text, var(--orbyt-neutral-200)); 8 + } 9 + 10 + #profile > header { 11 + background-color: var(--orbyt-profile-bg); 12 + } 13 + #profile .author { 14 + color: var(--orbyt-profile-text); 15 + } 16 + #profile .beta-icon { 17 + color: inherit; 18 + } 19 + #profile .author .avatar { 20 + border-color: var(--orbyt-profile-text); 21 + } 22 + #profile .decorative-hr path { 23 + stroke: var(--orbyt-profile-text); 24 + } 25 + #profile .install-button { 26 + background-color: var(--orbyt-profile-text); 27 + color: var(--orbyt-profile-bg); 9 28 } 10 29 header { 11 30 padding: 20px; ··· 97 116 } 98 117 .empty-feed-message { 99 118 font-size: clamp(1.25rem, 3vw, 1.75rem); 100 - color: #9ca3af; 119 + color: var(--orbyt-text-subtle); 101 120 line-height: 1.5; 102 121 } 103 122 ··· 134 153 width: 50%; 135 154 margin-bottom: 2px; 136 155 box-sizing: border-box; 137 - color: #ccd7e9; 156 + color: var(--orbyt-profile-text, var(--orbyt-neutral-200)); 138 157 align-items: flex-end; 139 158 padding: 0; 140 159 overflow: hidden; ··· 174 193 top: 0; 175 194 height: 100%; 176 195 object-fit: contain; 177 - background-color: var(--orbyt-chrome, #0c1117); 196 + background-color: var(--orbyt-chrome, var(--orbyt-chrome-fallback)); 178 197 } 179 198 .post .play-button { 180 199 width: 60px; ··· 186 205 left: 0; 187 206 right: 0; 188 207 bottom: 0; 189 - /* Orbyt black rgb(5,7,10) — matches app `Colors.overlay`, not pure #000 */ 190 - background: linear-gradient(360deg, rgba(5, 7, 10, 0.4) 0%, rgba(5, 7, 10, 0.2) 43.67%, rgba(5, 7, 10, 0) 96.43%); 208 + background: linear-gradient( 209 + 360deg, 210 + rgb(var(--orbyt-scrim-rgb) / 0.4) 0%, 211 + rgb(var(--orbyt-scrim-rgb) / 0.2) 43.67%, 212 + rgb(var(--orbyt-scrim-rgb) / 0) 96.43% 213 + ); 191 214 padding: 20px 10px 8px; 192 215 box-sizing: border-box; 193 216 } ··· 207 230 margin-bottom: 50px; 208 231 } 209 232 #load-more { 210 - background: #888; 233 + background: var(--orbyt-profile-text); 234 + color: var(--orbyt-profile-bg); 211 235 border-radius: 30px; 212 - color: #ccd7e9; 213 236 cursor: pointer; 214 237 padding: 5px 20px; 215 238 text-decoration: none; ··· 217 240 #load-more:hover, 218 241 #load-more:active, 219 242 #load-more:visited { 220 - color: #ccd7e9; 243 + color: var(--orbyt-profile-bg); 221 244 text-decoration: none; 222 245 } 223 246 224 247 .loading { 225 248 padding: 20px; 226 249 text-align: center; 227 - color: #ccd7e9; 250 + color: var(--orbyt-profile-text, var(--orbyt-neutral-200)); 228 251 } 229 252 230 253 /*--- Responsive styles ---*/
+7 -5
public/css/shared.css
··· 1 + @import url('./tokens.css'); 2 + 1 3 body { 2 - background: #000; 3 - color: #B1BACF; 4 + background: var(--orbyt-page-bg); 5 + color: var(--orbyt-page-fg); 4 6 font-family: 'Figtree', sans-serif; 5 7 line-height: 28px; 6 8 } 7 9 8 10 .install-button { 9 11 align-items: center; 10 - background: #F4F7FF; 12 + background: var(--orbyt-install-surface); 11 13 border-radius: 31px; 12 14 box-shadow: 0px 6px 10px rgba(7, 25, 72, 0.13); 13 - color: black; 15 + color: var(--orbyt-black); 14 16 display: flex; 15 17 font-size: 16px; 16 18 font-weight: normal; ··· 28 30 } 29 31 .install-button:hover, 30 32 .install-button:active { 31 - color: black; 33 + color: var(--orbyt-black); 32 34 } 33 35 34 36 .horizontal-rule {
+34
public/css/tokens.css
··· 1 + /* Design tokens (aligned with app theme). Profile body overrides --orbyt-chrome / --orbyt-profile-*. */ 2 + html.orbyt-doc-dark { 3 + color-scheme: dark; 4 + } 5 + html.orbyt-doc-light { 6 + color-scheme: light; 7 + } 8 + 9 + :root { 10 + /* Brand */ 11 + --orbyt-white: #f3f5fe; 12 + --orbyt-black: #05070a; 13 + --orbyt-purple: #551def; 14 + 15 + /* Neutrals (common surfaces / text) */ 16 + --orbyt-neutral-900: #0e141b; 17 + --orbyt-neutral-200: #ccd7e9; 18 + --orbyt-neutral-muted: #b1bacf; 19 + 20 + /* Marketing / embed surfaces */ 21 + --orbyt-page-bg: var(--orbyt-black); 22 + --orbyt-page-fg: var(--orbyt-neutral-muted); 23 + --orbyt-install-surface: #f4f7ff; 24 + 25 + /* Default profile chrome (SSR normally sets --orbyt-chrome on body) */ 26 + --orbyt-chrome-fallback: #0c1117; 27 + 28 + /* Scrims / shadows (Orbyt black rgb — matches app Colors.overlay) */ 29 + --orbyt-scrim-rgb: 5, 7, 10; 30 + --orbyt-shadow-soft: rgb(var(--orbyt-scrim-rgb) / 0.15); 31 + 32 + /* Profile: optional empty state */ 33 + --orbyt-text-subtle: #9ca3af; 34 + }
+4 -64
public/js/post-view.js
··· 1 - /** 2 - * Video Post View 3 - * Handles video playback for post pages 4 - * Uses SSR data from data attributes - no API calls needed 5 - */ 6 - 7 - /** 8 - * Format relative time (e.g., "2h", "3d", "1mo") 9 - */ 10 1 function formatRelativeTime(createdAt) { 11 2 if (!createdAt) return ''; 12 3 ··· 26 17 return 'now'; 27 18 } 28 19 29 - /** 30 - * Initialize video player with HLS support 31 - */ 32 20 function initVideoPlayer(videoUrl, thumbnail) { 33 21 const videoEl = document.getElementById('vinit'); 34 22 const thumbnailEl = document.getElementById('video-thumbnail'); ··· 38 26 return; 39 27 } 40 28 41 - // Clean up any existing HLS instance 42 29 if (videoEl.hlsInstance) { 43 - try { 44 - videoEl.hlsInstance.destroy(); 45 - videoEl.hlsInstance = null; 46 - } catch (e) { 47 - console.warn('Error destroying HLS instance:', e); 48 - } 30 + videoEl.hlsInstance.destroy(); 31 + videoEl.hlsInstance = null; 49 32 } 50 33 51 - // Reset video element 52 34 videoEl.pause(); 53 35 videoEl.src = ''; 54 36 videoEl.removeAttribute('src'); 55 37 videoEl.load(); 56 38 57 - // Set video attributes 58 39 videoEl.setAttribute('autoplay', ''); 59 40 videoEl.setAttribute('loop', ''); 60 41 videoEl.setAttribute('playsinline', ''); 61 42 videoEl.setAttribute('preload', 'auto'); 62 43 videoEl.muted = true; 63 44 64 - // Thumbnail visibility handlers (thumbnail is rendered in HTML, no need to set src) 65 45 const hideThumbnail = () => { 66 46 if (thumbnailEl) { 67 47 thumbnailEl.classList.add('hidden'); ··· 77 57 videoEl.addEventListener('playing', hideThumbnail, { once: true }); 78 58 videoEl.addEventListener('pause', showThumbnail); 79 59 80 - // Setup HLS playback 81 60 if (videoUrl.includes('.m3u8')) { 82 61 if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { 83 - // Native HLS (Safari/iOS) 84 62 videoEl.src = videoUrl; 85 63 videoEl.load(); 86 64 } else if (typeof Hls !== 'undefined') { 87 - // Use hls.js 88 65 const hls = new Hls({ 89 66 enableWorker: true, 90 67 lowLatencyMode: true, ··· 125 102 videoEl.load(); 126 103 } 127 104 128 - // Mute/unmute on tap/click anywhere on video (video keeps playing) 129 105 const videoContainer = thumbnailEl?.closest('.video-container'); 130 106 const handleVideoAreaClick = (e) => { 131 107 e.stopPropagation(); ··· 144 120 videoContainer.addEventListener('click', handleVideoAreaClick); 145 121 } 146 122 147 - // Initialize sizing 148 123 resizeVideoAndOverlay(); 149 124 } 150 125 151 - /** 152 - * Initialize mute toggle controls 153 - */ 154 126 function initMuteControls() { 155 127 const videoEl = document.getElementById('vinit'); 156 128 const muteToggle = document.getElementById('mute-toggle'); ··· 179 151 } 180 152 } 181 153 182 - /** 183 - * Resize video and overlay for responsive layout 184 - */ 185 154 function resizeVideoAndOverlay() { 186 155 const videoEl = document.getElementById('vinit'); 187 156 const postOverlay = document.getElementById('post-overlay'); ··· 198 167 let videoHeight = Math.max(minHeight, maxHeight); 199 168 let columnWidth = (videoHeight * 9) / 16; 200 169 201 - // Scale for narrower viewports 202 170 if (viewportWidth < 900) { 203 171 const scaleFactor = 0.75 + (0.25 * (viewportWidth - 700)) / 200; 204 172 videoHeight = videoHeight * Math.max(0.75, Math.min(1.0, scaleFactor)); 205 173 columnWidth = (videoHeight * 9) / 16; 206 174 } 207 175 208 - // Fit within available space 209 176 let reservedSpace = viewportWidth < 900 210 177 ? Math.max(400, 540 * (0.7 + (0.3 * (viewportWidth - 700)) / 200)) 211 178 : 540; ··· 227 194 videoEl.style.height = 'auto'; 228 195 } 229 196 230 - // Adjust overlay position 231 197 if (postOverlay) { 232 198 const scrollPosition = window.scrollY; 233 199 const offsetHeight = videoEl.offsetHeight; ··· 239 205 240 206 let resizeInitialized = false; 241 207 242 - /** 243 - * Initialize resize listeners (once) 244 - */ 245 208 function initResizeListeners() { 246 209 if (resizeInitialized) { 247 210 resizeVideoAndOverlay(); ··· 265 228 }); 266 229 } 267 230 268 - /** 269 - * Update display elements with SSR data 270 - * Note: Captions are now rendered server-side via set:html 271 - */ 272 231 function updateDisplay(data) { 273 - // Update time (computed client-side for accurate relative time) 274 232 const relativeTime = formatRelativeTime(data.createdAt); 275 233 const timeMobile = document.getElementById('post-time-mobile'); 276 234 const timeDesktop = document.getElementById('post-time-desktop'); ··· 283 241 timeDesktop.style.display = relativeTime ? 'block' : 'none'; 284 242 } 285 243 286 - // Update likes 287 244 const likesCountMobile = document.getElementById('likes-count-mobile'); 288 245 const likesCountDesktop = document.getElementById('likes-count-desktop'); 289 246 if (likesCountMobile) likesCountMobile.textContent = data.likeCount; 290 247 if (likesCountDesktop) likesCountDesktop.textContent = data.likeCount; 291 248 } 292 249 293 - /** 294 - * Main initialization 295 - */ 296 250 function initPostView() { 297 251 const postEl = document.getElementById('post'); 298 252 if (!postEl) return; 299 253 300 - // Get SSR data from data attributes 301 - // Note: caption is now rendered server-side, not needed here 302 254 const data = { 303 255 handle: postEl.dataset.handle, 304 256 postId: postEl.dataset.postId, ··· 311 263 createdAt: postEl.dataset.createdAt, 312 264 }; 313 265 314 - // Update display with SSR data 315 266 updateDisplay(data); 316 267 317 - // Initialize video player 318 268 if (data.videoUrl) { 319 269 initVideoPlayer(data.videoUrl, data.thumbnail); 320 270 } 321 271 322 - // Initialize controls 323 272 initMuteControls(); 324 273 initResizeListeners(); 325 274 326 - // Show post content 327 275 postEl.classList.remove('hidden'); 328 276 } 329 277 330 - // Show thumbnail before view transition captures snapshot (for back navigation) 331 278 document.addEventListener('astro:before-preparation', () => { 332 279 const videoEl = document.getElementById('vinit'); 333 280 const thumbnailEl = document.getElementById('video-thumbnail'); 334 281 335 - // Pause video and show thumbnail so view transition can capture it 336 282 if (videoEl) { 337 283 videoEl.pause(); 338 284 } ··· 341 287 } 342 288 }); 343 289 344 - // Cleanup before page swap 345 290 document.addEventListener('astro:before-swap', () => { 346 291 const videoEl = document.getElementById('vinit'); 347 292 const muteToggle = document.getElementById('mute-toggle'); 348 293 349 294 if (videoEl && videoEl.hlsInstance) { 350 - try { 351 - videoEl.hlsInstance.destroy(); 352 - videoEl.hlsInstance = null; 353 - } catch (e) { 354 - console.warn('Error cleaning up HLS:', e); 355 - } 295 + videoEl.hlsInstance.destroy(); 296 + videoEl.hlsInstance = null; 356 297 videoEl.pause(); 357 298 videoEl.src = ''; 358 299 } ··· 361 302 resizeInitialized = false; 362 303 }); 363 304 364 - // Initialize on page load 365 305 document.addEventListener('astro:page-load', initPostView);
+1 -6
src/actions/index.ts
··· 4 4 import { parseRichText } from "../utils/richtext"; 5 5 6 6 export const server = { 7 - /** 8 - * Load more video posts for a profile page 9 - * Returns pre-rendered HTML for captions to avoid client-side parsing 10 - */ 11 7 loadMorePosts: defineAction({ 12 8 input: z.object({ 13 9 handle: z.string(), ··· 15 11 }), 16 12 handler: async ({ handle, cursor }) => { 17 13 const videoFeed = await fetchVideoPosts(handle, cursor, 30); 18 - 19 - // Return pre-rendered HTML for each post's caption 14 + 20 15 const posts = videoFeed.posts.map(post => ({ 21 16 postId: post.postId, 22 17 thumbnail: post.thumbnail,
+13
src/components/DocumentColorScheme.astro
··· 1 + --- 2 + /** Document `<meta>` for color-scheme / theme-color; emit right after charset. */ 3 + interface Props { 4 + colorScheme: 'dark' | 'light'; 5 + themeColor: string; 6 + tileColor?: string; 7 + } 8 + 9 + const { colorScheme, themeColor, tileColor } = Astro.props; 10 + --- 11 + <meta name="color-scheme" content={colorScheme} /> 12 + <meta name="theme-color" content={themeColor} /> 13 + {tileColor && <meta name="msapplication-TileColor" content={tileColor} />}
+10 -5
src/pages/404.astro
··· 1 1 --- 2 2 export const prerender = true; 3 + 4 + import DocumentColorScheme from '../components/DocumentColorScheme.astro'; 5 + import { SITE_DOCUMENT } from '../utils/site-document-theme'; 3 6 --- 4 7 5 8 <!doctype html> 6 - <html lang="en"> 9 + <html lang="en" class="orbyt-doc-dark"> 7 10 <head> 8 11 <meta charset="UTF-8"> 12 + <DocumentColorScheme 13 + colorScheme="dark" 14 + themeColor={SITE_DOCUMENT.orbytBlack} 15 + tileColor={SITE_DOCUMENT.orbytBlack} 16 + /> 9 17 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 10 18 <title>404 - Page Not Found | Orbyt</title> 11 19 ··· 20 28 <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png"> 21 29 <link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png"> 22 30 <link rel="manifest" href="/favicon/site.webmanifest"> 23 - 24 - <!-- Theme colors for mobile browsers --> 25 - <meta name="theme-color" content="#000000"> 26 - <meta name="msapplication-TileColor" content="#000000"> 31 + <link rel="stylesheet" href="/css/tokens.css" /> 27 32 <style> 28 33 * { 29 34 margin: 0;
+21 -30
src/pages/@[handle].astro
··· 6 6 import { getColor } from '../utils/orbyt-api'; 7 7 import { parseRichText } from '../utils/richtext'; 8 8 import { getChromeBackgroundColor, getUiColorScheme } from '../utils/profile-colors'; 9 + import DocumentColorScheme from '../components/DocumentColorScheme.astro'; 9 10 import { ClientRouter } from 'astro:transitions'; 10 11 11 12 const { handle } = Astro.params; ··· 65 66 --- 66 67 67 68 <!doctype html> 68 - <html lang="en" style={`color-scheme: ${uiColorScheme};`}> 69 + <html lang="en" class={uiColorScheme === 'dark' ? 'orbyt-doc-dark' : 'orbyt-doc-light'}> 69 70 70 71 <head> 71 72 <meta charset="utf-8"> 72 - <!-- MDN: early color-scheme + theme-color reduce UA chrome mismatch / flash (CSS Color Adjustment / HTML) --> 73 - <meta name="color-scheme" content={uiColorScheme} /> 74 - <meta name="theme-color" content={chromeBackgroundColor} /> 73 + <DocumentColorScheme colorScheme={uiColorScheme} themeColor={chromeBackgroundColor} /> 75 74 76 - <!-- Critical: Inline font-face for immediate availability (no network delay) --> 77 75 <style> 78 76 @font-face { 79 77 font-family: 'Figtree'; ··· 83 81 font-display: fallback; /* Short block window, then swap - prevents FOUT for logo */ 84 82 } 85 83 </style> 86 - <!-- Preload Figtree font to start download early --> 87 84 <link rel="preload" href="/fonts/Figtree/Figtree-VariableFont_wght.woff2" as="font" type="font/woff2" crossorigin> 88 85 89 86 <link rel="stylesheet" href="/css/normalize.css"> ··· 111 108 <meta name="twitter:description" content={ogDescription} /> 112 109 <meta name="twitter:image" content={ogImage} /> 113 110 114 - <!-- Structured Data for SEO - Breadcrumbs --> 115 111 <script type="application/ld+json" is:inline set:html={JSON.stringify({ 116 112 "@context": "https://schema.org", 117 113 "@type": "BreadcrumbList", ··· 130 126 } 131 127 ] 132 128 })}></script> 133 - <!-- Profile Structured Data --> 134 129 <script type="application/ld+json" is:inline set:html={JSON.stringify({ 135 130 "@context": "https://schema.org", 136 131 "@type": "ProfilePage", ··· 146 141 147 142 <meta name="viewport" content="width=device-width, initial-scale=1"> 148 143 149 - <!-- View Transitions --> 150 144 <ClientRouter /> 151 145 152 - <!-- Favicon setup --> 153 146 <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png"> 154 147 <link rel="icon" type="image/x-icon" href="/favicon/favicon.ico"> 155 148 <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png"> ··· 174 167 line-height: 1; 175 168 text-box-trim: trim-start; 176 169 text-box-edge: cap alphabetic; 177 - color: #ccd7e9; 170 + color: var(--orbyt-profile-text); 178 171 } 179 172 .install-button { 180 - background-color: #ccd7e9; 181 - color: #0e141b; 182 173 border: none; 183 174 text-transform: none; 184 175 letter-spacing: 0.6px; 185 176 font-weight: 600; 186 177 transition: all 0.2s ease; 187 - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); 178 + box-shadow: 0 2px 8px var(--orbyt-shadow-soft); 188 179 padding: 0 18px; 189 180 margin-top: 0; 190 181 } 191 182 .install-button:hover { 192 183 transform: translateY(-1px); 193 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); 184 + box-shadow: 0 4px 12px rgb(var(--orbyt-scrim-rgb) / 0.2); 194 185 } 195 186 .install-button:active { 196 187 transform: translateY(0); 197 - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); 188 + box-shadow: 0 2px 6px var(--orbyt-shadow-soft); 198 189 } 199 190 .install-button .svg-button { 200 191 display: inline-block; ··· 216 207 </head> 217 208 218 209 <body 219 - style={`background-color: ${chromeBackgroundColor}; color: ${textColor}; --orbyt-chrome: ${chromeBackgroundColor}; --orbyt-profile-bg: ${backgroundColor};`} 210 + style={`--orbyt-chrome: ${chromeBackgroundColor}; --orbyt-profile-bg: ${backgroundColor}; --orbyt-profile-text: ${textColor}; background-color: var(--orbyt-chrome); color: var(--orbyt-profile-text);`} 220 211 > 221 212 <div id="profile" data-handle={handle} data-cursor={nextCursor || ''}> 222 - <header style={`background-color: ${backgroundColor};`}> 213 + <header> 223 214 <div class="links"> 224 215 <a href='/' class="logo-wordmark"> 225 - <span class="logo-text" style={`color: ${textColor};`}>orbyt</span> 216 + <span class="logo-text">orbyt</span> 226 217 </a> 227 - <a class='install-button mobile' href="https://apps.apple.com/us/app/orbyt-video/id6751679299" style={`background-color: ${textColor}; color: ${backgroundColor};`}> 218 + <a class='install-button mobile' href="https://apps.apple.com/us/app/orbyt-video/id6751679299"> 228 219 Get the app 229 220 <svg class="svg-button" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg"> 230 221 <path d="M1.93892 19.9988L18.9736 2.96362" stroke="currentColor" stroke-width="3.993"/> ··· 233 224 </a> 234 225 </div> 235 226 236 - <svg class="hr-inline mobile" height="7" fill="none" xmlns="http://www.w3.org/2000/svg"> 227 + <svg class="hr-inline mobile decorative-hr" height="7" fill="none" xmlns="http://www.w3.org/2000/svg"> 237 228 <defs> 238 229 <pattern id="squigglePattern" width="374" height="7" patternUnits="userSpaceOnUse"> 239 - <path d="M0 1C5.50021 1 5.50021 5.84456 11.0004 5.84456C16.5006 5.84456 16.5006 1 22.0008 1C27.501 1 27.501 5.84456 33.0013 5.84456C38.5015 5.84456 38.5015 1 44.0017 1C49.5019 1 49.5019 5.84456 55.0021 5.84456C60.5023 5.84456 60.5023 1 66.0025 1C71.5027 1 71.5027 5.84456 77.0029 5.84456C82.5031 5.84456 82.5031 1 87.9998 1C93.5 1 93.5 5.84456 98.9967 5.84456C104.497 5.84456 104.497 1 109.994 1C115.494 1 115.494 5.84456 120.99 5.84456C126.491 5.84456 126.491 1 131.991 1C137.491 1 137.491 5.84456 142.991 5.84456C148.491 5.84456 148.491 1 153.992 1C159.492 1 159.492 5.84456 164.988 5.84456C170.489 5.84456 170.489 1 175.985 1C181.486 1 181.486 5.84456 186.986 5.84456C192.486 5.84456 192.486 1 197.986 1C203.486 1 203.486 5.84456 208.987 5.84456C214.487 5.84456 214.487 1 219.987 1C225.487 1 225.487 5.84456 230.987 5.84456C236.488 5.84456 236.488 1 241.988 1C247.488 1 247.488 5.84456 252.988 5.84456C258.488 5.84456 258.488 1 263.989 1C269.489 1 269.489 5.84456 274.989 5.84456C280.489 5.84456 280.489 1 285.99 1C291.49 1 291.49 5.84456 296.99 5.84456C302.49 5.84456 302.49 1 307.99 1C313.491 1 313.491 5.84456 318.991 5.84456C324.491 5.84456 324.491 1 329.991 1C335.491 1 335.491 5.84456 340.992 5.84456C346.492 5.84456 346.492 1 351.992 1C357.492 1 357.492 5.84456 362.996 5.84456C368.5 5.84456 368.496 1 374 1" stroke={textColor} stroke-width="2" stroke-miterlimit="10"/> 230 + <path d="M0 1C5.50021 1 5.50021 5.84456 11.0004 5.84456C16.5006 5.84456 16.5006 1 22.0008 1C27.501 1 27.501 5.84456 33.0013 5.84456C38.5015 5.84456 38.5015 1 44.0017 1C49.5019 1 49.5019 5.84456 55.0021 5.84456C60.5023 5.84456 60.5023 1 66.0025 1C71.5027 1 71.5027 5.84456 77.0029 5.84456C82.5031 5.84456 82.5031 1 87.9998 1C93.5 1 93.5 5.84456 98.9967 5.84456C104.497 5.84456 104.497 1 109.994 1C115.494 1 115.494 5.84456 120.99 5.84456C126.491 5.84456 126.491 1 131.991 1C137.491 1 137.491 5.84456 142.991 5.84456C148.491 5.84456 148.491 1 153.992 1C159.492 1 159.492 5.84456 164.988 5.84456C170.489 5.84456 170.489 1 175.985 1C181.486 1 181.486 5.84456 186.986 5.84456C192.486 5.84456 192.486 1 197.986 1C203.486 1 203.486 5.84456 208.987 5.84456C214.487 5.84456 214.487 1 219.987 1C225.487 1 225.487 5.84456 230.987 5.84456C236.488 5.84456 236.488 1 241.988 1C247.488 1 247.488 5.84456 252.988 5.84456C258.488 5.84456 258.488 1 263.989 1C269.489 1 269.489 5.84456 274.989 5.84456C280.489 5.84456 280.489 1 285.99 1C291.49 1 291.49 5.84456 296.99 5.84456C302.49 5.84456 302.49 1 307.99 1C313.491 1 313.491 5.84456 318.991 5.84456C324.491 5.84456 324.491 1 329.991 1C335.491 1 335.491 5.84456 340.992 5.84456C346.492 5.84456 346.492 1 351.992 1C357.492 1 357.492 5.84456 362.996 5.84456C368.5 5.84456 368.496 1 374 1" stroke-width="2" stroke-miterlimit="10"/> 240 231 </pattern> 241 232 </defs> 242 233 <rect fill="url(#squigglePattern)" width="100%" height="100%"/> 243 234 </svg> 244 235 245 236 <div class="author"> 246 - <img class='avatar' src={avatar} alt={`${displayName}'s avatar`} style={`border-color: ${textColor};`} /> 247 - <h1 class="username" style={`color: ${textColor};`}>{displayName}{colorData?.isBeta && ( 248 - <span class="beta-badge">&#8288;<svg class="beta-icon" viewBox="28 24 160 160" xmlns="http://www.w3.org/2000/svg" style={`color: ${textColor};`}> 237 + <img class='avatar' src={avatar} alt={`${displayName}'s avatar`} /> 238 + <h1 class="username">{displayName}{colorData?.isBeta && ( 239 + <span class="beta-badge">&#8288;<svg class="beta-icon" viewBox="28 24 160 160" xmlns="http://www.w3.org/2000/svg"> 249 240 <title>{joinDate ? `Beta Tester | Joined on ${joinDate}` : 'Beta Tester'}</title> 250 241 <polygon points="62.5,25 62.5,130 153.43,77.5" fill="currentColor" fill-opacity="0.5"/> 251 242 <polygon points="62.5,77.5 62.5,182.5 153.43,130" fill="currentColor" fill-opacity="0.5"/> 252 243 </svg></span> 253 244 )}</h1> 254 245 {bio && ( 255 - <div class="bio" style={`color: ${textColor};`} set:html={parseRichText(bio)}></div> 246 + <div class="bio" set:html={parseRichText(bio)}></div> 256 247 )} 257 248 </div> 258 249 259 250 <div class="hr-inline-wrapper desktop"> 260 - <svg class="hr-inline" width="374" height="7" viewBox="0 0 374 7" fill="none" xmlns="http://www.w3.org/2000/svg"> 261 - <path d="M0 1C5.50021 1 5.50021 5.84456 11.0004 5.84456C16.5006 5.84456 16.5006 1 22.0008 1C27.501 1 27.501 5.84456 33.0013 5.84456C38.5015 5.84456 38.5015 1 44.0017 1C49.5019 1 49.5019 5.84456 55.0021 5.84456C60.5023 5.84456 60.5023 1 66.0025 1C71.5027 1 71.5027 5.84456 77.0029 5.84456C82.5031 5.84456 82.5031 1 87.9998 1C93.5 1 93.5 5.84456 98.9967 5.84456C104.497 5.84456 104.497 1 109.994 1C115.494 1 115.494 5.84456 120.99 5.84456C126.491 5.84456 126.491 1 131.991 1C137.491 1 137.491 5.84456 142.991 5.84456C148.491 5.84456 148.491 1 153.992 1C159.492 1 159.492 5.84456 164.988 5.84456C170.489 5.84456 170.489 1 175.985 1C181.486 1 181.486 5.84456 186.986 5.84456C192.486 5.84456 192.486 1 197.986 1C203.486 1 203.486 5.84456 208.987 5.84456C214.487 5.84456 214.487 1 219.987 1C225.487 1 225.487 5.84456 230.987 5.84456C236.488 5.84456 236.488 1 241.988 1C247.488 1 247.488 5.84456 252.988 5.84456C258.488 5.84456 258.488 1 263.989 1C269.489 1 269.489 5.84456 274.989 5.84456C280.489 5.84456 280.489 1 285.99 1C291.49 1 291.49 5.84456 296.99 5.84456C302.49 5.84456 302.49 1 307.99 1C313.491 1 313.491 5.84456 318.991 5.84456C324.491 5.84456 324.491 1 329.991 1C335.491 1 335.491 5.84456 340.992 5.84456C346.492 5.84456 346.492 1 351.992 1C357.492 1 357.492 5.84456 362.996 5.84456C368.5 5.84456 368.496 1 374 1" stroke={textColor} stroke-width="2" stroke-miterlimit="10"/> 251 + <svg class="hr-inline decorative-hr" width="374" height="7" viewBox="0 0 374 7" fill="none" xmlns="http://www.w3.org/2000/svg"> 252 + <path d="M0 1C5.50021 1 5.50021 5.84456 11.0004 5.84456C16.5006 5.84456 16.5006 1 22.0008 1C27.501 1 27.501 5.84456 33.0013 5.84456C38.5015 5.84456 38.5015 1 44.0017 1C49.5019 1 49.5019 5.84456 55.0021 5.84456C60.5023 5.84456 60.5023 1 66.0025 1C71.5027 1 71.5027 5.84456 77.0029 5.84456C82.5031 5.84456 82.5031 1 87.9998 1C93.5 1 93.5 5.84456 98.9967 5.84456C104.497 5.84456 104.497 1 109.994 1C115.494 1 115.494 5.84456 120.99 5.84456C126.491 5.84456 126.491 1 131.991 1C137.491 1 137.491 5.84456 142.991 5.84456C148.491 5.84456 148.491 1 153.992 1C159.492 1 159.492 5.84456 164.988 5.84456C170.489 5.84456 170.489 1 175.985 1C181.486 1 181.486 5.84456 186.986 5.84456C192.486 5.84456 192.486 1 197.986 1C203.486 1 203.486 5.84456 208.987 5.84456C214.487 5.84456 214.487 1 219.987 1C225.487 1 225.487 5.84456 230.987 5.84456C236.488 5.84456 236.488 1 241.988 1C247.488 1 247.488 5.84456 252.988 5.84456C258.488 5.84456 258.488 1 263.989 1C269.489 1 269.489 5.84456 274.989 5.84456C280.489 5.84456 280.489 1 285.99 1C291.49 1 291.49 5.84456 296.99 5.84456C302.49 5.84456 302.49 1 307.99 1C313.491 1 313.491 5.84456 318.991 5.84456C324.491 5.84456 324.491 1 329.991 1C335.491 1 335.491 5.84456 340.992 5.84456C346.492 5.84456 346.492 1 351.992 1C357.492 1 357.492 5.84456 362.996 5.84456C368.5 5.84456 368.496 1 374 1" stroke-width="2" stroke-miterlimit="10"/> 262 253 </svg> 263 254 </div> 264 255 265 256 <div class="install-button-wrapper desktop"> 266 - <a class='install-button' href="https://apps.apple.com/us/app/orbyt-video/id6751679299" style={`background-color: ${textColor}; color: ${backgroundColor};`}> 257 + <a class='install-button' href="https://apps.apple.com/us/app/orbyt-video/id6751679299"> 267 258 Get the app 268 259 <svg class="svg-button" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg"> 269 260 <path d="M1.93892 19.9988L18.9736 2.96362" stroke="currentColor" stroke-width="3.993"/> ··· 293 284 )} 294 285 {nextCursor && ( 295 286 <footer> 296 - <a id="load-more" style={`background-color: ${textColor}; color: ${backgroundColor};`}>Load more</a> 287 + <a id="load-more">Load more</a> 297 288 </footer> 298 289 )} 299 290 </div>
+8 -11
src/pages/@[handle]/[postId].astro
··· 3 3 4 4 import { fetchPost, toAbsoluteUrl, getImageMimeType } from '../../utils/bluesky-api'; 5 5 import { parseRichText } from '../../utils/richtext'; 6 + import DocumentColorScheme from '../../components/DocumentColorScheme.astro'; 7 + import { SITE_DOCUMENT } from '../../utils/site-document-theme'; 6 8 import { ClientRouter } from 'astro:transitions'; 7 9 8 10 const { handle, postId } = Astro.params; ··· 88 90 --- 89 91 90 92 <!doctype html> 91 - <html lang="en"> 93 + <html lang="en" class="orbyt-doc-dark"> 92 94 93 95 <head> 94 96 <meta charset="utf-8"> 97 + <DocumentColorScheme 98 + colorScheme="dark" 99 + themeColor={SITE_DOCUMENT.orbytBlack} 100 + tileColor={SITE_DOCUMENT.orbytBlack} 101 + /> 95 102 96 - <!-- Critical: Inline font-face for immediate availability (no network delay) --> 97 103 <style> 98 104 @font-face { 99 105 font-family: 'Figtree'; ··· 103 109 font-display: fallback; /* Short block window, then swap - prevents FOUT for logo */ 104 110 } 105 111 </style> 106 - <!-- Preload Figtree font to start download early --> 107 112 <link rel="preload" href="/fonts/Figtree/Figtree-VariableFont_wght.woff2" as="font" type="font/woff2" crossorigin> 108 113 109 114 <link rel="stylesheet" href="/css/normalize.css"> ··· 262 267 <meta name="description" content={ogDescription} /> 263 268 <meta name="viewport" content="width=device-width, initial-scale=1"> 264 269 265 - <!-- View Transitions --> 266 270 <ClientRouter /> 267 271 268 - <!-- Open Graph meta tags --> 269 272 <meta property="og:title" content={finalOgTitle} /> 270 273 <meta property="og:type" content="video.other" /> 271 274 <meta property="og:description" content={ogDescription} /> ··· 299 302 <meta name="twitter:description" content={ogDescription} /> 300 303 <meta name="twitter:image" content={ogImage} /> 301 304 302 - <!-- Structured Data for SEO - Breadcrumbs --> 303 305 <script type="application/ld+json" is:inline set:html={JSON.stringify({ 304 306 "@context": "https://schema.org", 305 307 "@type": "BreadcrumbList", ··· 324 326 } 325 327 ] 326 328 })}></script> 327 - <!-- Video Structured Data --> 328 329 {videoUrl && ( 329 330 <script type="application/ld+json" is:inline set:html={JSON.stringify({ 330 331 "@context": "https://schema.org", ··· 343 344 })}></script> 344 345 )} 345 346 346 - <!-- Favicon setup --> 347 347 <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png"> 348 348 <link rel="icon" type="image/x-icon" href="/favicon/favicon.ico"> 349 349 <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png"> ··· 354 354 <body> 355 355 <div id="background"> </div> 356 356 357 - <!-- Post content --> 358 357 <div id="post" 359 358 data-handle={handle} 360 359 data-post-id={postId} ··· 484 483 485 484 </div> 486 485 487 - <!-- HLS.js library for cross-browser HLS playback support --> 488 486 <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script> 489 487 490 - <!-- Video player initialization --> 491 488 <script is:inline src="/js/post-view.js"></script> 492 489 493 490
+10 -1
src/pages/app.astro
··· 1 1 --- 2 2 // All users see this landing page with QR code 3 + import DocumentColorScheme from '../components/DocumentColorScheme.astro'; 4 + import { SITE_DOCUMENT } from '../utils/site-document-theme'; 5 + 3 6 const appUrl = 'https://getorbyt.com/app'; 4 7 const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&bgcolor=000000&color=f3f5fe&data=${encodeURIComponent(appUrl)}`; 5 8 --- 6 9 <!doctype html> 7 - <html lang="en"> 10 + <html lang="en" class="orbyt-doc-dark"> 8 11 <head> 9 12 <meta charset="utf-8"> 13 + <DocumentColorScheme 14 + colorScheme="dark" 15 + themeColor={SITE_DOCUMENT.orbytBlack} 16 + tileColor={SITE_DOCUMENT.orbytBlack} 17 + /> 18 + <link rel="stylesheet" href="/css/tokens.css" /> 10 19 <meta name="viewport" content="width=device-width, initial-scale=1"> 11 20 <title>Get the App - orbyt</title> 12 21
+11 -1
src/pages/beta.astro
··· 1 1 --- 2 2 export const prerender = true; 3 + 4 + import DocumentColorScheme from '../components/DocumentColorScheme.astro'; 5 + import { SITE_DOCUMENT } from '../utils/site-document-theme'; 3 6 --- 4 7 5 8 <!doctype html> 6 - <html> 9 + <html lang="en" class="orbyt-doc-dark"> 7 10 <head> 11 + <meta charset="utf-8" /> 12 + <DocumentColorScheme 13 + colorScheme="dark" 14 + themeColor={SITE_DOCUMENT.orbytBlack} 15 + tileColor={SITE_DOCUMENT.orbytBlack} 16 + /> 17 + <link rel="stylesheet" href="/css/tokens.css" /> 8 18 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"> 9 19 <title>Join the waitlist</title> 10 20 <script is:inline async src="https://tally.so/widgets/embed.js"></script>
+10 -5
src/pages/contact.astro
··· 1 1 --- 2 2 export const prerender = true; 3 + 4 + import DocumentColorScheme from '../components/DocumentColorScheme.astro'; 5 + import { SITE_DOCUMENT } from '../utils/site-document-theme'; 3 6 --- 4 7 <!DOCTYPE html> 5 - <html lang="en"> 8 + <html lang="en" class="orbyt-doc-dark"> 6 9 <head> 7 10 <meta charset="UTF-8"> 11 + <DocumentColorScheme 12 + colorScheme="dark" 13 + themeColor={SITE_DOCUMENT.orbytBlack} 14 + tileColor={SITE_DOCUMENT.orbytBlack} 15 + /> 8 16 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 9 17 <title>Contact Us - Orbyt</title> 10 18 ··· 27 35 <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png"> 28 36 <link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png"> 29 37 <link rel="manifest" href="/favicon/site.webmanifest"> 30 - 31 - <!-- Theme colors for mobile browsers --> 32 - <meta name="theme-color" content="#f3f5fe"> 33 - <meta name="msapplication-TileColor" content="#f3f5fe"> 38 + <link rel="stylesheet" href="/css/tokens.css" /> 34 39 <style> 35 40 * { 36 41 margin: 0;
+10 -1
src/pages/discord.astro
··· 1 1 --- 2 2 export const prerender = true; 3 + 4 + import DocumentColorScheme from '../components/DocumentColorScheme.astro'; 5 + import { SITE_DOCUMENT } from '../utils/site-document-theme'; 3 6 --- 4 7 <!doctype html> 5 - <html lang="en"> 8 + <html lang="en" class="orbyt-doc-dark"> 6 9 <head> 7 10 <meta charset="utf-8"> 11 + <DocumentColorScheme 12 + colorScheme="dark" 13 + themeColor={SITE_DOCUMENT.orbytBlack} 14 + tileColor={SITE_DOCUMENT.orbytBlack} 15 + /> 16 + <link rel="stylesheet" href="/css/tokens.css" /> 8 17 <meta name="viewport" content="width=device-width, initial-scale=1"> 9 18 <title>Community - orbyt</title> 10 19 <meta http-equiv="refresh" content="0; url=https://community.getorbyt.com">
+11 -12
src/pages/index.astro
··· 1 1 --- 2 2 export const prerender = true; 3 + 4 + import DocumentColorScheme from '../components/DocumentColorScheme.astro'; 5 + import { SITE_DOCUMENT } from '../utils/site-document-theme'; 3 6 --- 4 7 5 8 <!doctype html> 6 - <html lang="en"> 9 + <html lang="en" class="orbyt-doc-dark"> 7 10 <head> 8 - <meta name="google-site-verification" content="4xaimUg28uLCqKwsPNIz6QeG_tfnLlAdHgS5bC_90JU"/> 9 11 <meta charset="utf-8"> 12 + <DocumentColorScheme 13 + colorScheme="dark" 14 + themeColor={SITE_DOCUMENT.orbytBlack} 15 + tileColor={SITE_DOCUMENT.orbytBlack} 16 + /> 17 + <link rel="stylesheet" href="/css/tokens.css" /> 18 + <meta name="google-site-verification" content="4xaimUg28uLCqKwsPNIz6QeG_tfnLlAdHgS5bC_90JU"/> 10 19 <title>orbyt - video communities</title> 11 20 <meta name="description" content="a new video app for bluesky"> 12 21 <meta name="viewport" content="width=device-width, initial-scale=1"> 13 22 14 - <!-- Critical: Inline font-face for immediate availability (no network delay) --> 15 23 <style> 16 24 @font-face { 17 25 font-family: 'Figtree'; ··· 21 29 font-display: fallback; /* Short block window, then swap - prevents FOUT for logo */ 22 30 } 23 31 </style> 24 - <!-- Preload Figtree font to start download early --> 25 32 <link rel="preload" href="/fonts/Figtree/Figtree-VariableFont_wght.woff2" as="font" type="font/woff2" crossorigin> 26 33 27 34 <meta property="og:title" content="orbyt - video communities"/> ··· 31 38 <meta property="og:image" content="https://getorbyt.com/images/orbyt-logotype.png"/> 32 39 <meta property="og:image:type" content="image/png"/> 33 40 34 - <!-- Twitter --> 35 41 <meta name="twitter:card" content="summary_large_image"/> 36 42 <meta name="twitter:title" content="orbyt - video communities"/> 37 43 <meta name="twitter:description" content="a new video app for bluesky"/> 38 44 <meta name="twitter:image" content="https://getorbyt.com/images/orbyt-logotype.png"/> 39 45 40 - <!-- Structured Data for SEO --> 41 46 <script type="application/ld+json" is:inline> 42 47 { 43 48 "@context": "https://schema.org", ··· 73 78 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css"> 74 79 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css"> 75 80 76 - <!-- Favicon setup --> 77 81 <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png"> 78 82 <link rel="icon" type="image/x-icon" href="/favicon/favicon.ico"> 79 83 <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png"> ··· 305 309 <div class="logo twelve columns"> 306 310 <svg class="logo-icon" xmlns="http://www.w3.org/2000/svg" viewBox="65 65 894 894"> 307 311 <defs> 308 - <!-- Mask: white = visible, black = fully transparent --> 309 312 <mask id="cutout-mask"> 310 - <!-- Start with full white rectangle (everything visible) --> 311 313 <rect x="65" y="65" width="894" height="894" fill="white"/> 312 - <!-- Cut out the body shape with evenodd so screen stays visible --> 313 314 <path fill="black" fill-rule="evenodd" d="M349 129.3c-23.9 5.4-36.8 31.5-26.1 53.2 8 16.2 25.2 24.4 42.1 20.1 7.4-1.9 5.3-3.4 18.5 13.9 10.3 13.6 33.6 44.2 36.7 48.4 3.5 4.6 3.5 4.6-1.1 8.9-5.9 5.6-14.1 16.9-19 26-5 9.4-3.4 8.6-22.1 10.2-136.2 11.1-177 34.7-192.6 111.5-16.5 81.2-16.5 193.6.1 276.7 10.5 52.9 39.8 83.7 89.3 93.9 6 1.3 6 1.3 2.5 8.8-18.4 39.4-5.4 73.1 30 78.1 24.5 3.5 32.3-1.8 73.7-50 20.8-24.3 16.8-22.1 37-20.5 50.9 4.1 138.2 4.1 189 0 20.2-1.6 16.2-3.8 37 20.5 41.4 48.2 49.2 53.5 73.7 50 35.4-5 48.4-38.7 30-78.1-3.5-7.5-3.5-7.5 2.5-8.8 49.5-10.2 78.8-41 89.3-93.9 16.6-83.1 16.6-195.5.1-276.7C824 344.7 783.2 321.1 647 310c-18.7-1.6-17.1-.8-22.1-10.2-4.9-9.1-13.1-20.4-19-26-4.6-4.3-4.6-4.3-1.1-8.9 3.1-4.2 26.4-34.8 36.7-48.4 13.2-17.3 11.1-15.8 18.5-13.9 41.5 10.6 64.2-49.2 26.3-69.3-32-17.1-68 17.2-51.7 49.3 3.1 6.1 3.1 6.1-11.8 25.5-8.2 10.7-20 26-26.1 34.1-11.1 14.8-11.1 14.8-14.7 12.6-44.5-26.5-94.5-26.5-139 0-3.6 2.2-3.6 2.2-14.7-12.6-6.1-8.1-17.9-23.4-26.1-34.1-14.9-19.4-14.9-19.4-11.8-25.5 13.8-27.1-11.7-59.8-41.4-53.3m249.3 243.2C752 381.3 773.8 399.3 781.1 523c5.8 98.9-9.4 168.3-41.5 189.8-59.5 39.8-394.7 39.8-454.2 0-32.1-21.5-47.3-90.9-41.5-189.8 7.7-130.5 28.8-144.8 226.1-152.4 19.3-.8 104.3.5 128.3 1.9Z"/> 314 - <!-- Cut out the eyes --> 315 315 <rect fill="black" x="372" y="482" width="82" height="136" rx="41" ry="41"/> 316 316 <rect fill="black" x="570" y="482" width="82" height="136" rx="41" ry="41"/> 317 317 </mask> 318 318 </defs> 319 - <!-- White outline with cutout mask applied --> 320 319 <path class="logo-fg" mask="url(#cutout-mask)" stroke-width="65" stroke-linejoin="round" stroke-linecap="round" d="M349 129.3c-23.9 5.4-36.8 31.5-26.1 53.2 8 16.2 25.2 24.4 42.1 20.1 7.4-1.9 5.3-3.4 18.5 13.9 10.3 13.6 33.6 44.2 36.7 48.4 3.5 4.6 3.5 4.6-1.1 8.9-5.9 5.6-14.1 16.9-19 26-5 9.4-3.4 8.6-22.1 10.2-136.2 11.1-177 34.7-192.6 111.5-16.5 81.2-16.5 193.6.1 276.7 10.5 52.9 39.8 83.7 89.3 93.9 6 1.3 6 1.3 2.5 8.8-18.4 39.4-5.4 73.1 30 78.1 24.5 3.5 32.3-1.8 73.7-50 20.8-24.3 16.8-22.1 37-20.5 50.9 4.1 138.2 4.1 189 0 20.2-1.6 16.2-3.8 37 20.5 41.4 48.2 49.2 53.5 73.7 50 35.4-5 48.4-38.7 30-78.1-3.5-7.5-3.5-7.5 2.5-8.8 49.5-10.2 78.8-41 89.3-93.9 16.6-83.1 16.6-195.5.1-276.7C824 344.7 783.2 321.1 647 310c-18.7-1.6-17.1-.8-22.1-10.2-4.9-9.1-13.1-20.4-19-26-4.6-4.3-4.6-4.3-1.1-8.9 3.1-4.2 26.4-34.8 36.7-48.4 13.2-17.3 11.1-15.8 18.5-13.9 41.5 10.6 64.2-49.2 26.3-69.3-32-17.1-68 17.2-51.7 49.3 3.1 6.1 3.1 6.1-11.8 25.5-8.2 10.7-20 26-26.1 34.1-11.1 14.8-11.1 14.8-14.7 12.6-44.5-26.5-94.5-26.5-139 0-3.6 2.2-3.6 2.2-14.7-12.6-6.1-8.1-17.9-23.4-26.1-34.1-14.9-19.4-14.9-19.4-11.8-25.5 13.8-27.1-11.7-59.8-41.4-53.3Z"/> 321 320 </svg> 322 321 <div class="logo-text">orbyt</div>
+10 -5
src/pages/privacy.astro
··· 1 1 --- 2 2 export const prerender = true; 3 + 4 + import DocumentColorScheme from '../components/DocumentColorScheme.astro'; 5 + import { SITE_DOCUMENT } from '../utils/site-document-theme'; 3 6 --- 4 7 <!DOCTYPE html> 5 - <html lang="en"> 8 + <html lang="en" class="orbyt-doc-dark"> 6 9 <head> 7 10 <meta charset="UTF-8"> 11 + <DocumentColorScheme 12 + colorScheme="dark" 13 + themeColor={SITE_DOCUMENT.orbytBlack} 14 + tileColor={SITE_DOCUMENT.orbytBlack} 15 + /> 8 16 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 9 17 <title>Privacy Policy - Orbyt</title> 10 18 ··· 27 35 <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png"> 28 36 <link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png"> 29 37 <link rel="manifest" href="/favicon/site.webmanifest"> 30 - 31 - <!-- Theme colors for mobile browsers --> 32 - <meta name="theme-color" content="#f3f5fe"> 33 - <meta name="msapplication-TileColor" content="#f3f5fe"> 38 + <link rel="stylesheet" href="/css/tokens.css" /> 34 39 <style> 35 40 * { 36 41 margin: 0;
+1 -1
src/pages/sitemap.xml.ts
··· 10 10 { path: '/', changefreq: 'weekly', priority: '1.0' }, 11 11 { path: '/app/', changefreq: 'monthly', priority: '0.8' }, 12 12 { path: '/beta/', changefreq: 'monthly', priority: '0.8' }, 13 - { path: '/contact/', changefreq: 'monthly', priority: '0.6' }, 13 + { path: '/contact/', changefreq: 'monthly', priority: '0.6' }, 14 14 { path: '/discord/', changefreq: 'monthly', priority: '0.6' }, 15 15 { path: '/privacy/', changefreq: 'yearly', priority: '0.4' }, 16 16 { path: '/terms/', changefreq: 'yearly', priority: '0.4' },
+10 -5
src/pages/terms.astro
··· 1 1 --- 2 2 export const prerender = true; 3 + 4 + import DocumentColorScheme from '../components/DocumentColorScheme.astro'; 5 + import { SITE_DOCUMENT } from '../utils/site-document-theme'; 3 6 --- 4 7 <!DOCTYPE html> 5 - <html lang="en"> 8 + <html lang="en" class="orbyt-doc-dark"> 6 9 <head> 7 10 <meta charset="UTF-8"> 11 + <DocumentColorScheme 12 + colorScheme="dark" 13 + themeColor={SITE_DOCUMENT.orbytBlack} 14 + tileColor={SITE_DOCUMENT.orbytBlack} 15 + /> 8 16 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 9 17 <title>Terms of Use - Orbyt</title> 10 18 ··· 27 35 <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png"> 28 36 <link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png"> 29 37 <link rel="manifest" href="/favicon/site.webmanifest"> 30 - 31 - <!-- Theme colors for mobile browsers --> 32 - <meta name="theme-color" content="#f3f5fe"> 33 - <meta name="msapplication-TileColor" content="#f3f5fe"> 38 + <link rel="stylesheet" href="/css/tokens.css" /> 34 39 <style> 35 40 * { 36 41 margin: 0;
+12 -40
src/utils/bluesky-api.ts
··· 1 1 const BLUESKY_API_BASE = 'https://public.api.bsky.app/xrpc'; 2 2 const SITE_URL = 'https://getorbyt.com'; 3 - const API_TIMEOUT = 5000; // 5 seconds 3 + const API_TIMEOUT = 5000; 4 4 5 5 export interface ProfileData { 6 6 handle: string; ··· 12 12 13 13 export interface PostData { 14 14 text: string; 15 - createdAt?: string; // ISO 8601 timestamp from post.record.createdAt 16 - indexedAt?: string; // ISO 8601 timestamp from post.indexedAt 17 - likeCount?: number; // Number of likes 15 + createdAt?: string; 16 + indexedAt?: string; 17 + likeCount?: number; 18 18 author: { 19 19 handle: string; 20 20 displayName?: string; ··· 30 30 }; 31 31 } 32 32 33 - // Raw API response shapes (AT Protocol / app.bsky.*) 34 33 interface BskyProfileResponse { 35 34 handle?: string; 36 35 displayName?: string; ··· 135 134 136 135 export async function fetchPost(handle: string, postId: string): Promise<PostData | null> { 137 136 try { 138 - // getPosts requires AT-URI format which needs DID, not handle 139 - // So we must resolve handle to DID first 140 137 const did = await resolveHandle(handle); 141 138 if (!did) return null; 142 139 ··· 151 148 if (!posts || posts.length === 0) return null; 152 149 153 150 const post = posts[0]!; 154 - 155 - // Extract video data from embed - handle video embed structure 151 + 156 152 let thumbnail: string | undefined; 157 153 let videoUrl: string | undefined; 158 154 let aspectRatio: { width: number; height: number } | undefined; 159 155 160 156 if (post.embed) { 161 - // Check for video embed (app.bsky.embed.video#view) 162 157 if (post.embed.$type === 'app.bsky.embed.video#view') { 163 158 thumbnail = post.embed.thumbnail; 164 159 aspectRatio = post.embed.aspectRatio; 165 - 166 - // Extract video URL - prefer playlist if available, otherwise construct from CID/DID 160 + 167 161 if (post.embed.playlist) { 168 162 videoUrl = post.embed.playlist; 169 163 } else if (post.embed.cid && post.uri) { ··· 173 167 videoUrl = `https://video.bsky.app/watch/${encodeURIComponent(videoDid)}/${encodeURIComponent(post.embed.cid)}/playlist.m3u8`; 174 168 } 175 169 } 176 - } 177 - // Check for recordWithMedia format 178 - else if (post.embed.$type === 'app.bsky.embed.recordWithMedia' && post.embed.media) { 170 + } else if (post.embed.$type === 'app.bsky.embed.recordWithMedia' && post.embed.media) { 179 171 if (post.embed.media.$type === 'app.bsky.embed.video#view') { 180 172 thumbnail = post.embed.media.thumbnail; 181 173 aspectRatio = post.embed.media.aspectRatio; 182 - 183 - // Extract video URL 174 + 184 175 if (post.embed.media.playlist) { 185 176 videoUrl = post.embed.media.playlist; 186 177 } else if (post.embed.media.cid && post.uri) { ··· 191 182 } 192 183 } 193 184 } 194 - } 195 - // Check for images embed (fallback) 196 - else if (post.embed.$type === 'app.bsky.embed.images#view' && post.embed.images && post.embed.images[0]) { 185 + } else if (post.embed.$type === 'app.bsky.embed.images#view' && post.embed.images && post.embed.images[0]) { 197 186 thumbnail = post.embed.images[0].thumb || post.embed.images[0].fullsize; 198 187 } 199 188 } 200 189 201 190 return { 202 191 text: post.record?.text || '', 203 - createdAt: post.record?.createdAt, // ISO 8601 timestamp 204 - indexedAt: post.indexedAt, // ISO 8601 timestamp 192 + createdAt: post.record?.createdAt, 193 + indexedAt: post.indexedAt, 205 194 likeCount: post.likeCount || 0, 206 195 author: { 207 196 handle: post.author?.handle || handle, ··· 231 220 cursor: string | null; 232 221 } 233 222 234 - /** 235 - * Extract post ID from AT Protocol URI 236 - */ 237 223 function extractPostId(uri: string): string | null { 238 224 if (!uri) return null; 239 225 const parts = uri.split('/'); 240 226 return parts[parts.length - 1] || null; 241 227 } 242 228 243 - /** 244 - * Truncate text to a maximum length with ellipsis 245 - */ 246 229 function truncateText(text: string, maxLength: number = 90): string { 247 230 if (!text) return ''; 248 231 if (text.length <= maxLength) return text; 249 232 return text.substring(0, maxLength).trim() + '...'; 250 233 } 251 234 252 - /** 253 - * Fetch video posts from a user's feed 254 - */ 255 235 export async function fetchVideoPosts(handle: string, cursor?: string, limit: number = 30): Promise<VideoFeedResult> { 256 236 try { 257 237 let url = `${BLUESKY_API_BASE}/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(handle)}&filter=posts_with_video&limit=${limit}`; ··· 271 251 return { posts: [], cursor: null }; 272 252 } 273 253 274 - // Filter and map video posts 275 254 const posts: VideoPost[] = []; 276 255 277 256 for (const item of data.feed) { ··· 281 260 const embed = post.embed; 282 261 if (!embed) continue; 283 262 284 - // Only include video posts 285 263 if (embed.$type !== 'app.bsky.embed.video#view') continue; 286 264 287 265 const postUri = post.uri; ··· 312 290 return `${SITE_URL}/${url}`; 313 291 } 314 292 315 - /** 316 - * Detect image MIME type from URL extension 317 - * @param url - Image URL 318 - * @returns MIME type string (e.g., "image/jpeg", "image/png", "image/webp") 319 - */ 320 293 export function getImageMimeType(url: string): string { 321 294 if (!url) return 'image/jpeg'; 322 295 ··· 334 307 if (urlLower.includes('.jpg') || urlLower.includes('.jpeg') || urlLower.endsWith('jpg') || urlLower.endsWith('jpeg')) { 335 308 return 'image/jpeg'; 336 309 } 337 - 338 - // Default to JPEG for unknown types 310 + 339 311 return 'image/jpeg'; 340 312 }
-5
src/utils/orbyt-api.ts
··· 5 5 isBeta: boolean; 6 6 } 7 7 8 - // Service binding type for Worker-to-Worker calls 9 8 export interface OrbytApiBinding { 10 9 fetch(request: Request | string, init?: RequestInit): Promise<Response>; 11 10 } 12 11 13 - /** 14 - * Get color data for a single DID via Service Binding 15 - */ 16 12 export async function getColor( 17 13 did: string, 18 14 binding: OrbytApiBinding ··· 24 20 if (!response.ok) return null; 25 21 return response.json(); 26 22 } catch (error) { 27 - // Gracefully handle binding failures (e.g., local dev without service binding) 28 23 console.warn('Color API unavailable:', error instanceof Error ? error.message : error); 29 24 return null; 30 25 }
+2 -5
src/utils/profile-colors.ts
··· 42 42 ); 43 43 } 44 44 45 - /** 46 - * Same WCAG-relative luminance threshold as orbyt `isColorDark` (sRGB luminance ≈ 128). 47 - * Drives `color-scheme` so scrollbars / form UI match the page (MDN color-scheme). 48 - */ 45 + /** Same threshold as app `isColorDark`; used to pick document `color-scheme`. */ 49 46 export function isColorDark(hex: string): boolean { 50 47 const raw = hex.replace('#', ''); 51 48 if (raw.length !== 6 || !/^[0-9a-fA-F]+$/.test(raw)) return true; ··· 56 53 return brightness < 128; 57 54 } 58 55 59 - /** Value for `<meta name="color-scheme">` and CSS `color-scheme` on the document root. */ 56 + /** Maps chrome color to `dark` | `light` for document theming. */ 60 57 export function getUiColorScheme(chromeBackgroundHex: string): 'dark' | 'light' { 61 58 return isColorDark(chromeBackgroundHex) ? 'dark' : 'light'; 62 59 }
+14 -62
src/utils/richtext.ts
··· 1 - /** 2 - * RichText utility for parsing and rendering text with mentions and links 3 - * 4 - * - @ mentions (must contain a dot/TLD like @example.com) link internally to /@handle 5 - * - URLs are made clickable as external links 6 - */ 1 + /** Mentions with a TLD link to /@handle; URLs become external links. */ 7 2 8 - /** 9 - * Escape HTML special characters to prevent XSS 10 - */ 11 3 function escapeHtml(text: string): string { 12 4 return text 13 5 .replace(/&/g, '&amp;') ··· 17 9 .replace(/'/g, '&#x27;'); 18 10 } 19 11 20 - /** 21 - * Parse text and convert mentions and URLs to clickable links 22 - * 23 - * @param text - Raw text to parse 24 - * @param options - Optional configuration 25 - * @returns HTML string with links 26 - */ 27 12 export function parseRichText(text: string, options?: { 28 13 mentionClass?: string; 29 14 linkClass?: string; ··· 32 17 33 18 const mentionClass = options?.mentionClass || 'mention'; 34 19 const linkClass = options?.linkClass || 'link'; 35 - 36 - // Combined regex to match URLs and mentions in order of appearance 37 - // This ensures we process them in the correct order and don't double-process 38 - 39 - // URL pattern - matches: 40 - // 1. http:// or https:// URLs 41 - // 2. www. prefixed URLs 42 - // 3. domain.tld patterns (word.word with common TLDs) 20 + 43 21 const urlPattern = /(?:https?:\/\/[^\s<>"{}|\\^`\[\]]+|www\.[^\s<>"{}|\\^`\[\]]+|[a-zA-Z0-9][a-zA-Z0-9-]*\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})?(?:\/[^\s<>"{}|\\^`\[\]]*)?)/g; 44 - 45 - // Mention pattern - @ followed by handle with at least one dot (TLD requirement) 46 - // Handles like @user.bsky.social, @example.com, @news.org 47 22 const mentionPattern = /@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+)/g; 48 - 49 - // First, find all matches with their positions 23 + 50 24 interface Match { 51 25 type: 'url' | 'mention'; 52 26 start: number; 53 27 end: number; 54 28 text: string; 55 - handle?: string; // For mentions 29 + handle?: string; 56 30 } 57 - 31 + 58 32 const matches: Match[] = []; 59 - 60 - // Find all URLs 33 + 61 34 let urlMatch: RegExpExecArray | null; 62 35 while ((urlMatch = urlPattern.exec(text)) !== null) { 63 36 matches.push({ ··· 67 40 text: urlMatch[0], 68 41 }); 69 42 } 70 - 71 - // Find all mentions 43 + 72 44 let mentionMatch: RegExpExecArray | null; 73 45 while ((mentionMatch = mentionPattern.exec(text)) !== null) { 74 46 const handle = mentionMatch[1]; 75 - // Skip if this mention overlaps with a URL (e.g., @example.com in https://example.com/@user) 76 47 const mentionStart = mentionMatch.index; 77 48 const mentionEnd = mentionMatch.index + mentionMatch[0].length; 78 49 ··· 91 62 }); 92 63 } 93 64 } 94 - 95 - // Sort matches by position 65 + 96 66 matches.sort((a, b) => a.start - b.start); 97 - 98 - // Filter out URLs that are part of mentions (e.g., user.bsky.social in @user.bsky.social) 99 - const filteredMatches = matches.filter((match, _index) => { 67 + 68 + const filteredMatches = matches.filter((match) => { 100 69 if (match.type === 'url') { 101 - // Check if this URL is contained within a mention 102 70 return !matches.some(m => 103 71 m.type === 'mention' && 104 72 match.start >= m.start && match.end <= m.end ··· 106 74 } 107 75 return true; 108 76 }); 109 - 110 - // Build the result string 77 + 111 78 let result = ''; 112 79 let lastIndex = 0; 113 80 114 81 for (const match of filteredMatches) { 115 - // Add text before this match (escaped) 116 82 if (match.start > lastIndex) { 117 83 result += escapeHtml(text.slice(lastIndex, match.start)); 118 84 } 119 85 120 86 if (match.type === 'url') { 121 - // Clean up trailing punctuation that's likely not part of the URL 122 87 let url = match.text; 123 88 let trailing = ''; 124 89 125 - // Remove trailing punctuation that's commonly not part of URLs 126 90 const trailingMatch = url.match(/[.,;:!?)]+$/); 127 91 if (trailingMatch) { 128 - // Check for balanced parentheses - if URL has ( but ends with ), keep the ) 129 92 const openParens = (url.match(/\(/g) || []).length; 130 93 const closeParens = (url.match(/\)/g) || []).length; 131 94 132 95 if (closeParens > openParens) { 133 - // Remove excess closing parens and other trailing punctuation 134 96 const excess = closeParens - openParens; 135 97 let toRemove = trailingMatch[0]; 136 - // Keep removing from the end until we've removed excess ) and other punctuation 137 98 let removed = 0; 138 99 for (let i = toRemove.length - 1; i >= 0 && removed < excess; i--) { 139 100 if (toRemove[i] === ')') removed++; ··· 141 102 trailing = toRemove; 142 103 url = url.slice(0, -toRemove.length); 143 104 } else if (!/\)$/.test(url) || openParens === 0) { 144 - // No parens or doesn't end with ), safe to remove trailing punctuation 145 105 trailing = trailingMatch[0]; 146 106 url = url.slice(0, -trailing.length); 147 107 } 148 108 } 149 - 150 - // Add https:// prefix if URL doesn't have a protocol 109 + 151 110 const href = url.match(/^https?:\/\//) ? url : `https://${url}`; 152 111 result += `<a href="${escapeHtml(href)}" class="${linkClass}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>${escapeHtml(trailing)}`; 153 112 } else if (match.type === 'mention' && match.handle) { 154 - // Internal link to profile 155 113 result += `<a href="/@${escapeHtml(match.handle)}" class="${mentionClass}">@<span class="mention-handle">${escapeHtml(match.handle)}</span></a>`; 156 114 } 157 115 158 116 lastIndex = match.end; 159 117 } 160 - 161 - // Add remaining text after last match (escaped) 118 + 162 119 if (lastIndex < text.length) { 163 120 result += escapeHtml(text.slice(lastIndex)); 164 121 } 165 - 166 - // Convert newlines to <br> tags 122 + 167 123 result = result.replace(/\n/g, '<br>'); 168 124 169 125 return result; 170 126 } 171 127 172 - /** 173 - * Truncate text while preserving whole words, then parse as rich text 174 - * Useful for captions that need to be shortened 175 - */ 176 128 export function parseRichTextTruncated(text: string, maxLength: number = 90, options?: { 177 129 mentionClass?: string; 178 130 linkClass?: string;
+5
src/utils/site-document-theme.ts
··· 1 + /** Hex literals for theme-color meta (vars invalid there); keep in sync with tokens.css. */ 2 + export const SITE_DOCUMENT = { 3 + orbytBlack: '#05070a', 4 + orbytWhite: '#f3f5fe', 5 + } as const;