Tools for the Atmosphere tools.slices.network
quickslice atproto html

feat: add lexicon validator tool

- Single-page HTML tool to validate AT Protocol lexicon schemas and records
- NSID dropdown auto-populates from entered lexicons with record types
- Record template auto-generates from required fields with sensible defaults
- Reset button to regenerate template
- Powered by honk library (hexdocs.pm/honk)
- Add prettier config and format.sh for HTML formatting
- Update index.html and README.md

+986 -78
+9
.prettierrc
··· 1 + { 2 + "printWidth": 100, 3 + "tabWidth": 2, 4 + "useTabs": false, 5 + "semi": true, 6 + "singleQuote": false, 7 + "htmlWhitespaceSensitivity": "css", 8 + "bracketSameLine": false 9 + }
+1
README.md
··· 6 6 7 7 - [Teal Plays](https://tools.slices.network/teal-plays) - Live music feed from the Atmosphere 8 8 - [Statusphere](https://tools.slices.network/statusphere) - Set your status on the Atmosphere 9 + - [Lexicon Validator](https://tools.slices.network/lexicon-validator) - Validate AT Protocol lexicon schemas and records
+2
format.sh
··· 1 + #!/bin/bash 2 + npx prettier --write "**/*.html"
+40 -9
index.html
··· 6 6 <title>Tools</title> 7 7 <style> 8 8 /* CSS Reset */ 9 - *, *::before, *::after { box-sizing: border-box; } 10 - * { margin: 0; } 11 - body { line-height: 1.5; -webkit-font-smoothing: antialiased; } 9 + *, 10 + *::before, 11 + *::after { 12 + box-sizing: border-box; 13 + } 14 + * { 15 + margin: 0; 16 + } 17 + body { 18 + line-height: 1.5; 19 + -webkit-font-smoothing: antialiased; 20 + } 12 21 13 22 :root { 14 23 --bg-primary: #fafaf9; ··· 27 36 padding: 3rem 1rem; 28 37 } 29 38 30 - #app { max-width: 500px; margin: 0 auto; } 39 + #app { 40 + max-width: 500px; 41 + margin: 0 auto; 42 + } 31 43 32 - header { text-align: center; margin-bottom: 2.5rem; } 44 + header { 45 + text-align: center; 46 + margin-bottom: 2.5rem; 47 + } 33 48 34 49 .logo { 35 50 width: 64px; ··· 44 59 margin-bottom: 0.25rem; 45 60 } 46 61 47 - .tagline { color: var(--text-secondary); font-size: 0.875rem; } 62 + .tagline { 63 + color: var(--text-secondary); 64 + font-size: 0.875rem; 65 + } 48 66 49 67 .tools-list { 50 68 display: flex; ··· 59 77 padding: 1.25rem; 60 78 border: 1px solid var(--border); 61 79 text-decoration: none; 62 - transition: box-shadow 0.15s, transform 0.15s; 80 + transition: 81 + box-shadow 0.15s, 82 + transform 0.15s; 63 83 } 64 84 65 85 .tool-card:hover { ··· 120 140 <span class="tool-icon">🎵</span> 121 141 <span class="tool-name">Teal Plays</span> 122 142 </div> 123 - <div class="tool-description">Live music feed from the Atmosphere. See what everyone is listening to in real-time.</div> 143 + <div class="tool-description"> 144 + Live music feed from the Atmosphere. See what everyone is listening to in real-time. 145 + </div> 124 146 </a> 125 147 <a href="statusphere" class="tool-card"> 126 148 <div class="tool-header"> 127 149 <span class="tool-icon">😎</span> 128 150 <span class="tool-name">Statusphere</span> 129 151 </div> 130 - <div class="tool-description">Set your status on the Atmosphere. Share how you're feeling with a single emoji.</div> 152 + <div class="tool-description"> 153 + Set your status on the Atmosphere. Share how you're feeling with a single emoji. 154 + </div> 155 + </a> 156 + <a href="lexicon-validator" class="tool-card"> 157 + <div class="tool-header"> 158 + <span class="tool-icon" style="color: #0066cc">{ }</span> 159 + <span class="tool-name">Lexicon Validator</span> 160 + </div> 161 + <div class="tool-description">Validate AT Protocol lexicon schemas and records.</div> 131 162 </a> 132 163 </main> 133 164 </div>
+795
lexicon-validator.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <meta 7 + http-equiv="Content-Security-Policy" 8 + content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline';" 9 + /> 10 + <title>{ Lexicon Validator }</title> 11 + <style> 12 + /* CSS Reset */ 13 + *, 14 + *::before, 15 + *::after { 16 + box-sizing: border-box; 17 + } 18 + * { 19 + margin: 0; 20 + } 21 + body { 22 + line-height: 1.5; 23 + -webkit-font-smoothing: antialiased; 24 + } 25 + input, 26 + button, 27 + textarea { 28 + font: inherit; 29 + } 30 + 31 + /* Light Theme */ 32 + :root { 33 + --bg-primary: #f5f5f5; 34 + --bg-card: #ffffff; 35 + --bg-input: #fafafa; 36 + --text-primary: #1a1a1a; 37 + --text-secondary: #666666; 38 + --accent: #0066cc; 39 + --accent-hover: #0052a3; 40 + --border: #e0e0e0; 41 + --border-focus: #0066cc; 42 + --error-bg: #fef2f2; 43 + --error-border: #fca5a5; 44 + --error-text: #dc2626; 45 + --success-bg: #f0fdf4; 46 + --success-border: #86efac; 47 + --success-text: #16a34a; 48 + } 49 + 50 + body { 51 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 52 + background: var(--bg-primary); 53 + color: var(--text-primary); 54 + min-height: 100vh; 55 + padding: 2rem 1rem; 56 + } 57 + 58 + #app { 59 + max-width: 700px; 60 + margin: 0 auto; 61 + } 62 + 63 + header { 64 + text-align: center; 65 + margin-bottom: 2rem; 66 + } 67 + 68 + header h1 { 69 + font-size: 2rem; 70 + color: var(--text-primary); 71 + margin-bottom: 0.25rem; 72 + } 73 + 74 + .tagline { 75 + color: var(--text-secondary); 76 + font-size: 0.875rem; 77 + } 78 + 79 + .tagline a { 80 + color: var(--accent); 81 + text-decoration: none; 82 + } 83 + 84 + .tagline a:hover { 85 + text-decoration: underline; 86 + } 87 + 88 + /* Sections */ 89 + .section { 90 + background: var(--bg-card); 91 + border-radius: 0.5rem; 92 + padding: 1rem; 93 + margin-bottom: 1rem; 94 + border: 1px solid var(--border); 95 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); 96 + } 97 + 98 + .section-header { 99 + display: flex; 100 + justify-content: space-between; 101 + align-items: center; 102 + margin-bottom: 0.75rem; 103 + } 104 + 105 + .section-title { 106 + font-weight: 600; 107 + font-size: 0.875rem; 108 + text-transform: uppercase; 109 + letter-spacing: 0.05em; 110 + color: var(--text-secondary); 111 + } 112 + 113 + /* Buttons */ 114 + .btn { 115 + padding: 0.5rem 1rem; 116 + border: none; 117 + border-radius: 0.375rem; 118 + font-size: 0.875rem; 119 + font-weight: 500; 120 + cursor: pointer; 121 + transition: 122 + background-color 0.15s, 123 + opacity 0.15s; 124 + } 125 + 126 + .btn-primary { 127 + background: var(--accent); 128 + color: #ffffff; 129 + } 130 + 131 + .btn-primary:hover { 132 + background: var(--accent-hover); 133 + } 134 + 135 + .btn-secondary { 136 + background: var(--bg-card); 137 + color: var(--text-primary); 138 + border: 1px solid var(--border); 139 + } 140 + 141 + .btn-secondary:hover { 142 + background: var(--bg-primary); 143 + } 144 + 145 + .btn-small { 146 + padding: 0.25rem 0.5rem; 147 + font-size: 0.75rem; 148 + } 149 + 150 + .btn-danger { 151 + background: var(--error-bg); 152 + color: var(--error-text); 153 + border: 1px solid var(--error-border); 154 + } 155 + 156 + .btn-danger:hover { 157 + background: #fee2e2; 158 + } 159 + 160 + .btn:disabled { 161 + opacity: 0.5; 162 + cursor: not-allowed; 163 + } 164 + 165 + /* Editors */ 166 + .editor-card { 167 + background: var(--bg-input); 168 + border: 1px solid var(--border); 169 + border-radius: 0.375rem; 170 + margin-bottom: 0.75rem; 171 + } 172 + 173 + .editor-header { 174 + display: flex; 175 + justify-content: space-between; 176 + align-items: center; 177 + padding: 0.5rem 0.75rem; 178 + border-bottom: 1px solid var(--border); 179 + background: var(--bg-card); 180 + border-radius: 0.375rem 0.375rem 0 0; 181 + } 182 + 183 + .editor-label { 184 + font-size: 0.75rem; 185 + color: var(--text-secondary); 186 + font-weight: 500; 187 + } 188 + 189 + textarea { 190 + width: 100%; 191 + min-height: 200px; 192 + padding: 0.75rem; 193 + background: var(--bg-input); 194 + border: none; 195 + color: var(--text-primary); 196 + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 197 + font-size: 0.8125rem; 198 + resize: vertical; 199 + border-radius: 0 0 0.375rem 0.375rem; 200 + } 201 + 202 + textarea:focus { 203 + outline: none; 204 + background: #ffffff; 205 + } 206 + 207 + textarea::placeholder { 208 + color: var(--text-secondary); 209 + opacity: 0.6; 210 + } 211 + 212 + /* NSID Input */ 213 + .nsid-row { 214 + display: flex; 215 + align-items: center; 216 + gap: 0.75rem; 217 + margin-bottom: 0.75rem; 218 + } 219 + 220 + .nsid-label { 221 + font-size: 0.875rem; 222 + color: var(--text-secondary); 223 + white-space: nowrap; 224 + } 225 + 226 + input[type="text"], 227 + select { 228 + flex: 1; 229 + padding: 0.5rem 0.75rem; 230 + background: var(--bg-input); 231 + border: 1px solid var(--border); 232 + border-radius: 0.375rem; 233 + color: var(--text-primary); 234 + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 235 + font-size: 0.8125rem; 236 + } 237 + 238 + input[type="text"]:focus, 239 + select:focus { 240 + outline: none; 241 + border-color: var(--border-focus); 242 + background: #ffffff; 243 + } 244 + 245 + input[type="text"]::placeholder { 246 + color: var(--text-secondary); 247 + opacity: 0.6; 248 + } 249 + 250 + select:disabled { 251 + opacity: 0.6; 252 + cursor: not-allowed; 253 + } 254 + 255 + /* Results */ 256 + .result { 257 + padding: 1rem; 258 + border-radius: 0.375rem; 259 + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; 260 + font-size: 0.8125rem; 261 + white-space: pre-wrap; 262 + word-break: break-word; 263 + } 264 + 265 + .result-success { 266 + background: var(--success-bg); 267 + border: 1px solid var(--success-border); 268 + color: var(--success-text); 269 + } 270 + 271 + .result-error { 272 + background: var(--error-bg); 273 + border: 1px solid var(--error-border); 274 + color: var(--error-text); 275 + } 276 + 277 + .result:empty { 278 + display: none; 279 + } 280 + 281 + /* Button row */ 282 + .button-row { 283 + display: flex; 284 + gap: 0.5rem; 285 + margin-top: 0.75rem; 286 + } 287 + 288 + .hidden { 289 + display: none !important; 290 + } 291 + </style> 292 + </head> 293 + <body> 294 + <div id="app"> 295 + <header> 296 + <h1><span style="color: var(--accent)">{</span> Lexicon Validator <span style="color: var(--accent)">}</span></h1> 297 + <p class="tagline"> 298 + Powered by <a href="https://hexdocs.pm/honk/index.html" target="_blank">honk</a> 299 + </p> 300 + </header> 301 + <main> 302 + <div id="lexicons-section" class="section"></div> 303 + <div id="record-section" class="section"></div> 304 + <div id="results-section"></div> 305 + </main> 306 + </div> 307 + <script src="https://cdn.jsdelivr.net/gh/bigmoves/honk@deaa420/dist/honk.min.js"></script> 308 + <script> 309 + // ============================================================================= 310 + // STATE 311 + // ============================================================================= 312 + 313 + const state = { 314 + lexicons: [{ id: 1, value: "" }], 315 + nextLexiconId: 2, 316 + record: "", 317 + nsid: "", 318 + availableNsids: [], 319 + result: null, 320 + }; 321 + 322 + // ============================================================================= 323 + // PLACEHOLDERS 324 + // ============================================================================= 325 + 326 + const LEXICON_PLACEHOLDER = `{ 327 + "lexicon": 1, 328 + "id": "com.example.myRecord", 329 + "defs": { 330 + "main": { 331 + "type": "record", 332 + "record": { 333 + "type": "object", 334 + "required": ["status", "createdAt"], 335 + "properties": { 336 + "status": { 337 + "type": "string", 338 + "maxLength": 100 339 + }, 340 + "createdAt": { 341 + "type": "string", 342 + "format": "datetime" 343 + } 344 + } 345 + } 346 + } 347 + } 348 + }`; 349 + 350 + const RECORD_PLACEHOLDER = `{ 351 + "status": "Hello world", 352 + "createdAt": "2025-01-15T12:00:00Z" 353 + }`; 354 + 355 + // ============================================================================= 356 + // HELPERS 357 + // ============================================================================= 358 + 359 + function esc(str) { 360 + const d = document.createElement("div"); 361 + d.textContent = str || ""; 362 + return d.innerHTML; 363 + } 364 + 365 + function escapeAttr(str) { 366 + return (str || "") 367 + .replace(/&/g, "&amp;") 368 + .replace(/"/g, "&quot;") 369 + .replace(/</g, "&lt;") 370 + .replace(/>/g, "&gt;"); 371 + } 372 + 373 + function unwrapHonkResult(result) { 374 + if (typeof result.isOk === "function") { 375 + return { ok: result.isOk(), value: result[0] }; 376 + } 377 + return { ok: true, value: result }; 378 + } 379 + 380 + function formatHonkError(err) { 381 + if (!err) return "Unknown error"; 382 + if (typeof err === "string") return err; 383 + if (err.message) { 384 + return err.path ? `${err.message} (at ${err.path})` : err.message; 385 + } 386 + return JSON.stringify(err, null, 2); 387 + } 388 + 389 + // ============================================================================= 390 + // RENDERING 391 + // ============================================================================= 392 + 393 + function renderLexiconsSection() { 394 + const section = document.getElementById("lexicons-section"); 395 + 396 + const editorsHtml = state.lexicons 397 + .map( 398 + (lex, idx) => ` 399 + <div class="editor-card" data-lexicon-id="${lex.id}"> 400 + <div class="editor-header"> 401 + <span class="editor-label">Lexicon ${idx + 1}</span> 402 + <button 403 + class="btn btn-danger btn-small" 404 + onclick="removeLexicon(${lex.id})" 405 + ${state.lexicons.length === 1 ? "disabled" : ""} 406 + >Remove</button> 407 + </div> 408 + <textarea 409 + placeholder="${escapeAttr(LEXICON_PLACEHOLDER)}" 410 + onchange="updateLexicon(${lex.id}, this.value)" 411 + oninput="updateLexicon(${lex.id}, this.value)" 412 + >${esc(lex.value)}</textarea> 413 + </div> 414 + `, 415 + ) 416 + .join(""); 417 + 418 + section.innerHTML = ` 419 + <div class="section-header"> 420 + <span class="section-title">Lexicons</span> 421 + </div> 422 + ${editorsHtml} 423 + <div class="button-row"> 424 + <button class="btn btn-secondary" onclick="addLexicon()">+ Add Lexicon</button> 425 + <button class="btn btn-primary" onclick="validateLexicons()">Validate Lexicons</button> 426 + </div> 427 + `; 428 + } 429 + 430 + function renderRecordSection() { 431 + const section = document.getElementById("record-section"); 432 + 433 + const optionsHtml = 434 + state.availableNsids.length === 0 435 + ? '<option value="" disabled selected>Enter a lexicon with a record type</option>' 436 + : [ 437 + `<option value="" disabled ${!state.nsid ? "selected" : ""}>Select a record type</option>`, 438 + ] 439 + .concat( 440 + state.availableNsids.map( 441 + (nsid) => 442 + `<option value="${escapeAttr(nsid)}" ${state.nsid === nsid ? "selected" : ""}>${esc(nsid)}</option>`, 443 + ), 444 + ) 445 + .join(""); 446 + 447 + section.innerHTML = ` 448 + <div class="section-header"> 449 + <span class="section-title">Record</span> 450 + </div> 451 + <div class="nsid-row"> 452 + <span class="nsid-label">NSID:</span> 453 + <select 454 + id="nsid-select" 455 + onchange="updateNsid(this.value)" 456 + ${state.availableNsids.length === 0 ? "disabled" : ""} 457 + >${optionsHtml}</select> 458 + </div> 459 + <div class="editor-card"> 460 + <div class="editor-header"> 461 + <span class="editor-label">Record Data</span> 462 + <button 463 + class="btn btn-secondary btn-small" 464 + onclick="resetRecordTemplate()" 465 + ${!state.nsid ? "disabled" : ""} 466 + >Reset</button> 467 + </div> 468 + <textarea 469 + id="record-input" 470 + placeholder="${escapeAttr(RECORD_PLACEHOLDER)}" 471 + onchange="updateRecord(this.value)" 472 + oninput="updateRecord(this.value)" 473 + >${esc(state.record)}</textarea> 474 + </div> 475 + <div class="button-row"> 476 + <button class="btn btn-primary" onclick="validateRecord()">Validate Record</button> 477 + </div> 478 + `; 479 + } 480 + 481 + function renderResult() { 482 + const section = document.getElementById("results-section"); 483 + 484 + if (!state.result) { 485 + section.innerHTML = ""; 486 + return; 487 + } 488 + 489 + const cls = state.result.success ? "result-success" : "result-error"; 490 + const icon = state.result.success ? "✓" : "✗"; 491 + 492 + section.innerHTML = ` 493 + <div class="result ${cls}">${icon} ${esc(state.result.message)}</div> 494 + `; 495 + } 496 + 497 + // ============================================================================= 498 + // STATE UPDATES 499 + // ============================================================================= 500 + 501 + function addLexicon() { 502 + state.lexicons.push({ id: state.nextLexiconId++, value: "" }); 503 + renderLexiconsSection(); 504 + } 505 + 506 + function removeLexicon(id) { 507 + if (state.lexicons.length <= 1) return; 508 + state.lexicons = state.lexicons.filter((l) => l.id !== id); 509 + renderLexiconsSection(); 510 + } 511 + 512 + function updateLexicon(id, value) { 513 + const lex = state.lexicons.find((l) => l.id === id); 514 + if (lex) lex.value = value; 515 + updateAvailableNsids(); 516 + } 517 + 518 + function updateAvailableNsids() { 519 + const nsids = []; 520 + for (const lex of state.lexicons) { 521 + const trimmed = lex.value.trim(); 522 + if (!trimmed) continue; 523 + try { 524 + const obj = JSON.parse(trimmed); 525 + if (obj.id && obj.defs?.main?.type === "record") { 526 + nsids.push(obj.id); 527 + } 528 + } catch (e) { 529 + // Invalid JSON, skip 530 + } 531 + } 532 + state.availableNsids = nsids; 533 + renderRecordSection(); 534 + } 535 + 536 + function updateRecord(value) { 537 + state.record = value; 538 + } 539 + 540 + function resetRecordTemplate() { 541 + if (state.nsid) { 542 + const template = generateRecordTemplate(state.nsid); 543 + if (template) { 544 + state.record = template; 545 + renderRecordSection(); 546 + } 547 + } 548 + } 549 + 550 + function updateNsid(value) { 551 + state.nsid = value; 552 + if (value) { 553 + const template = generateRecordTemplate(value); 554 + if (template) { 555 + state.record = template; 556 + } 557 + } 558 + renderRecordSection(); 559 + } 560 + 561 + function generateRecordTemplate(nsid) { 562 + for (const lex of state.lexicons) { 563 + const trimmed = lex.value.trim(); 564 + if (!trimmed) continue; 565 + try { 566 + const obj = JSON.parse(trimmed); 567 + if (obj.id === nsid && obj.defs?.main?.type === "record") { 568 + const record = obj.defs.main.record; 569 + if (record?.type === "object" && record.properties) { 570 + const template = {}; 571 + const required = record.required || []; 572 + for (const field of required) { 573 + const prop = record.properties[field]; 574 + if (prop) { 575 + template[field] = getDefaultValue(prop); 576 + } 577 + } 578 + return JSON.stringify(template, null, 2); 579 + } 580 + } 581 + } catch (e) { 582 + // Invalid JSON, skip 583 + } 584 + } 585 + return null; 586 + } 587 + 588 + function getDefaultValue(prop) { 589 + switch (prop.type) { 590 + case "string": 591 + if (prop.format === "datetime") return new Date().toISOString(); 592 + if (prop.format === "uri") return "https://example.com"; 593 + if (prop.format === "at-uri") return "at://did:plc:example/app.bsky.feed.post/abc123"; 594 + if (prop.format === "did") return "did:plc:example"; 595 + if (prop.format === "handle") return "user.example.com"; 596 + if (prop.format === "cid") return "bafyreib..."; 597 + if (prop.const) return prop.const; 598 + if (prop.enum) return prop.enum[0]; 599 + return ""; 600 + case "integer": 601 + return prop.minimum ?? prop.default ?? 0; 602 + case "boolean": 603 + return prop.default ?? false; 604 + case "array": 605 + return []; 606 + case "object": 607 + return {}; 608 + case "blob": 609 + return { $type: "blob", ref: { $link: "" }, mimeType: "", size: 0 }; 610 + default: 611 + return null; 612 + } 613 + } 614 + 615 + // ============================================================================= 616 + // VALIDATION 617 + // ============================================================================= 618 + 619 + function parseLexicons() { 620 + const parsed = []; 621 + for (let i = 0; i < state.lexicons.length; i++) { 622 + const lex = state.lexicons[i]; 623 + const trimmed = lex.value.trim(); 624 + 625 + if (!trimmed) { 626 + return { 627 + error: `Lexicon ${i + 1}: Empty - please enter a lexicon schema`, 628 + }; 629 + } 630 + 631 + // Validate JSON syntax first 632 + try { 633 + const obj = JSON.parse(trimmed); 634 + if (Array.isArray(obj)) { 635 + return { 636 + error: `Lexicon ${i + 1}: Expected a single lexicon object, not an array. Use "+ Add Lexicon" for multiple.`, 637 + }; 638 + } 639 + } catch (e) { 640 + return { error: `Lexicon ${i + 1}: Invalid JSON - ${e.message}` }; 641 + } 642 + 643 + // Parse to Gleam Json type 644 + const parseResult = honk.parse_json_string(trimmed); 645 + const unwrapped = unwrapHonkResult(parseResult); 646 + if (!unwrapped.ok) { 647 + return { error: `Lexicon ${i + 1}: ${formatHonkError(unwrapped.value)}` }; 648 + } 649 + parsed.push(unwrapped.value); 650 + } 651 + return { lexicons: parsed }; 652 + } 653 + 654 + function validateLexicons() { 655 + state.result = null; 656 + 657 + const parseResult = parseLexicons(); 658 + if (parseResult.error) { 659 + state.result = { success: false, message: parseResult.error }; 660 + renderResult(); 661 + return; 662 + } 663 + 664 + try { 665 + // Convert JS array to Gleam List 666 + const lexiconList = honk.toList(parseResult.lexicons); 667 + const result = honk.validate(lexiconList); 668 + const unwrapped = unwrapHonkResult(result); 669 + 670 + if (unwrapped.ok) { 671 + const count = parseResult.lexicons.length; 672 + const noun = count === 1 ? "lexicon" : "lexicons"; 673 + state.result = { success: true, message: `${count} ${noun} valid` }; 674 + } else { 675 + state.result = { 676 + success: false, 677 + message: `Validation failed: ${formatHonkError(unwrapped.value)}`, 678 + }; 679 + } 680 + } catch (e) { 681 + state.result = { 682 + success: false, 683 + message: `Validation error: ${e.message}`, 684 + }; 685 + } 686 + 687 + renderResult(); 688 + } 689 + 690 + function validateRecord() { 691 + state.result = null; 692 + 693 + const nsid = state.nsid.trim(); 694 + if (!nsid) { 695 + state.result = { 696 + success: false, 697 + message: "NSID is required for record validation", 698 + }; 699 + renderResult(); 700 + return; 701 + } 702 + 703 + if (!honk.is_valid_nsid(nsid)) { 704 + state.result = { 705 + success: false, 706 + message: `Invalid NSID format: "${nsid}"`, 707 + }; 708 + renderResult(); 709 + return; 710 + } 711 + 712 + const parseResult = parseLexicons(); 713 + if (parseResult.error) { 714 + state.result = { success: false, message: parseResult.error }; 715 + renderResult(); 716 + return; 717 + } 718 + 719 + const recordTrimmed = state.record.trim(); 720 + if (!recordTrimmed) { 721 + state.result = { success: false, message: "Record data is required" }; 722 + renderResult(); 723 + return; 724 + } 725 + 726 + // Validate JSON syntax first 727 + try { 728 + JSON.parse(recordTrimmed); 729 + } catch (e) { 730 + state.result = { 731 + success: false, 732 + message: `Record: Invalid JSON - ${e.message}`, 733 + }; 734 + renderResult(); 735 + return; 736 + } 737 + 738 + // Parse record to Gleam Json type 739 + const recordParseResult = honk.parse_json_string(recordTrimmed); 740 + const recordUnwrapped = unwrapHonkResult(recordParseResult); 741 + if (!recordUnwrapped.ok) { 742 + state.result = { 743 + success: false, 744 + message: `Record: ${formatHonkError(recordUnwrapped.value)}`, 745 + }; 746 + renderResult(); 747 + return; 748 + } 749 + 750 + try { 751 + // Convert JS array to Gleam List 752 + const lexiconList = honk.toList(parseResult.lexicons); 753 + const result = honk.validate_record(lexiconList, nsid, recordUnwrapped.value); 754 + const unwrapped = unwrapHonkResult(result); 755 + 756 + if (unwrapped.ok) { 757 + state.result = { 758 + success: true, 759 + message: `Record valid against ${nsid}`, 760 + }; 761 + } else { 762 + state.result = { 763 + success: false, 764 + message: `Record invalid: ${formatHonkError(unwrapped.value)}`, 765 + }; 766 + } 767 + } catch (e) { 768 + state.result = { 769 + success: false, 770 + message: `Validation error: ${e.message}`, 771 + }; 772 + } 773 + 774 + renderResult(); 775 + } 776 + 777 + // ============================================================================= 778 + // INIT 779 + // ============================================================================= 780 + 781 + function init() { 782 + if (typeof honk === "undefined") { 783 + document.getElementById("results-section").innerHTML = ` 784 + <div class="result result-error">✗ Failed to load honk library from CDN. Check your internet connection.</div> 785 + `; 786 + return; 787 + } 788 + renderLexiconsSection(); 789 + renderRecordSection(); 790 + } 791 + 792 + window.addEventListener("DOMContentLoaded", init); 793 + </script> 794 + </body> 795 + </html>
+10 -28
statusphere.html
··· 45 45 46 46 /* Layout */ 47 47 body { 48 - font-family: 49 - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 48 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 50 49 background: var(--gray-100); 51 50 color: var(--gray-900); 52 51 min-height: 100vh; ··· 419 418 // Check if this is an OAuth callback 420 419 if (window.location.search.includes("code=")) { 421 420 if (!CLIENT_ID) { 422 - showError( 423 - "OAuth callback received but CLIENT_ID is not configured.", 424 - ); 421 + showError("OAuth callback received but CLIENT_ID is not configured."); 425 422 renderLoginForm(); 426 423 return; 427 424 } ··· 518 515 body: JSON.stringify({ query }), 519 516 }); 520 517 const result = await response.json(); 521 - return ( 522 - result.data?.xyzStatusphereStatus?.edges?.map((e) => e.node) || [] 523 - ); 518 + return result.data?.xyzStatusphereStatus?.edges?.map((e) => e.node) || []; 524 519 } 525 520 } 526 521 ··· 614 609 615 610 try { 616 611 // Disable buttons while posting 617 - document 618 - .querySelectorAll(".emoji-btn") 619 - .forEach((btn) => (btn.disabled = true)); 612 + document.querySelectorAll(".emoji-btn").forEach((btn) => (btn.disabled = true)); 620 613 621 614 await postStatus(emoji); 622 615 ··· 625 618 } catch (error) { 626 619 showError(`Failed to post status: ${error.message}`); 627 620 // Re-enable buttons 628 - document 629 - .querySelectorAll(".emoji-btn") 630 - .forEach((btn) => (btn.disabled = false)); 621 + document.querySelectorAll(".emoji-btn").forEach((btn) => (btn.disabled = false)); 631 622 } 632 623 } 633 624 ··· 674 665 return date.toLocaleDateString("en-US", { 675 666 month: "short", 676 667 day: "numeric", 677 - year: 678 - date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 668 + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 679 669 }); 680 670 } 681 671 ··· 720 710 721 711 function renderUserCard(viewer) { 722 712 const container = document.getElementById("auth-section"); 723 - const displayName = 724 - viewer?.appBskyActorProfileByDid?.displayName || "User"; 713 + const displayName = viewer?.appBskyActorProfileByDid?.displayName || "User"; 725 714 const handle = viewer?.handle || "unknown"; 726 715 const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url; 727 716 ··· 729 718 <div class="card user-card"> 730 719 <div class="user-info"> 731 720 <div class="user-avatar"> 732 - ${ 733 - avatarUrl 734 - ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` 735 - : "👤" 736 - } 721 + ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` : "👤"} 737 722 </div> 738 723 <div> 739 724 <div class="user-name">Hi, ${escapeHtml(displayName)}!</div> ··· 786 771 <ul class="status-list"> 787 772 ${statuses 788 773 .map((status) => { 789 - const handle = 790 - status.appBskyActorProfileByDid?.actorHandle || status.did; 774 + const handle = status.appBskyActorProfileByDid?.actorHandle || status.did; 791 775 const displayHandle = handle.startsWith("did:") 792 776 ? handle.substring(0, 20) + "..." 793 777 : handle; ··· 805 789 )}</a> 806 790 is feeling ${status.status} 807 791 </span> 808 - <div class="status-date">${formatDate( 809 - status.createdAt, 810 - )}</div> 792 + <div class="status-date">${formatDate(status.createdAt)}</div> 811 793 </div> 812 794 </li> 813 795 `;
+129 -41
teal-plays.html
··· 10 10 <title>Teal Plays</title> 11 11 <style> 12 12 /* CSS Reset */ 13 - *, *::before, *::after { box-sizing: border-box; } 14 - * { margin: 0; } 15 - body { line-height: 1.5; -webkit-font-smoothing: antialiased; } 16 - input, button { font: inherit; } 13 + *, 14 + *::before, 15 + *::after { 16 + box-sizing: border-box; 17 + } 18 + * { 19 + margin: 0; 20 + } 21 + body { 22 + line-height: 1.5; 23 + -webkit-font-smoothing: antialiased; 24 + } 25 + input, 26 + button { 27 + font: inherit; 28 + } 17 29 18 30 /* Dark Music Theme */ 19 31 :root { ··· 38 50 padding: 2rem 1rem; 39 51 } 40 52 41 - #app { max-width: 600px; margin: 0 auto; } 53 + #app { 54 + max-width: 600px; 55 + margin: 0 auto; 56 + } 42 57 43 58 /* Header */ 44 - header { text-align: center; margin-bottom: 1.5rem; } 59 + header { 60 + text-align: center; 61 + margin-bottom: 1.5rem; 62 + } 45 63 46 64 header h1 { 47 65 font-size: 2rem; ··· 67 85 } 68 86 69 87 @keyframes pulse { 70 - 0%, 100% { opacity: 1; transform: scale(1); } 71 - 50% { opacity: 0.5; transform: scale(1.2); } 88 + 0%, 89 + 100% { 90 + opacity: 1; 91 + transform: scale(1); 92 + } 93 + 50% { 94 + opacity: 0.5; 95 + transform: scale(1.2); 96 + } 72 97 } 73 98 74 - .tagline { color: var(--text-secondary); font-size: 0.875rem; } 99 + .tagline { 100 + color: var(--text-secondary); 101 + font-size: 0.875rem; 102 + } 75 103 76 104 /* Buttons */ 77 105 .btn { ··· 81 109 font-size: 0.875rem; 82 110 font-weight: 500; 83 111 cursor: pointer; 84 - transition: background-color 0.15s, opacity 0.15s; 112 + transition: 113 + background-color 0.15s, 114 + opacity 0.15s; 85 115 } 86 116 87 - .btn-primary { background: var(--accent); color: var(--bg-primary); } 88 - .btn-primary:hover { background: var(--accent-hover); } 89 - .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } 117 + .btn-primary { 118 + background: var(--accent); 119 + color: var(--bg-primary); 120 + } 121 + .btn-primary:hover { 122 + background: var(--accent-hover); 123 + } 124 + .btn-primary:disabled { 125 + opacity: 0.5; 126 + cursor: not-allowed; 127 + } 90 128 91 129 .btn-secondary { 92 130 background: var(--bg-hover); 93 131 color: var(--text-primary); 94 132 border: 1px solid var(--border); 95 133 } 96 - .btn-secondary:hover { background: var(--border); } 134 + .btn-secondary:hover { 135 + background: var(--border); 136 + } 97 137 98 138 /* Cards */ 99 139 .card { ··· 109 149 } 110 150 111 151 @keyframes highlight-fade { 112 - 0% { border-color: var(--accent); box-shadow: 0 0 10px rgba(29, 185, 84, 0.3); } 113 - 100% { border-color: var(--border); box-shadow: none; } 152 + 0% { 153 + border-color: var(--accent); 154 + box-shadow: 0 0 10px rgba(29, 185, 84, 0.3); 155 + } 156 + 100% { 157 + border-color: var(--border); 158 + box-shadow: none; 159 + } 114 160 } 115 161 116 162 /* Play Card */ ··· 130 176 flex-shrink: 0; 131 177 } 132 178 133 - .play-avatar img { width: 100%; height: 100%; object-fit: cover; } 179 + .play-avatar img { 180 + width: 100%; 181 + height: 100%; 182 + object-fit: cover; 183 + } 134 184 135 - .play-meta { flex: 1; min-width: 0; } 185 + .play-meta { 186 + flex: 1; 187 + min-width: 0; 188 + } 136 189 137 190 .play-user { 138 191 color: var(--accent); ··· 140 193 font-weight: 500; 141 194 font-size: 0.875rem; 142 195 } 143 - .play-user:hover { text-decoration: underline; } 196 + .play-user:hover { 197 + text-decoration: underline; 198 + } 144 199 145 - .play-time { color: var(--text-secondary); font-size: 0.75rem; } 200 + .play-time { 201 + color: var(--text-secondary); 202 + font-size: 0.75rem; 203 + } 146 204 147 205 .play-track { 148 206 font-size: 1.125rem; ··· 168 226 flex-shrink: 0; 169 227 } 170 228 171 - .play-artist { color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.125rem; } 172 - .play-album { color: var(--text-secondary); font-size: 0.75rem; font-style: italic; } 229 + .play-artist { 230 + color: var(--text-secondary); 231 + font-size: 0.875rem; 232 + margin-bottom: 0.125rem; 233 + } 234 + .play-album { 235 + color: var(--text-secondary); 236 + font-size: 0.75rem; 237 + font-style: italic; 238 + } 173 239 174 - .play-content { display: flex; gap: 0.75rem; } 240 + .play-content { 241 + display: flex; 242 + gap: 0.75rem; 243 + } 175 244 176 245 .play-art { 177 246 width: 64px; ··· 197 266 color: var(--text-secondary); 198 267 } 199 268 200 - .play-art img.hidden { display: none; } 201 - .play-art:has(img:not(.hidden)) .play-art-fallback { display: none; } 269 + .play-art img.hidden { 270 + display: none; 271 + } 272 + .play-art:has(img:not(.hidden)) .play-art-fallback { 273 + display: none; 274 + } 202 275 203 - .play-info { flex: 1; min-width: 0; } 276 + .play-info { 277 + flex: 1; 278 + min-width: 0; 279 + } 204 280 205 281 .play-links { 206 282 display: flex; ··· 215 291 text-decoration: none; 216 292 font-size: 0.75rem; 217 293 } 218 - .play-link:hover { color: var(--accent); } 294 + .play-link:hover { 295 + color: var(--accent); 296 + } 219 297 220 298 /* Status */ 221 299 .status-msg { ··· 224 302 padding: 2rem; 225 303 } 226 304 227 - .load-more { text-align: center; padding: 1rem; } 305 + .load-more { 306 + text-align: center; 307 + padding: 1rem; 308 + } 228 309 229 310 /* Error Banner */ 230 311 #error-banner { ··· 244 325 z-index: 100; 245 326 } 246 327 247 - #error-banner.hidden { display: none; } 328 + #error-banner.hidden { 329 + display: none; 330 + } 248 331 #error-banner button { 249 332 background: none; 250 333 border: none; ··· 254 337 line-height: 1; 255 338 } 256 339 257 - .hidden { display: none !important; } 340 + .hidden { 341 + display: none !important; 342 + } 258 343 259 344 /* Spinner */ 260 345 .spinner { ··· 268 353 } 269 354 270 355 @keyframes spin { 271 - to { transform: rotate(360deg); } 356 + to { 357 + transform: rotate(360deg); 358 + } 272 359 } 273 360 274 361 .loading-container { ··· 317 404 cursor: null, 318 405 hasMore: true, 319 406 isLoading: false, 320 - liveConnected: false 407 + liveConnected: false, 321 408 }; 322 409 323 410 // ============================================================================= ··· 389 476 const res = await fetch(`${SERVER_URL}/graphql`, { 390 477 method: "POST", 391 478 headers: { "Content-Type": "application/json" }, 392 - body: JSON.stringify({ query: PLAYS_QUERY, variables }) 479 + body: JSON.stringify({ query: PLAYS_QUERY, variables }), 393 480 }); 394 481 395 482 if (!res.ok) throw new Error(`HTTP ${res.status}`); ··· 441 528 } 442 529 443 530 function getArtists(play) { 444 - if (play.artists?.length) return play.artists.map(a => a.artistName).join(", "); 531 + if (play.artists?.length) return play.artists.map((a) => a.artistName).join(", "); 445 532 if (play.artistNames?.length) return play.artistNames.join(", "); 446 533 return "Unknown Artist"; 447 534 } ··· 455 542 "tidal.com": "Tidal", 456 543 "deezer.com": "Deezer", 457 544 "soundcloud.com": "SoundCloud", 458 - "music.youtube.com": "YouTube Music" 545 + "music.youtube.com": "YouTube Music", 459 546 }; 460 547 return map[domain] || domain; 461 548 } ··· 467 554 } 468 555 469 556 function getAlbumArtUrl(play) { 470 - if (play.releaseMbId) return `https://coverartarchive.org/release/${play.releaseMbId}/front-250`; 557 + if (play.releaseMbId) 558 + return `https://coverartarchive.org/release/${play.releaseMbId}/front-250`; 471 559 return ""; 472 560 } 473 561 ··· 588 676 589 677 function handleNewPlay(play) { 590 678 // Skip duplicates 591 - if (state.plays.some(p => p.uri === play.uri)) return; 679 + if (state.plays.some((p) => p.uri === play.uri)) return; 592 680 593 681 // Insert in correct position by playedTime (descending) 594 682 const playTime = new Date(play.playedTime).getTime(); 595 - const insertIdx = state.plays.findIndex(p => new Date(p.playedTime).getTime() < playTime); 683 + const insertIdx = state.plays.findIndex((p) => new Date(p.playedTime).getTime() < playTime); 596 684 if (insertIdx === -1) { 597 685 state.plays.push(play); 598 686 } else { ··· 604 692 605 693 function renderFeedWithHighlight(highlightUri) { 606 694 const el = document.getElementById("play-feed"); 607 - el.innerHTML = state.plays.map(p => renderPlayCard(p, p.uri === highlightUri)).join(""); 695 + el.innerHTML = state.plays.map((p) => renderPlayCard(p, p.uri === highlightUri)).join(""); 608 696 } 609 697 610 698 function renderLoadMore() { ··· 646 734 return; 647 735 } 648 736 649 - el.innerHTML = state.plays.map(p => renderPlayCard(p)).join(""); 737 + el.innerHTML = state.plays.map((p) => renderPlayCard(p)).join(""); 650 738 } 651 739 652 740 async function loadPlays(append = false) { ··· 657 745 658 746 try { 659 747 const data = await fetchPlays(append ? state.cursor : null); 660 - const newPlays = data.edges.map(e => e.node); 748 + const newPlays = data.edges.map((e) => e.node); 661 749 662 750 state.plays = append ? [...state.plays, ...newPlays] : newPlays; 663 751 state.cursor = data.pageInfo.endCursor;