An API you can curl, or open in a browser, to receive Bluesky data as markdown!
11
fork

Configure Feed

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

Polish accessibility, interaction states, and responsive controls

j4ck.xyz 12142160 5f82a2c1

+195 -42
+7
.gitignore
··· 39 39 # typescript 40 40 *.tsbuildinfo 41 41 next-env.d.ts 42 + 43 + # local tooling / agent context 44 + .impeccable.md 45 + 46 + # local python virtual environments 47 + .venv/ 48 + test/.venv/
+18 -7
app/components/FollowPrompt.module.css
··· 112 112 113 113 .handle { 114 114 font-family: var(--mono); 115 - font-size: 0.68rem; 115 + font-size: 0.72rem; 116 116 color: var(--text-muted); 117 117 letter-spacing: 0.04em; 118 118 white-space: nowrap; ··· 121 121 } 122 122 123 123 .dismiss { 124 - width: 24px; 125 - height: 24px; 124 + width: 36px; 125 + height: 36px; 126 126 border-radius: 50%; 127 127 border: 1px solid var(--line); 128 128 background: var(--surface); 129 129 cursor: pointer; 130 130 color: var(--text-muted); 131 - font-size: 0.75rem; 131 + font-size: 0.82rem; 132 132 display: flex; 133 133 align-items: center; 134 134 justify-content: center; ··· 142 142 background: var(--surface-2); 143 143 color: color-mix(in oklch, var(--text) 72%, var(--blue-accent) 28%); 144 144 border-color: color-mix(in oklch, var(--line-strong) 60%, var(--blue-accent) 40%); 145 + } 146 + 147 + .dismiss:focus-visible { 148 + outline: 2px solid color-mix(in oklch, var(--blue-accent) 70%, var(--text) 30%); 149 + outline-offset: 2px; 145 150 } 146 151 147 152 .dismiss:active { ··· 149 154 } 150 155 151 156 .label { 152 - font-size: 0.78rem; 157 + font-size: 0.82rem; 153 158 color: var(--text-soft); 154 159 margin-bottom: 10px; 155 160 line-height: 1.45; ··· 161 166 justify-content: center; 162 167 gap: 8px; 163 168 width: 100%; 164 - min-height: 36px; 169 + min-height: 40px; 165 170 padding: 0 12px; 166 171 background: var(--text); 167 172 color: var(--bg); 168 173 border: 1px solid var(--line-strong); 169 174 border-radius: var(--radius-sm); 170 - font-size: 0.7rem; 175 + font-size: 0.74rem; 171 176 font-weight: 600; 172 177 font-family: var(--mono); 173 178 cursor: pointer; ··· 189 194 color: var(--bg); 190 195 } 191 196 197 + .followBtn:focus-visible { 198 + outline: 2px solid color-mix(in oklch, var(--blue-accent) 70%, var(--text) 30%); 199 + outline-offset: 2px; 200 + } 201 + 192 202 .followBtn:active { 193 203 transform: translateY(0); 194 204 opacity: 0.9; ··· 199 209 height: 13px; 200 210 flex-shrink: 0; 201 211 opacity: 0.92; 212 + color: var(--bg); 202 213 } 203 214 204 215 @media (prefers-reduced-motion: reduce) {
+14 -2
app/components/FollowPrompt.tsx
··· 38 38 39 39 useEffect(() => { 40 40 if (!visible) return 41 + const ctrl = new AbortController() 41 42 fetch( 42 43 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${CREATOR_DID}`, 44 + { signal: ctrl.signal }, 43 45 ) 44 46 .then((r) => r.json()) 45 47 .then((d: Profile) => setProfile(d)) 46 48 .catch(() => {}) 49 + return () => ctrl.abort() 47 50 }, [visible]) 48 51 49 52 const dismiss = () => { ··· 63 66 <div className={s.body}> 64 67 <div className={s.header}> 65 68 {profile?.avatar ? ( 66 - <img src={profile.avatar} alt={displayName} className={s.avatar} /> 69 + <img 70 + src={profile.avatar} 71 + alt={`${displayName} profile avatar`} 72 + className={s.avatar} 73 + width="40" 74 + height="40" 75 + loading="lazy" 76 + decoding="async" 77 + referrerPolicy="no-referrer" 78 + /> 67 79 ) : ( 68 80 <div className={s.avatarFallback}>🦋</div> 69 81 )} ··· 87 99 > 88 100 {/* Bluesky icon */} 89 101 <svg className={s.bskyIcon} xmlns="http://www.w3.org/2000/svg" viewBox="0 30.56 512 450.84" aria-hidden="true"> 90 - <path d="M111 60.9c58.7 44.1 121.8 133.4 145 181.4 23.2-47.9 86.3-137.3 145-181.4 42.4-31.8 111-56.4 111 21.9 0 15.6-9 131.3-14.2 150.1-18.3 65.3-84.9 82-144.1 71.9 103.5 17.6 129.9 76 73 134.4-108 110.9-155.3-27.8-167.4-63.4-2.2-6.5-3.3-9.6-3.3-7 0-2.6-1.1.5-3.3 7-12.1 35.5-59.4 174.2-167.4 63.4-56.9-58.4-30.5-116.8 73-134.4-59.2 10.1-125.8-6.5-144.1-71.8C9 214.2 0 98.5 0 82.8 0 4.5 68.6 29.1 111 60.9" fill="white"/> 102 + <path d="M111 60.9c58.7 44.1 121.8 133.4 145 181.4 23.2-47.9 86.3-137.3 145-181.4 42.4-31.8 111-56.4 111 21.9 0 15.6-9 131.3-14.2 150.1-18.3 65.3-84.9 82-144.1 71.9 103.5 17.6 129.9 76 73 134.4-108 110.9-155.3-27.8-167.4-63.4-2.2-6.5-3.3-9.6-3.3-7 0-2.6-1.1.5-3.3 7-12.1 35.5-59.4 174.2-167.4 63.4-56.9-58.4-30.5-116.8 73-134.4-59.2 10.1-125.8-6.5-144.1-71.8C9 214.2 0 98.5 0 82.8 0 4.5 68.6 29.1 111 60.9" fill="currentColor"/> 91 103 </svg> 92 104 Follow on Bluesky 93 105 </a>
+2 -2
app/globals.css
··· 61 61 --text: oklch(18% 0.006 250); 62 62 --text-soft: oklch(31.2% 0.007 250); 63 63 --text-dim: oklch(44.2% 0.008 250); 64 - --text-muted: oklch(54.2% 0.008 250); 64 + --text-muted: oklch(52.2% 0.008 250); 65 65 --danger: oklch(56% 0.2 26); 66 66 --blue-accent: oklch(58% 0.22 257); 67 67 --blue-accent-soft: oklch(72% 0.15 255); ··· 79 79 --text: oklch(18% 0.006 250); 80 80 --text-soft: oklch(31.2% 0.007 250); 81 81 --text-dim: oklch(44.2% 0.008 250); 82 - --text-muted: oklch(54.2% 0.008 250); 82 + --text-muted: oklch(52.2% 0.008 250); 83 83 --danger: oklch(56% 0.2 26); 84 84 --blue-accent: oklch(58% 0.22 257); 85 85 --blue-accent-soft: oklch(72% 0.15 255);
+132 -22
app/page.module.css
··· 43 43 linear-gradient(to bottom, color-mix(in oklch, var(--line) 72%, transparent) 1px, transparent 1px); 44 44 background-size: 30px 30px; 45 45 opacity: 0.11; 46 - mask-image: radial-gradient(circle at 50% 36%, #000 38%, transparent 84%); 46 + mask-image: radial-gradient(circle at 50% 36%, black 38%, transparent 84%); 47 47 } 48 48 49 49 .header { ··· 107 107 } 108 108 109 109 .themeBtn { 110 - height: 28px; 111 - min-width: 54px; 110 + min-height: 34px; 111 + min-width: 58px; 112 112 border: 1px solid transparent; 113 113 background: transparent; 114 114 color: var(--text-dim); ··· 124 124 color: color-mix(in oklch, var(--text) 72%, var(--blue-accent) 28%); 125 125 } 126 126 127 + .themeBtn:active { 128 + opacity: 0.84; 129 + } 130 + 131 + .themeBtn:focus-visible { 132 + outline: 2px solid color-mix(in oklch, var(--blue-accent) 70%, var(--text) 30%); 133 + outline-offset: 2px; 134 + } 135 + 127 136 .themeBtnActive { 128 137 background: var(--surface-2); 129 138 border-color: color-mix(in oklch, var(--line-strong) 58%, var(--blue-accent) 42%); ··· 132 141 133 142 .navLinks a { 134 143 color: var(--text-dim); 144 + display: inline-flex; 145 + align-items: center; 135 146 text-decoration: none; 136 147 font-size: 0.78rem; 137 148 letter-spacing: 0.08em; ··· 143 154 color: color-mix(in oklch, var(--text) 72%, var(--blue-accent) 28%); 144 155 } 145 156 157 + .navLinks a:active { 158 + opacity: 0.82; 159 + } 160 + 161 + .navLinks a:focus-visible { 162 + border-radius: var(--radius-xs); 163 + } 164 + 146 165 .hero { 147 166 width: min(1120px, 100% - 48px); 148 167 margin: 0 auto; 149 168 padding: clamp(56px, 9vw, 100px) 0 18px; 150 169 position: relative; 151 170 animation: rise 360ms var(--ease-out); 171 + } 172 + 173 + .inputLabel { 174 + position: absolute; 175 + width: 1px; 176 + height: 1px; 177 + padding: 0; 178 + margin: -1px; 179 + overflow: hidden; 180 + clip: rect(0, 0, 0, 0); 181 + white-space: nowrap; 182 + border: 0; 152 183 } 153 184 154 185 .kicker { ··· 230 261 background: var(--surface-2); 231 262 } 232 263 264 + .input:disabled { 265 + opacity: 0.62; 266 + cursor: not-allowed; 267 + } 268 + 233 269 .detectedBadge { 234 270 position: absolute; 235 271 right: 122px; ··· 248 284 } 249 285 250 286 .convertBtn { 251 - min-width: 108px; 252 - height: 56px; 287 + min-width: 112px; 288 + min-height: 56px; 253 289 padding: 0 22px; 254 290 border: 1px solid var(--line-strong); 255 291 border-radius: var(--radius-md); ··· 265 301 0 0 0 1px color-mix(in oklch, var(--blue-accent) 16%, transparent), 266 302 0 0 0 0 var(--blue-glow); 267 303 transition: transform 120ms ease, opacity 120ms ease, box-shadow 180ms ease; 304 + } 305 + 306 + .convertBtn:disabled { 307 + opacity: 0.54; 308 + transform: none; 309 + box-shadow: 0 0 0 1px color-mix(in oklch, var(--blue-accent) 12%, transparent); 310 + cursor: not-allowed; 311 + } 312 + 313 + .convertBtn:focus-visible { 314 + outline: 2px solid color-mix(in oklch, var(--blue-accent) 70%, var(--text) 30%); 315 + outline-offset: 2px; 268 316 } 269 317 270 318 .convertBtn:hover { ··· 291 339 background: var(--surface); 292 340 color: var(--text-dim); 293 341 border-radius: 999px; 294 - height: 33px; 342 + min-height: 36px; 295 343 padding: 0 13px; 296 344 font-family: var(--mono); 297 345 font-size: 0.72rem; ··· 303 351 color: color-mix(in oklch, var(--text) 74%, var(--blue-accent) 26%); 304 352 border-color: color-mix(in oklch, var(--line-strong) 56%, var(--blue-accent) 44%); 305 353 background: color-mix(in oklch, var(--surface-2) 88%, var(--blue-accent) 12%); 354 + } 355 + 356 + .pill:focus-visible { 357 + outline: 2px solid color-mix(in oklch, var(--blue-accent) 70%, var(--text) 30%); 358 + outline-offset: 2px; 306 359 } 307 360 308 361 .resultSection, ··· 381 434 } 382 435 383 436 .actionBtn { 384 - height: 30px; 437 + min-height: 34px; 385 438 display: inline-flex; 386 439 align-items: center; 387 440 justify-content: center; ··· 404 457 background: color-mix(in oklch, var(--surface-2) 86%, var(--blue-accent) 14%); 405 458 } 406 459 460 + .actionBtn:focus-visible { 461 + outline: 2px solid color-mix(in oklch, var(--blue-accent) 70%, var(--text) 30%); 462 + outline-offset: 2px; 463 + } 464 + 407 465 .actionBtnSuccess { 408 466 background: var(--text); 409 467 color: var(--bg); ··· 418 476 } 419 477 420 478 .toggleBtn { 421 - height: 30px; 479 + min-height: 34px; 422 480 border: 1px solid transparent; 423 481 background: transparent; 424 482 color: var(--text-dim); ··· 434 492 color: var(--text); 435 493 } 436 494 495 + .toggleBtn:focus-visible { 496 + outline: 2px solid color-mix(in oklch, var(--blue-accent) 70%, var(--text) 30%); 497 + outline-offset: 2px; 498 + } 499 + 437 500 .toggleActive { 438 501 border-color: var(--line-strong); 439 502 color: var(--text); ··· 452 515 } 453 516 454 517 .charCount { 455 - color: var(--text-muted); 518 + color: var(--text-dim); 456 519 font-family: var(--mono); 457 - font-size: 0.68rem; 520 + font-size: 0.72rem; 458 521 letter-spacing: 0.06em; 459 522 text-transform: uppercase; 460 523 } ··· 518 581 519 582 .infoKey { 520 583 font-family: var(--mono); 521 - font-size: 0.64rem; 584 + font-size: 0.68rem; 522 585 letter-spacing: 0.12em; 523 586 text-transform: uppercase; 524 587 color: var(--text-muted); ··· 541 604 color: var(--text-muted); 542 605 letter-spacing: 0.14em; 543 606 text-transform: uppercase; 544 - font-size: 0.68rem; 607 + font-size: 0.72rem; 545 608 margin-bottom: 12px; 546 609 } 547 610 ··· 629 692 .cardBadge { 630 693 display: inline-flex; 631 694 min-width: 36px; 632 - height: 20px; 695 + min-height: 24px; 633 696 align-items: center; 634 697 justify-content: center; 635 698 border: 1px solid color-mix(in oklch, var(--line-strong) 54%, var(--blue-accent) 46%); 636 699 border-radius: 999px; 637 700 font-family: var(--mono); 638 - font-size: 0.61rem; 701 + font-size: 0.66rem; 639 702 text-transform: uppercase; 640 703 color: color-mix(in oklch, var(--text-muted) 58%, var(--blue-accent) 42%); 641 704 letter-spacing: 0.08em; ··· 697 760 color: color-mix(in oklch, var(--text) 56%, var(--blue-accent) 44%); 698 761 } 699 762 763 + .agentSkillsLink:active, 764 + .agentRawLink:active { 765 + opacity: 0.82; 766 + } 767 + 768 + .agentSkillsLink:focus-visible, 769 + .agentRawLink:focus-visible { 770 + border-radius: var(--radius-xs); 771 + } 772 + 700 773 .skillCopyBtn { 701 - min-width: 134px; 702 - height: 37px; 774 + min-width: 140px; 775 + min-height: 40px; 703 776 border: 1px solid var(--line-strong); 704 777 border-radius: var(--radius-sm); 705 778 background: var(--text); ··· 716 789 cursor: default; 717 790 } 718 791 792 + .skillCopyBtn:focus-visible { 793 + outline: 2px solid color-mix(in oklch, var(--blue-accent) 70%, var(--text) 30%); 794 + outline-offset: 2px; 795 + } 796 + 719 797 .skillCopyBtnDone { 720 798 background: var(--surface-2); 721 799 color: var(--text); ··· 745 823 } 746 824 747 825 .agentFooterHint { 748 - color: var(--text-muted); 749 - font-size: 0.74rem; 826 + color: var(--text-dim); 827 + font-size: 0.76rem; 750 828 } 751 829 752 830 .footer { ··· 764 842 765 843 .footerLinks a { 766 844 color: var(--text-dim); 845 + display: inline-flex; 846 + align-items: center; 767 847 text-decoration: none; 768 848 font-size: 0.76rem; 769 849 text-transform: uppercase; ··· 774 854 color: var(--text); 775 855 } 776 856 857 + .footerLinks a:active { 858 + opacity: 0.82; 859 + } 860 + 861 + .footerLinks a:focus-visible { 862 + border-radius: var(--radius-xs); 863 + } 864 + 777 865 .footerNote { 778 866 margin-top: 16px; 779 - color: var(--text-muted); 867 + color: var(--text-dim); 780 868 font-family: var(--mono); 781 - font-size: 0.72rem; 869 + font-size: 0.74rem; 782 870 letter-spacing: 0.05em; 871 + } 872 + 873 + @media (pointer: coarse) { 874 + .themeBtn, 875 + .convertBtn, 876 + .pill, 877 + .actionBtn, 878 + .toggleBtn, 879 + .skillCopyBtn, 880 + .navLinks a, 881 + .footerLinks a, 882 + .agentSkillsLink, 883 + .agentRawLink { 884 + min-height: 44px; 885 + } 886 + 887 + .navLinks a, 888 + .footerLinks a, 889 + .agentSkillsLink, 890 + .agentRawLink { 891 + padding-inline: 10px; 892 + } 783 893 } 784 894 785 895 @media (max-width: 980px) { ··· 860 970 } 861 971 862 972 .themeBtn { 863 - min-width: 48px; 973 + min-width: 52px; 864 974 padding: 0 8px; 865 975 } 866 976 ··· 873 983 } 874 984 875 985 .pill { 876 - height: 31px; 986 + min-height: 36px; 877 987 padding: 0 10px; 878 988 font-size: 0.66rem; 879 989 }
+22 -9
app/page.tsx
··· 141 141 142 142 // Live type detection as user types 143 143 const detected = input.trim() ? parseBskyInput(input) : null 144 + const canRun = input.trim().length > 0 && !loading 144 145 145 146 const getPath = useCallback((p: Parsed, mode: 'post' | 'thread') => { 146 147 if (p.isPost && mode === 'thread') return p.path + '/thread' ··· 176 177 ) 177 178 178 179 const handleConvert = useCallback(() => { 180 + if (loading) return 179 181 const p = parseBskyInput(input) 180 182 if (!p) return 181 183 run(p, viewMode) 182 - }, [input, viewMode, run]) 184 + }, [input, viewMode, run, loading]) 183 185 184 186 const handleQuick = useCallback( 185 187 (path: string) => { ··· 300 302 for copy, curl, or coding agents. 301 303 </p> 302 304 305 + <label htmlFor="bsky-input" className={s.inputLabel}> 306 + Bluesky URL, handle, hashtag, or search query 307 + </label> 303 308 <div className={s.inputWrapper}> 304 309 <input 310 + id="bsky-input" 305 311 className={s.input} 306 312 type="text" 307 313 placeholder="bsky.app/profile/... | post URL | #hashtag | search" ··· 313 319 autoComplete="off" 314 320 /> 315 321 {detected && <span className={s.detectedBadge}>{detected.label}</span>} 316 - <button className={s.convertBtn} onClick={handleConvert}> 317 - Run 322 + <button type="button" className={s.convertBtn} onClick={handleConvert} disabled={!canRun}> 323 + {loading ? 'Running' : 'Run'} 318 324 </button> 319 325 </div> 320 326 321 327 <div className={s.pills}> 322 328 {QUICK_LINKS.map((ql) => ( 323 - <button key={ql.path} className={s.pill} onClick={() => handleQuick(ql.path)}> 329 + <button type="button" key={ql.path} className={s.pill} onClick={() => handleQuick(ql.path)}> 324 330 {ql.label} 325 331 </button> 326 332 ))} ··· 335 341 <code className={s.resultUrl}>{activePath}</code> 336 342 <div className={s.resultActions}> 337 343 <button 344 + type="button" 338 345 className={`${s.actionBtn} ${copiedUrl ? s.actionBtnSuccess : ''}`} 339 346 onClick={copyUrl} 340 347 > ··· 354 361 {parsed.isPost && ( 355 362 <div className={s.toggle}> 356 363 <button 364 + type="button" 357 365 className={`${s.toggleBtn} ${viewMode === 'thread' ? s.toggleActive : ''}`} 358 366 onClick={() => handleViewToggle('thread')} 367 + aria-pressed={viewMode === 'thread'} 359 368 > 360 369 Full Thread 361 370 </button> 362 371 <button 372 + type="button" 363 373 className={`${s.toggleBtn} ${viewMode === 'post' ? s.toggleActive : ''}`} 364 374 onClick={() => handleViewToggle('post')} 375 + aria-pressed={viewMode === 'post'} 365 376 > 366 377 Single Post 367 378 </button> ··· 372 383 <div className={s.previewToolbar}> 373 384 <span className={s.charCount}>{fmtBytes(charCount)}</span> 374 385 <button 386 + type="button" 375 387 className={`${s.actionBtn} ${copiedMd ? s.actionBtnSuccess : ''}`} 376 388 onClick={copyMarkdown} 377 389 > ··· 381 393 )} 382 394 383 395 {loading && ( 384 - <div className={s.previewLoading}> 396 + <div className={s.previewLoading} role="status" aria-live="polite"> 385 397 <span className={s.spinner} /> 386 398 Fetching... 387 399 </div> 388 400 )} 389 401 {!loading && error && ( 390 - <pre className={`${s.preview} ${s.previewError}`}>{error}</pre> 402 + <pre className={`${s.preview} ${s.previewError}`} role="alert">{error}</pre> 391 403 )} 392 404 {!loading && markdown && ( 393 405 <pre className={s.preview}>{markdown}</pre> ··· 416 428 </div> 417 429 418 430 <section className={s.terminalSection}> 419 - <p className={s.sectionTitle}>Terminal workflow</p> 431 + <h2 className={s.sectionTitle}>Terminal workflow</h2> 420 432 <p className={s.terminalSubtitle}> 421 433 Use <code>curl</code> directly and pipe output to any terminal renderer, script, or coding agent. 422 434 </p> ··· 455 467 </section> 456 468 457 469 <section className={s.endpointsSection}> 458 - <p className={s.sectionTitle}>All Endpoints</p> 470 + <h2 className={s.sectionTitle}>All Endpoints</h2> 459 471 <div className={s.grid}> 460 472 {ENDPOINTS.map((ep) => ( 461 473 <a key={ep.path} className={s.card} href={ep.example} target="_blank" rel="noopener noreferrer"> ··· 468 480 </section> 469 481 470 482 <section className={s.agentSection}> 471 - <p className={s.sectionTitle}>Add to your coding agent</p> 483 + <h2 className={s.sectionTitle}>Add to your coding agent</h2> 472 484 <div className={s.agentCard}> 473 485 <div className={s.agentHeader}> 474 486 <div> ··· 486 498 </a> 487 499 </div> 488 500 <button 501 + type="button" 489 502 className={`${s.skillCopyBtn} ${copiedSkill ? s.skillCopyBtnDone : ''}`} 490 503 onClick={copySkill} 491 504 disabled={!skillMd}