| 1 | <meta charset="utf-8" /> |
| 2 | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> |
| 3 | <meta name="theme-color" content="#5b6cff" /> |
| 4 | <meta name="app-version" content="__APP_VERSION__" /> |
| 5 | <!-- Whether ordinary members may invite people (admin setting). Gates the invite |
| 6 | UI only; the server enforces it regardless. "1" = on, "0" = off. --> |
| 7 | <meta name="user-invites" content="__USER_INVITES__" /> |
| 8 | <!-- Per-instance feature gates (admin settings, default on). Same contract as |
| 9 | user-invites: these only hide entry points; the server enforces each gate. --> |
| 10 | <meta name="enable-ai" content="__ENABLE_AI__" /> |
| 11 | <meta name="enable-bots" content="__ENABLE_BOTS__" /> |
| 12 | <meta name="enable-apps" content="__ENABLE_APPS__" /> |
| 13 | <meta name="enable-member-apps" content="__ENABLE_MEMBER_APPS__" /> |
| 14 | <!-- Space-install policy (admin setting, default off): "1" = only admins may |
| 15 | add apps for everyone in the space. Client-enforced — see docs/space-apps.md. --> |
| 16 | <meta name="space-installs-admins" content="__SPACE_INSTALLS_ADMINS__" /> |
| 17 | <title>__GROUP_NAME__</title> |
| 18 | <!-- Native wrapper detection: the Mac app's user agent carries "lchatMac". Tag the |
| 19 | document so CSS can adopt native conventions (e.g. drop web hand cursors). --> |
| 20 | <script nonce="__CSP_NONCE__"> |
| 21 | if (navigator.userAgent.includes("lchatMac")) |
| 22 | document.documentElement.classList.add("native"); |
| 23 | </script> |
| 24 | <!-- The home server that hosts passkey backup enrolment (server-configured via |
| 25 | LCHAT_HOME_ORIGIN). backup.js reads it to open the enrol popup — the origin |
| 26 | is public, so exposing it here is fine. --> |
| 27 | <script nonce="__CSP_NONCE__"> |
| 28 | window.__homeOrigin = "__HOME_ORIGIN__"; |
| 29 | // The provisioner-pushed plan flag ("free"/"paid"/"demo"; empty when |
| 30 | // self-hosted). shell.js reveals the menu's Upgrade entry (free) or the |
| 31 | // demo countdown band (demo) from it — display only, nothing here enforces |
| 32 | // billing or the demo TTL. |
| 33 | window.__entitlement = "__ENTITLEMENT__"; |
| 34 | // The public-web fetch service this instance prefers for unfurls (docs/fetch.md; |
| 35 | // server-configured via LCHAT_FETCH_URL, empty = unfurl via this instance). |
| 36 | window.__fetchURL = "__FETCH_URL__"; |
| 37 | </script> |
| 38 | <!-- Pre-paint bounce: a signed-out browser visit belongs on home's sign-in |
| 39 | page. This must be client-side — the session lives in localStorage, which |
| 40 | the server (deliberately) cannot see, so an HTTP redirect would bounce |
| 41 | signed-in members too. Running it here, before first paint, makes it feel |
| 42 | like one: nothing renders before the hop. shell.js's showNoInvite keeps |
| 43 | the same check as a backstop (corrupt session, storage denied). Stays put: |
| 44 | native webviews (__lchatEmbed, injected at documentStart), the installed |
| 45 | Home-Screen app (paste-to-join is its only way to consume an invite |
| 46 | link), ?stay=1, home's ?restore=1 handoff, restore returns from home, and |
| 47 | unsubstituted dev pages. --> |
| 48 | <script nonce="__CSP_NONCE__"> |
| 49 | (function () { |
| 50 | try { |
| 51 | if (window.__lchatEmbed) return; |
| 52 | if (matchMedia("(display-mode: standalone)").matches || navigator.standalone === true) return; |
| 53 | if (localStorage.getItem("lchat_session")) return; |
| 54 | if (location.hash.indexOf("lchat_restore=") !== -1) return; |
| 55 | var params = new URLSearchParams(location.search); |
| 56 | if (params.has("stay") || params.get("restore") === "1") return; |
| 57 | var home = window.__homeOrigin || ""; |
| 58 | if (home.indexOf("http") === 0) location.replace(home); |
| 59 | } catch (e) {} /* storage denied etc.: route() decides after boot */ |
| 60 | })(); |
| 61 | </script> |
| 62 | <link rel="stylesheet" href="/static/style.css" /> |
| 63 | <link rel="manifest" href="/manifest.webmanifest" /> |
| 64 | <link rel="icon" href="/static/icon-192.png" /> |
| 65 | <link rel="apple-touch-icon" href="/static/icon-180.png" /> |
| 66 | <meta name="apple-mobile-web-app-capable" content="yes" /> |
| 67 | <meta name="apple-mobile-web-app-title" content="__GROUP_NAME__" /> |
| 68 | |
| 69 | <!-- Apply the saved theme + appearance before first paint (no flash of the |
| 70 | default). The canonical list + defaults live in web/static/ui.js; this small |
| 71 | copy must stay in sync. The choice is a local preference only — it is never |
| 72 | sent to the server. --> |
| 73 | <script nonce="__CSP_NONCE__"> |
| 74 | (function () { |
| 75 | try { |
| 76 | var THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]; |
| 77 | var PANEL = { |
| 78 | litroom: ["#fff8f0", "#241a1e"], lagoon: ["#f3fbfa", "#0c2a29"], |
| 79 | sunny: ["#fffaf0", "#241d12"], plum: ["#fdf8fd", "#221829"], |
| 80 | linen: ["#f5f1ea", "#211f1b"], classic: ["#ffffff", "#1d2026"], |
| 81 | }; |
| 82 | var t = localStorage.getItem("lchat_theme"); |
| 83 | if (THEMES.indexOf(t) < 0) t = "classic"; |
| 84 | var a = localStorage.getItem("lchat_appearance"); |
| 85 | var root = document.documentElement; |
| 86 | root.dataset.theme = t; |
| 87 | root.style.colorScheme = a === "light" ? "light" : a === "dark" ? "dark" : "light dark"; |
| 88 | var dark = a === "dark" || (a !== "light" && matchMedia("(prefers-color-scheme: dark)").matches); |
| 89 | var m = document.querySelector('meta[name="theme-color"]'); |
| 90 | if (m) m.setAttribute("content", PANEL[t][dark ? 1 : 0]); |
| 91 | } catch (e) {} |
| 92 | })(); |
| 93 | </script> |
| 94 | |
| 95 | <div id="app"></div> |
| 96 | <!-- Full-screen cover shown until the app's first meaningful paint (theme bg is |
| 97 | already set pre-paint above), so a refresh doesn't flash an empty shell and |
| 98 | then jump as messages load + scroll. shell.js removes it once ready. --> |
| 99 | <div id="app-loading" class="app-loading"> |
| 100 | <!-- The boot scene (bootscene.js): smoke and dust in a haze-filled room, lit |
| 101 | from below by an UNSEEN 11-segment meter — two strips of light from the |
| 102 | bottom corners converging behind the server's avatar. The cover |
| 103 | assembles in stages as a boot takes longer (spinner → brand → quote → |
| 104 | smoke); at ready it slams to eleven and bows out. The children stay |
| 105 | hidden (opacity 0) until bootscene.js poses them, so the embed and |
| 106 | signed-out paths, which never run the scene, show only the plain bg. --> |
| 107 | <canvas class="boot-canvas" id="boot-canvas" aria-hidden="true"></canvas> |
| 108 | <div class="boot-brand" id="boot-brand"> |
| 109 | <div class="boot-avatar" id="boot-avatar-tile" aria-hidden="true"> |
| 110 | <span class="boot-avatar-letter"></span> |
| 111 | <img id="boot-avatar-img" alt="" hidden /> |
| 112 | </div> |
| 113 | <div class="boot-name">__GROUP_NAME__</div> |
| 114 | </div> |
| 115 | <div class="boot-spin" id="boot-spin" role="progressbar" aria-label="Loading"></div> |
| 116 | <div class="boot-quote" id="boot-quote"> |
| 117 | <div class="boot-quote-text" id="boot-quote-text"></div> |
| 118 | <div class="boot-quote-attr" id="boot-quote-attr"></div> |
| 119 | </div> |
| 120 | <a class="boot-goesto" id="boot-goesto" href="https://elevenmessenger.com" target="_blank" rel="noopener">This group chat goes to <b>Eleven</b></a> |
| 121 | </div> |
| 122 | <div id="toast" class="toast"></div> |
| 123 | |
| 124 | <!-- The multi-thread shell: a thread list (master) + an open thread (detail). |
| 125 | On mobile the two panes stack and we toggle .show-thread to navigate. --> |
| 126 | <template id="app-tpl"> |
| 127 | <div class="tt" id="tt"> |
| 128 | <div class="conn-status hidden" id="conn-status" role="status" aria-live="polite">Reconnecting…</div> |
| 129 | <!-- Tamper-evidence failure banner (docs/tamper-evidence.md): a full-width |
| 130 | top band shown ONLY when this space fails an integrity check. Inline |
| 131 | (occupies layout, never overlays); contents built in shell.js. --> |
| 132 | <div class="integrity-banner hidden" id="integrity-banner" role="alert"></div> |
| 133 | <!-- Demo countdown band (docs/demo-spaces.md): shown only on a demo space. |
| 134 | Inline (occupies layout, never overlays); contents built in shell.js |
| 135 | from the HOME server's live status — the boot-time entitlement can go |
| 136 | stale the moment someone claims the space. --> |
| 137 | <div class="demo-banner hidden" id="demo-banner"></div> |
| 138 | <div class="tt-body"> |
| 139 | <aside class="sidebar"> |
| 140 | <header class="side-head"> |
| 141 | <button class="avatar-btn" id="me-btn" aria-label="Edit profile" title="Edit profile"> |
| 142 | <span class="avatar" id="me-avatar"></span> |
| 143 | </button> |
| 144 | <!-- The heading is the space's name, so the top of the sidebar says WHERE |
| 145 | you are — kept in step with document.title (sealed branding updates both). --> |
| 146 | <div class="title" id="side-space-name">__GROUP_NAME__</div> |
| 147 | <div class="head-actions"> |
| 148 | <div class="menu-wrap"> |
| 149 | <button class="icon-btn" id="menu-btn" aria-label="Menu" aria-haspopup="true">⋮</button> |
| 150 | <div class="menu hidden" id="menu" role="menu"> |
| 151 | <button class="menu-item" id="menu-about" role="menuitem">About this space</button> |
| 152 | <button class="menu-item" id="menu-apps" role="menuitem">Apps</button> |
| 153 | <button class="menu-item" id="menu-notif" role="menuitem">Notifications</button> |
| 154 | <button class="menu-item" id="menu-theme" role="menuitem">Theme</button> |
| 155 | <button class="menu-item" id="menu-add-device" role="menuitem">Add a device</button> |
| 156 | <!-- Mac desktop browsers only (shell.js unhides): the one |
| 157 | platform with a scheme receiver today; iOS follows. --> |
| 158 | <button class="menu-item" id="menu-open-app" role="menuitem" hidden>Open in the Eleven app</button> |
| 159 | <button class="menu-item" id="menu-rename" role="menuitem">Edit profile</button> |
| 160 | <a class="menu-item" id="menu-upgrade" role="menuitem" hidden target="_blank" rel="noopener">Upgrade</a> |
| 161 | <button class="menu-item danger" id="menu-leave" role="menuitem">Leave</button> |
| 162 | </div> |
| 163 | </div> |
| 164 | <button class="icon-btn" id="new-chat" aria-label="New chat" title="New chat"> |
| 165 | <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" |
| 166 | stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> |
| 167 | <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> |
| 168 | <path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> |
| 169 | </svg> |
| 170 | </button> |
| 171 | </div> |
| 172 | </header> |
| 173 | <div class="chat-search hidden" id="chat-search"> |
| 174 | <!-- Combobox semantics for the message-results dropdown: focus stays |
| 175 | here while aria-activedescendant tracks the highlighted row |
| 176 | (searchui.js). aria-controls targets the lazily-built listbox. --> |
| 177 | <input type="search" id="chat-filter" placeholder="Search chats" aria-label="Search chats" autocomplete="off" |
| 178 | role="combobox" aria-autocomplete="list" aria-haspopup="listbox" aria-expanded="false" |
| 179 | aria-controls="msg-results-list" /> |
| 180 | <!-- List filters: online-only + People/Bots/Apps categories (shell.js). --> |
| 181 | <div class="menu-wrap"> |
| 182 | <button type="button" id="filter-btn" class="icon-btn filter-btn" aria-label="Filter chats" title="Filter chats" |
| 183 | aria-haspopup="true" aria-expanded="false"> |
| 184 | <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" |
| 185 | stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> |
| 186 | <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon> |
| 187 | </svg> |
| 188 | </button> |
| 189 | <div class="menu filter-menu hidden" id="filter-menu"> |
| 190 | <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-online" /><span>Online only</span></label> |
| 191 | <div class="filter-sep" role="separator"></div> |
| 192 | <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-people" checked /><span>People</span></label> |
| 193 | <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-bots" checked /><span>Bots</span></label> |
| 194 | <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-apps" checked /><span>Apps</span></label> |
| 195 | </div> |
| 196 | </div> |
| 197 | </div> |
| 198 | <!-- Consolidated search results (#586, searchui.js): filled while the |
| 199 | search box holds a query — matching conversations first (a row opens |
| 200 | the chat), then message hits (a row jumps to the message). A dropdown |
| 201 | under the search bar, floating over the thread list like the filter |
| 202 | menu beside it (the never-float rule covers toasts/nudges, not a |
| 203 | user-invoked, transient surface); the zero-height anchor keeps it |
| 204 | out of the sidebar column's flow. --> |
| 205 | <div class="msg-results-anchor"> |
| 206 | <div class="msg-results hidden" id="msg-results" role="region" aria-label="Search results"></div> |
| 207 | </div> |
| 208 | <!-- Inline passkey-backup nudge (backup.js fills it in). It occupies layout |
| 209 | space in the sidebar — never a floating toast. --> |
| 210 | <div class="backup-banner hidden" id="backup-banner"></div> |
| 211 | <!-- A subtle, dismissible "a new app is available — Reload / Later" offer |
| 212 | (shell.js offerAppReload). Inline, never a forced reload. --> |
| 213 | <div class="update-banner hidden" id="app-update-banner"></div> |
| 214 | <div class="thread-list" id="thread-list"></div> |
| 215 | </aside> |
| 216 | <section class="thread-pane"> |
| 217 | <div class="empty" id="thread-empty"> |
| 218 | <div class="empty-art">💬</div> |
| 219 | <p>Pick a chat on the left, or start a new one.</p> |
| 220 | </div> |
| 221 | <div class="thread-view hidden" id="thread-view"> |
| 222 | <header class="thread-head"> |
| 223 | <button class="icon-btn back-btn" id="back-btn" aria-label="Back to chats">‹</button> |
| 224 | <span class="avatar thread-head-avatar" id="thread-avatar"></span> |
| 225 | <div class="thread-head-title"> |
| 226 | <div class="thread-title" id="thread-title"></div> |
| 227 | <div class="thread-sub" id="thread-sub"></div> |
| 228 | </div> |
| 229 | <div class="menu-wrap hidden" id="views-menu-wrap"> |
| 230 | <button class="icon-btn views-btn" id="views-menu-btn" aria-haspopup="true"><span id="views-menu-label">Open</span> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button> |
| 231 | <div class="menu hidden" id="views-menu" role="menu"></div> |
| 232 | </div> |
| 233 | <div class="menu-wrap" id="thread-menu-wrap"> |
| 234 | <button class="icon-btn" id="thread-menu-btn" aria-label="Chat options" aria-haspopup="true">⋮</button> |
| 235 | <div class="menu hidden" id="thread-menu" role="menu"> |
| 236 | <button class="menu-item" id="thread-add" role="menuitem">Add people</button> |
| 237 | <button class="menu-item" id="thread-edit" role="menuitem">Edit group</button> |
| 238 | <button class="menu-item" id="thread-members" role="menuitem">View members</button> |
| 239 | <button class="menu-item" id="thread-media" role="menuitem">Media, links and docs</button> |
| 240 | <!-- "Apps", not "Add apps": the sheet manages two scopes now (this |
| 241 | chat + the whole space) and shows in DMs too. --> |
| 242 | <button class="menu-item" id="thread-apps" role="menuitem">Apps</button> |
| 243 | <button class="menu-item" id="thread-pin" role="menuitem">Pin chat</button> |
| 244 | <button class="menu-item" id="thread-mute" role="menuitem">Mute chat</button> |
| 245 | <button class="menu-item" id="thread-close" role="menuitem">Close chat</button> |
| 246 | <button class="menu-item danger" id="thread-block" role="menuitem">Block</button> |
| 247 | <button class="menu-item danger" id="thread-remove" role="menuitem">Remove</button> |
| 248 | <button class="menu-item danger" id="thread-end" role="menuitem">End conversation</button> |
| 249 | <button class="menu-item danger" id="thread-leave" role="menuitem">Leave group</button> |
| 250 | </div> |
| 251 | </div> |
| 252 | </header> |
| 253 | <!-- Shared-app panel surfaces (e.g. a poll) mount here, above the feed. |
| 254 | Collapses to nothing when no app has state (see .app-surface). --> |
| 255 | <div class="app-surface" id="app-surface"></div> |
| 256 | <!-- App view surfaces (shown only in .app-mode). A personal app-thread is |
| 257 | two Views of one thread: the Chat pane (a DM with its bot) and the |
| 258 | Interface pane (its interactive UI) — side by side on desktop, one at a |
| 259 | time on mobile (see the split CSS). A normal chat thread uses only the |
| 260 | chat pane; #interface-pane stays empty (and hidden). --> |
| 261 | <div class="app-split" id="app-split"> |
| 262 | <div class="app-pane" id="app-pane"></div> |
| 263 | <div class="app-split-divider" id="app-split-divider" role="separator" aria-orientation="vertical" aria-label="Resize chat and interface panes" aria-valuemin="20" aria-valuemax="80" tabindex="0"></div> |
| 264 | <div class="app-pane interface-pane" id="interface-pane"></div> |
| 265 | </div> |
| 266 | <div class="drop-overlay hidden" id="drop-overlay">Drop a photo or file to send</div> |
| 267 | </div> |
| 268 | </section> |
| 269 | </div> |
| 270 | </div> |
| 271 | </template> |
| 272 | |
| 273 | <template id="noinvite-tpl"> |
| 274 | <!-- The signed-out landing: a single-CTA screen — the server's branding and |
| 275 | one button, Sign in. New members arrive on an invite LINK (which opens |
| 276 | /join/… directly), so the paste form is folded behind "Have an invite |
| 277 | link?" — still reachable, because the iOS Home-Screen app has no other |
| 278 | way to consume a link (the A2HS onboarding says to paste it here). --> |
| 279 | <div class="center"> |
| 280 | <div class="card landing"> |
| 281 | <!-- Instance branding (plaintext by design — see CLAUDE.md): the photo is |
| 282 | revealed by showNoInvite once /api/group confirms one is set. --> |
| 283 | <div class="join-brand"> |
| 284 | <img id="join-brand-photo" class="join-brand-photo hidden" alt="" /> |
| 285 | <div class="join-brand-name">__GROUP_NAME__</div> |
| 286 | </div> |
| 287 | <div class="row" id="restore-row" style="display:none"> |
| 288 | <button type="button" id="restore-btn" style="width:100%">Sign in</button> |
| 289 | </div> |
| 290 | <div id="join-fold"> |
| 291 | <p id="join-intro" class="hidden">Paste your secret invite link to join.</p> |
| 292 | <form id="paste-join" autocomplete="off" class="hidden"> |
| 293 | <input id="link" placeholder="Paste your invite link" autocomplete="off" /> |
| 294 | <input id="name" placeholder="Screen name (e.g. Sophie)" maxlength="32" autocomplete="off" class="hidden" style="margin-top: 10px" /> |
| 295 | <div class="row"><button type="submit">Join the chat</button></div> |
| 296 | </form> |
| 297 | <p id="join-err" class="banner warn hidden" style="text-align:left;padding:0;margin-top:10px"></p> |
| 298 | <div class="row" id="invite-reveal-row"> |
| 299 | <button type="button" id="invite-reveal" class="signin-link">Have an invite link?</button> |
| 300 | </div> |
| 301 | </div> |
| 302 | </div> |
| 303 | </div> |
| 304 | </template> |
| 305 | |
| 306 | <template id="a2hs-tpl"> |
| 307 | <div class="center"> |
| 308 | <div class="card onboard"> |
| 309 | <div class="onboard-icon">📲</div> |
| 310 | <h1>Add __GROUP_NAME__ to your Home Screen</h1> |
| 311 | <p>On iPhone & iPad, add this to your Home Screen so it opens like an app — and can send you notifications.</p> |
| 312 | <ol class="steps"> |
| 313 | <li>Tap the <strong>Share</strong> button (the square with an up arrow) in Safari's toolbar.</li> |
| 314 | <li>Scroll down and choose <strong>Add to Home Screen</strong>.</li> |
| 315 | <li>Open <strong>__GROUP_NAME__</strong> from your Home Screen, then paste your invite link to join.</li> |
| 316 | </ol> |
| 317 | <div class="row"><button id="a2hs-continue" class="secondary">Continue in browser</button></div> |
| 318 | </div> |
| 319 | </div> |
| 320 | </template> |
| 321 | |
| 322 | <template id="push-tpl"> |
| 323 | <div class="center"> |
| 324 | <div class="card onboard"> |
| 325 | <div class="onboard-icon">🔔</div> |
| 326 | <h1>Turn on notifications</h1> |
| 327 | <p>Get an alert when there's a new message — even when the chat is closed.</p> |
| 328 | <div class="row"><button id="push-enable">Enable notifications</button></div> |
| 329 | <div class="row"><button id="push-skip" class="secondary">Not now</button></div> |
| 330 | <p id="push-note" class="banner warn hidden" style="margin-top:12px"></p> |
| 331 | </div> |
| 332 | </div> |
| 333 | </template> |
| 334 | |
| 335 | <!-- bootscene.js is loaded DIRECTLY (not just via shell.js's import graph) so |
| 336 | the cover's chrome — avatar, name, spinner, quote, footer — appears the |
| 337 | moment this one small file lands; the smoke and lights fade in later, |
| 338 | when the shell has booted. Module caching makes this the same instance |
| 339 | shell.js imports. --> |
| 340 | <script type="module" src="/static/bootscene.js"></script> |
| 341 | <script type="module" src="/static/shell.js"></script> |
| 342 | |