···11+# Following Feed Example Implementation Plan
22+33+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
44+55+**Goal:** Transform the 02-following-feed example from a Statusphere clone into a Bluesky profile posts viewer with URL-based routing.
66+77+**Architecture:** Single HTML file with client-side routing using History API. Login redirects to `/profile/{handle}`, profile pages fetch posts via GraphQL query filtering by actorHandle and excluding replies. Read-only (no posting).
88+99+**Tech Stack:** Vanilla HTML/CSS/JavaScript, GraphQL, OAuth PKCE
1010+1111+---
1212+1313+## Task 1: Update Branding and Remove Statusphere Elements
1414+1515+**Files:**
1616+- Modify: `examples/02-following-feed/index.html`
1717+1818+**Step 1: Update HTML header and title**
1919+2020+Change the title and header from "Statusphere" to "Following Feed":
2121+2222+```html
2323+<title>Following Feed</title>
2424+```
2525+2626+```html
2727+<header>
2828+ <h1>Following Feed</h1>
2929+ <p class="tagline">View posts on the Atmosphere</p>
3030+</header>
3131+```
3232+3333+**Step 2: Remove emoji picker container from HTML**
3434+3535+Remove this line from the main section:
3636+```html
3737+<div id="emoji-picker"></div>
3838+```
3939+4040+**Step 3: Remove status feed container, replace with posts feed**
4141+4242+Change:
4343+```html
4444+<div id="status-feed"></div>
4545+```
4646+To:
4747+```html
4848+<div id="posts-feed"></div>
4949+```
5050+5151+**Step 4: Test manually**
5252+5353+Open `examples/02-following-feed/index.html` in browser.
5454+Expected: See "Following Feed" title, no emoji picker section.
5555+5656+**Step 5: Commit**
5757+5858+```bash
5959+git add examples/02-following-feed/index.html
6060+git commit -m "feat(example): update 02-following-feed branding, remove emoji picker"
6161+```
6262+6363+---
6464+6565+## Task 2: Remove Emoji-Related JavaScript
6666+6767+**Files:**
6868+- Modify: `examples/02-following-feed/index.html`
6969+7070+**Step 1: Remove EMOJIS constant**
7171+7272+Delete these lines from the CONSTANTS section:
7373+```javascript
7474+const EMOJIS = [
7575+ '👍', '👎', '💙', '😧', '😤', '🙃', '😉', '😎', '🤩',
7676+ '🥳', '😭', '😱', '🥺', '😡', '💀', '🤖', '👻', '👽',
7777+ '🎃', '🤡', '💩', '🔥', '⭐', '🌈', '🍕', '🎉', '💯'
7878+];
7979+```
8080+8181+**Step 2: Remove postStatus function**
8282+8383+Delete the entire `postStatus` function (lines ~645-665):
8484+```javascript
8585+async function postStatus(emoji) {
8686+ // ... entire function
8787+}
8888+```
8989+9090+**Step 3: Remove renderEmojiPicker function**
9191+9292+Delete the entire `renderEmojiPicker` function (lines ~765-784):
9393+```javascript
9494+function renderEmojiPicker(currentStatus, enabled = true) {
9595+ // ... entire function
9696+}
9797+```
9898+9999+**Step 4: Remove selectStatus function**
100100+101101+Delete the entire `selectStatus` function (lines ~857-876):
102102+```javascript
103103+async function selectStatus(emoji) {
104104+ // ... entire function
105105+}
106106+```
107107+108108+**Step 5: Test manually**
109109+110110+Open in browser, check console for errors.
111111+Expected: No JavaScript errors, page loads without emoji picker.
112112+113113+**Step 6: Commit**
114114+115115+```bash
116116+git add examples/02-following-feed/index.html
117117+git commit -m "feat(example): remove emoji picker and status posting code"
118118+```
119119+120120+---
121121+122122+## Task 3: Remove Statusphere Status Feed Code
123123+124124+**Files:**
125125+- Modify: `examples/02-following-feed/index.html`
126126+127127+**Step 1: Remove fetchStatuses function**
128128+129129+Delete the entire `fetchStatuses` function that queries `xyzStatusphereStatus`:
130130+```javascript
131131+async function fetchStatuses() {
132132+ const query = `
133133+ query GetStatuses {
134134+ xyzStatusphereStatus(
135135+ // ...
136136+ ) {
137137+ // ...
138138+ }
139139+ }
140140+ `;
141141+ // ...
142142+}
143143+```
144144+145145+**Step 2: Remove renderStatusFeed function**
146146+147147+Delete the entire `renderStatusFeed` function.
148148+149149+**Step 3: Remove status-related CSS**
150150+151151+Delete these CSS blocks:
152152+- `.status-list`
153153+- `.status-item` and its `::before` pseudo-elements
154154+- `.status-emoji`
155155+- `.status-content`
156156+- `.status-author`
157157+- `.status-text`
158158+- `.status-date`
159159+160160+**Step 4: Commit**
161161+162162+```bash
163163+git add examples/02-following-feed/index.html
164164+git commit -m "feat(example): remove statusphere feed code and styles"
165165+```
166166+167167+---
168168+169169+## Task 4: Add Client-Side Routing
170170+171171+**Files:**
172172+- Modify: `examples/02-following-feed/index.html`
173173+174174+**Step 1: Add router utility object**
175175+176176+Add after the storage utilities section:
177177+178178+```javascript
179179+// =============================================================================
180180+// ROUTING
181181+// =============================================================================
182182+183183+const router = {
184184+ getPath() {
185185+ return window.location.pathname;
186186+ },
187187+188188+ getProfileHandle() {
189189+ const match = this.getPath().match(/^\/profile\/(.+)$/);
190190+ return match ? decodeURIComponent(match[1]) : null;
191191+ },
192192+193193+ navigateTo(path) {
194194+ window.history.pushState({}, '', path);
195195+ renderApp();
196196+ },
197197+198198+ isProfilePage() {
199199+ return this.getPath().startsWith('/profile/');
200200+ }
201201+};
202202+203203+// Handle browser back/forward
204204+window.addEventListener('popstate', () => renderApp());
205205+```
206206+207207+**Step 2: Commit**
208208+209209+```bash
210210+git add examples/02-following-feed/index.html
211211+git commit -m "feat(example): add client-side routing utilities"
212212+```
213213+214214+---
215215+216216+## Task 5: Add Posts Fetching Function
217217+218218+**Files:**
219219+- Modify: `examples/02-following-feed/index.html`
220220+221221+**Step 1: Add fetchPosts function**
222222+223223+Add to the DATA FETCHING section:
224224+225225+```javascript
226226+async function fetchPosts(handle) {
227227+ const query = `
228228+ query GetPosts($handle: String!) {
229229+ appBskyFeedPost(
230230+ sortBy: [{direction: DESC, field: createdAt}]
231231+ where: {
232232+ and: [
233233+ {actorHandle: {eq: $handle}},
234234+ {reply: {isNull: true}}
235235+ ]
236236+ }
237237+ ) {
238238+ edges {
239239+ node {
240240+ uri
241241+ text
242242+ createdAt
243243+ appBskyActorProfileByDid {
244244+ displayName
245245+ actorHandle
246246+ avatar {
247247+ url
248248+ }
249249+ }
250250+ embed {
251251+ ... on AppBskyEmbedImages {
252252+ images {
253253+ image {
254254+ url
255255+ }
256256+ }
257257+ }
258258+ }
259259+ }
260260+ }
261261+ }
262262+ }
263263+ `;
264264+265265+ const data = await graphqlQuery(query, { handle }, true);
266266+ return data.appBskyFeedPost?.edges?.map(e => e.node) || [];
267267+}
268268+```
269269+270270+**Step 2: Commit**
271271+272272+```bash
273273+git add examples/02-following-feed/index.html
274274+git commit -m "feat(example): add fetchPosts function for profile posts"
275275+```
276276+277277+---
278278+279279+## Task 6: Add Profile Fetching Function
280280+281281+**Files:**
282282+- Modify: `examples/02-following-feed/index.html`
283283+284284+**Step 1: Add fetchProfile function**
285285+286286+Add to the DATA FETCHING section:
287287+288288+```javascript
289289+async function fetchProfile(handle) {
290290+ const query = `
291291+ query GetProfile($handle: String!) {
292292+ appBskyActorProfile(
293293+ where: {actorHandle: {eq: $handle}}
294294+ first: 1
295295+ ) {
296296+ edges {
297297+ node {
298298+ did
299299+ actorHandle
300300+ displayName
301301+ avatar {
302302+ url
303303+ }
304304+ }
305305+ }
306306+ }
307307+ }
308308+ `;
309309+310310+ const data = await graphqlQuery(query, { handle }, true);
311311+ const edges = data.appBskyActorProfile?.edges || [];
312312+ return edges.length > 0 ? edges[0].node : null;
313313+}
314314+```
315315+316316+**Step 2: Commit**
317317+318318+```bash
319319+git add examples/02-following-feed/index.html
320320+git commit -m "feat(example): add fetchProfile function"
321321+```
322322+323323+---
324324+325325+## Task 7: Add Post Card CSS
326326+327327+**Files:**
328328+- Modify: `examples/02-following-feed/index.html`
329329+330330+**Step 1: Add post card styles**
331331+332332+Add to the CSS section:
333333+334334+```css
335335+/* Post Cards */
336336+.posts-list {
337337+ display: flex;
338338+ flex-direction: column;
339339+ gap: 1rem;
340340+}
341341+342342+.post-card {
343343+ background: white;
344344+ border-radius: 0.5rem;
345345+ padding: 1rem;
346346+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
347347+}
348348+349349+.post-header {
350350+ display: flex;
351351+ align-items: center;
352352+ gap: 0.75rem;
353353+ margin-bottom: 0.75rem;
354354+}
355355+356356+.post-avatar {
357357+ width: 40px;
358358+ height: 40px;
359359+ border-radius: 50%;
360360+ background: var(--gray-200);
361361+ overflow: hidden;
362362+ flex-shrink: 0;
363363+}
364364+365365+.post-avatar img {
366366+ width: 100%;
367367+ height: 100%;
368368+ object-fit: cover;
369369+}
370370+371371+.post-author-info {
372372+ flex: 1;
373373+ min-width: 0;
374374+}
375375+376376+.post-author-name {
377377+ font-weight: 600;
378378+ color: var(--gray-900);
379379+ white-space: nowrap;
380380+ overflow: hidden;
381381+ text-overflow: ellipsis;
382382+}
383383+384384+.post-author-handle {
385385+ font-size: 0.875rem;
386386+ color: var(--gray-500);
387387+}
388388+389389+.post-text {
390390+ color: var(--gray-700);
391391+ line-height: 1.5;
392392+ white-space: pre-wrap;
393393+ word-break: break-word;
394394+}
395395+396396+.post-images {
397397+ display: grid;
398398+ gap: 0.5rem;
399399+ margin-top: 0.75rem;
400400+}
401401+402402+.post-images.single {
403403+ grid-template-columns: 1fr;
404404+}
405405+406406+.post-images.multiple {
407407+ grid-template-columns: repeat(2, 1fr);
408408+}
409409+410410+.post-images img {
411411+ width: 100%;
412412+ border-radius: 0.5rem;
413413+ max-height: 300px;
414414+ object-fit: cover;
415415+}
416416+417417+.post-date {
418418+ font-size: 0.75rem;
419419+ color: var(--gray-500);
420420+ margin-top: 0.75rem;
421421+}
422422+423423+/* Profile Header */
424424+.profile-header {
425425+ display: flex;
426426+ align-items: center;
427427+ gap: 1rem;
428428+ margin-bottom: 1.5rem;
429429+}
430430+431431+.profile-avatar {
432432+ width: 64px;
433433+ height: 64px;
434434+ border-radius: 50%;
435435+ background: var(--gray-200);
436436+ overflow: hidden;
437437+ flex-shrink: 0;
438438+}
439439+440440+.profile-avatar img {
441441+ width: 100%;
442442+ height: 100%;
443443+ object-fit: cover;
444444+}
445445+446446+.profile-name {
447447+ font-size: 1.25rem;
448448+ font-weight: 600;
449449+}
450450+451451+.profile-handle {
452452+ color: var(--gray-500);
453453+}
454454+```
455455+456456+**Step 2: Commit**
457457+458458+```bash
459459+git add examples/02-following-feed/index.html
460460+git commit -m "feat(example): add post card and profile header CSS"
461461+```
462462+463463+---
464464+465465+## Task 8: Add renderPostsFeed Function
466466+467467+**Files:**
468468+- Modify: `examples/02-following-feed/index.html`
469469+470470+**Step 1: Add renderPostsFeed function**
471471+472472+Add to the UI RENDERING section:
473473+474474+```javascript
475475+function renderPostsFeed(posts) {
476476+ const container = document.getElementById('posts-feed');
477477+478478+ if (posts.length === 0) {
479479+ container.innerHTML = `
480480+ <div class="card">
481481+ <p class="loading">No posts found.</p>
482482+ </div>
483483+ `;
484484+ return;
485485+ }
486486+487487+ container.innerHTML = `
488488+ <div class="posts-list">
489489+ ${posts.map(post => {
490490+ const profile = post.appBskyActorProfileByDid;
491491+ const displayName = profile?.displayName || profile?.actorHandle || 'Unknown';
492492+ const handle = profile?.actorHandle || '';
493493+ const avatarUrl = profile?.avatar?.url;
494494+ const images = post.embed?.images || [];
495495+496496+ return `
497497+ <div class="post-card">
498498+ <div class="post-header">
499499+ <div class="post-avatar">
500500+ ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="">` : ''}
501501+ </div>
502502+ <div class="post-author-info">
503503+ <div class="post-author-name">${escapeHtml(displayName)}</div>
504504+ <div class="post-author-handle">@${escapeHtml(handle)}</div>
505505+ </div>
506506+ </div>
507507+ <div class="post-text">${escapeHtml(post.text || '')}</div>
508508+ ${images.length > 0 ? `
509509+ <div class="post-images ${images.length === 1 ? 'single' : 'multiple'}">
510510+ ${images.map(img => `
511511+ <img src="${escapeHtml(img.image?.url || '')}" alt="" loading="lazy">
512512+ `).join('')}
513513+ </div>
514514+ ` : ''}
515515+ <div class="post-date">${formatDate(post.createdAt)}</div>
516516+ </div>
517517+ `;
518518+ }).join('')}
519519+ </div>
520520+ `;
521521+}
522522+```
523523+524524+**Step 2: Commit**
525525+526526+```bash
527527+git add examples/02-following-feed/index.html
528528+git commit -m "feat(example): add renderPostsFeed function"
529529+```
530530+531531+---
532532+533533+## Task 9: Add renderProfileHeader Function
534534+535535+**Files:**
536536+- Modify: `examples/02-following-feed/index.html`
537537+538538+**Step 1: Add renderProfileHeader function**
539539+540540+Add to the UI RENDERING section:
541541+542542+```javascript
543543+function renderProfileHeader(profile) {
544544+ const displayName = profile?.displayName || profile?.actorHandle || 'Unknown';
545545+ const handle = profile?.actorHandle || '';
546546+ const avatarUrl = profile?.avatar?.url;
547547+548548+ return `
549549+ <div class="profile-header">
550550+ <div class="profile-avatar">
551551+ ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="">` : ''}
552552+ </div>
553553+ <div>
554554+ <div class="profile-name">${escapeHtml(displayName)}</div>
555555+ <div class="profile-handle">@${escapeHtml(handle)}</div>
556556+ </div>
557557+ </div>
558558+ `;
559559+}
560560+```
561561+562562+**Step 2: Commit**
563563+564564+```bash
565565+git add examples/02-following-feed/index.html
566566+git commit -m "feat(example): add renderProfileHeader function"
567567+```
568568+569569+---
570570+571571+## Task 10: Add renderProfilePage Function
572572+573573+**Files:**
574574+- Modify: `examples/02-following-feed/index.html`
575575+576576+**Step 1: Add renderProfilePage function**
577577+578578+Add to the UI RENDERING section:
579579+580580+```javascript
581581+async function renderProfilePage(handle) {
582582+ const container = document.getElementById('posts-feed');
583583+584584+ // Show loading state
585585+ container.innerHTML = `
586586+ <div class="card">
587587+ <p class="loading">Loading profile...</p>
588588+ </div>
589589+ `;
590590+591591+ try {
592592+ // Fetch profile and posts in parallel
593593+ const [profile, posts] = await Promise.all([
594594+ fetchProfile(handle),
595595+ fetchPosts(handle)
596596+ ]);
597597+598598+ if (!profile) {
599599+ container.innerHTML = `
600600+ <div class="card">
601601+ <p class="loading" style="color: var(--error-text);">Profile not found: @${escapeHtml(handle)}</p>
602602+ </div>
603603+ `;
604604+ return;
605605+ }
606606+607607+ // Render profile header + posts
608608+ container.innerHTML = `
609609+ <div class="card">
610610+ ${renderProfileHeader(profile)}
611611+ </div>
612612+ `;
613613+614614+ renderPostsFeed(posts);
615615+ } catch (error) {
616616+ console.error('Failed to load profile:', error);
617617+ container.innerHTML = `
618618+ <div class="card">
619619+ <p class="loading" style="color: var(--error-text);">
620620+ Failed to load profile. ${error.message}
621621+ </p>
622622+ </div>
623623+ `;
624624+ }
625625+}
626626+```
627627+628628+**Step 2: Commit**
629629+630630+```bash
631631+git add examples/02-following-feed/index.html
632632+git commit -m "feat(example): add renderProfilePage function"
633633+```
634634+635635+---
636636+637637+## Task 11: Update renderLoginForm for Routing
638638+639639+**Files:**
640640+- Modify: `examples/02-following-feed/index.html`
641641+642642+**Step 1: Update renderLoginForm**
643643+644644+Modify the existing `renderLoginForm` function to show a message when on profile page but not logged in:
645645+646646+```javascript
647647+function renderLoginForm() {
648648+ const container = document.getElementById('auth-section');
649649+ const savedClientId = storage.get(STORAGE_KEYS.clientId) || '';
650650+ const profileHandle = router.getProfileHandle();
651651+652652+ const message = profileHandle
653653+ ? `<p style="margin-bottom: 1rem; color: var(--gray-700);">Login to view @${escapeHtml(profileHandle)}'s posts</p>`
654654+ : '';
655655+656656+ container.innerHTML = `
657657+ <div class="card">
658658+ ${message}
659659+ <form class="login-form" onsubmit="handleLogin(event)">
660660+ <div class="form-group">
661661+ <label for="client-id">OAuth Client ID</label>
662662+ <input
663663+ type="text"
664664+ id="client-id"
665665+ placeholder="your-client-id"
666666+ value="${escapeHtml(savedClientId)}"
667667+ required
668668+ >
669669+ </div>
670670+ <div class="form-group">
671671+ <label for="handle">Bluesky Handle</label>
672672+ <input
673673+ type="text"
674674+ id="handle"
675675+ placeholder="you.bsky.social"
676676+ required
677677+ >
678678+ </div>
679679+ <button type="submit" class="btn btn-primary">Login with Bluesky</button>
680680+ </form>
681681+ <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;">
682682+ Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a>
683683+ </p>
684684+ </div>
685685+ `;
686686+}
687687+```
688688+689689+**Step 2: Commit**
690690+691691+```bash
692692+git add examples/02-following-feed/index.html
693693+git commit -m "feat(example): update login form with profile context message"
694694+```
695695+696696+---
697697+698698+## Task 12: Update renderUserCard with Profile Link
699699+700700+**Files:**
701701+- Modify: `examples/02-following-feed/index.html`
702702+703703+**Step 1: Update renderUserCard**
704704+705705+Modify to make the user info clickable to their profile:
706706+707707+```javascript
708708+function renderUserCard(viewer) {
709709+ const container = document.getElementById('auth-section');
710710+ const displayName = viewer?.appBskyActorProfileByDid?.displayName || 'User';
711711+ const handle = viewer?.handle || 'unknown';
712712+ const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url;
713713+714714+ container.innerHTML = `
715715+ <div class="card user-card">
716716+ <a href="/profile/${escapeHtml(handle)}" class="user-info" onclick="event.preventDefault(); router.navigateTo('/profile/${escapeHtml(handle)}')">
717717+ <div class="user-avatar">
718718+ ${avatarUrl
719719+ ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">`
720720+ : '👤'}
721721+ </div>
722722+ <div>
723723+ <div class="user-name">${escapeHtml(displayName)}</div>
724724+ <div class="user-handle">@${escapeHtml(handle)}</div>
725725+ </div>
726726+ </a>
727727+ <button class="btn btn-secondary" onclick="logout()">Logout</button>
728728+ </div>
729729+ `;
730730+}
731731+```
732732+733733+**Step 2: Add hover style for user-info link**
734734+735735+Add to CSS:
736736+737737+```css
738738+a.user-info {
739739+ text-decoration: none;
740740+ color: inherit;
741741+}
742742+743743+a.user-info:hover .user-name {
744744+ color: var(--primary-500);
745745+}
746746+```
747747+748748+**Step 3: Commit**
749749+750750+```bash
751751+git add examples/02-following-feed/index.html
752752+git commit -m "feat(example): make user card link to profile page"
753753+```
754754+755755+---
756756+757757+## Task 13: Create Main renderApp Function
758758+759759+**Files:**
760760+- Modify: `examples/02-following-feed/index.html`
761761+762762+**Step 1: Add renderApp function**
763763+764764+Replace the `main` function with a new routing-aware `renderApp` function:
765765+766766+```javascript
767767+// =============================================================================
768768+// MAIN APPLICATION
769769+// =============================================================================
770770+771771+let currentViewer = null;
772772+773773+async function renderApp() {
774774+ const profileHandle = router.getProfileHandle();
775775+776776+ // Always render auth section first
777777+ if (isLoggedIn()) {
778778+ try {
779779+ if (!currentViewer) {
780780+ currentViewer = await fetchViewer();
781781+ }
782782+ renderUserCard(currentViewer);
783783+ } catch (error) {
784784+ console.error('Failed to fetch viewer:', error);
785785+ renderUserCard(null);
786786+ }
787787+ } else {
788788+ renderLoginForm();
789789+ }
790790+791791+ // Clear posts feed
792792+ document.getElementById('posts-feed').innerHTML = '';
793793+794794+ // Route handling
795795+ if (profileHandle) {
796796+ // Profile page
797797+ if (!isLoggedIn()) {
798798+ document.getElementById('posts-feed').innerHTML = `
799799+ <div class="card">
800800+ <p class="loading">Please login to view profiles.</p>
801801+ </div>
802802+ `;
803803+ return;
804804+ }
805805+ await renderProfilePage(profileHandle);
806806+ } else {
807807+ // Home page
808808+ if (isLoggedIn() && currentViewer?.handle) {
809809+ // Redirect logged-in users to their profile
810810+ router.navigateTo(`/profile/${currentViewer.handle}`);
811811+ }
812812+ }
813813+}
814814+```
815815+816816+**Step 2: Commit**
817817+818818+```bash
819819+git add examples/02-following-feed/index.html
820820+git commit -m "feat(example): add renderApp function with routing logic"
821821+```
822822+823823+---
824824+825825+## Task 14: Update Initialization and OAuth Callback
826826+827827+**Files:**
828828+- Modify: `examples/02-following-feed/index.html`
829829+830830+**Step 1: Replace main() with init()**
831831+832832+Add new initialization function that handles OAuth callback and initial render:
833833+834834+```javascript
835835+async function init() {
836836+ try {
837837+ // Check if this is an OAuth callback
838838+ const isCallback = await handleOAuthCallback();
839839+ if (isCallback) {
840840+ console.log('OAuth callback handled successfully');
841841+ // Fetch viewer and redirect to profile
842842+ const viewer = await fetchViewer();
843843+ currentViewer = viewer;
844844+ if (viewer?.handle) {
845845+ router.navigateTo(`/profile/${viewer.handle}`);
846846+ return;
847847+ }
848848+ }
849849+ } catch (error) {
850850+ showError(`Authentication failed: ${error.message}`);
851851+ storage.clear();
852852+ }
853853+854854+ // Render the app
855855+ await renderApp();
856856+}
857857+858858+// Run on page load
859859+init();
860860+```
861861+862862+**Step 2: Remove the old main() call**
863863+864864+Delete:
865865+```javascript
866866+// Run on page load
867867+main();
868868+```
869869+870870+And delete the old `main()` function entirely.
871871+872872+**Step 3: Commit**
873873+874874+```bash
875875+git add examples/02-following-feed/index.html
876876+git commit -m "feat(example): update init to handle OAuth callback and redirect to profile"
877877+```
878878+879879+---
880880+881881+## Task 15: Update README
882882+883883+**Files:**
884884+- Modify: `examples/02-following-feed/README.md`
885885+886886+**Step 1: Read current README**
887887+888888+Check current content.
889889+890890+**Step 2: Update README content**
891891+892892+```markdown
893893+# Following Feed Example
894894+895895+A simple HTML example demonstrating how to view Bluesky profile posts using Quickslice's GraphQL API.
896896+897897+## Features
898898+899899+- OAuth login with PKCE flow
900900+- Client-side routing (`/profile/{handle}`)
901901+- View any user's posts (excluding replies)
902902+- Display post text and embedded images
903903+904904+## Setup
905905+906906+1. Start the Quickslice server on `localhost:8080`
907907+2. Open `index.html` in a browser
908908+3. Enter your OAuth Client ID and Bluesky handle
909909+4. After login, you'll be redirected to your profile page
910910+911911+## Routes
912912+913913+- `/` - Home page (redirects to profile when logged in)
914914+- `/profile/{handle}` - View posts from a specific user
915915+916916+## GraphQL Query Used
917917+918918+```graphql
919919+query GetPosts($handle: String!) {
920920+ appBskyFeedPost(
921921+ sortBy: [{direction: DESC, field: createdAt}]
922922+ where: {
923923+ and: [
924924+ {actorHandle: {eq: $handle}},
925925+ {reply: {isNull: true}}
926926+ ]
927927+ }
928928+ ) {
929929+ edges {
930930+ node {
931931+ text
932932+ createdAt
933933+ appBskyActorProfileByDid {
934934+ displayName
935935+ actorHandle
936936+ avatar { url }
937937+ }
938938+ embed {
939939+ ... on AppBskyEmbedImages {
940940+ images {
941941+ image { url }
942942+ }
943943+ }
944944+ }
945945+ }
946946+ }
947947+ }
948948+}
949949+```
950950+951951+## Notes
952952+953953+- Requires authentication to view profiles
954954+- Posts are sorted by creation date (newest first)
955955+- Replies are filtered out to show only original posts
956956+```
957957+958958+**Step 3: Commit**
959959+960960+```bash
961961+git add examples/02-following-feed/README.md
962962+git commit -m "docs(example): update README for following feed example"
963963+```
964964+965965+---
966966+967967+## Task 16: Manual Testing
968968+969969+**Step 1: Test login flow**
970970+971971+1. Open `examples/02-following-feed/index.html` in browser
972972+2. Should see "Following Feed" header and login form
973973+3. Enter client ID and handle, click login
974974+4. Complete OAuth flow
975975+5. Should redirect to `/profile/{your-handle}`
976976+977977+**Step 2: Test profile page**
978978+979979+1. Should see your profile header (avatar, name, handle)
980980+2. Should see your posts (newest first)
981981+3. Should NOT see replies
982982+4. Posts with images should display images
983983+984984+**Step 3: Test viewing other profiles**
985985+986986+1. Manually navigate to `/profile/other-user.bsky.social`
987987+2. Should see that user's profile and posts
988988+989989+**Step 4: Test logout**
990990+991991+1. Click logout button
992992+2. Should return to login form
993993+3. URL should stay on profile page
994994+4. Should see "Login to view @handle's posts" message
995995+996996+---
997997+998998+## Summary
999999+10001000+| Task | Description |
10011001+|------|-------------|
10021002+| 1 | Update branding, remove emoji picker HTML |
10031003+| 2 | Remove emoji-related JavaScript constants and functions |
10041004+| 3 | Remove statusphere feed code and CSS |
10051005+| 4 | Add client-side routing utilities |
10061006+| 5 | Add fetchPosts function |
10071007+| 6 | Add fetchProfile function |
10081008+| 7 | Add post card CSS |
10091009+| 8 | Add renderPostsFeed function |
10101010+| 9 | Add renderProfileHeader function |
10111011+| 10 | Add renderProfilePage function |
10121012+| 11 | Update renderLoginForm for routing context |
10131013+| 12 | Update renderUserCard with profile link |
10141014+| 13 | Create main renderApp function |
10151015+| 14 | Update initialization and OAuth callback |
10161016+| 15 | Update README |
10171017+| 16 | Manual testing |
+63
examples/02-following-feed-wip/README.md
···11+# Following Feed Example
22+33+A simple HTML example demonstrating how to view Bluesky profile posts using Quickslice's GraphQL API.
44+55+## Features
66+77+- OAuth login with PKCE flow
88+- Client-side routing (`/profile/{handle}`)
99+- View any user's posts (excluding replies)
1010+- Display post text and embedded images
1111+1212+## Setup
1313+1414+1. Start the Quickslice server on `localhost:8080`
1515+2. Open `index.html` in a browser
1616+3. Enter your OAuth Client ID and Bluesky handle
1717+4. After login, you'll be redirected to your profile page
1818+1919+## Routes
2020+2121+- `/` - Home page (redirects to profile when logged in)
2222+- `/profile/{handle}` - View posts from a specific user
2323+2424+## GraphQL Query Used
2525+2626+```graphql
2727+query GetPosts($handle: String!) {
2828+ appBskyFeedPost(
2929+ sortBy: [{direction: DESC, field: createdAt}]
3030+ where: {
3131+ and: [
3232+ {actorHandle: {eq: $handle}},
3333+ {reply: {isNull: true}}
3434+ ]
3535+ }
3636+ ) {
3737+ edges {
3838+ node {
3939+ text
4040+ createdAt
4141+ appBskyActorProfileByDid {
4242+ displayName
4343+ actorHandle
4444+ avatar { url }
4545+ }
4646+ embed {
4747+ ... on AppBskyEmbedImages {
4848+ images {
4949+ image { url }
5050+ }
5151+ }
5252+ }
5353+ }
5454+ }
5555+ }
5656+}
5757+```
5858+5959+## Notes
6060+6161+- Requires authentication to view profiles
6262+- Posts are sorted by creation date (newest first)
6363+- Replies are filtered out to show only original posts
-75
examples/02-following-feed/README.md
···11-# Statusphere HTML Example
22-33-A single-file HTML example demonstrating quickslice's GraphQL API with OAuth authentication.
44-55-## Features
66-77-- OAuth PKCE authentication flow
88-- Post status updates (emoji)
99-- View recent statuses from the network
1010-- Display user profiles
1111-1212-## Prerequisites
1313-1414-1. Quickslice server running at `http://localhost:8080`
1515-2. A registered OAuth client
1616-1717-## Setup
1818-1919-### 1. Start Quickslice
2020-2121-```bash
2222-cd /path/to/quickslice
2323-make run
2424-```
2525-2626-### 2. Register an OAuth Client
2727-2828-Navigate to the admin settings page at `http://localhost:8080/admin/settings` and register a new OAuth client with:
2929-3030-- **Name:** Statusphere HTML Example
3131-- **Token Endpoint Auth Method:** Public
3232-- **Redirect URIs:** `http://127.0.0.1:3000/`
3333-3434-**Important:** Set the redirect URI to match where you'll serve this HTML file.
3535-3636-### 3. Serve the HTML File
3737-3838-```bash
3939-npx http-server . -p 3000
4040-# Open http://127.0.0.1:3000
4141-```
4242-4343-### 4. Login
4444-4545-1. Enter your OAuth Client ID
4646-2. Enter your Bluesky handle (e.g., `you.bsky.social`)
4747-3. Click "Login with Bluesky"
4848-4. Authorize the app on your AT Protocol PDS
4949-5. You'll be redirected back and logged in
5050-5151-## Usage
5252-5353-- Click any emoji to set your status
5454-- View recent statuses from the network
5555-- Click "Logout" to clear your session
5656-5757-## Security Notes
5858-5959-- Tokens are stored in `sessionStorage` (cleared when tab closes)
6060-- No external dependencies - all code is inline
6161-- Uses PKCE for secure OAuth flow
6262-- CSP header restricts connections to localhost:8080
6363-6464-## Troubleshooting
6565-6666-**"Failed to load statuses"**
6767-- Ensure quickslice server is running at localhost:8080
6868-6969-**OAuth redirect fails**
7070-- Verify redirect URI matches exactly in OAuth client config
7171-- Check that the client ID is correct
7272-7373-**Can't post status**
7474-- Ensure you're logged in (session may have expired)
7575-- Check browser console for error details