Story Viewer
alphav0.5.0Full-screen story viewer — segmented progress, 3D cube transitions, finger-following swipe, tap zones, and an engagement overlay.
Context
Use anywhere stories appear — Instagram-style modal viewer over a feed. Pairs with story-rail which fires onItemClick(item, index); host opens <StoryViewer isOpen stories={...} initialStoryIndex={index} onClose={...} /> in response. The viewer's onStoryViewed(storyId) callback is what hosts wire back into railRef.current.markViewed(storyId) to clear the unread ring — viewer is fully decoupled from the rail. Image and video items both supported (video composes media/video-player). v0.4 ships pure-CSS 3D cube transitions + pointer-driven swipe (no framer-motion peer dep); the cube engages only during the animation window and the front face sits at the perspective plane (no scale-jump). The engagement overlay (v0.2) composes engagement-bar v0.3.x; the comments panel (v0.3) and share panel (v0.3.1) are bottom-sheet slots typically host-wired to CommentThread and ShareMenu. Migration origin: kasder kas-social-front-v0 StoryViewer.tsx.
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/story-viewerAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/story-viewer-fixturesPreview
Demo source
Usage
When to use
Reach for StoryViewer when you need an Instagram-style full-screen modal viewer for sequential stories. Pairs with StoryRail: rail fires onItemClick(item, index); host opens <StoryViewer isOpen ... />with the matching index. The viewer's onStoryViewed(storyId) feeds back into railRef.current.markViewed(storyId) so the unread ring clears.
Footgun: the `stories` prop is mount-only
Component captures stories as initial state on mount; subsequent prop reference changes are ignored. Realtime subscribemutates the internal store; for external pushes use the imperative handle's reset(next) or surgical dispatch(action). Cursor is ID-anchored (not index-based), so insertions / removals don't desync your position.
Cursor reset semantics
Cursor resets to (initialStoryIndex, 0) whenever the (initialStoryIndex, isOpen) pair changes — opening with a different index re-seeds; re-opening with the same index also goes back to item 0. Mid-view, in-component nav (tap zones / arrows / keyboard) is preserved across renders.
Minimal usage
import { StoryViewer } from "@/components/story-viewer";
<StoryViewer
stories={stories}
initialStoryIndex={activeStoryIndex}
isOpen={open}
onClose={() => setOpen(false)}
/>Wired with story-rail (canonical)
const railRef = useRef<StoryRailHandle>(null);
const [activeIdx, setActiveIdx] = useState(-1);
<StoryRail
ref={railRef}
items={stories}
onItemClick={(_item, index) => setActiveIdx(index)}
/>
{activeIdx >= 0 ? (
<StoryViewer
stories={stories}
initialStoryIndex={activeIdx}
isOpen
onClose={() => setActiveIdx(-1)}
onStoryViewed={(id) => railRef.current?.markViewed(id)}
/>
) : null}Forward-only viewed semantics
onStoryViewed fires only on forward completion (last item OR forward navigation OR auto-close at end). Backward navigation does NOT mark stories viewed — matches Instagram.
Realtime via subscribe
import type {
Subscribe,
StoryViewerDelta,
} from "@/components/story-viewer";
const subscribe = useCallback<Subscribe<StoryViewerDelta>>(
(handler) => channel.on("stories", handler),
[channel],
);
<StoryViewer
stories={stories}
initialStoryIndex={0}
isOpen={open}
onClose={onClose}
subscribe={subscribe}
onSubscribeDelta={(d) => analytics.track("story-viewer-delta", d)}
/>Imperative handle
const ref = useRef<StoryViewerHandle>(null);
ref.current?.goToStory(2);
ref.current?.goToItem(1);
ref.current?.setPaused(true);
ref.current?.dispatch({
kind: "patch-story",
storyId: "story-1",
partial: { username: "newName" },
});
ref.current?.reset(updatedStories);Custom item rendering
Pass renderItem for full takeover (Lottie items, polls, sponsored placements, etc.). Hosts using this MUST set item.duration explicitly for non-video items, since the video metadata fallback only applies to the default video branch. Hosts wanting to mix custom + default rendering should branch inside their renderItem and re-implement the image / video defaults themselves.
Role-aware mode (v0.2.0)
Pass viewerMode="viewer" to opt into the engagement overlay + DM composer + kebab. Pass viewerMode="owner" for owners (no engagement; owner overlay with view-count + viewers list instead). Per-action overrides go through permissions (e.g. { canReact: false }) or the universal canPerformAction(action, story, item) predicate, which wins over both. Resolution order: predicate → matrix → viewerMode-derived defaults.
Engagement overlay (v0.2.0)
<StoryViewer
stories={stories}
initialStoryIndex={0}
isOpen={open}
onClose={onClose}
viewerMode="viewer"
currentUser={{ id: "u1", name: "Hessam", avatar: "/me.png" }}
reactionKinds={[
{ key: "love", icon: <Heart />, label: "Love", count: 0 },
{ key: "laugh", icon: <Laugh />, label: "Laugh", count: 0 },
]}
onLikeStory={(storyId, itemId, nextLiked) => api.like(storyId, itemId, nextLiked)}
onReactStory={(storyId, itemId, kind) => api.react(storyId, itemId, kind)}
onShareStory={(storyId, itemId) => api.share(storyId, itemId)}
onAddReply={(storyId, itemId, content) => api.dm(storyId, itemId, content)}
/>Comments panel (v0.3.0)
Wire renderCommentsPanel to host the per-item comment thread (typically CommentThread). Tapping the comment icon opens a bottom-sheet (~62% viewer height) — the visual stack above scales to 55% and translates up; tap on the shrunk visual closes the panel. Always-mounted so the consumer's draft state survives open/close. The story timer auto-pauses while the panel is open. Set disableComments to fall back to the v0.2.x behavior (comment icon focuses the DM input).
<StoryViewer
/* … */
renderCommentsPanel={(story, item, helpers) => (
<CommentThread
comments={getCommentsFor(story.id, item.id)}
onAddComment={(content) => api.addComment(story.id, item.id, content)}
onLoadMore={() => api.loadMoreComments(story.id, item.id)}
/>
)}
/>Share panel (v0.3.1)
Same shape as renderCommentsPanel — wire renderSharePanel to a share UI (typically ShareMenu from @ilinxa/engagement-bar). Comments + share panels are mutually exclusive (opening one closes the other). disableSharePanel falls back to firing onShareStory directly (v0.2.x system-share behavior).
Story-to-story 3D cube + swipe (v0.4)
Story-to-story navigation (auto-advance + nav arrows + tap-zone spillover + keyboard + programmatic goToStory) animates an Instagram-canonical 3D cube with rotateY 0 → ∓90° over 400ms and Apple-spring easing. Item-to-item navigation within a story stays a hard cut. The cube is also finger-drivable: drag-left to advance, drag-right to return; release commits past 30% width or 0.5 px/ms velocity. Pass storyTransitionDurationMs to tune (default 400) or disableStoryTransition to revert to v0.3.x hard cuts.
Public types & helpers
The barrel exports every public type referenced by props + callbacks — Story / StoryItem / StoryItemLink / StoryViewerMode / StoryViewerPermissions / StoryEngagementReactionKind / StoryKebabMenuItem / ViewerListItem / StoryCurrentUser / StoryEngagementDelta + companions. Three internal hooks are also exported standalone for advanced consumers (custom viewers reusing the same reducer / progress timer / keyboard nav): useStoryViewerState, useStoryProgress, useStoryKeyboardNav. useCubeTransition and useLongPressPausestay internal — they're tightly coupled to this viewer's render shape.
Features
- Radix Dialog modal — focus trap + portal + Escape + backdrop click free
- Mobile full-screen (h-dvh) / desktop centered portrait modal (md:h-175 md:w-100)
- Segmented progress bars (one per item; CSS `transition-[width]` fill; ARIA progressbar)
- Pause-preserving accumulator-based progress timer (fixes kasder's ~50ms drift per pause/resume)
- Item duration resolution: explicit `item.duration` → video metadata → default fallback
- Tap zones: left=prev item / middle=pause / right=next item (mobile + desktop)
- Desktop nav arrows: ← → between stories (story-level navigation)
- Keyboard nav: ArrowLeft/Right (item nav) + Space (pause) + Escape (close)
- Header: avatar + username + relative time + pause/play + mute (video only) + close
- video-player composed for video items (cross-folder via registryDependencies)
- Subscribe<StoryViewerDelta> realtime contract: story-added / story-removed / item-added / item-removed / story-viewed
- ID-anchored cursor (NOT index-based) — story/item insertions / removals don't desync the cursor
- Always-uncontrolled state with `reset(next)` + `dispatch(action)` imperative escape hatches (matches story-rail / post-card / comment-thread)
- Cursor reset on (initialStoryIndex, isOpen) pair change — re-opening with same initialStoryIndex still goes back to item 0
- Forward-only `onStoryViewed` semantics (matches Instagram — backward navigation doesn't mark viewed)
- Auto-close at end of last story with synchronous `onAutoCloseAtEnd` callback before `onClose`
- renderItem slot for custom item types (Lottie, polls, sponsored, etc.)
- useStoryProgress + useStoryKeyboardNav exported standalone for advanced consumers
- i18n via 10-key labels object (defaults to English + native Intl.DateTimeFormat)
- a11y: DialogTitle (sr-only) + per-button aria-labels + per-segment role=progressbar with aria-valuenow
- v0.2.0 — Engagement overlay composing engagement-bar v0.3.x (variant=stacked) — like + reaction (host-supplied kinds) + comment + share (bookmark removed in v0.3.0; kebab moved to header in v0.3.5; column collapsed-by-default with heart toggle in v0.3.7)
- v0.2.0 — DM composer (always-visible bottom 'Reply to @user…' input — Instagram-canonical Direct Message channel, NOT public comments). Composes comment-thread v0.2.1 CommentComposer with auto-pause-on-type. `onAddReply` callback name preserved for back-compat.
- v0.2.0 — Role-aware mode (viewerMode='owner'|'viewer') + StoryViewerPermissions matrix + canPerformAction predicate (mirrors post-card v0.3.0 resolver)
- v0.2.0 — Owner overlay: view-count chip (eager from story.viewerCount) + lazy viewers list panel (onLoadViewers slot; reuses LikersStrip)
- v0.2.0 — Kebab as engagement-overlay item (moved to ViewerHeader's right cluster in v0.3.5)
- Render slots: 9 total (v0.1 renderItem + v0.2 renderHeader/renderProgress/renderNavArrows/renderTapZones/renderEngagementOverlay/renderReplyComposer/renderOwnerOverlay + v0.3.0 renderCommentsPanel + v0.3.1 renderSharePanel)
- Disable opt-outs: 12 flags (v0.2 disableTapZones/disableKeyboardNav/disableNavArrows/disableAutoClose/disableProgressBars/disableEngagement/disableReplyComposer/disableOwnerOverlay + v0.3.0 disableComments + v0.3.1 disableSharePanel + v0.4.0 disableStoryTransition + storyTransitionDurationMs tuning)
- v0.2.0 — Imperative handle: 7→13 methods (added setMuted/triggerLike/triggerReaction/triggerReply/triggerShare/openKebab)
- v0.2.0 — Polymorphic linkComponent + StoryItem.link CTA (redesigned as a top-anchored collapsible drawer in v0.3.8)
- v0.2.0 — Long-press pause additive (Instagram-canonical mobile gesture; preserves v0.1 middle-tap-pause as desktop fallback; longPressThresholdMs prop tunable)
- v0.2.0 — F-S1 hygiene: VideoPlayer import switched to specific-file path
- v0.2.0 — Touch-target patch: header buttons 32×32 → 44×44 (WCAG 2.5.5 compliant)
- v0.2.1 — F-cross-13 viewer-shell patch: drop `showCloseButton={false}` prop (not in consumer's Radix dialog) + suppress close button via `[&>button.absolute]:hidden` CSS (works on both backends)
- v0.2.2 — Author tap-target additive: `onAuthorClick(story)` + polymorphic `authorComponent` (default `"button"` when handler set). Avatar + username strip becomes a real tap-target with hover/focus affordance; consumers can pass Next.js `<Link>` or `<a>` for href-based nav.
- v0.3.0 — Bookmark action removed from engagement overlay (stories are ephemeral; viewers don't bookmark stories. Owner-side `Save to highlights` stays in the kebab).
- v0.3.0 — Instagram-canonical comments panel: comment-icon tap opens a bottom-sheet (~62% viewer height) holding the host-supplied comments thread (typically `<CommentThread />` via `renderCommentsPanel`). Visual content above scales to 55% + translates up; tap anywhere on the shrunk visual closes the panel. Always-mounted (CommentThread draft state survives open/close). Story timer auto-pauses when panel open.
- v0.3.0 — DM input semantic clarified: the always-visible bottom `<ReplyComposer>` is the Direct Message channel to the story author (Instagram-canonical 'Reply to @user…'), NOT public comments. Public comments live in the new panel. `onAddReply` callback name preserved for back-compat.
- v0.3.0 — `disableComments?: boolean` opt-out — when set, comment-icon falls back to focusing the DM input (v0.2.x behavior).
- v0.3.1 — Share panel: share-icon tap opens an Instagram-canonical bottom-sheet holding the host-supplied share targets (typically `<ShareMenu />` from `@ilinxa/engagement-bar`) via `renderSharePanel`. `disableSharePanel` opt-out falls back to v0.2.x onShareStory-only behavior. Comments + share panels are mutually exclusive (opening one closes the other).
- v0.3.1 — `BottomSheet` part extracted (shared chrome for CommentsPanel + SharePanel). Drag-handle bar + heading row + scroll area + close button.
- v0.3.1 — Scroll fix: panel content area uses `overflow-y-auto overscroll-contain` (was `overflow-hidden`) so CommentThread + ShareMenu scroll properly on mobile.
- v0.3.1 — UI polish: backdrop dim (`bg-black/40`) behind shrunk visual when any panel is open; engagement icon sizes unified (kebab `h-5 → h-6` to match like/comment/share/reaction).
- v0.3.1 — DM input clickability fix: explicit `pointer-events-auto` + `z-[31]` + `right-16` (leaves space for right-side engagement overlay) so the always-visible Direct Message input wins focus reliably.
- v0.3.2 — DM composer + engagement overlay collision fix (user-flagged): when the composer is focused or has content, it expands to full width (`right-0`) AND the engagement overlay fades out (opacity-0 + pointer-events-none) so the Cancel + Send chrome no longer overlaps the right-edge icons. Lift via new `onActiveChange?: (active) => void` prop on ReplyComposer.
- v0.3.3 — DM bar layout overhaul (user-flagged 'engagement pushes the bottom area to the left'): gradient strip is now full-width always (`right-0`); engagement column visually overlays it on the right. Cancel button removed entirely — Instagram-canonical story DM has no Cancel. Engagement column stays always visible (no longer fades when composer is active). `onActiveChange` prop kept on ReplyComposer for forward compat (e.g., future heart-toggle that reveals engagement on demand).
- v0.3.4 — DM input full-width follow-up: removed leftover `pr-12` padding on the CommentComposer in v0.3.3 — the engagement column sits at `bottom-24` while the DM input lives at `bottom-0`, so they don't overlap vertically and the input can extend to the right edge.
- v0.3.5 — Engagement column UX overhaul (user-flagged): kebab moved out of the engagement column into the ViewerHeader's right cluster (between mute and close). Engagement column now collapsed by default — only the heart toggle visible. Tap the heart → engagement icons (like / reaction / comment / share) reveal with a staggered bottom-to-top animation (delay-0/75/150/200ms). Tap the heart again or anywhere else → icons collapse back. Outside-pointer-down listener handles the dismiss. New EngagementOverlay props: `expanded` + `onToggle` + `containerRef`. ViewerHeader gains optional `onKebabClick` prop.
- v0.3.6 — DM input height shrink (user-flagged: 'too high — match avatar height'). Two coordinated fixes: ReplyComposer's outer vertical padding `pt-8 pb-4` → `pt-3 pb-3`. CommentComposer's textarea overrides shadcn-baked `min-h-16` (64px) with `min-h-9` (36px) + `py-1.5 text-sm` via `[&_textarea]:` arbitrary-selector className passthrough. Avatar (h-8) and textarea (min-h-9) now visually align.
- v0.3.7 — Heart toggle moved inline with the DM bar (user-flagged: 'put the heart in the same row with the direct input'). EngagementOverlay no longer renders the toggle; it only renders the engagement icons themselves and sits at `bottom-20` (just above the DM row). The toggle is now an absolute button at `right-3 bottom-3 z-32` rendered by story-viewer.tsx, aligned with the DM input avatar. ReplyComposer's outer gains `pr-16` so the input doesn't extend under the toggle. Outside-pointerdown listener checks both the engagement column ref AND the toggle ref so tapping the toggle doesn't trigger an immediate dismiss.
- v0.3.8 — StoryItem.link CTA redesigned as a top-anchored collapsible drawer (user-flagged: bottom button collided with the DM bar). Default state: small rounded chip at `top-16 right-3` showing the host domain + link icon. Tap the chip → drawer slides down (origin-top-right scale+fade transition) showing the host preview + the CTA button + an X-close. Tap chip again or anywhere outside → collapses. Outside-pointer-down listener handles the dismiss. Matches Instagram-canonical link-sticker UX. Polymorphic `linkComponent` + `onLinkClick` semantics preserved.
- v0.3.9 — Full-component review cleanup pass. (1) New label keys: `linkCloseLabel`, `engagementShowLabel`, `engagementHideLabel`, `replyAriaLabel` (function). (2) Removed hardcoded English aria-labels from heart toggle + link-drawer X (was wrongly using `commentsCloseLabel`) + DM textarea. (3) Stale JSDoc cleaned: kebab-panel, engagement-overlay, story-viewer.tsx scaling-wrapper inventory; meta.ts feature bullets reworded to reflect v0.3.x layout (DM composer vs reply composer naming; kebab in header; top-anchored link drawer). (4) Demo custom-slots tab engagement-overlay positioning `bottom-24 → bottom-20` to match v0.3.7. (5) `onActiveChange` on `ReplyComposer` marked `@deprecated` forward-compat. (6) Inline-copied `kind: "bookmark"` + `kind: "view-count"` arms documented as orphan-but-structurally-required.
- v0.4.0 — Instagram-canonical 3D cube transition between stories (user-flagged: 'transition from story to other story must be more professional and more like Instagram'). Story-to-story navigation (auto-advance + next-tap-zone spillover at last item + nav arrows + keyboard arrows + programmatic `goToStory`) animates a `perspective-distant` cube swinging `rotateY 0 → ∓90deg` over 400ms with the Apple-spring easing `cubic-bezier(0.32, 0.72, 0, 1)`. The leaving story renders as a static ghost face (`parts/story-cube-face.tsx` — progress bars + header + image/video poster, no interactivity) on the front wall; the incoming story is pre-placed on the side wall and rotated into view. Detection runs during render (mid-render `setState` pattern) so the cube engages in the SAME React commit as the cursor change — no 1-frame flash. Item-to-item navigation within a single story stays a hard cut (matches Instagram). New opt-outs: `disableStoryTransition?: boolean` and `storyTransitionDurationMs?: number` (default 400). New hook `useCubeTransition` (internal). CSS uses Tailwind v4's `perspective-distant` + `@container` + `transform-3d` + `backface-hidden` plus `translateZ(50cqw)` inline so no JS width measurement is needed.
- v0.4.1 — Finger-following swipe gesture + mobile-fullscreen hardening. (1) **Swipe**: pointer drag on the viewer body drives the cube angle in real-time (Δx → angle, 1:1 at half-width = 90°). Drag-left advances to the next story; drag-right returns to previous. On release: distance > 30% width OR velocity > 0.5 px/ms commits — else snap-back to current. During drag, prev + next ghost faces are mounted on the left/right walls so the user can swing either way. Boundary resistance (×0.25) at first/last story. Cube hook extended with `beginDrag` / `setDragAngle` / `releaseDrag` API; CSS transition disabled mid-drag (pointer is the driver) and re-enabled for release. Coexists with longPress pause (drag-intent cancels the long-press timer) and tap-zone clicks (a `swipeJustEnded` flag suppresses the click after a successful drag). (2) **Mobile full-screen fix**: shadcn `DialogContent` ships `sm:max-w-sm` (caps width at 384px on 640–767px viewports). Viewer-shell now explicitly clears the `sm:` cap with `sm:max-w-none sm:rounded-none sm:h-dvh sm:w-screen` so the modal stays truly full-screen across the entire `<md` range. Resolves the issue where on intermediate-mobile widths the modal floated as a 384px column with the docs-page features list bleeding through the `bg-black/10` overlay around it.
- v0.4.2 — Cube-engagement scale-jump fix (user-flagged: 'scale gets bigger on swipe, must scale down for cubic effect'). Root cause: front face sat at `translateZ(50cqw)`, which CSS perspective magnifies ≈1.2× at rest (`perspective / (perspective − halfWidth)` ≈ `1200 / 1000`). The moment the cube engaged mid-swipe, the live story jumped from natural size 1.0× to 1.2×, then shrank during rotation — visually reads as 'big then shrinking', not a clean cube. Fix: prefix the rotator transform with `translateZ(-50cqw)` so the front face lands at world z=0 (the natural perspective plane) at idle. Now scale is 1.0× at engagement (no jump), shrinks DOWN to ≈0.857× as the face rotates to ∓90°, and the incoming face mirrors the curve (starts at 0.857×, grows to 1.0× as it arrives at front). Proper cube perspective behavior throughout. Tailwind v4 important-suffix (`h-dvh!` etc.) added to viewer-shell mobile sizing so shadcn's `sm:max-w-sm` no longer wins the cascade. Docs page (`src/app/components/[slug]/page.tsx`) gained `overflow-x-hidden sm:overflow-x-visible` + `wrap-break-word` on feature `<li>` so the long v0.4.x bullets wrap cleanly on mobile.
- v0.4.3 — Full-component readiness review pass. Surfaced + closed five drift findings: (1) **🚫 BLOCKER** — `hooks/use-cube-transition.ts` and `parts/story-cube-face.tsx` were missing from `registry.json`, so consumer installs (`pnpm dlx shadcn add @ilinxa/story-viewer`) would have broken with missing-import TS errors. Both files added to the registry roster. (2) Stale `meta.context` + `registry.json.description` claiming 'framer-motion swipe-to-dismiss is the locked v0.2 adoption gate' and 'eighth and final ship in the social-posts-system arc' — both rewritten to reflect the actual v0.4 ship (pure-CSS cube + pointer-driven swipe; no framer-motion peer dep). (3) Feature bullets stale on counts — slots said 1→7, actual 9 (renderCommentsPanel + renderSharePanel were missing); disable opt-outs said 8, actual 12 (disableComments + disableSharePanel + disableStoryTransition + storyTransitionDurationMs were missing). (4) `index.ts` barrel was missing 13 public-API types referenced by props/handlers — `StoryViewerMode`, `StoryViewerPermissions`, `StoryPermissionAction`, `StoryEngagementDelta`, `StoryEngagementLocalAction`, `StoryEngagementAction`, `StoryEngagementActionAlign`, `StoryEngagementReactionKind`, `StoryEngagementBarLabels`, `ViewerListItem`, `StoryReactorProfile`, `StoryCurrentUser`, `StoryReplyComposerLabels`, `StoryKebabMenuItem`, `StoryViewerSlotHelpers`, `StoryItemLink`, `ResolvedStoryViewer01Labels` — all added. (5) `usage.tsx` documented only v0.1 (no engagement / comments / share / cube / swipe / role-aware) — refreshed with role-aware mode + engagement overlay + comments panel + share panel + cube/swipe + public-types sections. tsc / meta-deps / registry:build all clean post-review.
- v0.4.4 — Docs + demo alignment pass. Surfaced + closed four follow-on drift findings: (1) **demo.tsx** — the 'Multi-story nav' tab silently exercised the v0.4 cube + swipe without calling them out. Renamed to 'Cube + swipe', updated the explainer to describe the gesture (drag-left → next, drag-right → prev, 30% / 0.5 px·ms commit thresholds), and added a `disableStoryTransition` checkbox + `storyTransitionDurationMs` slider (100–1000ms, step 50ms) so users can A/B the feature inline. (2) **guide.md '5 rules'** — Rule 5 still claimed 'framer-motion enters in v0.2 for swipe-to-dismiss (the locked motion-substrate adoption gate)'. Replaced with two rules: 'engagement/comments/share/cube are opt-in' and 'everything is pure CSS' (motion substrate stays deferred; v0.4's cube + swipe use Tailwind v4 3D utilities, not framer-motion). (3) **guide.md engagement overlay** — bullets still listed `bookmark` (removed v0.3.0) and 'kebab — 6th item' (moved to header in v0.3.5). Rewritten to reflect the v0.3.5+ collapsed-by-default column with heart toggle reveal + kebab in header right cluster. Slot count table extended to 9 (added renderCommentsPanel + renderSharePanel); opt-out table extended to 12 (added disableComments + disableSharePanel + disableStoryTransition + storyTransitionDurationMs). (4) **guide.md missing sections** — added new sections for v0.3.0 comments panel + DM-vs-comments semantic, v0.3.1 share panel + mutual-exclusion, v0.3.8 link-CTA drawer, v0.3.9 label keys, v0.4.0 cube geometry, v0.4.1 swipe + mobile-fullscreen sizing fix. 'What's NOT in v0.1' section retitled 'Still out of scope (as of v0.4)' with a 'now shipped' subsection clearing engagement/reply/kebab/swipe. Per-version planning docs (`description.md`/`description-v0.2.0.md`/`plan.md`/`plan-v0.2.0.md`) intentionally left frozen — they are historical records, not live docs.