-
+
Knowledge Mapper
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 93e5c7e5..4afe72ef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,8 @@
"dependencies": {
"@nanostores/persistent": "^1.3.3",
"deck.gl": "^9.2.7",
- "nanostores": "^1.1.0"
+ "nanostores": "^1.1.0",
+ "pako": "^2.1.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
@@ -1048,6 +1049,12 @@
"@loaders.gl/core": "^4.3.0"
}
},
+ "node_modules/@loaders.gl/compression/node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/@loaders.gl/core": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz",
@@ -4199,6 +4206,12 @@
"setimmediate": "^1.0.5"
}
},
+ "node_modules/jszip/node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/ktx-parse": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz",
@@ -4431,9 +4444,9 @@
"license": "MIT"
},
"node_modules/pako": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
- "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/pathe": {
diff --git a/package.json b/package.json
index 2525da20..6122c6c8 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,8 @@
"dependencies": {
"@nanostores/persistent": "^1.3.3",
"deck.gl": "^9.2.7",
- "nanostores": "^1.1.0"
+ "nanostores": "^1.1.0",
+ "pako": "^2.1.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
diff --git a/public/og-preview.png b/public/og-preview.png
new file mode 100644
index 00000000..380b6e40
Binary files /dev/null and b/public/og-preview.png differ
diff --git a/specs/008-shareable-map-links/checklists/comprehensive.md b/specs/008-shareable-map-links/checklists/comprehensive.md
new file mode 100644
index 00000000..dde40faa
--- /dev/null
+++ b/specs/008-shareable-map-links/checklists/comprehensive.md
@@ -0,0 +1,73 @@
+# Requirements Quality Checklist: Shareable Map Links
+
+**Purpose**: Comprehensive requirements quality validation — PR review gate after implementation
+**Created**: 2026-03-12
+**Feature**: [spec.md](../spec.md)
+**Depth**: Standard (~30 items)
+**Audience**: PR reviewer
+
+## Requirement Completeness
+
+- [ ] CHK001 - Are encoding value semantics defined for all possible response states (correct, incorrect, skipped, unanswered)? [Completeness, Spec §FR-002]
+- [ ] CHK002 - Is the binary wire format fully specified with byte offsets, endianness, and field sizes? [Completeness, Contract §token-format]
+- [ ] CHK003 - Are requirements for the "Copy Link" button placement and styling within the share modal specified? [Gap, Spec §FR-009]
+- [ ] CHK004 - Is the CTA button's position, styling, and responsive behavior in the shared view defined? [Gap, Contract §url-contract]
+- [ ] CHK005 - Are requirements specified for all 5 social platform share buttons (LinkedIn, X, Bluesky, Facebook, Instagram)? [Completeness, Spec §FR-015]
+- [ ] CHK006 - Are loading/progress state requirements defined for the shared view while the GP estimator runs? [Gap]
+
+## Requirement Clarity
+
+- [ ] CHK007 - Is "minimal chrome" quantified with an explicit list of hidden vs. visible UI elements? [Clarity, Spec §FR-006]
+- [ ] CHK008 - Is "visually identical" (SC-005) defined with measurable criteria (pixel diff threshold, screenshot comparison method)? [Ambiguity, Spec §SC-005]
+- [ ] CHK009 - Is the "stable, deterministic index" sort order precisely defined (sort key, tie-breaking, collation)? [Clarity, Spec §FR-001]
+- [ ] CHK010 - Is "gracefully handle" for invalid tokens defined with specific fallback behavior? [Clarity, Spec §FR-011]
+- [ ] CHK011 - Is the OG preview image content specified with enough detail for a designer (text content, font sizes, positioning, contrast requirements)? [Clarity, Spec §FR-018]
+- [ ] CHK012 - Is the Instagram "prompt to paste" UX described with specific wording, duration, and dismissal behavior? [Clarity, Spec §FR-020]
+
+## Requirement Consistency
+
+- [ ] CHK013 - Does the "read-only" requirement in US2 align with the "minimal chrome" clarification listing hidden elements? [Consistency, Spec §US2 vs §FR-006]
+- [ ] CHK014 - Are the URL size guarantees in the token format contract consistent with FR-014 (under 2000 chars for ≤200 answers)? [Consistency, Contract §token-format vs Spec §FR-014]
+- [ ] CHK015 - Is the terminology "token" used consistently across spec, plan, contracts, and tasks (not mixed with "hash", "code", "key")? [Consistency]
+- [ ] CHK016 - Does the social platform list match across US3 acceptance scenarios, FR-015, FR-019, and SC-007 (all must list the same 5 platforms)? [Consistency]
+
+## Acceptance Criteria Quality
+
+- [ ] CHK017 - Can SC-001 ("generate shareable link in under 2 seconds") be objectively measured with a defined starting and ending event? [Measurability, Spec §SC-001]
+- [ ] CHK018 - Can SC-002 ("fully rendered knowledge map within 3 seconds") be measured — is "fully rendered" defined (first paint? all dots visible? estimator complete?)? [Measurability, Spec §SC-002]
+- [ ] CHK019 - Can SC-004 ("tokens remain decodable after question bank updates") be verified with a defined test procedure? [Measurability, Spec §SC-004]
+- [ ] CHK020 - Can SC-007 ("link previews display correct title, description, and preview image") be verified — are "correct" values specified? [Measurability, Spec §SC-007]
+
+## Scenario Coverage
+
+- [ ] CHK021 - Are requirements defined for the zero-response state in shared view (user shares before answering any questions)? [Coverage, Edge Case]
+- [ ] CHK022 - Are requirements specified for what happens when a shared URL is opened on an unsupported/old browser? [Coverage, Gap]
+- [ ] CHK023 - Are requirements defined for the shared view when the "all" domain bundle fails to load (network error)? [Coverage, Exception Flow]
+- [ ] CHK024 - Are requirements specified for concurrent sharing scenarios (user generates link, answers more questions, then recipient opens the old link)? [Coverage, Alternate Flow]
+
+## Edge Case Coverage
+
+- [ ] CHK025 - Does the spec define behavior when URL contains both `?t=TOKEN` and other query parameters (e.g., UTM tracking)? [Edge Case, Spec §Edge Cases]
+- [ ] CHK026 - Are requirements defined for token URLs that pass through URL shorteners (bit.ly, t.co) — does the token survive? [Edge Case, Gap]
+- [ ] CHK027 - Is behavior specified for extremely long tokens (all 2500 questions answered) on platforms with URL length limits? [Edge Case, Spec §Edge Cases]
+- [ ] CHK028 - Are requirements defined for what happens if pako (compression library) fails to load or is unavailable? [Edge Case, Gap]
+
+## Non-Functional Requirements
+
+- [ ] CHK029 - Are accessibility requirements specified for the shared view CTA button (keyboard focus, screen reader label, contrast)? [Gap, Accessibility]
+- [ ] CHK030 - Are performance requirements defined for token encoding/decoding operations (max latency on mid-range hardware)? [Gap, Performance]
+- [ ] CHK031 - Are privacy considerations documented — do tokens reveal any personally identifiable information? [Gap, Privacy]
+- [ ] CHK032 - Are WCAG AA color contrast requirements specified for the CTA button and shared view elements? [Gap, Accessibility]
+
+## Dependencies & Assumptions
+
+- [ ] CHK033 - Is the assumption "social platforms support URLs up to 2000 characters" validated for all 5 target platforms? [Assumption, Spec §Assumptions]
+- [ ] CHK034 - Is the pako dependency version-pinned, and are fallback requirements specified if the CDN or npm package is unavailable? [Dependency, Gap]
+- [ ] CHK035 - Is the assumption "GitHub Pages serves static OG meta tags correctly to social crawlers" validated? [Assumption, Spec §Assumptions]
+
+## Notes
+
+- This checklist validates the REQUIREMENTS quality, not the implementation
+- Items marked [Gap] indicate missing requirements that should be added before final PR approval
+- Items marked [Ambiguity] indicate requirements that need sharper definition
+- Each item should be resolved by updating spec.md, not by verbal agreement
diff --git a/specs/008-shareable-map-links/checklists/requirements.md b/specs/008-shareable-map-links/checklists/requirements.md
new file mode 100644
index 00000000..40da1dff
--- /dev/null
+++ b/specs/008-shareable-map-links/checklists/requirements.md
@@ -0,0 +1,36 @@
+# Specification Quality Checklist: Shareable Map Links
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-03-12
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [X] No implementation details (languages, frameworks, APIs)
+- [X] Focused on user value and business needs
+- [X] Written for non-technical stakeholders
+- [X] All mandatory sections completed
+
+## Requirement Completeness
+
+- [X] No [NEEDS CLARIFICATION] markers remain
+- [X] Requirements are testable and unambiguous
+- [X] Success criteria are measurable
+- [X] Success criteria are technology-agnostic (no implementation details)
+- [X] All acceptance scenarios are defined
+- [X] Edge cases are identified
+- [X] Scope is clearly bounded
+- [X] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [X] All functional requirements have clear acceptance criteria
+- [X] User scenarios cover primary flows
+- [X] Feature meets measurable outcomes defined in Success Criteria
+- [X] No implementation details leak into specification
+
+## Notes
+
+- All open questions from the issue were resolved via the author's comment (read-only shared views, "All (general)" domain only, no watched-video state in tokens)
+- FR-013 explicitly prevents the "separate load page" drift risk raised in the issue comments
+- Compression/encoding approach mentioned in Assumptions section is descriptive context, not prescriptive implementation detail
diff --git a/specs/008-shareable-map-links/contracts/token-format.md b/specs/008-shareable-map-links/contracts/token-format.md
new file mode 100644
index 00000000..82517352
--- /dev/null
+++ b/specs/008-shareable-map-links/contracts/token-format.md
@@ -0,0 +1,64 @@
+# Contract: Response Token Binary Format
+
+**Version**: 1 | **Date**: 2026-03-12
+
+## Wire Format
+
+```text
+Byte 0: version (uint8) — currently 0x01
+Bytes 1-2: count (uint16 big-endian) — number of response entries
+Bytes 3+: entries, each 3 bytes:
+ [0-1] index (uint16 big-endian) — question index
+ [2] value (int8) — response value
+```
+
+## Value Encoding
+
+| Response | Encoded Value | Byte Representation |
+|-|-|-|
+| Correct | 2 | 0x02 |
+| Skipped | 1 | 0x01 |
+| Incorrect | -1 | 0xFF |
+| Unanswered | 0 | (not stored) |
+
+## Compression
+
+1. Serialize to binary using the wire format above
+2. Compress with raw DEFLATE (pako `deflate` with `raw: true`)
+3. Encode compressed bytes as base64url (RFC 4648 §5): `+` → `-`, `/` → `_`, strip `=` padding
+
+## URL Format
+
+```text
+https://context-lab.com/mapper/?t={base64url_token}
+```
+
+## Size Guarantees
+
+| Answered Questions | Raw Bytes | Compressed (est.) | Base64url Chars | Total URL Length |
+|-|-|-|-|-|
+| 50 | 153 | ~100 | ~134 | ~175 |
+| 100 | 303 | ~200 | ~268 | ~309 |
+| 200 | 603 | ~380 | ~508 | ~549 |
+| 500 | 1503 | ~800 | ~1068 | ~1109 |
+| 2500 (all) | 7503 | ~3000 | ~4000 | ~4041 |
+
+Base URL (`https://context-lab.com/mapper/?t=`) = 41 characters.
+
+## Decoding Rules
+
+1. Extract `t` parameter from URL query string
+2. Restore base64url: `-` → `+`, `_` → `/`, re-pad with `=` to multiple of 4
+3. Decode base64 to bytes
+4. Inflate with pako (`inflate` with `raw: true`)
+5. Parse version byte — if unsupported version, abort (fall back to normal app)
+6. Parse count (bytes 1-2, big-endian)
+7. For each entry: read index (uint16 BE), value (int8)
+8. Look up question_id from QuestionIndex using the token's version
+9. Skip entries whose index has no matching question (question was removed)
+
+## Versioning
+
+- Version byte `0x01`: Initial format as described above
+- Future versions may change field sizes or add metadata
+- Decoders MUST check the version byte and reject unknown versions gracefully
diff --git a/specs/008-shareable-map-links/contracts/url-contract.md b/specs/008-shareable-map-links/contracts/url-contract.md
new file mode 100644
index 00000000..1726e506
--- /dev/null
+++ b/specs/008-shareable-map-links/contracts/url-contract.md
@@ -0,0 +1,44 @@
+# Contract: URL Parameter Interface
+
+**Version**: 1 | **Date**: 2026-03-12
+
+## Query Parameters
+
+| Parameter | Type | Required | Description |
+|-|-|-|-|
+| `t` | string (base64url) | No | Encoded response token. When present, app boots in shared view mode. |
+
+## Behavior Matrix
+
+| URL | Behavior |
+|-|-|
+| `/mapper/` | Normal app — landing screen, full UI |
+| `/mapper/?t={valid_token}` | Shared view — minimal chrome, read-only map |
+| `/mapper/?t=` | Normal app (empty token treated as absent) |
+| `/mapper/?t={invalid}` | Normal app (decode failure → silent fallback) |
+| `/mapper/?t={valid}&other=param` | Shared view (extra params ignored) |
+
+## Shared View Mode
+
+When a valid `?t=` token is detected:
+
+1. **Skip** landing/welcome screen
+2. **Load** "All (general)" domain bundle
+3. **Decode** token into SyntheticResponse array
+4. **Run** GP estimator with SyntheticResponses
+5. **Render** map with heatmap + response dots
+6. **Show** minimal chrome: map canvas + "Map your *own* knowledge!" CTA button
+7. **Hide** header toolbar, quiz panel, video panel, minimap, drawer pulls
+
+## CTA Button
+
+- Text: "Map your *own* knowledge!"
+- Action: Navigate to `/mapper/` (no token — starts fresh normal session)
+- Position: Fixed bottom-center, visually prominent
+- Style: Consistent with app primary button styling
+
+## localStorage Interaction
+
+- Shared view MUST NOT read from or write to localStorage
+- Existing localStorage data is preserved but ignored during shared view
+- Navigating to the main app via CTA uses normal localStorage-based state
diff --git a/specs/008-shareable-map-links/data-model.md b/specs/008-shareable-map-links/data-model.md
new file mode 100644
index 00000000..77310535
--- /dev/null
+++ b/specs/008-shareable-map-links/data-model.md
@@ -0,0 +1,93 @@
+# Data Model: Shareable Map Links
+
+**Date**: 2026-03-12 | **Branch**: `008-shareable-map-links`
+
+## Entities
+
+### QuestionIndex
+
+A deterministic mapping from every question in the question bank to a stable integer index. Used for compact binary encoding in tokens.
+
+| Field | Type | Description |
+|-|-|-|
+| version | uint8 | Index version — increments when questions are added/removed |
+| entries | Map\ | question_id → integer index |
+| reverseEntries | Map\ | integer index → question_id |
+
+**Construction rule**: Sort all questions across all domains by `(domain_ids[0], id)` alphabetically. Assign index 0, 1, 2, ... in sort order.
+
+**Invariants**:
+- Every question has exactly one index
+- Index assignment is deterministic (same question bank → same indices)
+- Indices are contiguous (0 to N-1 for N questions)
+
+### ResponseToken
+
+A versioned, compressed, URL-safe encoding of a user's quiz responses.
+
+| Field | Type | Description |
+|-|-|-|
+| version | uint8 | Token format version (currently 1) |
+| count | uint16 | Number of encoded responses |
+| entries | Array\<{index: uint16, value: int8}\> | Sparse response pairs |
+
+**Value encoding**:
+
+| Response State | Value |
+|-|-|
+| Unanswered | 0 (not stored — sparse) |
+| Skipped | 1 |
+| Correct | 2 |
+| Incorrect | -1 (0xFF as uint8) |
+
+**Binary layout**: `[version:1][count:2][index:2,value:1]×count`
+- Total bytes: `3 + (count × 3)`
+- Big-endian for multi-byte integers
+
+**Lifecycle**:
+1. **Created** when user clicks "Copy Link" in share modal
+2. **Compressed** via pako deflate
+3. **Encoded** to base64url string
+4. **Appended** to URL as `?t=` parameter
+5. **Decoded** when recipient opens the URL
+6. **Inflated** via pako inflate
+7. **Mapped** back to response objects using QuestionIndex reverse lookup
+
+### SyntheticResponse
+
+A minimal response object reconstructed from a decoded token. Compatible with the existing `$responses` store format for rendering purposes.
+
+| Field | Type | Description |
+|-|-|-|
+| question_id | string | From QuestionIndex reverse lookup |
+| is_correct | boolean | true if value === 2 |
+| is_skipped | boolean | true if value === 1 |
+| x | number | From question data (looked up by question_id) |
+| y | number | From question data (looked up by question_id) |
+
+**Note**: SyntheticResponses are NOT written to localStorage. They exist only in memory for the shared view session.
+
+## Relationships
+
+```text
+QuestionIndex ──builds──> ResponseToken (encoding)
+ResponseToken ──decodes──> SyntheticResponse[] (decoding)
+SyntheticResponse ──feeds──> GP Estimator ──renders──> Heatmap
+```
+
+## State Transitions
+
+```text
+[User answers questions]
+ │
+ ▼
+$responses (localStorage) ──encode──> ResponseToken ──compress──> base64url ──> URL
+
+[Recipient opens URL]
+ │
+ ▼
+URL ──parse ?t=──> base64url ──inflate──> ResponseToken ──decode──> SyntheticResponse[]
+ │
+ ▼
+[Shared view renders map with SyntheticResponses]
+```
diff --git a/specs/008-shareable-map-links/plan.md b/specs/008-shareable-map-links/plan.md
new file mode 100644
index 00000000..a9ddd3db
--- /dev/null
+++ b/specs/008-shareable-map-links/plan.md
@@ -0,0 +1,76 @@
+# Implementation Plan: Shareable Map Links
+
+**Branch**: `008-shareable-map-links` | **Date**: 2026-03-12 | **Spec**: [spec.md](spec.md)
+**Input**: Feature specification from `/specs/008-shareable-map-links/spec.md`
+
+## Summary
+
+Enable shareable URLs that encode a user's quiz responses as a compressed token in the query string (`?t=TOKEN`). Recipients open the link and see a read-only knowledge map rendered entirely client-side using the same renderer, estimator, and layout as the main app. The share modal gains a "Copy Link" button, Facebook/Instagram share buttons, and all social share buttons use the token URL. Open Graph and Twitter Card meta tags provide attractive link previews on all major platforms.
+
+## Technical Context
+
+**Language/Version**: JavaScript ES2022+ (ES modules), HTML5, CSS3
+**Primary Dependencies**: nanostores 1.1, Vite 7.3, deck.gl 9.2, KaTeX (CDN), pako (new — for deflate compression)
+**Storage**: localStorage (user progress), URL query parameter (shared state)
+**Testing**: Vitest (unit), Playwright (visual/E2E)
+**Target Platform**: Web (GitHub Pages at context-lab.com/mapper/), all modern browsers
+**Project Type**: Single-page web application (static hosting, no server)
+**Performance Goals**: Token generation <100ms, shared map render <3s, 60fps map interaction
+**Constraints**: No server-side processing; all encoding/decoding client-side; URLs <2000 chars for typical sessions
+**Scale/Scope**: ~2500 questions, typical share sessions 50-200 answered questions
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+- **Principle I (Accuracy)**: Token encoding/decoding must be lossless — every response (correct, incorrect, skipped) must round-trip exactly. Verified by unit tests with real response data, not mocks.
+- **Principle II (User Delight)**: Shared map view must be visually identical to main app. Verified by Playwright screenshot comparison. CTA button and minimal chrome must look polished. OG preview image must be attractive.
+- **Principle III (Compatibility)**: Shared URLs must work across all supported browsers. Token decoding must handle edge cases (truncated URLs, URL-encoded characters). Social share buttons must work on mobile and desktop. OG tags verified on all 5 platforms.
+
+**Gate status**: PASS — no violations. Feature adds a new module without modifying core rendering.
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/008-shareable-map-links/
+├── plan.md # This file
+├── research.md # Phase 0 output
+├── data-model.md # Phase 1 output
+├── quickstart.md # Phase 1 output
+├── contracts/ # Phase 1 output
+│ ├── token-format.md # Token binary format contract
+│ └── url-contract.md # URL parameter contract
+└── tasks.md # Phase 2 output (/speckit.tasks)
+```
+
+### Source Code (repository root)
+
+```text
+src/
+├── app.js # Modified: detect ?t= param, boot shared view mode
+├── sharing/ # NEW module
+│ ├── token-codec.js # Encode/decode response tokens (pako + base64url)
+│ ├── question-index.js # Stable question→integer index mapping
+│ └── shared-view.js # Read-only view bootstrap (minimal chrome, CTA)
+├── ui/
+│ └── share.js # Modified: add Copy Link, Facebook, Instagram buttons
+└── img/
+ └── og-preview.png # NEW: Open Graph preview image (1200x630)
+
+index.html # Modified: add OG + Twitter Card meta tags
+
+tests/
+├── unit/
+│ ├── token-codec.test.js # Round-trip, compression, edge cases
+│ └── question-index.test.js # Deterministic ordering, stability
+└── visual/
+ └── shared-view.spec.js # Playwright: shared URL loads correctly
+```
+
+**Structure Decision**: New `src/sharing/` module keeps token logic isolated from existing code. The shared view bootstrapper (`shared-view.js`) imports from existing modules (renderer, estimator) to guarantee visual parity (FR-013). No separate HTML page — `index.html` detects the `?t=` parameter and switches to shared view mode.
+
+## Complexity Tracking
+
+No constitution violations — table not needed.
diff --git a/specs/008-shareable-map-links/quickstart.md b/specs/008-shareable-map-links/quickstart.md
new file mode 100644
index 00000000..a0d9ef60
--- /dev/null
+++ b/specs/008-shareable-map-links/quickstart.md
@@ -0,0 +1,101 @@
+# Quickstart Scenarios: Shareable Map Links
+
+**Date**: 2026-03-12 | **Branch**: `008-shareable-map-links`
+
+## Scenario 1: Generate and Share a Link
+
+**Precondition**: User has answered 10+ questions across domains.
+
+1. Click the Share button in the header toolbar
+2. Share modal opens showing the knowledge map preview image
+3. Click "Copy Link" button
+4. Button text changes to "Copied!" for 2 seconds
+5. Clipboard contains a URL like `https://context-lab.com/mapper/?t=eJxz...`
+6. Paste the URL into a new browser tab
+7. Map renders with the same heatmap and response dots — no landing screen, no quiz panel
+
+**Verify**: Dot positions and colors (green/red/yellow) match the original session.
+
+## Scenario 2: Open a Shared Link (Fresh Browser)
+
+**Precondition**: Have a token URL (from Scenario 1).
+
+1. Open the URL in an incognito/private window (no localStorage)
+2. Landing screen is skipped — map renders immediately
+3. Only the map canvas and a "Map your *own* knowledge!" CTA button are visible
+4. No header toolbar, no quiz panel, no video panel, no minimap
+5. Click the CTA button
+6. Navigates to `/mapper/` — normal app with landing screen
+
+**Verify**: Shared view has zero interactive quiz elements. CTA navigates correctly.
+
+## Scenario 3: Social Media Share with Token URL
+
+**Precondition**: User has answered 10+ questions.
+
+1. Open Share modal
+2. Click the Twitter/X button
+3. Twitter compose window opens with pre-filled text including the token URL
+4. Click the Facebook button
+5. Facebook share dialog opens with the token URL
+6. Click the Instagram button
+7. Clipboard is populated with share text + token URL; a brief prompt appears ("Copied! Paste into Instagram")
+
+**Verify**: Each platform's compose/share window contains the token URL, not the generic mapper URL.
+
+## Scenario 4: Link Preview on Social Platforms
+
+**Precondition**: App is deployed to GitHub Pages with OG meta tags.
+
+1. Paste a token URL into Twitter's Card Validator
+2. Preview shows: title "Knowledge Mapper", description, and the sample map screenshot image
+3. Paste the same URL into Facebook's Sharing Debugger
+4. Preview shows the same OG card with image, title, description
+5. Paste into LinkedIn's Post Inspector
+6. Preview shows the same card
+
+**Verify**: All 5 platforms (LinkedIn, X, Bluesky, Facebook, Instagram) render a preview card with the correct image and title.
+
+## Scenario 5: Invalid/Corrupted Token
+
+**Precondition**: None.
+
+1. Navigate to `/mapper/?t=INVALID_GARBAGE_STRING`
+2. App loads normally — landing screen appears, full UI
+3. No error message shown to the user
+4. Console shows a warning (not an error)
+
+**Verify**: Graceful fallback, no crash, no user-visible error.
+
+## Scenario 6: Token Forward Compatibility
+
+**Precondition**: Have a token URL generated with the current question bank.
+
+1. Add a new question to a domain JSON file
+2. Rebuild and reload the token URL
+3. Map renders correctly — all original responses appear
+4. The new question appears as unanswered (no dot)
+
+**Verify**: Old tokens survive question bank changes. No decoding errors.
+
+## Scenario 7: Extreme Token Size
+
+**Precondition**: Answer all ~2500 questions (or simulate programmatically).
+
+1. Open Share modal and click "Copy Link"
+2. URL is generated (may exceed 2000 chars — expected for extreme case)
+3. Paste into a new tab — map renders correctly
+4. Test pasting into Twitter compose — URL may be truncated by platform character limits
+
+**Verify**: Encoding handles max load. Document platform-specific URL length limits for edge cases.
+
+## Scenario 8: Mobile Shared View
+
+**Precondition**: Have a token URL.
+
+1. Open the token URL on a mobile device (or emulate 375×667 viewport)
+2. Map renders in minimal chrome — no toolbar, no drawers
+3. CTA button is visible and tappable at the bottom
+4. Map is pannable/zoomable via touch
+
+**Verify**: Responsive layout works for shared view on mobile.
diff --git a/specs/008-shareable-map-links/research.md b/specs/008-shareable-map-links/research.md
new file mode 100644
index 00000000..816a7e79
--- /dev/null
+++ b/specs/008-shareable-map-links/research.md
@@ -0,0 +1,95 @@
+# Research: Shareable Map Links
+
+**Date**: 2026-03-12 | **Branch**: `008-shareable-map-links`
+
+## R1: Client-Side Compression Library
+
+**Decision**: Use [pako](https://github.com/nickel-js/pako) for deflate compression/decompression.
+
+**Rationale**: pako is the de-facto standard JS deflate library (~50KB minified, tree-shakeable). It provides `deflate`/`inflate` for raw DEFLATE streams — no gzip header overhead. Used by thousands of projects for URL-safe data compression. No server required.
+
+**Alternatives considered**:
+- **lz-string**: Simpler API, but produces longer output for small payloads. Less efficient compression ratio for structured binary data.
+- **CompressionStream API**: Native browser API, but not available in all target browsers (Safari 16.4+), requires async streams, and is harder to test.
+- **No compression**: Raw base64url of sparse pairs. For 100 responses × 4 bytes each = 400 bytes → 534 base64 chars. Marginal — compression gives ~40-60% reduction.
+
+## R2: Base64url Encoding
+
+**Decision**: Use standard base64 encoding with URL-safe character substitution (`+` → `-`, `/` → `_`, strip `=` padding).
+
+**Rationale**: base64url is the standard for URL-safe binary encoding (RFC 4648 §5). Native `btoa`/`atob` handle standard base64; a simple character swap produces URL-safe output without dependencies.
+
+**Alternatives considered**:
+- **base62/base58**: Higher information density per character, but no native support and harder to debug.
+- **hex encoding**: 2x larger output than base64. Not practical for URL constraints.
+
+## R3: Sparse Response Encoding Format
+
+**Decision**: Encode only non-zero responses as packed binary: `[version_byte][count_uint16][index_uint16, value_int8]...`
+
+**Rationale**:
+- ~2500 questions total, so uint16 (0-65535) covers all indices
+- Values are {-1=incorrect, 1=skipped, 2=correct}, fitting in int8
+- 3 bytes per response entry + 3 bytes header = very compact
+- 100 responses = 303 bytes raw → ~200 bytes deflated → ~268 base64url chars
+- Well under the 2000-char URL limit
+
+**Alternatives considered**:
+- **Bitfield encoding**: 2 bits per question × 2500 = 625 bytes raw. Efficient for dense responses but wasteful for sparse (most questions unanswered). Also harder to version.
+- **JSON + compress**: `JSON.stringify({id: value, ...})` + deflate. Readable but ~3-5x larger than binary.
+- **Run-length encoding**: Overkill for sparse data with no runs.
+
+## R4: Stable Question Indexing Strategy
+
+**Decision**: Sort all questions by `(domain_id[0], question_id)` alphabetically to produce a deterministic integer index. Store a version byte in the token that maps to a snapshot of the question bank.
+
+**Rationale**:
+- Questions already have unique `id` fields and `domain_ids` arrays
+- Sorting by first domain_id + question_id gives stable ordering that only changes when questions are added/removed
+- Version byte allows decoder to detect mismatches and handle gracefully
+- New questions get indices at the end (appended after existing sort order) if we use insertion-order-preserving versioning
+
+**Alternatives considered**:
+- **Hash-based indexing**: Hash question_id to index. Collision risk, harder to debug.
+- **Sequential file order**: Depends on JSON array ordering in domain files. Fragile.
+- **Question ID as key**: Using string IDs in the token. Much larger payload.
+
+## R5: Open Graph Meta Tags for GitHub Pages
+
+**Decision**: Add static OG meta tags to `index.html`. Use a pre-generated screenshot as the `og:image` hosted in the repo at `src/img/og-preview.png` (served via GitHub Pages).
+
+**Rationale**: GitHub Pages serves static files — no server-side rendering to generate dynamic OG tags per token. A static preview image is the only option. Social platforms don't execute JavaScript, so the same preview appears for all shared links regardless of token content.
+
+**Alternatives considered**:
+- **Dynamic OG via serverless function**: Cloudflare Workers or Vercel Edge function could render per-token images. But this adds infrastructure complexity, a separate domain/CORS concerns, and goes against the "no server" constraint.
+- **Meta tag injection via JS**: Platforms don't execute JS when scraping, so this doesn't work.
+
+## R6: Social Platform Share Intents
+
+**Decision**: Use each platform's native share URL scheme:
+
+| Platform | URL Pattern |
+|-|-|
+| X/Twitter | `https://twitter.com/intent/tweet?text={text}` |
+| LinkedIn | `https://www.linkedin.com/sharing/share-offsite/?url={url}` |
+| Bluesky | `https://bsky.app/intent/compose?text={text}` |
+| Facebook | `https://www.facebook.com/sharer/sharer.php?u={url}` |
+| Instagram | No URL share intent — copy to clipboard + prompt user |
+
+**Rationale**: These are the standard, well-documented share URLs used by all major apps. Instagram has no web share intent API — the standard pattern is to copy text to clipboard and instruct the user to paste into the Instagram app.
+
+## R7: Read-Only Shared View Architecture
+
+**Decision**: Single `index.html` with conditional boot path. When `?t=` parameter is present, `app.js` calls `shared-view.js` instead of the normal boot sequence. The shared view imports the same renderer and estimator but skips quiz panel, video panel, minimap, header toolbar, and landing screen.
+
+**Rationale**: Using the same HTML file and renderer guarantees visual parity (FR-013). A separate `load.html` would inevitably drift. The conditional boot is a clean separation — `shared-view.js` is a small orchestrator that:
+1. Decodes the token
+2. Reconstructs synthetic response objects
+3. Loads the "all" domain bundle
+4. Runs the GP estimator
+5. Renders the map
+6. Adds minimal chrome (CTA button)
+
+**Alternatives considered**:
+- **Separate load.html**: Explicitly rejected by spec (FR-013) and issue author. Drift risk.
+- **iframe embed**: Overkill. Same-origin complexity.
diff --git a/specs/008-shareable-map-links/spec.md b/specs/008-shareable-map-links/spec.md
new file mode 100644
index 00000000..a94fe716
--- /dev/null
+++ b/specs/008-shareable-map-links/spec.md
@@ -0,0 +1,166 @@
+# Feature Specification: Shareable Map Links
+
+**Feature Branch**: `008-shareable-map-links`
+**Created**: 2026-03-12
+**Status**: Draft
+**Input**: GitHub Issue #59 — improve social media sharing
+**References**: [ContextLab/scheduler](https://github.com/ContextLab/scheduler) (prior art for token-based state encoding)
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Generate Shareable Link (Priority: P1)
+
+A user has answered several quiz questions and wants to share their knowledge map with someone else. Instead of downloading an image and manually pasting it, they click "Share" and get a URL that encodes their responses. They copy this link and paste it into a social media post, email, or message.
+
+**Why this priority**: This is the core value proposition — eliminating the awkward manual image-pasting workflow. A shareable URL is the simplest, most universal way to share state across any platform.
+
+**Independent Test**: Generate a token URL after answering questions, paste it into a new browser tab, and verify the map renders correctly with the same response data.
+
+**Acceptance Scenarios**:
+
+1. **Given** a user has answered at least 1 question, **When** they click "Share" and then "Copy Link", **Then** a URL containing an encoded token is copied to their clipboard
+2. **Given** a user has answered 100 questions, **When** a link is generated, **Then** the URL is under 2000 characters total
+3. **Given** a user has answered questions across multiple domains, **When** a link is generated, **Then** all responses (correct, incorrect, skipped) are encoded in the token
+4. **Given** a user has answered 0 questions, **When** they open the share modal, **Then** the "Copy Link" button generates a generic link to the mapper (no token)
+
+---
+
+### User Story 2 - View Shared Map (Priority: P1)
+
+A recipient clicks a shared link (e.g., `context-lab.com/mapper/?t=TOKEN`). The page loads directly into map view showing the "All (general)" domain with the sharer's responses pre-populated. The GP estimator runs, the heatmap renders, and answered questions appear as dots — all without user interaction. The view is read-only (no quiz panel, no ability to answer new questions).
+
+**Why this priority**: Without a working load route, shareable links have no value. This is co-equal with US1.
+
+**Independent Test**: Construct a token URL manually (or programmatically), open it in a fresh browser, and verify the map renders with correct response markers.
+
+**Acceptance Scenarios**:
+
+1. **Given** a valid token URL, **When** a user opens it in a new browser, **Then** the map renders with the encoded responses visible as colored dots (green=correct, red=incorrect, yellow=skipped)
+2. **Given** a valid token URL, **When** the page loads, **Then** the view is minimal chrome: map rendering only, no header toolbar, no video panel, no minimap, no quiz drawer — plus a "Map your *own* knowledge!" CTA button linking to the main app
+3. **Given** a valid token URL, **When** the page loads, **Then** it displays the "All (general)" domain view regardless of which domain the sharer was using
+4. **Given** a valid token URL, **When** the page loads, **Then** the landing/welcome screen is skipped entirely
+5. **Given** a token URL with an invalid or corrupted token, **When** a user opens it, **Then** the app loads normally (landing screen) with no error shown to the user
+
+---
+
+### User Story 3 - Social Media Share Buttons with Token URL (Priority: P2)
+
+When the share modal opens and a token URL is available, the social media buttons (LinkedIn, X/Twitter, Bluesky, Facebook, Instagram) pre-fill the post with the token URL instead of the generic `context-lab.com/mapper` link. Recipients who click the link in the social post see the sharer's actual map.
+
+**Why this priority**: Enhances the sharing experience but builds on US1/US2. The core copy-link flow works without this.
+
+**Independent Test**: Click each social share button, verify the pre-filled post text contains the token URL, and confirm the linked map renders correctly.
+
+**Acceptance Scenarios**:
+
+1. **Given** a user has answered questions and opens the share modal, **When** they click the Twitter/X button, **Then** the tweet compose window pre-fills with text containing the token URL
+2. **Given** a user has answered questions, **When** they click LinkedIn, **Then** the share URL passed to LinkedIn is the token URL (not the generic mapper URL)
+3. **Given** a user has answered questions, **When** they click Bluesky, **Then** the compose window pre-fills with the token URL
+4. **Given** a user has answered questions, **When** they click Facebook, **Then** the Facebook share dialog opens with the token URL
+5. **Given** a user has answered questions, **When** they click Instagram, **Then** the share text (with token URL) is copied to clipboard with a prompt to paste into Instagram (Instagram does not support direct URL share intents)
+
+---
+
+### User Story 4 - Token Versioning and Forward Compatibility (Priority: P2)
+
+The token format includes a version identifier so that when new questions are added or removed in future updates, old tokens remain valid. Questions that no longer exist are silently ignored. New questions appear as unanswered.
+
+**Why this priority**: Without versioning, shared links break every time the question bank changes. This is essential for link longevity but can be added alongside US1/US2.
+
+**Independent Test**: Generate a token, add a new question to a domain, reload the token URL, and verify the map still renders correctly (new question shows as unanswered, all old responses preserved).
+
+**Acceptance Scenarios**:
+
+1. **Given** a token generated with version 1 of the question bank, **When** new questions are added (version 2), **Then** the token still decodes correctly — old responses display, new questions show as unanswered
+2. **Given** a token generated with the current question bank, **When** a question is removed, **Then** the response for that question is silently ignored during decoding
+3. **Given** any valid token, **When** decoded, **Then** the version byte is present and parseable
+
+---
+
+### User Story 5 - Social Media Link Previews (Priority: P2)
+
+When a token URL is shared on social media platforms (LinkedIn, X/Twitter, Bluesky, Facebook, Instagram), the link preview card looks polished and inviting. Since social platforms don't execute JavaScript, the preview uses static Open Graph meta tags with a compelling generic image, title, and description that encourage clicks.
+
+**Why this priority**: Link previews are what most people see first — a broken or missing preview makes shares look unprofessional and reduces click-through. This is critical for virality but depends on US1/US2 being functional first.
+
+**Independent Test**: Paste a token URL into each platform's link preview debugger/validator tool and verify the preview card renders correctly with the expected image, title, and description.
+
+**Acceptance Scenarios**:
+
+1. **Given** a token URL is pasted into a LinkedIn post, **When** LinkedIn fetches the preview, **Then** a card appears with the Knowledge Mapper title, a descriptive tagline, and an attractive preview image
+2. **Given** a token URL is pasted into a tweet on X/Twitter, **When** the tweet is previewed, **Then** a Twitter Card appears with a large image preview, title, and description
+3. **Given** a token URL is posted on Facebook, **When** Facebook scrapes the URL, **Then** an Open Graph card renders with image, title, and description
+4. **Given** a token URL is shared on Bluesky, **When** Bluesky fetches the preview, **Then** a link card appears with image and title
+5. **Given** a generic mapper URL (no token) is shared, **When** any platform fetches the preview, **Then** the same preview card appears (previews are static, not token-specific)
+6. **Given** the preview image, **When** viewed at various card sizes across platforms, **Then** the image looks good at both small (thumbnail) and large (summary_large_image) sizes
+
+---
+
+### Edge Cases
+
+- What happens when a token URL is shared on a platform that truncates URLs beyond a certain length? Tokens for typical sessions (~100 questions) should stay under 2000 chars. For extreme cases (all 2500 questions answered), URLs may reach ~1000 chars — still well within browser and platform limits.
+- What happens when a user visits a token URL on mobile? The same responsive layout applies; the read-only map should render correctly on all screen sizes.
+- What happens when the same user opens their own shared link? They see their map in read-only mode (same as any other recipient). To continue answering, they use the normal app or import/export.
+- What happens if the token contains responses for questions that don't exist in the current question bank? Those responses are silently ignored.
+- What happens if the URL has `?t=` with an empty value? App loads normally (landing screen).
+- What happens if both `?t=TOKEN` and localStorage responses exist? The token takes precedence for the shared view — localStorage is not modified.
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: System MUST assign each question a stable, deterministic index based on domain name + question ID (sorted order)
+- **FR-002**: System MUST encode user responses as a sparse representation: only non-zero entries stored as (index, value) pairs where 0=unanswered, 1=skipped, 2=correct, -1=incorrect
+- **FR-003**: System MUST compress the sparse encoding and produce a URL-safe base64 token
+- **FR-004**: System MUST include a version byte in the token format to support forward compatibility
+- **FR-005**: System MUST decode a valid token from the `?t=` URL parameter and reconstruct the response array
+- **FR-006**: System MUST render the shared map in minimal chrome mode — map only with a "Map your *own* knowledge!" CTA button. No header toolbar, no video panel, no minimap, no quiz drawer
+- **FR-007**: System MUST always render shared maps in the "All (general)" domain view
+- **FR-008**: System MUST skip the landing/welcome screen when a valid token is present
+- **FR-009**: System MUST preserve the existing "Download Image" and "Copy Image" share options alongside the new "Copy Link" option
+- **FR-010**: System MUST update social media share buttons to use the token URL when responses exist
+- **FR-011**: System MUST gracefully handle invalid/corrupted tokens by falling back to normal app load
+- **FR-012**: System MUST NOT modify localStorage when loading a shared token URL
+- **FR-013**: The shared view page MUST use the exact same rendering code as the main mapper — no separate "load page" that could drift out of sync
+- **FR-014**: System MUST generate URLs under 2000 characters for sessions with 200 or fewer answered questions
+- **FR-015**: Share modal MUST include Facebook and Instagram share buttons in addition to existing LinkedIn, X/Twitter, and Bluesky buttons
+- **FR-016**: System MUST include Open Graph meta tags (`og:title`, `og:description`, `og:image`, `og:url`) in the page HTML for social media link previews
+- **FR-017**: System MUST include Twitter Card meta tags (`twitter:card`, `twitter:title`, `twitter:description`, `twitter:image`) for X/Twitter previews
+- **FR-018**: The preview image MUST be a pre-generated knowledge map screenshot with title/tagline overlaid, sized for both thumbnail and large card formats (minimum 1200x630px for Open Graph, 800x418px for Twitter)
+- **FR-019**: Link previews MUST display correctly on all 5 target platforms (LinkedIn, X/Twitter, Bluesky, Facebook, Instagram) as verified using each platform's preview debugger/validator
+- **FR-020**: Instagram share button MUST copy the share text (including token URL) to clipboard with a user-facing prompt, since Instagram does not support direct URL share intents
+
+### Key Entities
+
+- **Response Token**: A versioned, compressed, URL-safe encoding of a user's quiz responses. Contains: version byte, sparse (index, value) pairs for all non-zero responses.
+- **Question Index**: A stable mapping from (domain, question ID) to a deterministic integer index. Used to encode/decode response positions in the token.
+- **Shared View**: A read-only rendering of the knowledge map from a decoded token. Uses the same renderer, estimator, and layout as the main app but with the quiz panel disabled.
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: Users can generate a shareable link in under 2 seconds from the share modal
+- **SC-002**: Recipients see a fully rendered knowledge map within 3 seconds of opening a shared link
+- **SC-003**: 100% of token URLs generated from sessions with 200 or fewer answers are under 2000 characters
+- **SC-004**: Tokens generated today remain decodable after question bank updates (verified by adding/removing questions and re-testing)
+- **SC-005**: The shared map view is visually identical to the main app's map view for the same response data
+- **SC-006**: All existing share functionality (download image, copy image, social buttons) continues working without regression
+- **SC-007**: Link previews on all 5 target platforms (LinkedIn, X/Twitter, Bluesky, Facebook, Instagram) display the correct title, description, and preview image as verified by platform debugger tools
+- **SC-008**: Share modal includes all 5 social platform buttons (LinkedIn, X/Twitter, Bluesky, Facebook, Instagram) plus Copy Link, Copy Image, and Download Image
+
+## Clarifications
+
+### Session 2026-03-12
+
+- Q: What UI elements should be visible in the shared read-only view? → A: Minimal chrome — map only + a slim "Map your *own* knowledge!" CTA button linking to the main app. No header toolbar, no video panel, no minimap.
+- Q: What should the Open Graph preview image depict? → A: A real pre-generated knowledge map screenshot with title/tagline overlaid, showing what the product actually looks like.
+
+## Assumptions
+
+- The app is hosted on GitHub Pages at `context-lab.com/mapper/` — no server-side processing is available. All encoding/decoding must happen client-side.
+- Compression (deflate/pako) can reduce sparse response data to fit comfortably in URL query parameters.
+- Social media platforms (Twitter, LinkedIn, Bluesky) support URLs up to at least 2000 characters in share intents.
+- The question bank currently has ~2500 questions. A typical sharing session involves 50-200 answered questions.
+- Watched-video state is NOT included in the token (per issue discussion).
+- Active domain is NOT encoded — shared maps always show "All (general)" view.
diff --git a/specs/008-shareable-map-links/tasks.md b/specs/008-shareable-map-links/tasks.md
new file mode 100644
index 00000000..e057bc32
--- /dev/null
+++ b/specs/008-shareable-map-links/tasks.md
@@ -0,0 +1,192 @@
+# Tasks: Shareable Map Links
+
+**Input**: Design documents from `/specs/008-shareable-map-links/`
+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
+
+**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no dependencies)
+- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
+- Include exact file paths in descriptions
+
+## Phase 1: Setup
+
+**Purpose**: Install dependencies and create module structure
+
+- [ ] T001 Install pako dependency via `npm install pako` and verify in package.json
+- [ ] T002 Create `src/sharing/` directory structure with empty module files: `token-codec.js`, `question-index.js`, `shared-view.js`
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: Build the question index and token codec that all user stories depend on
+
+**CRITICAL**: No user story work can begin until this phase is complete
+
+- [ ] T003 Implement stable question index builder in `src/sharing/question-index.js` — load all domain question data, sort by `(domain_ids[0], id)`, assign deterministic integer indices. Export `buildIndex(allQuestions)` returning `{ version, toIndex: Map, toId: Map }`
+- [ ] T004 Implement token encoder in `src/sharing/token-codec.js` — `encodeToken(responses, questionIndex)` that maps response objects to sparse `(index, value)` pairs, serializes to binary format per `contracts/token-format.md` (version byte + uint16 count + entries), compresses with pako raw deflate, and returns base64url string
+- [ ] T005 Implement token decoder in `src/sharing/token-codec.js` — `decodeToken(base64urlString, questionIndex)` that reverses the encoding: base64url → pako inflate → parse binary → map indices back to question_ids. Return array of `{ question_id, value }` objects. Return `null` for invalid/corrupted tokens
+- [ ] T006 Write unit tests for question index in `tests/unit/question-index.test.js` — test deterministic ordering, stability across calls, handling of questions with multiple domain_ids
+- [ ] T007 Write unit tests for token codec in `tests/unit/token-codec.test.js` — test round-trip encode/decode for various response counts (0, 1, 50, 200, 500), verify URL-safe characters only, verify size under 2000 chars for 200 responses, test invalid input handling
+
+**Checkpoint**: Token codec and question index verified with unit tests
+
+---
+
+## Phase 3: User Story 1 - Generate Shareable Link (Priority: P1) MVP
+
+**Goal**: Users can click "Copy Link" in the share modal to get a URL with their encoded responses
+
+**Independent Test**: Click "Copy Link", paste URL in new tab — URL contains `?t=` parameter with valid base64url token
+
+### Implementation for User Story 1
+
+- [ ] T008 [US1] Add "Copy Link" button to the share modal in `src/ui/share.js` — insert a new button in both the teaser (pre-5-answers) and full share modal layouts, styled consistently with existing buttons. Use the link icon (`fa-solid fa-link`)
+- [ ] T009 [US1] Wire "Copy Link" button in `src/ui/share.js` — on click: build question index from loaded domain data, call `encodeToken()` with current `$responses`, construct full URL (`window.location.origin + '/mapper/?t=' + token`), copy to clipboard, show "Copied!" feedback for 2 seconds
+- [ ] T010 [US1] Handle edge case in `src/ui/share.js` — if user has 0 responses, "Copy Link" generates the generic URL (`context-lab.com/mapper/`) with no token parameter
+- [ ] T011 [US1] Write Playwright test in `tests/visual/shared-link-generate.spec.js` — answer 10 questions, open share modal, click "Copy Link", verify clipboard contains a URL with `?t=` parameter, verify the token is valid base64url
+
+**Checkpoint**: User Story 1 complete — "Copy Link" button generates valid token URLs
+
+---
+
+## Phase 4: User Story 2 - View Shared Map (Priority: P1) MVP
+
+**Goal**: Recipients open a token URL and see a read-only knowledge map with minimal chrome
+
+**Independent Test**: Open `localhost:5173/mapper/?t={token}` in incognito — map renders with response dots, no header/quiz/video, CTA button visible
+
+### Implementation for User Story 2
+
+- [ ] T012 [US2] Add `?t=` parameter detection in `src/app.js` — at boot, parse `URLSearchParams` for `t` param. If present and non-empty, set a flag (e.g., `window.__sharedViewMode = true`) and call `initSharedView(token)` instead of normal boot sequence
+- [ ] T013 [US2] Implement shared view bootstrapper in `src/sharing/shared-view.js` — export `initSharedView(tokenString)` that: (1) decodes the token, (2) loads the "all" domain bundle, (3) builds question index, (4) maps decoded entries to SyntheticResponse objects with x/y coordinates from question data, (5) runs GP estimator with synthetic responses, (6) initializes the renderer with the heatmap + response dots
+- [ ] T014 [US2] Implement minimal chrome in `src/sharing/shared-view.js` — hide header toolbar, quiz panel, video panel, minimap, drawer pulls, and landing screen. Show only the map canvas and a fixed-bottom CTA button: "Map your *own* knowledge!" linking to `/mapper/`
+- [ ] T015 [US2] Style the CTA button in `src/sharing/shared-view.js` — fixed position bottom-center, prominent primary color background, rounded, responsive (adapts to mobile/desktop), with hover effect
+- [ ] T016 [US2] Handle invalid tokens in `src/app.js` — if `decodeToken()` returns null, log a console warning and fall back to normal app boot (landing screen). No user-visible error
+- [ ] T017 [US2] Ensure shared view does NOT touch localStorage — verify in `src/sharing/shared-view.js` that no `$responses.set()`, `$watchedVideos.set()`, or other persistent store writes occur. Also verify that rendering uses ONLY the decoded SyntheticResponse array, NOT `$responses.get()` from localStorage. If existing localStorage data is present, it must be ignored entirely in shared view mode
+- [ ] T018 [US2] Write Playwright test in `tests/visual/shared-view.spec.js` — programmatically encode a token for 20 responses, navigate to `/?t={token}`, verify: (a) map canvas is visible, (b) response dots render (screenshot comparison), (c) no header toolbar visible, (d) CTA button visible and clickable, (e) CTA navigates to `/mapper/`
+
+**Checkpoint**: User Stories 1 + 2 complete — full shareable link round-trip works
+
+---
+
+## Phase 5: User Story 3 - Social Media Share Buttons (Priority: P2)
+
+**Goal**: Social share buttons use the token URL; Facebook and Instagram buttons added
+
+**Independent Test**: Click each social button, verify compose/share window contains the token URL
+
+### Implementation for User Story 3
+
+- [ ] T019 [P] [US3] Add Facebook share button to both share modal layouts in `src/ui/share.js` — use `fa-brands fa-facebook` icon, Facebook blue (#1877f2), position in the button grid alongside existing buttons
+- [ ] T020 [P] [US3] Add Instagram share button to both share modal layouts in `src/ui/share.js` — use `fa-brands fa-instagram` icon, Instagram gradient or purple (#e4405f), position in the button grid
+- [ ] T021 [US3] Implement Facebook share action in `handleShareAction()` in `src/ui/share.js` — open `https://www.facebook.com/sharer/sharer.php?u={tokenUrl}` in a new tab
+- [ ] T022 [US3] Implement Instagram share action in `handleShareAction()` in `src/ui/share.js` — copy share text (including token URL) to clipboard, show feedback prompt "Copied! Paste into Instagram" for 3 seconds
+- [ ] T023 [US3] Update all social share buttons in `src/ui/share.js` to use the token URL — when responses exist, generate the token URL and pass it as the share URL to LinkedIn, X/Twitter, Bluesky, Facebook. Fall back to generic URL when no responses
+- [ ] T024 [US3] Write Playwright test in `tests/visual/social-share-buttons.spec.js` — answer 5 questions, open share modal, verify all 5 social buttons are visible (LinkedIn, X, Bluesky, Facebook, Instagram), click Copy button and verify clipboard contains token URL
+
+**Checkpoint**: All social share buttons use token URLs, Facebook and Instagram added
+
+---
+
+## Phase 6: User Story 4 - Token Versioning (Priority: P2)
+
+**Goal**: Token format is versioned so old tokens remain valid when questions are added/removed
+
+**Independent Test**: Generate a token, modify the question bank, reload the token URL — map still renders
+
+### Implementation for User Story 4
+
+- [ ] T025 [US4] Add version management to `src/sharing/question-index.js` — compute a version hash from the sorted question ID list (e.g., simple CRC or count-based version). Store the version byte in the index. Export `getIndexVersion()`.
+- [ ] T026 [US4] Implement version-aware decoding in `src/sharing/token-codec.js` — when decoding, check the version byte. If the version matches the current index, decode normally. If it differs, decode entries and silently skip any index that has no matching question_id in the current index
+- [ ] T027 [US4] Write unit test for forward compatibility in `tests/unit/token-codec.test.js` — encode a token, simulate adding/removing questions (modify the index), decode the token with the new index, verify: old responses decode correctly, removed questions are silently skipped, new questions appear as unanswered
+
+**Checkpoint**: Token versioning verified — old tokens survive question bank changes
+
+---
+
+## Phase 7: User Story 5 - Social Media Link Previews (Priority: P2)
+
+**Goal**: Open Graph and Twitter Card meta tags produce attractive link preview cards on all platforms
+
+**Independent Test**: Paste URL into Twitter Card Validator and Facebook Sharing Debugger — preview card shows title, description, and image
+
+### Implementation for User Story 5
+
+- [ ] T028 [P] [US5] Create the OG preview image at `src/img/og-preview.png` — create a NEW 1200x630px canvas (do NOT reuse `generateShareImage()` which is 800x600). Render a pre-populated knowledge map heatmap at this size, then overlay white text with dark shadow: title "Knowledge Mapper" (top-center, bold, ~48px) and tagline "Map out everything you know!" (below title, ~24px). Ensure text is legible at both thumbnail and large card sizes
+- [ ] T029 [P] [US5] Add Open Graph meta tags to `index.html` — add `og:title` ("Knowledge Mapper"), `og:description` ("An interactive tool that maps out everything you know. Answer questions and watch your personalized knowledge map take shape."), `og:image` (absolute URL to og-preview.png on GitHub Pages), `og:url`, `og:type` ("website")
+- [ ] T030 [P] [US5] Add Twitter Card meta tags to `index.html` — add `twitter:card` ("summary_large_image"), `twitter:title`, `twitter:description`, `twitter:image` matching the OG values
+- [ ] T031 [US5] Verify link previews using platform debugger tools — test with Twitter Card Validator, Facebook Sharing Debugger, LinkedIn Post Inspector. Take screenshots of each preview for verification. Document any platform-specific issues
+- [ ] T032 [US5] Test Bluesky and Instagram link previews — share a deployed URL on Bluesky and verify the link card renders. For Instagram, verify the OG image appears when sharing a link in Instagram Stories/DMs
+
+**Checkpoint**: Link previews verified on all 5 target platforms
+
+---
+
+## Phase 8: Polish & Cross-Cutting Concerns
+
+**Purpose**: Final quality, compatibility, and regression checks
+
+- [ ] T033 Run all existing unit tests (`npm test`) and verify no regressions from new code
+- [ ] T034 Run all existing Playwright tests (`npm run test:visual`) and verify no regressions
+- [ ] T035 [P] Cross-browser Playwright test for shared view — test shared URL loading in Chromium, Firefox, and WebKit engines
+- [ ] T036 [P] Mobile viewport Playwright test — test shared view at 375x667 (iPhone), 390x844 (iPhone 14), 360x800 (Android) — verify CTA button is visible and map renders
+- [ ] T037 Run quickstart.md validation — manually execute all 8 quickstart scenarios and verify expected outcomes
+- [ ] T038 Verify token size constraints — generate tokens for 50, 100, 200, 500, and 2500 responses, confirm URL lengths match size guarantees in contracts/token-format.md
+- [ ] T039 Screenshot verification per Constitution Principle II — take Playwright screenshots of: (a) share modal with Copy Link button, (b) shared view with CTA, (c) shared view on mobile, (d) share modal with all 5 social buttons. Visually verify polish and consistency
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Setup (Phase 1)**: No dependencies — start immediately
+- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user stories
+- **US1 (Phase 3)**: Depends on Phase 2 — MVP target
+- **US2 (Phase 4)**: Depends on Phase 2 + partially on US1 (uses same codec)
+- **US3 (Phase 5)**: Depends on Phase 2 — can run in parallel with US1/US2
+- **US4 (Phase 6)**: Depends on Phase 2 — can run in parallel with US1/US2/US3
+- **US5 (Phase 7)**: No code dependencies on other stories — can start anytime after Setup
+- **Polish (Phase 8)**: Depends on all user stories being complete
+
+### User Story Dependencies
+
+- **US1 (P1)**: Depends on Foundational only — independent
+- **US2 (P1)**: Depends on Foundational only — uses same codec as US1 but independently testable
+- **US3 (P2)**: Depends on Foundational — builds on share modal (same file as US1 but different functions)
+- **US4 (P2)**: Depends on Foundational — extends codec (same file as Phase 2 but additive)
+- **US5 (P2)**: Depends on Setup only — static assets and HTML meta tags, no JS dependencies
+
+### Parallel Opportunities
+
+- T006 and T007 can run in parallel (different test files)
+- T019 and T020 can run in parallel (different buttons, same file but additive)
+- T028, T029, T030 can all run in parallel (different files)
+- T035 and T036 can run in parallel (different test configurations)
+- US5 can start as early as Phase 1 completion (no dependency on codec)
+
+---
+
+## Implementation Strategy
+
+### MVP First (User Stories 1 + 2)
+
+1. Complete Phase 1: Setup (install pako, create module structure)
+2. Complete Phase 2: Foundational (question index + token codec with tests)
+3. Complete Phase 3: US1 (Copy Link button in share modal)
+4. Complete Phase 4: US2 (shared view rendering)
+5. **STOP and VALIDATE**: Generate a link, open in new tab, verify full round-trip
+
+### Incremental Delivery
+
+1. Setup + Foundational → codec verified with unit tests
+2. Add US1 → "Copy Link" button works → testable
+3. Add US2 → full round-trip works → deployable MVP!
+4. Add US3 → social buttons use token URL → enhanced sharing
+5. Add US4 → versioned tokens → future-proof
+6. Add US5 → OG previews → polished social presence
+7. Polish → cross-browser, mobile, regression checks
diff --git a/specs/009-fix-mobile-mode/checklists/requirements.md b/specs/009-fix-mobile-mode/checklists/requirements.md
new file mode 100644
index 00000000..0985ae4e
--- /dev/null
+++ b/specs/009-fix-mobile-mode/checklists/requirements.md
@@ -0,0 +1,34 @@
+# Specification Quality Checklist: Fix Mobile Mode
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-03-12
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [X] No implementation details (languages, frameworks, APIs)
+- [X] Focused on user value and business needs
+- [X] Written for non-technical stakeholders
+- [X] All mandatory sections completed
+
+## Requirement Completeness
+
+- [X] No [NEEDS CLARIFICATION] markers remain
+- [X] Requirements are testable and unambiguous
+- [X] Success criteria are measurable
+- [X] Success criteria are technology-agnostic (no implementation details)
+- [X] All acceptance scenarios are defined
+- [X] Edge cases are identified
+- [X] Scope is clearly bounded
+- [X] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [X] All functional requirements have clear acceptance criteria
+- [X] User scenarios cover primary flows
+- [X] Feature meets measurable outcomes defined in Success Criteria
+- [X] No implementation details leak into specification
+
+## Notes
+
+- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
diff --git a/specs/009-fix-mobile-mode/spec.md b/specs/009-fix-mobile-mode/spec.md
new file mode 100644
index 00000000..7673cbc3
--- /dev/null
+++ b/specs/009-fix-mobile-mode/spec.md
@@ -0,0 +1,128 @@
+# Feature Specification: Fix Mobile Mode
+
+**Feature Branch**: `009-fix-mobile-mode`
+**Created**: 2026-03-12
+**Status**: Draft
+**Input**: User description: "100% functional mobile mode with systematic emulator verification. Header button grouping with swipe-reveal overflow. Drawer pull centering fix."
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Header Button Layout and Overflow (Priority: P1)
+
+A mobile user opens the app on a narrow phone screen. The header bar organizes buttons into two distinct groups separated by a fixed domain dropdown. The **left group** (reset, download, upload) sits immediately right of the dropdown. The **right group** (top areas/trophy, video recs/suggest, share, tutorial, info) sits at the far right. When the screen is too narrow to show all buttons in a group without overlapping, buttons hide individually (rightmost first in left group, leftmost first in right group). The user drags RIGHT on the header to reveal hidden left-group buttons, or drags LEFT to reveal hidden right-group buttons.
+
+**Why this priority**: Without correct button layout and overflow, core app functionality is inaccessible on mobile devices. This is the most-reported mobile issue.
+
+**Independent Test**: Load the app in an Android emulator at 360px width. Verify left-group buttons appear right of dropdown, right-group buttons appear at far right. Resize to 320px and confirm buttons hide progressively. Swipe right/left on header to reveal hidden buttons.
+
+**Acceptance Scenarios**:
+
+1. **Given** a 360px-wide screen, **When** the app loads on the map screen, **Then** the left group (reset/download/upload) appears immediately right of the dropdown, and the right group (trophy/suggest/share/tutorial/about) appears at the far right
+2. **Given** a 320px-wide screen where left-group buttons overflow, **When** the user drags RIGHT on the header bar, **Then** the hidden left-group buttons scroll into view
+3. **Given** a 320px-wide screen where right-group buttons overflow, **When** the user drags LEFT on the header bar, **Then** the hidden right-group buttons scroll into view
+4. **Given** any mobile width, **When** the header renders, **Then** the domain dropdown remains fixed (never scrolls or hides)
+5. **Given** an extremely narrow screen, **When** there is not enough room for the map icon + dropdown + all icons, **Then** the map icon hides first (dropdown and button groups remain)
+
+---
+
+### User Story 2 - Drawer Pull Horizontal Centering (Priority: P1)
+
+A mobile user sees the quiz panel collapsed at the bottom of the screen. The drawer pull handle (the small bar the user grabs to open the panel) is visually centered horizontally within the panel, regardless of device width or safe-area insets.
+
+**Why this priority**: The drawer pull is the primary interaction point for opening the quiz panel on mobile. Off-center placement looks broken and reduces user confidence.
+
+**Independent Test**: Open the app on both Android (360px) and iPhone (375px) emulators. With the quiz panel collapsed, verify the drawer pull bar is exactly centered horizontally. Measure pixel offset from left and right edges.
+
+**Acceptance Scenarios**:
+
+1. **Given** the quiz panel is collapsed on a 360px Android device, **When** the drawer pull renders, **Then** the pull bar is horizontally centered within the panel (equal left/right spacing, within 1px tolerance)
+2. **Given** the quiz panel is collapsed on a 375px iPhone device, **When** the drawer pull renders, **Then** the pull bar is horizontally centered within the panel
+3. **Given** the video panel drawer pull is visible, **When** it renders, **Then** it is also horizontally centered
+
+---
+
+### User Story 3 - Android Emulator Verification (Priority: P2)
+
+A developer runs the full app in an Android emulator to systematically verify that all mobile features work correctly. Every interactive element is reachable and functional: header buttons, dropdown, drawer pulls, quiz answering, video panel, map interaction, minimap, share, and about modal.
+
+**Why this priority**: Android represents the largest mobile platform. Systematic emulator testing catches device-specific rendering and touch interaction bugs that CSS-only inspection misses.
+
+**Independent Test**: Launch the app in Android Studio emulator (Pixel 7, 412x915). Walk through a complete user journey: welcome screen, start quiz, answer questions, open video panel, switch domains, share map, view about modal.
+
+**Acceptance Scenarios**:
+
+1. **Given** an Android emulator (Pixel 7, API 34), **When** the app loads, **Then** the welcome screen renders correctly with start button accessible
+2. **Given** the map screen on Android, **When** the user taps header buttons (share, about, dropdown), **Then** each responds correctly with no layout breakage
+3. **Given** the quiz panel on Android, **When** the user drags the drawer pull up, **Then** the panel opens smoothly revealing quiz content with tappable answer options
+4. **Given** any screen on Android, **When** the user interacts with the map (pan, pinch-zoom), **Then** the map responds without lag or visual artifacts
+
+---
+
+### User Story 4 - iPhone Emulator Verification (Priority: P2)
+
+A developer runs the full app in the Xcode iPhone Simulator to verify all mobile features work correctly on iOS Safari, accounting for safe-area insets (notch/Dynamic Island), iOS-specific touch behaviors, and WebKit rendering differences.
+
+**Why this priority**: iOS Safari has unique rendering behaviors (safe-area insets, rubber-band scrolling, viewport handling) that require separate verification from Android.
+
+**Independent Test**: Launch the app in Xcode Simulator (iPhone 15, iOS 17). Walk through the same complete user journey as Android verification.
+
+**Acceptance Scenarios**:
+
+1. **Given** an iPhone 15 Simulator, **When** the app loads, **Then** the welcome screen renders correctly respecting the Dynamic Island safe area
+2. **Given** the map screen on iPhone, **When** the user opens the quiz panel, **Then** the bottom sheet respects `env(safe-area-inset-bottom)` and content is not obscured by the home indicator
+3. **Given** the header on iPhone, **When** the user swipes to reveal hidden buttons, **Then** the swipe gesture works correctly without conflicting with iOS back-swipe navigation
+4. **Given** any modal on iPhone, **When** it opens, **Then** it renders correctly and is dismissible via expected iOS interaction patterns
+
+---
+
+### Edge Cases
+
+- What happens when the device is rotated from portrait to landscape? Header layout should adapt without breaking button grouping
+- What happens when the keyboard opens (e.g., if user focuses a text field)? Panels should not be pushed off-screen
+- What happens on a very wide phone (e.g., 430px iPhone Pro Max)? All buttons should be visible without overflow scrolling
+- What happens with accessibility font scaling (large/extra-large text)? Header buttons should remain tappable even if text overflows
+- What happens when the user drags the header but there are no hidden buttons? The scroll should bounce/resist naturally
+- What happens on devices with no safe-area insets (older phones, Android without gesture nav)? Layout should degrade gracefully with fallback spacing
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: Header MUST organize buttons into two scrollable groups: left group (reset, download, upload) positioned immediately right of the domain dropdown, and right group (trophy, suggest, share, tutorial, about) positioned at the far right
+- **FR-002**: Each button group MUST independently hide buttons when insufficient horizontal space exists, removing them one at a time (left group hides rightmost first; right group hides leftmost first)
+- **FR-003**: Users MUST be able to drag/swipe RIGHT on the header to reveal hidden left-group buttons, and drag/swipe LEFT to reveal hidden right-group buttons
+- **FR-004**: The domain dropdown MUST remain fixed (non-scrollable) at all screen widths on mobile
+- **FR-005**: The map icon MUST hide when there is insufficient room to display it alongside the dropdown and all visible menu icons
+- **FR-006**: The drawer pull bar on both quiz and video bottom sheets MUST be exactly horizontally centered within its container at all mobile screen widths
+- **FR-007**: All interactive elements (buttons, drawer pulls, quiz options, map gestures) MUST be functional on Android devices running Chrome
+- **FR-008**: All interactive elements MUST be functional on iOS devices running Safari, respecting safe-area insets
+- **FR-009**: Bottom sheet drawer pulls MUST respond to vertical drag gestures to open/close panels
+- **FR-010**: Header swipe gestures MUST NOT conflict with browser back/forward navigation gestures
+- **FR-011**: All touch targets MUST meet the 44x44px minimum size recommendation for mobile accessibility
+
+### Key Entities
+
+- **Header Left Group**: Contains reset button, download button, upload button. Scrollable container with right-swipe reveal
+- **Header Right Group**: Contains trophy, suggest, share, tutorial, about buttons. Scrollable container with left-swipe reveal
+- **Drawer Pull**: A 56x6px rounded bar centered in a 32px-tall touch target at the top of each bottom sheet panel
+- **Domain Dropdown**: Fixed-position element between header-left (logo) and header-actions (left button group)
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 100% of header buttons are reachable (via direct tap or swipe-reveal) on screens as narrow as 320px
+- **SC-002**: Drawer pull bar is centered within 1px tolerance on both Android (360px, 412px) and iPhone (375px, 393px) screen widths
+- **SC-003**: All acceptance scenarios pass on Android emulator (Pixel 7, API 34, Chrome)
+- **SC-004**: All acceptance scenarios pass on iPhone Simulator (iPhone 15, iOS 17, Safari)
+- **SC-005**: No header button overlaps another button or the dropdown at any supported screen width (320px-430px)
+- **SC-006**: Map pan/zoom gestures work without interference from header swipe areas on both platforms
+- **SC-007**: Quiz answering flow (open drawer, read question, tap answer, see feedback) completes successfully on both platforms
+
+## Assumptions
+
+- Android testing uses Android Studio emulator with a Pixel 7 profile (412x915, API 34)
+- iPhone testing uses Xcode Simulator with iPhone 15 profile (393x852, iOS 17)
+- Minimum supported mobile width is 320px (iPhone SE / small Android devices)
+- The app is accessed via mobile browser (Chrome on Android, Safari on iOS) — not a native app wrapper
+- Portrait orientation is the primary mobile layout; landscape is secondary but should not break
diff --git a/src/app.js b/src/app.js
index 6682afb5..54ff614a 100644
--- a/src/app.js
+++ b/src/app.js
@@ -38,6 +38,7 @@ import { computeRanking, takeSnapshot, handlePostVideoQuestion } from './learnin
import { updateConfidence, initConfidence } from './ui/progress.js';
import { announce, setupKeyboardNav } from './utils/accessibility.js';
import { lockLandscape, unlockOrientation } from './ui/orientation.js';
+import { decodeSharedToken, applySharedViewChrome } from './sharing/shared-view.js';
const GLOBAL_REGION = { x_min: 0, x_max: 1, y_min: 0, y_max: 1 };
const GLOBAL_GRID_SIZE = 50;
@@ -63,6 +64,11 @@ let mergedVideoWindows = []; // Accumulated window coords from recent unwatched-
let mapInitialized = false; // True once articles/questions/labels are set on the renderer
async function boot() {
+ // Detect shared view mode via ?t= URL parameter (T012/T016)
+ const urlParams = new URLSearchParams(window.location.search);
+ const sharedToken = urlParams.get('t');
+ let sharedData = null;
+
const storageAvailable = isAvailable();
if (!storageAvailable) {
showNotice('Progress won\u2019t be saved across visits (localStorage unavailable).');
@@ -81,6 +87,15 @@ async function boot() {
return;
}
+ // Decode shared token now that registry is ready (needs descendant info)
+ if (sharedToken) {
+ try {
+ sharedData = await decodeSharedToken(sharedToken);
+ } catch (err) {
+ console.warn('[app] Shared token decode failed, falling back to normal boot:', err);
+ }
+ }
+
const particleCanvas = document.getElementById('particle-canvas');
if (particleCanvas) {
particleSystem = new ParticleSystem();
@@ -103,7 +118,7 @@ async function boot() {
// Attach the landing button BEFORE the data load so it's responsive immediately.
// If clicked before data loads, we store the intent and act on it once data arrives.
- let earlyStartRequested = false;
+ let earlyStartRequested = !!sharedData; // Auto-start in shared mode
const landingStartBtn = document.getElementById('landing-start-btn');
if (landingStartBtn) {
landingStartBtn.addEventListener('click', () => {
@@ -146,10 +161,20 @@ async function boot() {
}
// If user clicked "Map my Knowledge" while data was loading, transition now.
+ // In shared mode, this auto-starts the map without user interaction.
if (earlyStartRequested) {
$activeDomain.set('all');
}
+ // In shared mode, inject decoded responses and apply read-only chrome
+ // after the map has initialized via switchDomain.
+ if (sharedData) {
+ // Wait a tick for switchDomain to finish rendering
+ await new Promise(r => setTimeout(r, 200));
+ injectSharedResponses(sharedData.responses);
+ applySharedViewChrome(sharedData.tokenString);
+ }
+
// Start background video catalog loading (T-V051, FR-V041)
// Videos are set on the renderer only after map initialization (in switchDomain)
// so they don't appear as static gray squares on the welcome screen.
@@ -189,6 +214,24 @@ async function boot() {
}
}
+ // Logo click → return to welcome screen (works in both regular and shared views)
+ const logo = headerEl.querySelector('.logo');
+ if (logo) {
+ logo.style.cursor = 'pointer';
+ logo.addEventListener('click', () => {
+ // If on map screen with responses, confirm before resetting
+ const appEl = document.getElementById('app');
+ if (appEl && appEl.dataset.screen === 'map' && $responses.get().length > 0) {
+ handleReset();
+ } else {
+ // Already on welcome or no responses — just go to welcome
+ const landing = document.getElementById('landing');
+ if (landing) landing.classList.remove('hidden');
+ if (appEl) appEl.dataset.screen = 'welcome';
+ }
+ });
+ }
+
const quizPanel = document.getElementById('quiz-panel');
quiz.init(quizPanel);
quiz.onAnswer(handleAnswer);
@@ -299,6 +342,11 @@ async function boot() {
}
}
return { estimateGrid: grid, articles, answeredQuestions, videos };
+ }, () => {
+ if (!allDomainBundle) return null;
+ // Use aggregatedQuestions (all 2500) for token encoding, not allDomainBundle.questions (only 50)
+ const qs = aggregatedQuestions.length > 0 ? aggregatedQuestions : allDomainBundle.questions;
+ return { responses: $responses.get(), questions: qs };
});
const minimapContainer = document.getElementById('minimap-container');
@@ -853,6 +901,56 @@ function handleSkip() {
advanceTutorial('skip');
}
+/** Inject shared-view responses into the running app and update visualization. */
+function injectSharedResponses(responses) {
+ // Reset estimators to clear any locally-loaded responses
+ estimator.reset();
+ estimator.init(GLOBAL_GRID_SIZE, GLOBAL_REGION);
+ globalEstimator.reset();
+ globalEstimator.init(GLOBAL_GRID_SIZE, GLOBAL_REGION);
+
+ // Back up the user's own responses before overwriting with shared data
+ const ownResponses = localStorage.getItem('mapper:responses');
+ if (ownResponses) {
+ localStorage.setItem('mapper:responses:backup', ownResponses);
+ }
+
+ // Set responses on the store (overrides any local responses)
+ $responses.set(responses || []);
+
+ // Restore the backup so navigating away doesn't lose the user's own progress
+ if (ownResponses) {
+ localStorage.setItem('mapper:responses', ownResponses);
+ } else {
+ localStorage.removeItem('mapper:responses');
+ }
+
+ if (!responses || responses.length === 0) {
+ // Empty shared map — clear heatmap and dots
+ renderer.setHeatmap([], GLOBAL_REGION);
+ renderer.setAnsweredQuestions([]);
+ if (minimap) minimap.setEstimates([], GLOBAL_REGION);
+ return;
+ }
+
+ // Feed each response into the estimators
+ for (const r of responses) {
+ if (r.x == null || r.y == null) continue;
+ const diff = r.difficulty || 1;
+ if (r.is_skipped) {
+ estimator.observeSkip(r.x, r.y, UNIFORM_LENGTH_SCALE, diff);
+ globalEstimator.observeSkip(r.x, r.y, UNIFORM_LENGTH_SCALE, diff);
+ } else {
+ estimator.observe(r.x, r.y, r.is_correct, UNIFORM_LENGTH_SCALE, diff);
+ globalEstimator.observe(r.x, r.y, r.is_correct, UNIFORM_LENGTH_SCALE, diff);
+ }
+ }
+
+ // Update the heatmap and answered dots
+ updateHeatmapDisplay();
+ renderer.setAnsweredQuestions(responsesToAnsweredDots(responses, questionIndex));
+}
+
function handleReset() {
if (!confirm('Are you sure? This will clear all progress.')) return;
dismissTutorial();
diff --git a/src/img/og-preview.png b/src/img/og-preview.png
new file mode 100644
index 00000000..380b6e40
Binary files /dev/null and b/src/img/og-preview.png differ
diff --git a/src/sharing/question-index.js b/src/sharing/question-index.js
new file mode 100644
index 00000000..013c5d5a
--- /dev/null
+++ b/src/sharing/question-index.js
@@ -0,0 +1,40 @@
+/** Stable question→integer index mapping for token encoding/decoding. */
+
+/**
+ * Build a deterministic question index from all loaded questions.
+ * Sort by (domain_ids[0], id) to produce stable integer indices.
+ * @param {Array} allQuestions - All questions from the domain bundle
+ * @returns {{ version: number, toIndex: Map, toId: Map }}
+ */
+export function buildIndex(allQuestions) {
+ const sorted = [...allQuestions].sort((a, b) => {
+ const aDomain = (a.domain_ids && a.domain_ids[0]) || '';
+ const bDomain = (b.domain_ids && b.domain_ids[0]) || '';
+ if (aDomain < bDomain) return -1;
+ if (aDomain > bDomain) return 1;
+ if (a.id < b.id) return -1;
+ if (a.id > b.id) return 1;
+ return 0;
+ });
+
+ const toIndex = new Map();
+ const toId = new Map();
+ for (let i = 0; i < sorted.length; i++) {
+ toIndex.set(sorted[i].id, i);
+ toId.set(i, sorted[i].id);
+ }
+
+ // Version is derived from question count — simple but effective for detecting changes
+ const version = allQuestions.length & 0xFF;
+
+ return { version, toIndex, toId };
+}
+
+/**
+ * Get the index version for the current question bank.
+ * @param {Array} allQuestions
+ * @returns {number}
+ */
+export function getIndexVersion(allQuestions) {
+ return allQuestions.length & 0xFF;
+}
diff --git a/src/sharing/shared-view.js b/src/sharing/shared-view.js
new file mode 100644
index 00000000..cf7adf26
--- /dev/null
+++ b/src/sharing/shared-view.js
@@ -0,0 +1,119 @@
+/**
+ * Shared view mode — reuses the normal app infrastructure but injects
+ * decoded token responses and hides interactive chrome.
+ *
+ * Instead of creating a separate renderer, we let boot() run normally,
+ * then inject responses and configure the UI for read-only shared mode.
+ */
+
+import { buildIndex } from './question-index.js';
+import { decodeToken } from './token-codec.js';
+import { loadQuestionsForDomain } from '../domain/loader.js';
+
+/**
+ * Decode a share token and prepare data for injecting into the running app.
+ * Called from boot() BEFORE the normal initialization, to set shared mode.
+ * @param {string} tokenString - base64url-encoded response token
+ * @returns {{ responses: Array, tokenString: string } | null}
+ */
+export async function decodeSharedToken(tokenString) {
+ try {
+ // Load ALL questions (2500) via descendant loading for correct token decoding
+ const allQuestions = await loadQuestionsForDomain('all');
+ const questionIndex = buildIndex(allQuestions);
+ const decoded = decodeToken(tokenString, questionIndex);
+
+ // Even empty/null decoded results are valid — show empty map
+ const entries = decoded || [];
+
+ // Build question lookup for coordinates
+ const questionMap = new Map(allQuestions.map(q => [q.id, q]));
+
+ // Create response objects with coordinates (matching $responses format)
+ const responses = [];
+ for (const entry of entries) {
+ const q = questionMap.get(entry.question_id);
+ if (q) {
+ responses.push({
+ question_id: entry.question_id,
+ domain_id: q.domain_ids?.[0] || 'all',
+ selected: entry.is_correct ? q.answer : 'X',
+ is_correct: entry.is_correct,
+ is_skipped: entry.is_skipped,
+ difficulty: q.difficulty || 1,
+ timestamp: Date.now(),
+ x: q.x,
+ y: q.y,
+ });
+ }
+ }
+
+ return { responses, tokenString };
+ } catch (err) {
+ console.warn('[shared-view] Failed to decode token:', err);
+ return null;
+ }
+}
+
+/**
+ * Configure the UI for shared view mode after boot() has finished.
+ * Hides interactive chrome, configures header, shows minimap.
+ * @param {string} tokenString - original token for re-sharing
+ */
+export function applySharedViewChrome(tokenString) {
+ // Hide interactive panels and drawer pulls
+ const hideSelectors = [
+ '#quiz-panel',
+ '#video-panel',
+ '.drawer-pull',
+ '#drawer-pull-quiz',
+ '#drawer-pull-video',
+ '.loading-modal',
+ ];
+ for (const sel of hideSelectors) {
+ for (const el of document.querySelectorAll(sel)) {
+ el.style.display = 'none';
+ }
+ }
+
+ // Configure header for shared view
+ const header = document.getElementById('app-header');
+ if (header) {
+ // Hide entire left-group action bar (reset/download/upload)
+ const headerActions = header.querySelector('.header-actions');
+ if (headerActions) headerActions.style.display = 'none';
+
+ // Hide right-group buttons not relevant to shared view
+ for (const id of ['trophy-btn', 'suggest-btn', 'tutorial-btn']) {
+ const el = document.getElementById(id);
+ if (el) el.style.display = 'none';
+ }
+
+ // Ensure share + about buttons are visible
+ for (const id of ['share-btn', 'about-btn']) {
+ const el = document.getElementById(id);
+ if (el) {
+ el.style.display = '';
+ el.disabled = false;
+ }
+ }
+
+ // Override share button to copy the shared URL (not open the share modal)
+ const shareBtn = document.getElementById('share-btn');
+ if (shareBtn) {
+ const newBtn = shareBtn.cloneNode(true);
+ shareBtn.parentNode.replaceChild(newBtn, shareBtn);
+ newBtn.addEventListener('click', () => {
+ const url = window.location.origin + '/mapper/?t=' + tokenString;
+ navigator.clipboard.writeText(url).then(() => {
+ const icon = newBtn.querySelector('i');
+ if (icon) {
+ const origClass = icon.className;
+ icon.className = 'fa-solid fa-check';
+ setTimeout(() => { icon.className = origClass; }, 2000);
+ }
+ });
+ });
+ }
+ }
+}
diff --git a/src/sharing/token-codec.js b/src/sharing/token-codec.js
new file mode 100644
index 00000000..791232fe
--- /dev/null
+++ b/src/sharing/token-codec.js
@@ -0,0 +1,147 @@
+/** Encode/decode response tokens using sparse binary format + pako deflate + base64url. */
+
+import { deflate, inflate } from 'pako';
+
+/** Response value encoding per contracts/token-format.md */
+const VALUE_CORRECT = 2;
+const VALUE_SKIPPED = 1;
+const VALUE_INCORRECT = 0xFF; // -1 as uint8
+
+/**
+ * Map a response object to its encoded value byte.
+ * @param {Object} response - { is_correct, is_skipped }
+ * @returns {number} encoded value (1, 2, or 0xFF)
+ */
+function responseToValue(response) {
+ if (response.is_skipped) return VALUE_SKIPPED;
+ if (response.is_correct) return VALUE_CORRECT;
+ return VALUE_INCORRECT;
+}
+
+/**
+ * Map an encoded value byte back to response flags.
+ * @param {number} value
+ * @returns {{ is_correct: boolean, is_skipped: boolean }}
+ */
+function valueToResponse(value) {
+ if (value === VALUE_CORRECT) return { is_correct: true, is_skipped: false };
+ if (value === VALUE_SKIPPED) return { is_correct: false, is_skipped: true };
+ return { is_correct: false, is_skipped: false }; // incorrect (0xFF or any other)
+}
+
+/**
+ * Encode base64url from Uint8Array (RFC 4648 §5).
+ * @param {Uint8Array} bytes
+ * @returns {string}
+ */
+function toBase64url(bytes) {
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary)
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+}
+
+/**
+ * Decode base64url string to Uint8Array.
+ * @param {string} str
+ * @returns {Uint8Array}
+ */
+function fromBase64url(str) {
+ let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
+ // Re-pad to multiple of 4
+ while (base64.length % 4 !== 0) base64 += '=';
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+}
+
+/**
+ * Encode user responses into a compressed, URL-safe token string.
+ * @param {Array} responses - Array of { question_id, is_correct, is_skipped }
+ * @param {{ version: number, toIndex: Map }} questionIndex
+ * @returns {string} base64url-encoded compressed token
+ */
+export function encodeToken(responses, questionIndex) {
+ // Filter to responses that exist in the index
+ const pairs = [];
+ for (const r of responses) {
+ const idx = questionIndex.toIndex.get(r.question_id);
+ if (idx !== undefined) {
+ pairs.push({ index: idx, value: responseToValue(r) });
+ }
+ }
+
+ // Sort by index for better compression
+ pairs.sort((a, b) => a.index - b.index);
+
+ // Binary format: [version:1][count:2][index:2,value:1]×count
+ const count = pairs.length;
+ const bufLen = 3 + count * 3;
+ const buf = new Uint8Array(bufLen);
+ const view = new DataView(buf.buffer);
+
+ buf[0] = questionIndex.version;
+ view.setUint16(1, count, false); // big-endian
+
+ for (let i = 0; i < count; i++) {
+ const offset = 3 + i * 3;
+ view.setUint16(offset, pairs[i].index, false); // big-endian
+ buf[offset + 2] = pairs[i].value;
+ }
+
+ // Compress with raw deflate
+ const compressed = deflate(buf, { raw: true });
+
+ return toBase64url(compressed);
+}
+
+/**
+ * Decode a base64url token string back into response entries.
+ * @param {string} base64urlString
+ * @param {{ version: number, toId: Map }} questionIndex
+ * @returns {Array<{ question_id: string, is_correct: boolean, is_skipped: boolean }>|null}
+ */
+export function decodeToken(base64urlString, questionIndex) {
+ try {
+ const compressed = fromBase64url(base64urlString);
+ const buf = inflate(compressed, { raw: true });
+
+ if (buf.length < 3) return null;
+
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
+ const version = buf[0];
+ const count = view.getUint16(1, false); // big-endian
+
+ // Verify buffer has enough bytes
+ if (buf.length < 3 + count * 3) return null;
+
+ const results = [];
+ for (let i = 0; i < count; i++) {
+ const offset = 3 + i * 3;
+ const index = view.getUint16(offset, false);
+ const value = buf[offset + 2];
+
+ const questionId = questionIndex.toId.get(index);
+ if (questionId) {
+ const flags = valueToResponse(value);
+ results.push({
+ question_id: questionId,
+ ...flags,
+ });
+ }
+ // Silently skip entries with no matching question (version mismatch / removed question)
+ }
+
+ return results;
+ } catch (err) {
+ console.warn('[token-codec] Failed to decode token:', err.message);
+ return null;
+ }
+}
diff --git a/src/ui/modes.js b/src/ui/modes.js
index 68992ab0..34491d84 100644
--- a/src/ui/modes.js
+++ b/src/ui/modes.js
@@ -1,9 +1,9 @@
/** Question mode selector with availability gating per FR-010/FR-011. */
const QUESTION_MODES = [
- { id: 'easy', label: 'Give me an easy one', icon: 'fa-face-smile', minAnswers: 5, type: 'question', enabledTooltip: 'Get a question from a high-knowledge area' },
- { id: 'hardest-can-answer', label: 'Challenge me', icon: 'fa-fire', minAnswers: 5, type: 'question', enabledTooltip: 'Get the hardest question the system thinks you can probably answer correctly' },
- { id: 'dont-know', label: "Test my weak spots", icon: 'fa-circle-question', minAnswers: 5, type: 'question', enabledTooltip: 'Get a question from a low-knowledge area' },
+ { id: 'easy', label: 'Give me an easy one', icon: 'fa-baby', minAnswers: 5, type: 'question', enabledTooltip: 'Give me an easy one' },
+ { id: 'hardest-can-answer', label: 'Challenge me', icon: 'fa-fire', minAnswers: 5, type: 'question', enabledTooltip: 'Challenge me' },
+ { id: 'dont-know', label: "Test my weak spots", icon: 'fa-bullseye', minAnswers: 5, type: 'question', enabledTooltip: 'Test my weak spots' },
];
const INSIGHT_MODES = [];
@@ -29,7 +29,8 @@ export function init(container) {
style.textContent = `
.modes-wrapper {
display: flex;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
+ align-items: center;
gap: 0.35rem;
margin-bottom: 0.35rem;
padding-bottom: 0.35rem;
@@ -38,18 +39,21 @@ export function init(container) {
.mode-btn {
display: inline-flex;
align-items: center;
- gap: 0.35rem;
- padding: 0.35rem 0.6rem;
+ justify-content: center;
+ padding: 0.35rem;
+ width: 32px;
+ height: 32px;
border: 1.5px solid var(--color-border);
- border-radius: 16px;
+ border-radius: 50%;
background: var(--color-surface-raised);
cursor: pointer;
- font-size: 0.75rem;
+ font-size: 0.85rem;
font-family: var(--font-body);
color: var(--color-text-muted);
transition: border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
white-space: nowrap;
position: relative;
+ flex-shrink: 0;
}
.mode-btn:hover:not(:disabled) {
border-color: var(--color-primary);
@@ -106,9 +110,7 @@ export function init(container) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
- margin-left: 0.25rem;
- margin-top: 0.25rem;
- margin-bottom: 0.25rem;
+ margin-left: auto;
}
.auto-advance-label {
font-size: 0.68rem;
@@ -181,7 +183,11 @@ export function init(container) {
const btn = document.createElement('button');
btn.className = 'mode-btn' + (mode.id === activeMode ? ' active' : '');
if (mode.type === 'insight') btn.classList.add('mode-btn--insight');
- btn.innerHTML = ` ${mode.label}`;
+ const icon = document.createElement('i');
+ icon.className = `fa-solid ${mode.icon}`;
+ btn.textContent = '';
+ btn.appendChild(icon);
+ btn.setAttribute('aria-label', mode.label);
btn.dataset.mode = mode.id;
btn.dataset.type = mode.type;
btn.dataset.tooltip = mode.enabledTooltip || '';
@@ -254,7 +260,7 @@ export function init(container) {
toggleWrap.appendChild(track);
toggleWrap.appendChild(label);
- wrapper.after(toggleWrap);
+ wrapper.appendChild(toggleWrap);
autoAdvanceToggleEl = track;
}
diff --git a/src/ui/share.js b/src/ui/share.js
index bbae6738..701bd2bb 100644
--- a/src/ui/share.js
+++ b/src/ui/share.js
@@ -1,4 +1,7 @@
-/** Social sharing: canvas → PNG, optional Imgur upload for embeddable links. */
+/** Social sharing: canvas → PNG, token URL generation, social platform sharing. */
+
+import { buildIndex } from '../sharing/question-index.js';
+import { encodeToken } from '../sharing/token-codec.js';
const IMGUR_CLIENT_ID = '';
@@ -15,10 +18,11 @@ let getCanvasRef = null;
let getExpertiseAreasRef = null;
let getAnswerCountRef = null;
let getShareDataRef = null;
+let getTokenDataRef = null;
const SHARE_MIN_ANSWERS = 5;
-export function init(headerElement, getCanvas, getExpertiseAreas, getAnswerCount, getShareData) {
+export function init(headerElement, getCanvas, getExpertiseAreas, getAnswerCount, getShareData, getTokenData) {
if (!headerElement) return;
headerEl = headerElement;
@@ -26,6 +30,7 @@ export function init(headerElement, getCanvas, getExpertiseAreas, getAnswerCount
getExpertiseAreasRef = getExpertiseAreas;
getAnswerCountRef = getAnswerCount || (() => 0);
getShareDataRef = getShareData || null;
+ getTokenDataRef = getTokenData || null;
const shareBtn = document.getElementById('share-btn');
if (shareBtn) {
@@ -207,6 +212,25 @@ function generateShareImage(data) {
return canvas.toDataURL('image/png');
}
+/**
+ * Generate a token URL from current responses, or return the generic URL.
+ * @returns {string}
+ */
+function generateTokenUrl() {
+ const tokenData = getTokenDataRef ? getTokenDataRef() : null;
+ if (!tokenData || !tokenData.responses || tokenData.responses.length === 0 || !tokenData.questions) {
+ return window.location.origin + '/mapper/';
+ }
+ try {
+ const index = buildIndex(tokenData.questions);
+ const token = encodeToken(tokenData.responses, index);
+ return window.location.origin + '/mapper/?t=' + token;
+ } catch (err) {
+ console.error('[share] Failed to generate token URL:', err);
+ return window.location.origin + '/mapper/';
+ }
+}
+
export function showShareDialog() {
const modal = document.getElementById('share-modal');
if (!modal) return;
@@ -216,8 +240,8 @@ export function showShareDialog() {
if (totalAnswers < SHARE_MIN_ANSWERS || !expertiseAreas || expertiseAreas.length === 0) {
const contentEl = modal.querySelector('.share-modal-content');
- const teaserUrl = 'https://context-lab.com/mapper';
- const teaserText = 'Check out \u{1F5FA}\uFE0F Knowledge Mapper: an interactive tool that maps out everything you know! Answer questions and watch a personalized map of YOUR knowledge take shape in real time.\n\nhttps://context-lab.com/mapper';
+ const teaserUrl = generateTokenUrl();
+ const teaserText = `Check out \u{1F5FA}\uFE0F Knowledge Mapper: an interactive tool that maps out everything you know! Answer questions and watch a personalized map of YOUR knowledge take shape in real time.\n\n${teaserUrl}`;
const remaining = Math.max(0, SHARE_MIN_ANSWERS - totalAnswers);
const progressNote = totalAnswers > 0 && remaining > 0
? `Answer ${remaining} more question${remaining !== 1 ? 's' : ''} to unlock your personalized share with top expertise areas!`
@@ -231,7 +255,7 @@ export function showShareDialog() {
${teaserText}
-
+
@@ -241,10 +265,19 @@ export function showShareDialog() {
+
+
+
${progressNote}
@@ -271,7 +304,6 @@ export function showShareDialog() {
}
// Get top 3 expertise areas
- const top3 = expertiseAreas.slice(0, 3).map(a => a.label).join(', ');
let imageDataUrl = '';
const shareData = getShareDataRef ? getShareDataRef() : null;
@@ -293,9 +325,9 @@ export function showShareDialog() {
}
}
- // Compose share text
- const shareText = `I mapped my knowledge with \u{1F5FA}\uFE0F Knowledge Mapper! My top areas: ${top3}\n\n\nhttps://context-lab.com/mapper`;
- const shareUrl = 'https://context-lab.com/mapper';
+ // Compose share text with token URL
+ const shareUrl = generateTokenUrl();
+ const shareText = `I mapped my knowledge with \u{1F5FA}\uFE0F Knowledge Mapper! Here's what I know: ${shareUrl}`;
// Populate modal
const contentEl = modal.querySelector('.share-modal-content');
@@ -315,7 +347,7 @@ export function showShareDialog() {