diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..7ac2fdb
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,83 @@
+# TODO
+
+## Lexicon Publisher
+
+Remaining issues from code review (2025-12-23).
+
+### Important
+
+#### CSP `unsafe-inline` vulnerability
+**lexicon-publisher.html:7-9**
+
+The CSP includes `'unsafe-inline'` for both scripts and styles, which defeats much of the purpose of having a CSP. Requires major refactor to move inline scripts/styles to external files or use nonces.
+
+#### Pin quickslice to specific version
+**lexicon-publisher.html:777**
+
+Currently using `@main` branch which can change at any time. Pin to a specific commit or tag and add SRI integrity attribute.
+
+#### Validate DNS lookup inputs
+**lexicon-publisher.html:1802-1812**
+
+The `resolveDNS()` function directly uses user-controlled domain names without validation. Should validate domain names against RFC standards before DNS lookup.
+
+#### Full re-render performance
+**lexicon-publisher.html**
+
+`renderAllSteps()` rebuilds the entire UI on every state change, losing focus/scroll position. Consider targeted updates for specific UI sections.
+
+#### Accessibility improvements
+**lexicon-publisher.html**
+
+- Add `role="progressbar"` or `role="tablist"` for wizard
+- Add `aria-label` to buttons with only icons
+- Add `aria-live` regions for status messages
+- Add `aria-expanded` for collapsible sections
+- Make step headers keyboard navigable
+- Add escape key handler for dialogs
+
+### Medium
+
+#### `escapeAttr()` missing single quote escape
+**lexicon-publisher.html:940**
+
+Add `.replace(/'/g, "'")` to prevent attribute injection with single quotes.
+
+#### Domain extraction edge cases
+**lexicon-publisher.html:1672-1698**
+
+`getUserDomain()` assumes all domains are TLD + SLD (e.g., `example.com`). Fails for country-code TLDs like `.co.uk`. Consider using a public suffix list.
+
+#### No file count limit in ZIP
+**lexicon-publisher.html:2044-2051**
+
+While there's a check for zero files, there's no upper limit. A ZIP with thousands of files could freeze the browser. Add reasonable limit (e.g., 100 files).
+
+#### Clipboard API fallback
+**lexicon-publisher.html:1836-1840**
+
+No user feedback on success/failure and no fallback for older browsers. Add toast notification and `document.execCommand('copy')` fallback.
+
+#### OAuth callback race condition
+**lexicon-publisher.html:2130-2146**
+
+If `handleRedirectCallback()` fails, the URL still has `?code=...` but history is only replaced on success. Move `window.history.replaceState()` to a `finally` block.
+
+### Low Priority
+
+#### Split into multiple files
+**lexicon-publisher.html (2200+ lines)**
+
+Consider splitting into separate HTML, CSS, and JS files for maintainability. This would also help solve the CSP `unsafe-inline` issue.
+
+#### Add lexicon preview
+Users can't see what a lexicon defines without reading JSON. Add human-readable schema preview.
+
+#### Add export functionality
+Users can upload ZIPs but can't download their lexicons. Add "Export as ZIP" feature.
+
+#### Add schema templates
+Provide multiple templates: record, query, procedure, subscription.
+
+#### Real-time validation
+Add debounced validation as users type instead of only on "Continue to Publish".
diff --git a/docs/plans/2025-12-23-external-dependency-validation.md b/docs/plans/2025-12-23-external-dependency-validation.md
new file mode 100644
index 0000000..76e6a95
--- /dev/null
+++ b/docs/plans/2025-12-23-external-dependency-validation.md
@@ -0,0 +1,265 @@
+# External Dependency Validation Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Warn users when their lexicons reference external dependencies that aren't published.
+
+**Architecture:** After honk validation passes, extract all `ref` properties pointing to external NSIDs, batch-query the lexicons quickslice to check which exist, show yellow warnings for missing ones.
+
+**Tech Stack:** JavaScript, GraphQL (lexicons quickslice)
+
+---
+
+### Task 1: Add missingDependencies to state
+
+**Files:**
+- Modify: `lexicon-publisher.html:642-644`
+
+**Step 1: Add the new state property**
+
+Find this block:
+```javascript
+ // New: DNS instructions
+ dnsInstructions: [], // [{ domain, record, lexicons }]
+ };
+```
+
+Change to:
+```javascript
+ // New: DNS instructions
+ dnsInstructions: [], // [{ domain, record, lexicons }]
+
+ // New: External dependency check
+ missingDependencies: [], // [nsid, ...]
+ };
+```
+
+**Step 2: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "feat(lexicon-publisher): add missingDependencies state"
+```
+
+---
+
+### Task 2: Add extractExternalRefs function
+
+**Files:**
+- Modify: `lexicon-publisher.html` (after `getPublishableLexicons` function, around line 1097)
+
+**Step 1: Add the function**
+
+After the `getPublishableLexicons` function closing brace, add:
+
+```javascript
+ function extractExternalRefs(lexicons, userDomain) {
+ const refs = new Set();
+
+ function walk(obj) {
+ if (!obj || typeof obj !== 'object') return;
+ if (obj.ref && typeof obj.ref === 'string') {
+ const nsid = obj.ref.split('#')[0];
+ const domain = getDomainAuthority(nsid);
+ if (domain !== userDomain && !domain.endsWith('.' + userDomain)) {
+ refs.add(nsid);
+ }
+ }
+ for (const val of Object.values(obj)) walk(val);
+ }
+
+ for (const lex of lexicons) walk(lex);
+ return [...refs];
+ }
+```
+
+**Step 2: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "feat(lexicon-publisher): add extractExternalRefs function"
+```
+
+---
+
+### Task 3: Add checkExternalDependencies function
+
+**Files:**
+- Modify: `lexicon-publisher.html` (after `extractExternalRefs`)
+
+**Step 1: Add the function**
+
+After `extractExternalRefs`, add:
+
+```javascript
+ async function checkExternalDependencies(externalNsids) {
+ if (externalNsids.length === 0) return { found: [], missing: [] };
+
+ try {
+ const result = await state.client.query(`
+ query CheckDeps($nsids: [String!]!) {
+ comAtprotoLexiconSchema(
+ first: 100,
+ where: { id: { in: $nsids } }
+ ) {
+ edges {
+ node { id }
+ }
+ }
+ }
+ `, { nsids: externalNsids });
+
+ const foundIds = (result?.comAtprotoLexiconSchema?.edges || [])
+ .map(e => e.node.id);
+
+ return {
+ found: externalNsids.filter(n => foundIds.includes(n)),
+ missing: externalNsids.filter(n => !foundIds.includes(n)),
+ };
+ } catch (err) {
+ console.error("External dependency check failed:", err);
+ return { found: [], missing: [] };
+ }
+ }
+```
+
+**Step 2: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "feat(lexicon-publisher): add checkExternalDependencies function"
+```
+
+---
+
+### Task 4: Integrate into validateLexicons
+
+**Files:**
+- Modify: `lexicon-publisher.html:1396-1440` (validateLexicons function)
+
+**Step 1: Reset missingDependencies at start**
+
+Find:
+```javascript
+ state.result = null;
+ state.validationPassed = false;
+ state.conflicts = [];
+```
+
+Change to:
+```javascript
+ state.result = null;
+ state.validationPassed = false;
+ state.conflicts = [];
+ state.missingDependencies = [];
+```
+
+**Step 2: Add external dependency check after honk passes**
+
+Find:
+```javascript
+ // Check for conflicts if authenticated
+ if (state.viewer) {
+ await checkConflicts();
+ }
+```
+
+Change to:
+```javascript
+ // Check for external dependencies and conflicts if authenticated
+ if (state.viewer) {
+ const userDomain = getUserDomain(state.viewer.handle);
+ const externalRefs = extractExternalRefs(parseResult.lexicons, userDomain);
+ const { missing } = await checkExternalDependencies(externalRefs);
+ state.missingDependencies = missing;
+
+ await checkConflicts();
+ }
+```
+
+**Step 3: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "feat(lexicon-publisher): check external deps during validation"
+```
+
+---
+
+### Task 5: Update renderResult to show warnings
+
+**Files:**
+- Modify: `lexicon-publisher.html:1251-1266` (renderResult function)
+
+**Step 1: Add warning display after success message**
+
+Find:
+```javascript
+ section.innerHTML = `
+
${icon} ${esc(state.result.message)}
+ `;
+ }
+```
+
+Change to:
+```javascript
+ let html = `${icon} ${esc(state.result.message)}
`;
+
+ if (state.missingDependencies.length > 0) {
+ html += `
+
+
Missing external dependencies:
+
+ ${state.missingDependencies.map(d => `- ${esc(d)}
`).join('')}
+
+
+ `;
+ }
+
+ section.innerHTML = html;
+ }
+```
+
+**Step 2: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "feat(lexicon-publisher): display missing dependency warnings"
+```
+
+---
+
+### Task 6: Manual test
+
+**Step 1: Test with external refs**
+
+1. Open lexicon-publisher in browser
+2. Sign in
+3. Upload or paste a lexicon that references an external type, e.g.:
+ ```json
+ {
+ "lexicon": 1,
+ "id": "slices.network.test",
+ "defs": {
+ "main": {
+ "type": "record",
+ "record": {
+ "type": "object",
+ "properties": {
+ "actor": { "type": "ref", "ref": "app.bsky.actor.defs#profileViewBasic" },
+ "fake": { "type": "ref", "ref": "com.fake.nonexistent#thing" }
+ }
+ }
+ }
+ }
+ }
+ ```
+4. Click Validate
+5. Verify: Success message appears, plus yellow warning box showing `com.fake.nonexistent` as missing (but not `app.bsky.actor.defs` if it's published)
+
+**Step 2: Final commit if needed**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "feat(lexicon-publisher): complete external dependency validation"
+```
diff --git a/docs/plans/2025-12-23-lexicon-publisher-wizard.md b/docs/plans/2025-12-23-lexicon-publisher-wizard.md
new file mode 100644
index 0000000..d132a1a
--- /dev/null
+++ b/docs/plans/2025-12-23-lexicon-publisher-wizard.md
@@ -0,0 +1,1193 @@
+# Lexicon Publisher Wizard Flow Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Transform lexicon-publisher into a 5-step wizard that guides first-time users through sign-in, lexicon definition, publishing, DNS verification, and viewing published lexicons.
+
+**Architecture:** Single-file HTML app refactored from section-based layout to step-based wizard. Each step renders as a collapsible container with visual states (current/completed/future). Step state machine controls progression and reset-on-edit behavior.
+
+**Tech Stack:** HTML/CSS/JS (inline), QuicksliceClient (OAuth/GraphQL), honk (validation), JSZip
+
+---
+
+## Task 1: Add Step State Management
+
+**Files:**
+- Modify: `lexicon-publisher.html:624-650` (state object)
+
+**Step 1: Add step state properties to state object**
+
+Find the state object and add these properties after `publishResult`:
+
+```javascript
+// Step wizard state
+currentStep: 1, // 1=SignIn, 2=Define, 3=Publish, 4=DNS, 5=Published
+stepCompleted: {
+ 1: false, // Sign In
+ 2: false, // Define Lexicons
+ 3: false, // Publish
+ 4: false, // Verify DNS (non-blocking)
+},
+```
+
+**Step 2: Verify change**
+
+Open `lexicon-publisher.html` in browser, open console, type `state.currentStep` - should return `1`.
+
+**Step 3: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): add step wizard state management
+
+Add currentStep and stepCompleted to track wizard progression.
+EOF
+)"
+```
+
+---
+
+## Task 2: Add Step Navigation Helper Functions
+
+**Files:**
+- Modify: `lexicon-publisher.html` (after state object, ~line 651)
+
+**Step 1: Add step navigation functions**
+
+Add these functions after the state object:
+
+```javascript
+// =============================================================================
+// STEP NAVIGATION
+// =============================================================================
+
+function goToStep(stepNumber) {
+ if (stepNumber < 1 || stepNumber > 5) return;
+
+ // Can only go to completed steps or the next available step
+ const maxAllowedStep = getMaxAllowedStep();
+ if (stepNumber > maxAllowedStep) return;
+
+ state.currentStep = stepNumber;
+ renderAllSteps();
+}
+
+function getMaxAllowedStep() {
+ // Can go to step N+1 if step N is completed
+ for (let i = 1; i <= 4; i++) {
+ if (!state.stepCompleted[i]) return i;
+ }
+ return 5;
+}
+
+function completeStep(stepNumber) {
+ state.stepCompleted[stepNumber] = true;
+ state.currentStep = Math.min(stepNumber + 1, 5);
+ renderAllSteps();
+}
+
+function resetStepsFrom(stepNumber) {
+ // Reset this step and all downstream steps
+ for (let i = stepNumber; i <= 4; i++) {
+ state.stepCompleted[i] = false;
+ }
+ // Clear downstream state
+ if (stepNumber <= 2) {
+ state.validationPassed = false;
+ state.conflicts = [];
+ state.missingDependencies = [];
+ state.result = null;
+ }
+ if (stepNumber <= 3) {
+ state.publishResult = null;
+ state.publishResults = [];
+ }
+}
+
+function getStepSummary(stepNumber) {
+ switch (stepNumber) {
+ case 1:
+ return state.viewer ? `@${state.viewer.handle}` : '';
+ case 2:
+ const validCount = state.lexicons.filter(l => l.value.trim()).length;
+ return `${validCount} lexicon${validCount !== 1 ? 's' : ''} ready`;
+ case 3:
+ const publishedCount = state.publishResults.filter(r => r.success).length;
+ return `Published ${publishedCount} lexicon${publishedCount !== 1 ? 's' : ''}`;
+ case 4:
+ const verifiedCount = state.dnsInstructions.filter(d => d.verified === true).length;
+ const totalDns = state.dnsInstructions.length;
+ if (totalDns === 0) return 'No DNS needed';
+ if (verifiedCount === totalDns) return 'DNS verified';
+ return `${verifiedCount}/${totalDns} domains verified`;
+ default:
+ return '';
+ }
+}
+```
+
+**Step 2: Verify change**
+
+Open console, type `getMaxAllowedStep()` - should return `1`.
+
+**Step 3: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): add step navigation helper functions
+
+Add goToStep, completeStep, resetStepsFrom, getStepSummary for wizard flow control.
+EOF
+)"
+```
+
+---
+
+## Task 3: Add Step Container CSS
+
+**Files:**
+- Modify: `lexicon-publisher.html:11-585` (style section)
+
+**Step 1: Add step container styles**
+
+Add before the closing `` tag (~line 585):
+
+```css
+/* Step wizard styles */
+.step {
+ background: var(--bg-card);
+ border-radius: 0.5rem;
+ margin-bottom: 0.75rem;
+ border: 1px solid var(--border);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+ overflow: hidden;
+}
+
+.step-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ cursor: pointer;
+ user-select: none;
+}
+
+.step-header:hover {
+ background: var(--bg-input);
+}
+
+.step-header-left {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.step-number {
+ width: 1.75rem;
+ height: 1.75rem;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.875rem;
+ font-weight: 600;
+ background: var(--border);
+ color: var(--text-secondary);
+}
+
+.step-title {
+ font-weight: 600;
+ font-size: 0.9375rem;
+}
+
+.step-summary {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+.step-content {
+ padding: 0 1rem 1rem 1rem;
+ border-top: 1px solid var(--border);
+}
+
+/* Step states */
+.step.step-current .step-number {
+ background: var(--accent);
+ color: #ffffff;
+}
+
+.step.step-current .step-header {
+ cursor: default;
+}
+
+.step.step-current .step-header:hover {
+ background: transparent;
+}
+
+.step.step-completed .step-number {
+ background: var(--success-text);
+ color: #ffffff;
+}
+
+.step.step-completed .step-number::after {
+ content: '✓';
+}
+
+.step.step-completed .step-number span {
+ display: none;
+}
+
+.step.step-future {
+ opacity: 0.5;
+}
+
+.step.step-future .step-header {
+ cursor: not-allowed;
+}
+
+.step.step-future .step-header:hover {
+ background: transparent;
+}
+
+.step.step-collapsed .step-content {
+ display: none;
+}
+
+.step.step-collapsed .step-header {
+ border-bottom: none;
+}
+```
+
+**Step 2: Verify change**
+
+Reload page, check that styles are loaded (no CSS errors in console).
+
+**Step 3: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): add step container CSS styles
+
+Add visual states for current, completed, future, and collapsed steps.
+EOF
+)"
+```
+
+---
+
+## Task 4: Create Step Container Render Function
+
+**Files:**
+- Modify: `lexicon-publisher.html` (in rendering section, ~line 816)
+
+**Step 1: Add renderStep helper function**
+
+Add before `renderAuthStatus()`:
+
+```javascript
+function renderStep(stepNumber, title, contentHtml) {
+ const isCompleted = state.stepCompleted[stepNumber];
+ const isCurrent = state.currentStep === stepNumber;
+ const isFuture = stepNumber > getMaxAllowedStep();
+ const isCollapsed = !isCurrent;
+
+ const classes = ['step'];
+ if (isCompleted) classes.push('step-completed');
+ if (isCurrent) classes.push('step-current');
+ if (isFuture) classes.push('step-future');
+ if (isCollapsed) classes.push('step-collapsed');
+
+ const summary = isCompleted ? getStepSummary(stepNumber) : '';
+ const clickHandler = !isCurrent && !isFuture ? `onclick="goToStep(${stepNumber})"` : '';
+
+ return `
+
+ `;
+}
+```
+
+**Step 2: Verify change**
+
+Console: `renderStep(1, 'Test', 'Content
')` should return HTML string.
+
+**Step 3: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): add renderStep helper function
+
+Creates step container HTML with proper state classes and click handlers.
+EOF
+)"
+```
+
+---
+
+## Task 5: Refactor to Step-Based Main Render
+
+**Files:**
+- Modify: `lexicon-publisher.html:601-606` (main element)
+- Modify: `lexicon-publisher.html` (add renderAllSteps function)
+
+**Step 1: Update main element HTML**
+
+Replace the main element content with a single container:
+
+```html
+
+
+```
+
+**Step 2: Add renderAllSteps function**
+
+Add after `renderStep` function:
+
+```javascript
+function renderAllSteps() {
+ const container = document.getElementById('steps-container');
+ if (!container) return;
+
+ container.innerHTML = [
+ renderStep(1, 'Sign In', renderSignInContent()),
+ renderStep(2, 'Define Lexicons', renderDefineLexiconsContent()),
+ renderStep(3, 'Publish', renderPublishContent()),
+ renderStep(4, 'Verify DNS', renderDnsContent()),
+ renderStep(5, 'Your Published Lexicons', renderPublishedLexiconsContent()),
+ ].join('');
+}
+```
+
+**Step 3: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): add step-based main render structure
+
+Replace section-based layout with single steps container and renderAllSteps function.
+EOF
+)"
+```
+
+---
+
+## Task 6: Implement Step 1 - Sign In
+
+**Files:**
+- Modify: `lexicon-publisher.html` (add renderSignInContent function)
+
+**Step 1: Add renderSignInContent function**
+
+Add after `renderAllSteps`:
+
+```javascript
+function renderSignInContent() {
+ if (state.viewer) {
+ // Already signed in - show minimal confirmation
+ return `
+
+ Signed in as @${esc(state.viewer.handle)}
+
+
+
+
+ `;
+ }
+
+ return `
+
+ Sign in with your Bluesky handle to publish lexicons to your PDS.
+
+
+ `;
+}
+
+async function handleStepLogin(event) {
+ event.preventDefault();
+ const handle = document.getElementById('step-login-handle').value.trim();
+ if (!handle) return;
+
+ try {
+ await initClient();
+ await state.client.loginWithRedirect({ handle });
+ } catch (err) {
+ console.error('Login error:', err);
+ state.result = { success: false, message: 'Login failed: ' + err.message };
+ renderAllSteps();
+ }
+}
+```
+
+**Step 2: Update init() to complete step 1 if signed in**
+
+In the init function, after setting `state.viewer`, add:
+
+```javascript
+// Complete step 1 if already signed in
+if (state.viewer) {
+ state.stepCompleted[1] = true;
+ state.currentStep = 2;
+}
+```
+
+**Step 3: Update OAuth callback handling**
+
+After successful OAuth callback in init(), add same step completion logic.
+
+**Step 4: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): implement Step 1 - Sign In
+
+Add inline sign-in form in step wizard, complete step on successful auth.
+EOF
+)"
+```
+
+---
+
+## Task 7: Implement Step 2 - Define Lexicons
+
+**Files:**
+- Modify: `lexicon-publisher.html` (add renderDefineLexiconsContent function)
+
+**Step 1: Add renderDefineLexiconsContent function**
+
+```javascript
+function renderDefineLexiconsContent() {
+ const getLexiconLabel = (lex, idx) => {
+ try {
+ const obj = JSON.parse(lex.value);
+ if (obj.id) return obj.id;
+ } catch (e) {}
+ return `Lexicon ${idx + 1}`;
+ };
+
+ const editorsHtml = state.lexicons
+ .map(
+ (lex, idx) => `
+
+
+
+
+`,
+ )
+ .join("");
+
+ const hasMultiple = state.lexicons.length > 1;
+ const allCollapsed = state.lexicons.every((l) => l.collapsed);
+ const collapseAllRow = hasMultiple
+ ? `
+
+
`
+ : "";
+
+ const resultHtml = state.result && state.currentStep === 2 ? `
+
+ ${state.result.success ? '✓' : '✗'} ${esc(state.result.message)}
+
+ ${state.missingDependencies.length > 0 ? `
+
+
Missing external dependencies:
+
+ ${state.missingDependencies.map(d => `- ${esc(d)}
`).join('')}
+
+
+ ` : ''}
+ ` : '';
+
+ return `
+
+
📦
+
+ Drop a .zip file or click to browse
+
+
Supports nested folders with .json lexicon files
+
+
+ ${collapseAllRow}
+ ${editorsHtml}
+
+
+
+
+ ${resultHtml}
+ `;
+}
+
+function handleLexiconChange(id, value) {
+ const lex = state.lexicons.find((l) => l.id === id);
+ if (lex) {
+ lex.value = value;
+ // Reset downstream steps when editing
+ if (state.stepCompleted[2]) {
+ resetStepsFrom(2);
+ }
+ }
+}
+
+async function continueToPublish() {
+ // Validate lexicons first
+ state.result = null;
+ state.validationPassed = false;
+ state.conflicts = [];
+ state.missingDependencies = [];
+
+ const parseResult = parseLexicons();
+ if (parseResult.error) {
+ state.result = { success: false, message: parseResult.error };
+ renderAllSteps();
+ return;
+ }
+
+ try {
+ const lexiconList = honk.toList(parseResult.lexicons);
+ const result = honk.validate(lexiconList);
+ const unwrapped = unwrapHonkResult(result);
+
+ if (!unwrapped.ok) {
+ state.result = {
+ success: false,
+ message: `Validation failed: ${formatHonkError(unwrapped.value)}`,
+ };
+ renderAllSteps();
+ return;
+ }
+
+ const count = parseResult.lexicons.length;
+ state.result = { success: true, message: `${count} lexicon${count !== 1 ? 's' : ''} valid` };
+ state.validationPassed = true;
+
+ // Check external dependencies and conflicts
+ if (state.viewer) {
+ const userDomain = getUserDomain(state.viewer.handle);
+ const externalRefs = extractExternalRefs(parseResult.lexicons, userDomain);
+ const { missing } = await checkExternalDependencies(externalRefs);
+ state.missingDependencies = missing;
+ await checkConflicts();
+ }
+
+ // Complete step 2 and move to step 3
+ completeStep(2);
+ } catch (e) {
+ state.result = {
+ success: false,
+ message: `Validation error: ${e.message}`,
+ };
+ renderAllSteps();
+ }
+}
+```
+
+**Step 2: Update addLexicon and removeLexicon to re-render**
+
+Modify `addLexicon()` and `removeLexicon()` to call `renderAllSteps()` instead of `renderLexiconsSection()`.
+
+**Step 3: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): implement Step 2 - Define Lexicons
+
+Add lexicon editors with Continue to Publish button that validates and advances.
+EOF
+)"
+```
+
+---
+
+## Task 8: Implement Step 3 - Publish
+
+**Files:**
+- Modify: `lexicon-publisher.html` (add renderPublishContent function)
+
+**Step 1: Add renderPublishContent function**
+
+```javascript
+function renderPublishContent() {
+ const { publishable, excluded } = getPublishableLexicons();
+ const userDomain = state.viewer ? getUserDomain(state.viewer.handle) : '';
+
+ const excludedWarning = excluded.length > 0 ? `
+
+
Excluded (not on ${esc(userDomain)}):
+
+ ${excluded.map(e => `- ${esc(e.nsid)} (${esc(e.domain)})
`).join('')}
+
+
+ ` : '';
+
+ const conflictWarnings = state.conflicts.length > 0 ? `
+
+
Conflicts detected:
+
+ ${state.conflicts.map(c => `
+ - ${esc(c.nsid)} - published by @${esc(c.existingOwner)}
+ ${c.isSameOwner ? '(you - will update)' : '(different owner)'}
+
+ `).join('')}
+
+
+ ` : '';
+
+ const publishCount = publishable.length;
+ const canPublish = publishCount > 0;
+
+ const resultHtml = state.publishResult ? `
+
+ ${state.publishResult.success ? '✓' : '✗'} ${esc(state.publishResult.message)}
+
+ ` : '';
+
+ // Show list of lexicons to be published
+ const lexiconList = publishable.length > 0 ? `
+
+
Ready to publish:
+
+ ${publishable.map(p => `- ${esc(p.nsid)}
`).join('')}
+
+
+ ` : '';
+
+ return `
+
+ ${canPublish
+ ? `Publish ${publishCount} lexicon${publishCount !== 1 ? 's' : ''} to @${esc(state.viewer?.handle || '')}'s PDS.`
+ : `No lexicons to publish for ${esc(userDomain)}.`}
+
+ ${lexiconList}
+ ${excludedWarning}
+ ${conflictWarnings}
+
+
+
+ ${resultHtml}
+ `;
+}
+
+async function publishAndContinue() {
+ if (!state.viewer || !state.client) return;
+
+ state.publishResults = [];
+ state.publishResult = null;
+
+ const { publishable: lexiconsToPublish } = getPublishableLexicons();
+
+ if (lexiconsToPublish.length === 0) {
+ state.publishResult = { success: false, message: "No valid lexicons to publish" };
+ renderAllSteps();
+ return;
+ }
+
+ // Publish each lexicon
+ for (const { nsid, schema } of lexiconsToPublish) {
+ try {
+ const ownedConflict = state.conflicts.find(c => c.nsid === nsid && c.isSameOwner);
+ const input = {
+ id: nsid,
+ defs: schema.defs,
+ lexicon: schema.lexicon || 1,
+ };
+
+ if (ownedConflict) {
+ await state.client.mutate(`
+ mutation UpdateSchema($input: ComAtprotoLexiconSchemaInput!, $rkey: String!) {
+ updateComAtprotoLexiconSchema(input: $input, rkey: $rkey) {
+ uri
+ }
+ }
+ `, { rkey: nsid, input });
+ state.publishResults.push({ nsid, success: true, updated: true });
+ } else {
+ await state.client.mutate(`
+ mutation CreateSchema($input: ComAtprotoLexiconSchemaInput!, $rkey: String!) {
+ createComAtprotoLexiconSchema(input: $input, rkey: $rkey) {
+ uri
+ }
+ }
+ `, { rkey: nsid, input });
+ state.publishResults.push({ nsid, success: true });
+ }
+ } catch (err) {
+ state.publishResults.push({ nsid, success: false, error: err.message });
+ }
+ }
+
+ // Generate DNS instructions
+ generateDnsInstructions(lexiconsToPublish.map(l => l.nsid));
+
+ // Refresh published lexicons list
+ await fetchPublishedLexicons();
+
+ // Show results
+ const successCount = state.publishResults.filter(r => r.success).length;
+ const failCount = state.publishResults.filter(r => !r.success).length;
+
+ if (failCount === 0) {
+ state.publishResult = { success: true, message: `Published ${successCount} lexicon${successCount !== 1 ? 's' : ''}` };
+ // Complete step 3 and move forward
+ completeStep(3);
+ } else {
+ const errors = state.publishResults.filter(r => !r.success).map(r => `${r.nsid}: ${r.error}`).join('\n');
+ state.publishResult = { success: false, message: `Published ${successCount}, failed ${failCount}:\n${errors}` };
+ renderAllSteps();
+ }
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): implement Step 3 - Publish
+
+Show publishable lexicons, warnings, and publish button that advances to DNS step.
+EOF
+)"
+```
+
+---
+
+## Task 9: Implement Step 4 - Verify DNS
+
+**Files:**
+- Modify: `lexicon-publisher.html` (add renderDnsContent function)
+
+**Step 1: Add renderDnsContent function**
+
+```javascript
+function renderDnsContent() {
+ if (state.dnsInstructions.length === 0) {
+ return `
+
+ No DNS records required. Your lexicons are published!
+
+
+
+
+ `;
+ }
+
+ const cards = state.dnsInstructions.map(instr => {
+ let statusIcon = '';
+ if (instr.verified === true) {
+ statusIcon = '✓';
+ } else if (instr.verified === false) {
+ statusIcon = '✗';
+ }
+
+ return `
+
+
${esc(instr.domain)} ${statusIcon}
+
+
+ Type:
+ TXT
+
+
+ Name:
+ ${esc(instr.recordName)}
+
+
+
+ Value:
+ ${esc(instr.recordValue)}
+
+
+
+
+
Lexicons:
+
+ ${instr.lexicons.map(l => `- ${esc(l)}
`).join('')}
+
+
+
+ `;
+ }).join('');
+
+ const allVerified = state.dnsInstructions.every(d => d.verified === true);
+
+ return `
+ ${cards}
+
+ Add these TXT records to your DNS provider. You must control these domains to complete lexicon resolution.
+
+
+
+
+
+ `;
+}
+
+async function verifyDnsAndRender() {
+ await verifyDnsRecords();
+ renderAllSteps();
+}
+
+function skipDnsAndContinue() {
+ // Mark DNS step as complete (non-blocking) and continue
+ state.stepCompleted[4] = true;
+ state.currentStep = 5;
+ renderAllSteps();
+}
+
+function continueToDone() {
+ // Mark DNS step as complete and move to final step
+ state.stepCompleted[4] = true;
+ state.currentStep = 5;
+ renderAllSteps();
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): implement Step 4 - Verify DNS
+
+Show DNS records with verify button and skip/continue options.
+EOF
+)"
+```
+
+---
+
+## Task 10: Implement Step 5 - Your Published Lexicons
+
+**Files:**
+- Modify: `lexicon-publisher.html` (add renderPublishedLexiconsContent function)
+
+**Step 1: Add renderPublishedLexiconsContent function**
+
+```javascript
+function renderPublishedLexiconsContent() {
+ if (state.publishedLexicons.length === 0) {
+ return `
+
+ You haven't published any lexicons yet.
+
+ `;
+ }
+
+ const lexiconCards = state.publishedLexicons.map(lex => `
+
+
+
+ Published ${new Date(lex.indexedAt).toLocaleDateString()}
+
+
+ `).join('');
+
+ return `
+
+ You have ${state.publishedLexicons.length} published lexicon${state.publishedLexicons.length !== 1 ? 's' : ''}.
+
+ ${lexiconCards}
+ `;
+}
+
+async function deleteLexiconAndRender(rkey) {
+ if (!confirm(`Delete lexicon ${rkey}?`)) return;
+
+ try {
+ await state.client.mutate(`
+ mutation DeleteSchema($rkey: String!) {
+ deleteComAtprotoLexiconSchema(rkey: $rkey) {
+ uri
+ }
+ }
+ `, { rkey });
+
+ state.publishedLexicons = state.publishedLexicons.filter(l => l.rkey !== rkey);
+
+ // Update DNS instructions if needed
+ if (state.publishedLexicons.length > 0) {
+ generateDnsInstructions(state.publishedLexicons.map(l => l.rkey));
+ } else {
+ state.dnsInstructions = [];
+ }
+
+ renderAllSteps();
+ } catch (err) {
+ alert(`Failed to delete: ${err.message}`);
+ }
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): implement Step 5 - Your Published Lexicons
+
+Show all published lexicons with delete functionality.
+EOF
+)"
+```
+
+---
+
+## Task 11: Update init() and Remove Old Render Functions
+
+**Files:**
+- Modify: `lexicon-publisher.html` (init function and cleanup)
+
+**Step 1: Update init() to use renderAllSteps**
+
+Replace the render calls at the end of `init()`:
+
+```javascript
+// Old:
+// renderLexiconsSection();
+// renderAuthStatus();
+// renderPublishedLexiconsSection();
+// renderDnsSection();
+
+// New:
+renderAllSteps();
+```
+
+**Step 2: Remove old render functions that are no longer used**
+
+Remove these functions (they're replaced by step content renderers):
+- `renderLexiconsSection()`
+- `renderPublishSection()`
+- `renderPublishResult()`
+- `renderPublishedLexiconsSection()`
+- `renderDnsSection()`
+- `renderResult()`
+
+Also remove the `renderAuthStatus()` function since auth is now in Step 1.
+
+**Step 3: Update header to remove auth-status div**
+
+Change header to just show title (no auth status):
+
+```html
+
+```
+
+**Step 4: Remove old section divs from HTML**
+
+Remove these from the main element since we now use steps-container:
+- ``
+- ``
+- ``
+- ``
+
+**Step 5: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+refactor(lexicon-publisher): remove old section-based render functions
+
+Clean up deprecated render functions and update init to use wizard steps.
+EOF
+)"
+```
+
+---
+
+## Task 12: Fix updateLexicon to Use New Handler
+
+**Files:**
+- Modify: `lexicon-publisher.html`
+
+**Step 1: Remove old updateLexicon function**
+
+The old `updateLexicon(id, value)` function should be removed since we now use `handleLexiconChange(id, value)` which also handles resetting downstream steps.
+
+**Step 2: Update any remaining references**
+
+Search for any remaining calls to old functions and update them.
+
+**Step 3: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+refactor(lexicon-publisher): consolidate lexicon update handlers
+
+Remove duplicate updateLexicon in favor of handleLexiconChange.
+EOF
+)"
+```
+
+---
+
+## Task 13: Handle Returning Users with Published Lexicons
+
+**Files:**
+- Modify: `lexicon-publisher.html` (init function)
+
+**Step 1: Update init to show step 5 preview for returning users**
+
+After fetching published lexicons, if the user has lexicons and comes back with session, generate DNS instructions:
+
+```javascript
+// In init(), after setting publishedLexicons:
+if (state.publishedLexicons.length > 0) {
+ generateDnsInstructions(state.publishedLexicons.map(l => l.rkey));
+}
+```
+
+This is already there, but ensure the step summary for step 5 shows the count even when collapsed.
+
+**Step 2: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): show published lexicon count for returning users
+
+Step 5 preview shows count when user has existing published lexicons.
+EOF
+)"
+```
+
+---
+
+## Task 14: Add File Drop Handler Updates
+
+**Files:**
+- Modify: `lexicon-publisher.html`
+
+**Step 1: Update processZipFile to call renderAllSteps**
+
+Replace calls to `renderLexiconsSection()` and `renderResult()` with `renderAllSteps()`.
+
+**Step 2: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+fix(lexicon-publisher): update ZIP file handler to use wizard renderer
+
+Replace old section renders with renderAllSteps.
+EOF
+)"
+```
+
+---
+
+## Task 15: Final Testing and Polish
+
+**Files:**
+- Modify: `lexicon-publisher.html`
+
+**Step 1: Manual test the full flow**
+
+1. Open page - Step 1 (Sign In) should be active, steps 2-5 grayed out
+2. Enter handle and sign in - after OAuth, Step 1 collapses, Step 2 expands
+3. Add/edit lexicons - content edits don't reset steps (until validated)
+4. Click "Continue to Publish" - validates, shows errors OR moves to Step 3
+5. Click collapsed Step 2 - should expand and reset Step 3+
+6. Re-validate and continue to Step 3
+7. Click "Publish to PDS" - publishes, moves to Step 4
+8. Verify DNS or skip - moves to Step 5
+9. View published lexicons, delete works
+10. Click earlier steps - can go back and edit
+
+**Step 2: Fix any issues found during testing**
+
+**Step 3: Final commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "$(cat <<'EOF'
+feat(lexicon-publisher): complete wizard flow implementation
+
+Tested full 5-step flow: Sign In → Define → Publish → DNS → Published.
+EOF
+)"
+```
+
+---
+
+## Summary
+
+This plan transforms the lexicon-publisher from a section-based layout to a 5-step wizard:
+
+1. **Sign In** - OAuth flow, must complete first
+2. **Define Lexicons** - Upload/edit, "Continue to Publish" validates
+3. **Publish** - Review warnings, publish to PDS
+4. **Verify DNS** - TXT records, non-blocking
+5. **Your Published Lexicons** - View all, delete
+
+Key behaviors implemented:
+- Steps collapse with summary when completed
+- Can click completed steps to go back
+- Going back resets downstream steps
+- Future steps grayed out until unlocked
+- No localStorage - refresh starts fresh at Step 2 (if signed in)
diff --git a/docs/plans/2025-12-23-lexicon-publisher.md b/docs/plans/2025-12-23-lexicon-publisher.md
new file mode 100644
index 0000000..de95714
--- /dev/null
+++ b/docs/plans/2025-12-23-lexicon-publisher.md
@@ -0,0 +1,1223 @@
+# Lexicon Publisher Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Build a web tool that validates, publishes, and manages ATProto lexicon schemas with OAuth authentication and DNS setup instructions.
+
+**Architecture:** Single-file HTML tool (like lexicon-validator.html) using quickslice-client-js for OAuth/GraphQL, honk for validation, and JSZip for file handling. Progressive auth: validate without login, publish requires OAuth.
+
+**Tech Stack:** HTML/CSS/JS, quickslice-client-js (CDN), honk (CDN), JSZip (CDN), GraphQL mutations via lexicons-quickslice backend.
+
+---
+
+## Task 1: Copy Lexicon Validator as Base
+
+**Files:**
+- Copy: `lexicon-validator.html` → `lexicon-publisher.html`
+
+**Step 1: Copy the file**
+
+```bash
+cp lexicon-validator.html lexicon-publisher.html
+```
+
+**Step 2: Update title and header**
+
+Change:
+- `{ Lexicon Validator }` → `{ Lexicon Publisher }`
+- Header h1 from "Lexicon Validator" → "Lexicon Publisher"
+- Tagline: add "Publish lexicons to your PDS"
+
+**Step 3: Update CSP to allow lexicons-quickslice**
+
+Add to Content-Security-Policy connect-src:
+```
+https://quickslice-production-4b57.up.railway.app wss://quickslice-production-4b57.up.railway.app https://public.api.bsky.app
+```
+
+**Step 4: Add quickslice-client script**
+
+After honk script, add:
+```html
+
+```
+
+**Step 5: Verify in browser**
+
+Open `lexicon-publisher.html` - should show renamed validator with same functionality.
+
+**Step 6: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "feat: scaffold lexicon-publisher from validator"
+```
+
+---
+
+## Task 2: Add State for Auth and Publishing
+
+**Files:**
+- Modify: `lexicon-publisher.html`
+
+**Step 1: Extend state object**
+
+Add these properties to the state object:
+
+```javascript
+const state = {
+ // Existing lexicon state
+ lexicons: [{ id: 1, value: "", collapsed: false }],
+ nextLexiconId: 2,
+ record: "",
+ nsid: "",
+ availableNsids: [],
+ result: null,
+
+ // New: Auth state
+ client: null,
+ viewer: null, // { did, handle }
+
+ // New: Published lexicons from user's PDS
+ publishedLexicons: [],
+
+ // New: Publish workflow
+ validationPassed: false,
+ conflicts: [], // [{ nsid, existingOwner, isSameOwner }]
+ publishResults: [], // [{ nsid, success, error }]
+
+ // New: DNS instructions
+ dnsInstructions: [], // [{ domain, record, lexicons }]
+};
+```
+
+**Step 2: Add constants**
+
+```javascript
+const SERVER_URL = "https://quickslice-production-4b57.up.railway.app";
+const CLIENT_ID = "client_0ygtMYHz-rzUtCMgvhigcA";
+const SCOPE = "atproto repo:com.atproto.lexicon";
+```
+
+**Step 3: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "feat: add auth and publishing state"
+```
+
+---
+
+## Task 3: Add OAuth Helpers
+
+**Files:**
+- Modify: `lexicon-publisher.html`
+
+**Step 1: Add initClient function**
+
+```javascript
+async function initClient() {
+ if (!state.client) {
+ state.client = await QuicksliceClient.createQuicksliceClient({
+ server: SERVER_URL,
+ clientId: CLIENT_ID,
+ scope: SCOPE,
+ });
+ }
+ return state.client;
+}
+```
+
+**Step 2: Add callback detection**
+
+```javascript
+function isCallbackRoute() {
+ return window.location.pathname === "/lexicon-publisher/callback";
+}
+```
+
+**Step 3: Add OAuth callback handler**
+
+```javascript
+async function handleOAuthCallback() {
+ if (!isCallbackRoute()) return false;
+
+ const params = new URLSearchParams(window.location.search);
+
+ // Start OAuth flow from callback route
+ if (params.get("start") === "1") {
+ window.history.replaceState({}, document.title, "/lexicon-publisher/callback");
+ await startOAuthFromCallback();
+ return true;
+ }
+
+ // Handle OAuth error
+ const urlError = params.get("error");
+ if (urlError) {
+ state.result = { success: false, message: params.get("error_description") || urlError };
+ window.location.href = "/lexicon-publisher";
+ return true;
+ }
+
+ // No code - redirect
+ if (!params.has("code")) {
+ window.location.href = "/lexicon-publisher";
+ return true;
+ }
+
+ // Exchange code for tokens
+ try {
+ await initClient();
+ await state.client.handleRedirectCallback();
+ window.history.replaceState({}, document.title, "/lexicon-publisher/callback");
+ const returnUrl = sessionStorage.getItem("oauth_return_url") || "/lexicon-publisher";
+ sessionStorage.removeItem("oauth_return_url");
+ window.location.href = returnUrl;
+ return true;
+ } catch (err) {
+ console.error("OAuth callback error:", err);
+ state.result = { success: false, message: err.message || "Authentication failed" };
+ window.location.href = "/lexicon-publisher";
+ return true;
+ }
+}
+```
+
+**Step 4: Add login flow**
+
+```javascript
+function showLoginModal() {
+ const existing = document.getElementById("login-modal");
+ if (existing) existing.remove();
+
+ const modal = document.createElement("div");
+ modal.id = "login-modal";
+ modal.className = "modal-overlay";
+ modal.innerHTML = `
+
+ `;
+ document.body.appendChild(modal);
+ document.getElementById("login-handle").focus();
+}
+
+function closeLoginModal() {
+ const modal = document.getElementById("login-modal");
+ if (modal) modal.remove();
+}
+
+async function handleLogin(event) {
+ event.preventDefault();
+ const handle = document.getElementById("login-handle").value.trim();
+ if (!handle) return;
+
+ sessionStorage.setItem("oauth_return_url", window.location.href);
+ sessionStorage.setItem("oauth_handle", handle);
+ window.location.href = "/lexicon-publisher/callback?start=1";
+}
+
+async function startOAuthFromCallback() {
+ const handle = sessionStorage.getItem("oauth_handle");
+ sessionStorage.removeItem("oauth_handle");
+
+ if (!handle) {
+ window.location.href = "/lexicon-publisher";
+ return;
+ }
+
+ try {
+ await initClient();
+ await state.client.loginWithRedirect({ handle });
+ } catch (err) {
+ console.error("Login error:", err);
+ sessionStorage.setItem("oauth_error", err.message);
+ window.location.href = "/lexicon-publisher";
+ }
+}
+
+function handleLogout() {
+ if (state.client) {
+ state.client.logout();
+ }
+ state.viewer = null;
+ state.publishedLexicons = [];
+ renderAll();
+}
+```
+
+**Step 5: Commit**
+
+```bash
+git add lexicon-publisher.html
+git commit -m "feat: add OAuth helpers"
+```
+
+---
+
+## Task 4: Add Modal and Auth UI Styles
+
+**Files:**
+- Modify: `lexicon-publisher.html`
+
+**Step 1: Add modal styles**
+
+Add to `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+