eleven / messenger Public

Joiner onboarding: Eleven welcomes new members (#278)

* web: Eleven welcomes joiners — chat-first user onboarding

Claiming an invite lands a new member straight in a conversation with
Eleven (no name form): name (with the inviter's suggested name as a
chip), a profile photo sent through the chat (decrypted and re-encrypted
under the instance key — never plaintext), saving their profile behind a
passkey (softer joiner copy, same resume-on-return and stayed-in-place
paths as setup), and a finish offering the inviter's DM and the tour.
Gated by a local pending marker the join page writes (suggested name
included; never sent to a server) and cleared on completion. The native
add-chat webview keeps its form (it hands a named identity to the
shell). The sidecar's welcome DM stands down — one voice. Nameless
senders fall back to 'New member' (not 'Admin').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server: LCHAT_SKIP_SETUP — test instances skip first-run onboarding

A dev/test convenience: mark the setup conversation complete on boot and
seed workable defaults (group name from LCHAT_GROUP_NAME or 'Test
Space'; member invites on) — the setup link still makes its claimer the
admin, they just skip the conversation. Only unset values are seeded, so
restarts never clobber in-test edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: spent action chips, a fuller welcome intro, join-card caveat spacing

- A chip whose step has resolved renders SPENT: disabled, quiet, and
  the tapped one shows its past-tense label (Skip for now → Skipped,
  Keep → Kept). The script's step guard (go.taken) is the source of
  truth; staleness folds into the notice vrev so the keyed feed
  rebuilds rows when chips go stale. Tour chips get the same guard.
- Eleven's joiner greeting assumes nothing: what the space is (private,
  invite-only), who runs it (the admin's name when set), and what the
  encryption means — in lock-and-key terms.
- The join card's browser-only caveat gets clear air under the button
  (.card .welcome-caveat so it outranks the generic .card p margin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: photo descriptions (encrypted alt), contextual composer placeholders

- A message sent with a profile photo becomes its description: encrypted
  under the instance key like the display name (users.avatar_alt_blob —
  the server stores an opaque blob), set and cleared with the photo,
  surfaced as the avatar's aria-label. The welcome photo prompt says so;
  a profile-editor photo swap preserves the existing description.
- The composer placeholder follows the conversation: a scripted question
  carries a hint — 'Type your name', 'Type a name for this space',
  'Optional: describe your photo' — that replaces 'Message' when the
  question renders and resets on the next hint-less reply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: Eleven's conversations persist as real history; passkey comes first

- makeSay (apps/eleven.js, shared by setup + welcome): each line Eleven
  says is emitted as an app event — encrypted and stored like any thread
  event — and folds into the chat view as a real bubble, so the
  conversation survives reloads as history instead of vanishing. Chips
  and composer hints ride a client-only row (they need the live script);
  on resume a line already in history re-arms its chips instantly, no
  duplicate bubble, no typing beat. Chips-only rows render no bubble.
- The welcome flow saves ACCESS first: the passkey ceremony leads, so
  the round-trip back lands mid-conversation with name and photo still
  ahead — never straight onto 'all done'.
- 'This is the start of your chat with X' intro rows: Eleven's threads
  and person-to-person DMs (no blank void under a first typing beat).
- Composer hints refined: sending resets the placeholder to 'Message'
  immediately; a photo question's hint is attach-gated — it appears only
  once a file is actually staged.
- join.js reloads on hashchange (a second invite link pasted into an
  open join tab used to leave a stale card doing nothing).
- Fix a pre-existing TDZ: route() runs at module top level before
  CUSTOM_APPS_TIMEOUT_MS initialized, so custom apps silently skipped on
  every synchronous boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web+server: welcome polish from the first two-device test

- The greeting says who runs the space: roster entries carry role
  (metadata; it already gates the admin API), the client derives the
  admin's name from their DECRYPTED label — never plaintext config —
  and the welcome waits briefly for the instance-key grant so it can
  actually say it. Names stay E2E.
- A successful invite claim broadcasts {type:joined}: the inviter's tab
  swaps the pending-invite card for the live DM and picks up the new
  member without a reload (it used to show copy/cancel-link forever).
- A keyless joiner polls for the instance key for ~1min, not 15s — the
  cause of the other side rendering as 'Someone' until reload.
- The passkey ask is ONE bubble (context + question + chips attached),
  so the opening run is two messages, not three.
- Chips flip to their spent state the instant they're tapped, not at
  the next feed render; a chips-only row hugs the bubble it belongs to;
  action lines get vertical breathing room.
- makeSay retries emits until the fold confirms them — the first line
  raced the socket opening and silently vanished — and falls back to a
  client-only notice if persistence is truly unavailable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: Enter saves the Edit-profile name

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web: fix the instance-key grant for fresh joiners; passkey-return ack

THE 'Someone' BUG: a keyless joiner's nudge asked holders to wrap the
instance key, but holders granted via their own connections() roster —
which doesn't include a joiner they share no thread with, so the very
person asking was silently skipped (always for bare invites; racy for DM
invites). Without the key the joiner can't decrypt anyone's name OR
publish their own — both sides render 'Someone' forever. The nudge now
carries the requester's user id + public key and holders wrap straight
to it. Verified live: granted in ~1.5s, unwraps clean (was: never).

Also:
- Back from the passkey ceremony, Eleven acknowledges properly ('Great —
  welcome back. 🔒 Now that your access is secure, let's continue.') as
  its own bubble, then asks the name — no generic pick-up line on top.
- Name question copy: 'First things first — what should folks here call
  you?'
- The chips row tucks tight under its bubble (was still floating too
  low).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the welcome shows your profile photo back before keeping it

Same shape as the admin setup's preview: the adopted photo renders in
the reply (decrypted object URL — an encrypted blob can't ride a
persisted event, so the preview is a client-only notice) with Keep /
Use-a-different chips; the step records and advances only on an
explicit keep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: Eleven wears the app icon; keyless tabs keep retrying for the key

- Apps can ship an avatar image (in-code URL only): Eleven's setup and
  welcome threads show the app icon in the thread list and header
  instead of the clock glyph.
- A keyless tab re-nudges for the instance key on window focus and on a
  slow timer until it lands — a member whose first poll expired used to
  stay 'Someone' (and see everyone as 'Someone') until a full reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: greeting credits the inviter (label reads moved after the key wait); round photo preview

The greeting said 'run by Paul' but not 'Paul invited you': the inviter
name was computed from the DM BEFORE the instance-key wait/decrypt that
was added for the admin name, so it read a still-encrypted label. All
label-derived reads now happen after the wait. Verified with a live
two-party repro (node inviter answering nudges + browser joiner):
'Paul invited you to Test Space — … run by Paul', sidebar shows Paul.

Also: the profile-photo preview renders as the circle it will actually
be.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: renaming in Edit profile publishes the label immediately

The save only wrote session.name locally; the encrypted label_blob was
only ever published as a boot side-effect, so after a rename every
other member kept seeing 'Someone' until the renamer's next reload.
This is THE remaining 'Someone' on real devices: the skip-setup admin
renames via Edit profile and invites without ever reloading. Verified
against the live canary: rename left users.label_blob empty; with the
fix it publishes on Save.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the joined event actually clears the pending-invite card

openThread(activeId) hits the already-open guard and returns before
updatePendingBanner — so the inviter kept staring at 'hasn't joined
yet' + copy/cancel until they navigated away. Refresh the banner
directly. Reproduced live on the canary: joiner fully in (claimed,
keyed, labelled) while the card sat stale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: staged-attachment strip takes real layout space; chips hug their bubble

- The staging preview floated ABSOLUTELY over the feed's bottom — with a
  photo staged it sat exactly on top of the welcome's 'Skip for now'
  chip, turning it into dead text (reproduced pixel-for-pixel on the
  canary). Per the repo rule that nothing overlays content someone may
  tap, the strip is in-flow now: the feed shrinks above it.
- The chips-row tightening never applied: the rules sat ABOVE the
  .local-notice/.chip-row base rules with equal specificity, so source
  order killed them (verified via computed styles: 6px/8px, not -4/0).
  Re-placed after the base rules with a doubled class; the chips row
  now tucks under its bubble.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: keep the feed pinned when the staging strip grows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: stable say keys (no duplicate questions on resume); Say-hi survives reloads

- Ack-carrying questions dedupe on a stable key, so a mid-step reload
  doesn't re-emit the same question with a different ack prefix.
- The finish's 'Say hi to <inviter>' chip survives a mid-welcome reload:
  fall back to the joiner's sole person-DM when the one-load join marker
  is already consumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: a 6px breath between a bubble and its chips (not glued, not floating)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: Eleven's messages glide in — no more scroll jumps between beats

The slide-up animation was already there (.msg.appear), but two INSTANT
scrollTop snaps sandwiched every smooth glide: the typing-indicator hook
pinned with a hard jump on each beat, and the chips-only notice row
force-snapped (pinNext) right after its bubble's glide. Typing beats now
scroll smoothly (reduced-motion honoured) and chips rows ride their
bubble's glide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the typing beat never flashes — 1s minimum, still scaling with length

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: scene-setting loading lines under the boot spinner

A fresh member's first load genuinely takes a few seconds — fill it with
shuffled lock-and-key one-liners ('Cutting your keys…', 'Sweeping for
eavesdroppers… all clear.') that ease in and out. The reveal waits until
the current line has been readable (≥1.1s); fast boots reveal before the
first line fires and keep the plain spinner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: fix the boot-breaking TDZ in the loading lines; typing row holds until the bubble lands

- LOADING_LINES/loadingMsg state were declared BELOW route()'s
  synchronous module-level boot() call — startLoadingMessages threw,
  boot aborted, and hideLoading threw too, leaving the spinner forever.
  The 6f085d5 canary was broken for every boot because of this. All
  boot-touched state now lives above the route() call, next to
  CUSTOM_APPS_TIMEOUT_MS, which learned the same lesson.
- The typing indicator stays up until Eleven's bubble has actually
  folded into the feed — dropping it at emit time collapsed the list
  and then re-grew it when the message arrived a beat later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the boot loader is a clock dial that fills 1→10, then clicks to 11

A conic-gradient dial with faint hour ticks: the pie sweeps from 1
o'clock asymptotically towards 10 while the app loads (fast at first,
easing off, never arriving), and the moment everything's ready it snaps
decisively to 11 with a little scale pulse, holds a beat, and the app
fades in. The scene-setting one-liners are gone — the dial going to
eleven IS the scene.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the boot loader is an amp knob that goes to eleven

Modelled on an amplifier volume pot: labels 1–11 around a 300° sweep (1
at 7 o'clock, 11 at 5), a pointer notch on the knob, and a pie arc
filling behind it. While loading it eases from 1 towards 10 and starts
to SHAKE as the volume climbs. The finish is choreographed to feel like
a human turning it, never instant: even a fast load gets a minimum turn
to 10, a beat of pause, a decisive snap to eleven, then a glow (the 11
lights up) while the tremble dies down — and only then does the chat
fade in. Reduced motion: no shake, no glow pulse.

(An earlier uncommitted version of this spliced OUT route()/boot() by
anchoring the replacement past them — the app defined everything and
called nothing. Caught by driving a real boot; splice anchors now sit
tight around the engine block.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: hitting eleven POPS the knob off the amp face (glow retired)

The glow read as nothing (a box-shadow pulsing on a gradient circle).
Instead: tremble dies, then the whole knob scales up with a lifted
drop-shadow and springs back — a physical pop — while the 11 stays lit
in accent. Reduced motion skips the pop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: one loader story — spinner, 'Plugging in…', then the amp knob

The claim page keeps its radial spinner; when the claim lands it flashes
'Plugging in…' (fades in, holds a second, fades out) before navigating,
and the amp knob fades in on the far side to run its turn-to-eleven
choreography. No more two-loader whiplash on the way into a new space.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: no pop — hit eleven, light the 11, breathe, fast fade

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the knob stays hidden until posed at 1 — no unposed pre-JS flash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: typing rows stay in view; tour gets beats; Say-hi focuses the composer

- The photo preview's image loads after its notice was pinned, growing
  the feed ~180px — everything after it (the next typing row included)
  hid below the fold, which read as 'the typing indicator is missing
  after I update my photo'. Images re-pin the feed on load.
- Tour replies get the same short typing beat as Eleven's script when
  launched from setup/welcome (one queued wrapper around respond).
- Tapping 'Say hi to <inviter>' focuses the composer in the opened DM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: after eleven, the knob fades out before the chat appears

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: linger at 10 a beat longer before the snap to eleven

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: intent-aware feed glide (typing row never below the fold); shake goes to eleven

- Follow checks used to read scrollTop mid-glide and conclude we'd
  scrolled away — the typing row then skipped its pin and sat below the
  fold (reported after the name answer and the photo step). A shared
  feedGlideToBottom now chases the growing bottom and counts as
  at-bottom while in flight; user wheel/touch cancels the chase. The
  render, typing row, staging strip, and the conversations' typing hook
  all ride it.
- The snap to 11 no longer calms the knob: the shake RAMPS through the
  snap and goes wild at eleven (2.3× the 10-tremble), rattling right
  through the fade-out bow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the move from 10 to 11 is a true SNAP — no travel, one frame

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: longer tremble at 10 before the snap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: photo prompts mention drag-and-drop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: tutorial's reminder example reads naturally (/remind me to call mum…)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: stage dust — particles on the loader floor that dance to the volume

A canvas strip along the bottom of the boot cover: ~60–110 little balls
(muted ink, a few accent) resting on the floor. The same loudness level
that shakes the knob kicks them — still at 1, hopping by 7, full popcorn
at the cranked eleven, and still airborne through the bow. Gravity,
floor bounce with level-scaled restitution, wrap at the edges. Skipped
entirely under prefers-reduced-motion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: heavier dust — sharp kicks, real gravity, crisp bounces (no floating)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: only scroll intent cancels the feed glide (touchmove, not touchstart)

Tapping an action chip is a touchstart inside the feed — on touch
devices every chip tap killed the in-flight glide, so the next bubble
or typing row landed below the fold (reported after 'I'm X').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: friendlier try-another-photo prompts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the join claim is single-fire — a double tap can't burn its own invite

The welcome card's first tap could feel dead (pre-hydration), inviting a
second tap: the first claim succeeded silently while the duplicate hit
'already used' and THAT error is all the person saw — a working session
painted over as a spent link (reproduced from the canary DB: claim 19s
after mint, session minted, user staring at the error). The Continue
button now disables on first press and onContinue can only run once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web+server: fixes from the high-effort branch review (10 confirmed)

Regressions / data loss / security:
- hideLoading only runs the amp-knob choreography when the dial was
  actually shown — bootEmbed/showNoInvite/showA2HS blanked the cover for
  ~2.4s otherwise (a dialStarted guard, not just 'knob markup exists').
- uploadAvatar caps alt text (280 chars) and CHECKS the /api/avatar
  response — a long photo caption over the 4096-char blob cap 400'd and
  the client showed the photo as kept while nothing saved.
- The 'joined' broadcast moved from claim time to setPubkey (first key
  only): the inviter's pending card now clears because the joiner is
  actually keyed when it fires; firing at claim raced the pubkey POST.
- makeSay emits ONCE it confirms the frame sent (transport returns true)
  instead of re-emitting on a slow echo — no more duplicated Eleven
  lines permanently in the transcript.
- The instance-key nudge handler no longer wraps to a public key handed
  to it in a raw WS frame: it reloads the roster and wraps only to the
  server-published key for that claimed member (a forged nudge can't
  harvest names/photos; the inviter shares the DM so grants still flow).

UX correctness:
- The photo step distinguishes 'still waiting on the space key' from a
  bad photo, so a not-yet-keyed joiner isn't sent into a futile retry.
- Re-prompts at the photo step are client-only notices, so a second
  text-only reply isn't swallowed by say-dedupe.
- A failed claim clears lchat_welcome_pending, so a stale marker can't
  hijack a later add-device sign-in into fresh-joiner onboarding.
- feedGlideToBottom bails when the user scrolls up against it (scrollbar
  drag / keyboard), not only on wheel/touchmove.

Verified: full go+node+js test suites, a deterministic two-party harness
(joined-after-pubkey, roster-validated grant, joiner sees the admin's
real name), and a clean browser boot of the merged tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4b7ef47c9a45542f51aecb18f91cabdc1e5fda2b parent 84342b7
16 files changed, +1469 −207
  • agent/agent.mjs +5 −2
  • changelog/2026-07-07-welcome-conversation.md +8 −0
  • db.go +22 −7
  • main.go +60 −4
  • roles_test.go +80 −0
  • test/apps.test.mjs +14 −0
  • web/pages/index.html +13 −1
  • web/static/apps.js +47 −18
  • web/static/apps/chat.js +98 −11
  • web/static/apps/eleven.js +119 −0
  • web/static/apps/setup.js +72 −75
  • web/static/apps/tutorial.js +25 −8
  • web/static/apps/welcome.js +301 −0
  • web/static/join.js +87 −20
  • web/static/shell.js +413 −46
  • web/static/style.css +105 −15
diff --git a/agent/agent.mjs b/agent/agent.mjs
index 1ba7061..a88c093 100644
--- a/agent/agent.mjs
+++ b/agent/agent.mjs
@@ -18,7 +18,7 @@ import { provision } from "./provision.mjs";
import { runBot } from "./runtime.mjs";
import { startAnnouncer } from "./announcer.mjs";
import { CHIP_TOOLS } from "./actions.mjs";
-import { ELEVEN_NAME, ELEVEN_PERSONA, ELEVEN_DOCS, loadElevenSession, startWelcomer } from "./eleven.mjs";
+import { ELEVEN_NAME, ELEVEN_PERSONA, ELEVEN_DOCS, loadElevenSession } from "./eleven.mjs";
// Synchronous logging: async stdout to a pipe is buffered and lost if the
// container is killed (e.g. a failed health check), so write key lines with
@@ -89,7 +89,10 @@ if (elevenSession) {
tools: CHIP_TOOLS,
commands: true,
});
- startWelcomer();
+ // The welcomer is STOOD DOWN: the client-side welcome conversation
+ // (web/static/apps/welcome.js) greets every joiner now — one voice, not two
+ // Elevens saying hello. startWelcomer() in eleven.mjs stays for standalone
+ // runs; re-wire it here only if the client welcome ever retires.
}
// --- Announcer: write-only deploy notes -------------------------------------
diff --git a/changelog/2026-07-07-welcome-conversation.md b/changelog/2026-07-07-welcome-conversation.md
new file mode 100644
index 0000000..51f919a
--- /dev/null
+++ b/changelog/2026-07-07-welcome-conversation.md
@@ -0,0 +1,8 @@
+🤖 Joining a space is now a conversation too. Open an invite link and you land
+straight in a chat with Eleven — no forms — who asks your name, offers to set
+your profile photo (send one like any picture, and a message with it becomes
+the photo's description), helps you save your profile behind a passkey so you
+can't lose access, and points you at whoever invited you. The message box even
+tells you what Eleven's waiting for — "Type your name" instead of "Message" —
+and buttons you've already used quietly grey out instead of pretending to
+still work.
diff --git a/db.go b/db.go
index f8f1c20..1a1f357 100644
--- a/db.go
+++ b/db.go
@@ -351,6 +351,10 @@ CREATE TABLE IF NOT EXISTS custom_apps (
// Bots keep their plaintext `label` (admin-authored); people move here so the
// server never holds a readable name. Set by the member who holds the key.
"ALTER TABLE users ADD COLUMN label_blob TEXT",
+ // The profile photo's alt text, encrypted under the instance key like the
+ // label — unlike the server photo's plaintext alt (branding), a member's
+ // photo description is user content and never readable by the server.
+ "ALTER TABLE users ADD COLUMN avatar_alt_blob TEXT",
// Group photo: an encrypted blob id (encrypted with the thread key).
"ALTER TABLE threads ADD COLUMN avatar_blob TEXT",
// User invites: the pending DM thread an invite bootstraps the joiner into
@@ -927,7 +931,13 @@ type RosterEntry struct {
LabelBlob string `json:"label_blob,omitempty"`
PublicKey string `json:"public_key"`
Avatar string `json:"avatar,omitempty"`
- IsAgent bool `json:"is_agent,omitempty"` // an agent/bot, not a person
+ // The photo's alt text, encrypted under the instance key (client decrypts).
+ AvatarAltBlob string `json:"avatar_alt_blob,omitempty"`
+ // admin | moderator | member — lets clients say who runs the space (the
+ // welcome conversation's "run by <admin>"). Server-known metadata, not
+ // content; it already gates the admin API.
+ Role string `json:"role,omitempty"`
+ IsAgent bool `json:"is_agent,omitempty"` // an agent/bot, not a person
// One-line admin-authored blurb for agents (the Helpers catalog entry —
// doubles as the consent hint). Always "" for people.
Description string `json:"description,omitempty"`
@@ -1190,12 +1200,17 @@ func (db *DB) fullBotConfig(userID string) (FullBotConfig, bool, error) {
return c, err == nil, err
}
-func (db *DB) setAvatar(userID, blobID string) error {
- var b any
+// setAvatar sets the photo and its (encrypted) alt text together — a photo
+// change always re-states the alt, so a swap never keeps a stale description.
+func (db *DB) setAvatar(userID, blobID, altBlob string) error {
+ var b, a any
if blobID != "" {
b = blobID
+ if altBlob != "" {
+ a = altBlob
+ }
}
- _, err := db.sql.Exec("UPDATE users SET avatar_blob = ? WHERE id = ?", b, userID)
+ _, err := db.sql.Exec("UPDATE users SET avatar_blob = ?, avatar_alt_blob = ? WHERE id = ?", b, a, userID)
return err
}
@@ -1204,7 +1219,7 @@ func (db *DB) setAvatar(userID, blobID string) error {
// they share a thread with). Kept for admin/back-end use and tests.
func (db *DB) roster() ([]RosterEntry, error) {
rows, err := db.sql.Query(
- `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai')
+ `SELECT u.id, u.label, COALESCE(u.label_blob, ''), u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai')
FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id
WHERE u.public_key IS NOT NULL AND u.public_key <> '' ORDER BY u.label`,
)
@@ -1227,7 +1242,7 @@ func (db *DB) roster() ([]RosterEntry, error) {
// connections-only; only vetted, clearly-badged helpers are instance-visible.
func (db *DB) connections(userID string) ([]RosterEntry, error) {
rows, err := db.sql.Query(`
- SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai')
+ SELECT u.id, u.label, COALESCE(u.label_blob, ''), COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.avatar_alt_blob, ''), COALESCE(u.role, 'member'), COALESCE(u.is_agent, 0), COALESCE(b.description, ''), COALESCE(b.kind, 'ai')
FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id
WHERE u.id = ?
OR u.id IN (
@@ -1251,7 +1266,7 @@ func scanRoster(rows *sql.Rows) ([]RosterEntry, error) {
var e RosterEntry
var isAgent int
var kind string
- if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &isAgent, &e.Description, &kind); err != nil {
+ if err := rows.Scan(&e.UserID, &e.Label, &e.LabelBlob, &e.PublicKey, &e.Avatar, &e.AvatarAltBlob, &e.Role, &isAgent, &e.Description, &kind); err != nil {
return nil, err
}
e.IsAgent = isAgent != 0
diff --git a/main.go b/main.go
index 3e4ffe8..cae86b1 100644
--- a/main.go
+++ b/main.go
@@ -189,6 +189,9 @@ func main() {
}
printAdminLink(*addr, tok)
}
+ if err := applySkipSetup(db); err != nil {
+ log.Fatalf("skip-setup defaults: %v", err)
+ }
pages, err := loadPages()
if err != nil {
@@ -785,6 +788,7 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) {
// DIFFERENT key — that would orphan every thread wrapped to the old key (e.g. an
// admin "device link" that made a new device generate its own keypair). Adding a
// device ships the SAME keypair, so re-registering the identical key is fine.
+ firstKey := s.db.userPublicKey(uid) == ""
if cur := s.db.userPublicKey(uid); cur != "" && cur != body.PublicKey {
httpError(w, http.StatusConflict, "public key already registered")
return
@@ -793,6 +797,13 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "db error")
return
}
+ // A fresh joiner is only NOW a keyed member — tell open tabs so the inviter's
+ // pending-invite card swaps to the live DM (pendingInvitee needs a public key
+ // to clear). Firing at claim time raced this POST and left the card stuck.
+ if firstKey {
+ out, _ := json.Marshal(map[string]any{"type": "joined", "user_id": uid})
+ s.hub.broadcast <- outbound{data: out}
+ }
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -1132,7 +1143,17 @@ func (s *Server) nudgeInstanceKey(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusForbidden, "not a member")
return
}
- out, _ := json.Marshal(map[string]any{"type": "instance_key_nudge"})
+ // Carry who's asking and their public key: a holder's own roster view
+ // (connections) may not include the nudger at all — a fresh joiner shares
+ // no thread with most members — so holders wrap to this key directly.
+ msg := map[string]any{"type": "instance_key_nudge"}
+ if uid, ok := s.db.deviceTokenUser(token); ok {
+ if pub := s.db.userPublicKey(uid); pub != "" {
+ msg["user_id"] = uid
+ msg["public_key"] = pub
+ }
+ }
+ out, _ := json.Marshal(msg)
s.hub.broadcast <- outbound{data: out} // members:nil → everyone
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -1172,6 +1193,9 @@ func (s *Server) postAvatar(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
BlobID string `json:"blob_id"`
+ // The photo's alt text, encrypted under the instance key by the client
+ // (opaque here, like the label blob).
+ AltBlob string `json:"alt_blob"`
}
if json.NewDecoder(r.Body).Decode(&body) != nil {
httpError(w, http.StatusBadRequest, "bad request")
@@ -1182,17 +1206,17 @@ func (s *Server) postAvatar(w http.ResponseWriter, r *http.Request) {
return
}
uid, ok := s.db.deviceTokenUser(body.Token)
- if !ok || len(body.BlobID) > 200 {
+ if !ok || len(body.BlobID) > 200 || len(body.AltBlob) > 4096 {
httpError(w, http.StatusBadRequest, "bad request")
return
}
- if err := s.db.setAvatar(uid, body.BlobID); err != nil {
+ if err := s.db.setAvatar(uid, body.BlobID, body.AltBlob); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
}
// Tell every connected client to refresh this user's avatar (profile photos
// are instance-wide). members:nil → broadcast to everyone.
- out, _ := json.Marshal(map[string]any{"type": "avatar", "user_id": uid, "blob_id": body.BlobID})
+ out, _ := json.Marshal(map[string]any{"type": "avatar", "user_id": uid, "blob_id": body.BlobID, "alt_blob": body.AltBlob})
s.hub.broadcast <- outbound{data: out}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -2342,6 +2366,38 @@ func (s *Server) setGroup(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name, "admin_name": adminName, "admin_email": adminEmail})
}
+// applySkipSetup is a TEST/DEV convenience (LCHAT_SKIP_SETUP=1): mark the
+// first-run setup conversation complete and seed workable defaults, so a
+// scratch instance is usable the moment its setup link is claimed — the
+// claimer still becomes the admin, they just skip the conversation. Values an
+// admin can change are seeded only when unset, so restarts never clobber
+// in-test edits. Never set this on a real instance: it forfeits onboarding.
+func applySkipSetup(db *DB) error {
+ if strings.TrimSpace(os.Getenv("LCHAT_SKIP_SETUP")) == "" {
+ return nil
+ }
+ log.Printf("LCHAT_SKIP_SETUP set: first-run setup conversation disabled (test/dev)")
+ if err := db.setSetupComplete(true); err != nil {
+ return err
+ }
+ if !db.groupNameSet() {
+ name := strings.TrimSpace(os.Getenv("LCHAT_GROUP_NAME"))
+ if name == "" {
+ name = "Test Space"
+ }
+ if err := db.setGroupName(name); err != nil {
+ return err
+ }
+ }
+ // Members can invite — the joiner flow is usually what a test instance is for.
+ if _, ok, _ := db.getConfig("user_invites_enabled"); !ok {
+ if err := db.setUsersCanInvite(true); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
// trimAltText caps the photo's alt text at a RUNE boundary — a plain byte slice
// mid-emoji would store invalid UTF-8 and render as U+FFFD on the join card.
func trimAltText(s string) string {
diff --git a/roles_test.go b/roles_test.go
index 28197c0..40675ed 100644
--- a/roles_test.go
+++ b/roles_test.go
@@ -156,6 +156,49 @@ func TestSetupCompleteBackfill(t *testing.T) {
}
}
+// LCHAT_SKIP_SETUP seeds a usable test instance: setup marked complete, a
+// group name and member invites defaulted — but only where nothing was set,
+// so restarts don't clobber in-test edits.
+func TestApplySkipSetup(t *testing.T) {
+ db, err := openDB(t.TempDir() + "/skip.db")
+ if err != nil {
+ t.Fatalf("openDB: %v", err)
+ }
+ t.Setenv("LCHAT_SKIP_SETUP", "1")
+ t.Setenv("LCHAT_GROUP_NAME", "Canary Nest")
+ if err := applySkipSetup(db); err != nil {
+ t.Fatalf("applySkipSetup: %v", err)
+ }
+ if !db.setupComplete() {
+ t.Fatal("setup should be marked complete")
+ }
+ if got := db.getGroupName(); got != "Canary Nest" {
+ t.Fatalf("group name: got %q", got)
+ }
+ if !db.usersCanInvite() {
+ t.Fatal("member invites should default on")
+ }
+ // A restart must not clobber values changed during the test session.
+ db.setGroupName("Renamed")
+ db.setUsersCanInvite(false)
+ if err := applySkipSetup(db); err != nil {
+ t.Fatalf("re-apply: %v", err)
+ }
+ if got := db.getGroupName(); got != "Renamed" {
+ t.Fatalf("restart clobbered the group name: %q", got)
+ }
+ if db.usersCanInvite() {
+ t.Fatal("restart clobbered the invites toggle")
+ }
+ // Unset → no-op.
+ t.Setenv("LCHAT_SKIP_SETUP", "")
+ db2, _ := openDB(t.TempDir() + "/plain.db")
+ applySkipSetup(db2)
+ if db2.setupComplete() {
+ t.Fatal("without the env var, setup must stay pending")
+ }
+}
+
// /api/me tells a member their role and whether first-run setup is done — the
// client-side gate for the setup conversation.
func TestMeExposesRoleAndSetupState(t *testing.T) {
@@ -183,3 +226,40 @@ func TestMeExposesRoleAndSetupState(t *testing.T) {
t.Fatalf("want setup_complete true after set")
}
}
+
+// A profile photo's alt text rides as an opaque encrypted blob, set with the
+// photo and cleared with it — the server never sees a readable description.
+func TestAvatarAltBlob(t *testing.T) {
+ ts, srv := newTestServer(t)
+ uid, _ := srv.db.createUser("")
+ srv.db.createKeyInvite("av", uid, "", testClaimHash, 0)
+ tok, _, _ := srv.db.claimKeyInvite("av", "", "", testClaimSecret)
+ srv.db.sql.Exec("UPDATE users SET public_key = 'pk' WHERE id = ?", uid) // roster lists joined (keyed) members only
+
+ body := `{"token":"` + tok + `","blob_id":"blob1","alt_blob":"enc-alt"}`
+ resp, err := http.Post(ts.URL+"/api/avatar", "application/json", strings.NewReader(body))
+ if err != nil || resp.StatusCode != 200 {
+ t.Fatalf("post avatar: %v %d", err, resp.StatusCode)
+ }
+ roster, _ := srv.db.roster()
+ var e *RosterEntry
+ for i := range roster {
+ if roster[i].UserID == uid {
+ e = &roster[i]
+ }
+ }
+ if e == nil || e.Avatar != "blob1" || e.AvatarAltBlob != "enc-alt" {
+ t.Fatalf("roster entry: %+v", e)
+ }
+
+ // Removing the photo removes its description with it.
+ if err := srv.db.setAvatar(uid, "", ""); err != nil {
+ t.Fatalf("clear: %v", err)
+ }
+ roster, _ = srv.db.roster()
+ for i := range roster {
+ if roster[i].UserID == uid && (roster[i].Avatar != "" || roster[i].AvatarAltBlob != "") {
+ t.Fatalf("clear left residue: %+v", roster[i])
+ }
+ }
+}
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index faf4066..eeef063 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -48,6 +48,7 @@ import { app as bookmarksApp } from "../web/static/apps/bookmarks.js"; // regist
import { app as boardApp, sanitizePoints } from "../web/static/apps/board.js";
import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // registers the DNSimple companion app
import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run setup conversation's app
+import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner welcome conversation's app
import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin)
import { app as chatApp, viewItems } from "../web/static/apps/chat.js"; // the chat app
@@ -1163,6 +1164,19 @@ await test("admin: system-scope takeover, badged as an app-persona's opposite",
assert.equal(setupApp.senderKind, "bot", "the setup thread presents as Eleven (BOT), not APP");
});
+await test("welcome: the joiner conversation mirrors setup's shape", () => {
+ const r = welcomeApp.reduce;
+ const s = r(undefined, { type: "welcome.step", step: "name", label: "noted: you're Ada", value: "Ada" });
+ assert.equal(s.steps.name.done, true);
+ assert.equal(r(s, { type: "welcome.step" }), s, "missing step → unchanged");
+ assert.equal(appWantsChatView("welcome"), true);
+ assert.equal(appChatOnly("welcome"), true, "conversation-only, no Interface");
+ assert.equal(welcomeApp.senderKind, "bot", "presents as Eleven (BOT) in the sidebar");
+ assert.equal(welcomeApp.scope, "system", "never offered in the Apps menu");
+ const line = welcomeApp.chatMessage({ type: "welcome.step", step: "name", label: "noted: you're Ada" });
+ assert.deepEqual(line, { text: "noted: you're Ada", action: true });
+});
+
await test("conversational answers: one waiter per thread, consumed on delivery", () => {
const got = [];
awaitAnswer("t1", (text) => got.push(text));
diff --git a/web/pages/index.html b/web/pages/index.html
index ce1d4ad..3de64fc 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -55,7 +55,19 @@
<!-- Full-screen cover shown until the app's first meaningful paint (theme bg is
already set pre-paint above), so a refresh doesn't flash an empty shell and
then jump as messages load + scroll. shell.js removes it once ready. -->
-<div id="app-loading" class="app-loading"><div class="spinner" aria-label="Loading"></div></div>
+<div id="app-loading" class="app-loading">
+ <!-- The amp knob: labelled 1–11 like the volume knob on an amplifier. The pie
+ fills and the pointer turns towards 10 while loading; when the app is
+ ready it goes to eleven, holds a beat, and glows. (Labels + motion are
+ built by startLoadingDial in shell.js.) -->
+ <!-- Dust on the stage: particles along the bottom that get bounced around as
+ the volume climbs (see stepDust in shell.js). Decorative only. -->
+ <canvas class="amp-dust" id="amp-dust" aria-hidden="true"></canvas>
+ <div class="amp-knob" id="amp-knob" role="progressbar" aria-label="Loading">
+ <div class="amp-fill" id="amp-fill"></div>
+ <div class="amp-body"><div class="amp-pointer" id="amp-pointer"></div></div>
+ </div>
+</div>
<div id="toast" class="toast"></div>
<!-- The multi-thread shell: a thread list (master) + an open thread (detail).
diff --git a/web/static/apps.js b/web/static/apps.js
index 0937237..4f19efe 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -951,33 +951,61 @@ function fillFullNotice(row, spec = {}, opts = {}) {
}
if (head.childElementCount) row.appendChild(head);
- const bubble = document.createElement("div");
- bubble.className = "local-notice-bubble";
- const body = document.createElement("div");
- body.className = "local-notice-body";
- renderNoticeBody(text || "", body);
- bubble.appendChild(body);
- // An optional preview image (spec.img). Only ever an IN-CODE same-origin URL
- // (e.g. the setup conversation previewing /api/server-avatar) — never user text.
- if (spec.img) {
- const img = document.createElement("img");
- img.className = "local-notice-img";
- img.src = spec.img;
- img.alt = spec.imgAlt || "";
- bubble.appendChild(img);
+ // A chips-only notice (a persisted bubble's live actions) has no bubble of
+ // its own — the text it belongs to is already in the feed as real history.
+ if (text || spec.img) {
+ const bubble = document.createElement("div");
+ bubble.className = "local-notice-bubble";
+ const body = document.createElement("div");
+ body.className = "local-notice-body";
+ renderNoticeBody(text || "", body);
+ bubble.appendChild(body);
+ // An optional preview image (spec.img). Only ever an IN-CODE same-origin URL
+ // (e.g. the setup conversation previewing /api/server-avatar) — never user text.
+ if (spec.img) {
+ const img = document.createElement("img");
+ img.className = "local-notice-img" + (spec.imgRound ? " local-notice-img--round" : "");
+ img.src = spec.img;
+ img.alt = spec.imgAlt || "";
+ // The image arrives after the notice was pinned and grows the feed —
+ // re-pin, or everything after it (the next typing row!) hides below the fold.
+ img.onload = () => {
+ const feed = img.closest(".chatapp-feed");
+ if (feed && feed.scrollHeight - feed.scrollTop - feed.clientHeight < 260) feed.scrollTop = feed.scrollHeight;
+ };
+ bubble.appendChild(img);
+ }
+ row.appendChild(bubble);
}
- row.appendChild(bubble);
if (Array.isArray(actions) && actions.length) {
+ if (!text && !spec.img) row.classList.add("local-notice--chips"); // hugs the bubble it belongs to
const chipRow = document.createElement("div");
chipRow.className = "chip-row";
+ const paint = (btn, act) => {
+ // A chip whose moment has passed (its step was answered or skipped —
+ // a.stale() is the script's step guard) renders spent: disabled, and the
+ // one that was actually tapped shows its past-tense label ("Skipped").
+ const stale = !!(act.stale && act.stale());
+ btn.className = "action-chip" + (stale ? " action-chip--spent" : "");
+ btn.textContent = stale && act.tapped ? act.done || act.label : act.label;
+ btn.disabled = stale;
+ };
+ const buttons = [];
for (const a of actions) {
if (!a || !a.label) continue;
const b = document.createElement("button");
b.type = "button";
- b.className = "action-chip";
- b.textContent = a.label;
- b.onclick = () => runNoticeAction(a);
+ paint(b, a);
+ b.onclick = () => {
+ if (a.stale && a.stale()) return; // raced a resolution — don't claim the tap did anything
+ a.tapped = true;
+ runNoticeAction(a);
+ // An advancing tap resolves the step SYNCHRONOUSLY (go.taken flips in
+ // run) — reflect it now, not at the next feed render.
+ for (const [btn, act] of buttons) paint(btn, act);
+ };
+ buttons.push([b, a]);
chipRow.appendChild(b);
}
row.appendChild(chipRow);
@@ -1010,6 +1038,7 @@ function fillCompactNotice(row, spec = {}) {
if (Array.isArray(actions)) {
for (const a of actions) {
if (!a || !a.label) continue;
+ if (a.stale && a.stale()) continue; // spent chips add nothing to a compact one-liner
const b = document.createElement("button");
b.type = "button";
b.className = "lc-action";
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 25fb3ba..afca96a 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -105,7 +105,82 @@ export function setNoticeTyping(threadId, name, on) {
// addLocalNotice records a command reply for `threadId` and re-renders the pane so
// it appears immediately. Anchored just above the current newest message so it
// sits where you typed it and never jumps around as the conversation continues.
+// The composer placeholder follows the conversation: a scripted question can
+// carry a `hint` ("Type your name") that replaces "Message" the moment the
+// question renders, or an `attachHint` ("Optional: describe your photo") that
+// applies only while attachments are staged — a photo question shouldn't
+// relabel the box before there's a photo. Sending resets to "Message"
+// immediately; the next question sets its own. Keyed like refreshFeed.
+const composerHints = new Map();
+const composerAttachHints = new Map();
+const refreshComposerHint = new Map();
+function applyComposerHint(threadId) {
+ const fn = refreshComposerHint.get(threadId);
+ if (fn) fn();
+}
+function clearComposerHint(threadId) {
+ composerHints.set(threadId, "");
+ composerAttachHints.set(threadId, "");
+ applyComposerHint(threadId);
+}
+
+// Feed-follow with intent: feedGlideToBottom eases towards the (still
+// growing) bottom, and while it's in flight feedNearBottom stays true — an
+// instantaneous scrollTop read mid-glide looks "scrolled away" and used to make
+// the typing row skip its pin. A user wheel/touch cancels the chase.
+const feedGlides = new WeakSet();
+const feedGlideCancels = new WeakSet();
+export function feedNearBottom(feed, slack = 80) {
+ return feedGlides.has(feed) || feed.scrollHeight - feed.scrollTop - feed.clientHeight < slack;
+}
+export function feedGlideToBottom(feed, jump) {
+ if (!feedGlideCancels.has(feed)) {
+ feedGlideCancels.add(feed);
+ const cancel = () => feedGlides.delete(feed);
+ // Cancel on SCROLL INTENT only — wheel, or an actual touch DRAG. A bare
+ // touchstart is any tap in the feed (action chips included), and cancelling
+ // on those broke the follow for every chip-tapped step on touch devices.
+ feed.addEventListener("wheel", cancel, { passive: true });
+ feed.addEventListener("touchmove", cancel, { passive: true });
+ }
+ if (jump || matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ feedGlides.delete(feed);
+ feed.scrollTop = feed.scrollHeight;
+ return;
+ }
+ if (feedGlides.has(feed)) return; // the running glide chases new growth itself
+ feedGlides.add(feed);
+ // Also detect intervention we DON'T get events for (scrollbar drag, PageUp,
+ // arrow keys): if scrollTop is meaningfully below where we last placed it, the
+ // user moved it — bail so we never fight them back down.
+ let lastSet = feed.scrollTop;
+ const step = () => {
+ if (!feedGlides.has(feed)) return; // cancelled by the user (wheel/touch)
+ if (feed.scrollTop < lastSet - 12) {
+ feedGlides.delete(feed);
+ return;
+ }
+ const target = feed.scrollHeight - feed.clientHeight;
+ const d = target - feed.scrollTop;
+ if (Math.abs(d) < 2) {
+ feed.scrollTop = target;
+ feedGlides.delete(feed);
+ return;
+ }
+ feed.scrollTop += d * 0.16;
+ lastSet = feed.scrollTop;
+ requestAnimationFrame(step);
+ };
+ requestAnimationFrame(step);
+}
+
function addLocalNotice(ctx, spec) {
+ composerHints.set(ctx.threadId, spec.hint || "");
+ composerAttachHints.set(ctx.threadId, spec.attachHint || "");
+ applyComposerHint(ctx.threadId);
+ // Hint-only (a persisted question with no chips): the placeholder was the
+ // whole point — don't add an empty row to the feed.
+ if (!spec.text && !spec.img && !(spec.actions && spec.actions.length)) return;
const msgs = (ctx.getState() && ctx.getState().msgs) || [];
const maxSid = msgs.length ? msgs[msgs.length - 1].sid : 0;
const list = localNotices.get(ctx.threadId) || [];
@@ -120,7 +195,10 @@ function addLocalNotice(ctx, spec) {
// A notice is always a response to something the reader just did (a command,
// an action chip, a scripted step) — snap to it like a local send, even if
// they'd scrolled up.
- pinNext.add(ctx.threadId);
+ // A full notice snaps the feed like a local send (pinNext → instant); a
+ // chips-only row rides the smooth glide of the bubble it belongs to — the
+ // extra instant jump right after a glide is what reads as jank.
+ if (spec.text || spec.img) pinNext.add(ctx.threadId);
const refresh = refreshFeed.get(ctx.threadId);
if (refresh) refresh();
}
@@ -428,7 +506,7 @@ export const app = defineApp({
// A local send arms pinNext: snap to the newest even if we'd scrolled up.
// Set.delete both reads and clears it, so it only forces this one render.
const forcePin = pinNext.delete(ctx.threadId);
- const wasAtBottom = forcePin || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60 || !feed.childElementCount;
+ const wasAtBottom = forcePin || feedNearBottom(feed, 60) || !feed.childElementCount;
const msgs = (state && state.msgs) || [];
// A sender's message arriving means they've stopped typing — clear their
@@ -457,7 +535,11 @@ export const app = defineApp({
for (const it of items) {
if (it.notice) {
it.cont = !!(prevNotice && prevNotice.sender && it.notice.sender && prevNotice.sender.name === it.notice.sender.name);
- it.vrev = `notice|${it.cont ? 1 : 0}|${it.solo ? 1 : 0}`;
+ // Spent chips (their step resolved — a.stale()) fold into the vrev so
+ // the keyed renderer rebuilds the row when a later render pass finds
+ // them newly stale; without this a resolved chip stays looking live.
+ const spent = (it.notice.actions || []).reduce((n, a) => n + (a && a.stale && a.stale() ? 1 : 0), 0);
+ it.vrev = `notice|${it.cont ? 1 : 0}|${it.solo ? 1 : 0}|${spent}`;
prevNotice = it.notice;
} else {
prevNotice = null;
@@ -482,11 +564,7 @@ export const app = defineApp({
// straight to the newest; a plain live append glides (the original ease),
// respecting reduced-motion. A send jumps rather than gliding the whole
// history so it lands instantly on your message, not after a long scroll.
- if (firstBuild || forcePin || matchMedia("(prefers-reduced-motion: reduce)").matches) {
- feed.scrollTop = feed.scrollHeight;
- } else {
- feed.scrollTo({ top: feed.scrollHeight, behavior: "smooth" });
- }
+ feedGlideToBottom(feed, firstBuild || forcePin);
}
// The in-feed typing row, exactly like the original feed: a .typing element
@@ -530,9 +608,9 @@ export const app = defineApp({
row.textContent = label;
}
}
- const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80;
+ const atBottom = feedNearBottom(feed);
feed.appendChild(row); // (re-)appending keeps it below the newest message
- if (atBottom) feed.scrollTop = feed.scrollHeight;
+ if (atBottom) feedGlideToBottom(feed);
};
refreshTyping.set(ctx.threadId, drawTyping);
drawTyping();
@@ -2195,7 +2273,11 @@ function buildSkeleton(el, ctx) {
const field = document.createElement("div");
field.className = "composer-field";
const input = document.createElement("textarea");
- input.placeholder = "Message";
+ input.placeholder = composerHints.get(ctx.threadId) || "Message";
+ refreshComposerHint.set(ctx.threadId, () => {
+ const attach = pending.length ? composerAttachHints.get(ctx.threadId) : "";
+ input.placeholder = attach || composerHints.get(ctx.threadId) || "Message";
+ });
input.rows = 1;
input.autocomplete = "off";
const send = document.createElement("button");
@@ -2312,6 +2394,10 @@ function buildSkeleton(el, ctx) {
// uploading, then a thumbnail (image) or a file chip, each with its remove.
const refreshStaged = () => {
if (inEmbed) host.native({ staged: pending.length > 0 }); // enables the native send button
+ applyComposerHint(ctx.threadId); // an attach-gated hint appears/disappears with the staged files
+ // The strip takes real layout space, so the feed shrinks when it appears —
+ // keep the newest messages (and any action chips) in view, not under the fold.
+ if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true));
preview.textContent = "";
preview.classList.toggle("hidden", pending.length === 0);
for (const p of pending) {
@@ -2815,6 +2901,7 @@ function buildSkeleton(el, ctx) {
input.style.height = "auto";
refreshSend();
stopTypingBeat(); // command / edit / send all clear the field — end the beat now
+ clearComposerHint(ctx.threadId); // the question was answered — back to "Message" now, not at the reply
};
// /me is the one command that BROADCASTS a real message — an action-style
// line ("Alice waves"), not a bubble — instead of answering privately, so
diff --git a/web/static/apps/eleven.js b/web/static/apps/eleven.js
new file mode 100644
index 0000000..2b99de8
--- /dev/null
+++ b/web/static/apps/eleven.js
@@ -0,0 +1,119 @@
+// Shared machinery for Eleven's scripted conversations (setup, welcome): the
+// paced reply queue, the per-step tap guards, and PERSISTENT lines — each new
+// line Eleven says is emitted as an app event (encrypted and stored like any
+// thread event) and folds into the chat view as a real bubble, so the
+// conversation survives reloads as history instead of vanishing. Only the
+// action chips and composer hints ride a client-only notice (they need the
+// running script's closures); on a resumed run a line already in history
+// re-arms its chips instantly, with no typing beat and no duplicate bubble.
+import { emitAppEvent, appStateFor, awaitAnswer } from "../apps.js";
+
+function sleep(ms) {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+// The per-step tap guard: first advancing tap (or typed answer) wins. `taken`
+// is readable so a step's chips can render themselves spent once it resolves.
+export function onceStep() {
+ const go = (fn) => {
+ if (go.taken) return;
+ go.taken = true;
+ fn();
+ };
+ go.taken = false;
+ return go;
+}
+
+// An advancing chip: consumes the step; `done` is its past-tense label once
+// tapped ("Skipped"), and stale ties its liveness to the step guard.
+export const advance = (go, label, fn, done) => ({ label, done, stale: () => go.taken, run: () => go(fn) });
+
+// Ask a typed question: the next composer message in this thread is the answer.
+export function ask(env, go, onText) {
+ awaitAnswer(env.threadId, (text) => go(() => onText(text.trim())));
+}
+
+// makeSay(appName, sayType): build the app's say(env, text, actions, extra).
+// Replies play through a queue with a "thinking" beat, then typing scaled to
+// the reply's length (with jitter, first message quickest). Each line is
+// persisted via a `sayType` event keyed by its text; the app's reducer records
+// the key in state.said and its chatMessage projects the text as a bubble.
+// extra: { hint, attachHint } ride the chips notice (composer placeholder);
+// { local: true } keeps a line client-only (e.g. an image preview).
+export function makeSay(appName, sayType) {
+ let queue = Promise.resolve();
+ let first = true;
+ const said = (env, key) => {
+ const st = appStateFor(env.threadId, appName);
+ return !!(st && st.said && st.said[key]);
+ };
+ return function say(env, text, actions, extra) {
+ const opener = first;
+ first = false;
+ const opts = extra || {};
+ // Steps that fold an ack into their question ("Lovely to meet you! …")
+ // pass a stable key, so a resumed run recognises the question even though
+ // its ack prefix differs — otherwise the variant re-emits as a duplicate.
+ const key = opts.key || text;
+ queue = queue.then(async () => {
+ // The beat turns the indicator ON and holds it a readable moment (≥1s,
+ // scaling with reply length) — but does NOT turn it off: the caller drops
+ // it only once the bubble is actually in the feed, so the list never
+ // collapses and then re-grows between indicator and message.
+ const beat = async () => {
+ if (!env.typing) return;
+ await sleep(opener ? 150 : 300 + Math.random() * 250);
+ env.typing(true);
+ const ms = Math.min(2000, (opener ? 350 : 600) + text.length * 4);
+ await sleep(Math.max(1000, ms * (0.85 + Math.random() * 0.3)));
+ };
+ const beatOff = () => env.typing && env.typing(false);
+ if (opts.local) {
+ await beat();
+ beatOff();
+ env.respond({ sender: env.sender, text, actions, ...opts });
+ return;
+ }
+ if (!said(env, key)) {
+ await beat();
+ // Emit ONCE it actually sends. The transport returns false while the
+ // socket is still connecting (the boot-time race the first line hits),
+ // so retry only until it reports the frame went out — never re-emit a
+ // frame that already sent, or a slow echo would persist the line twice.
+ let sent = false;
+ for (let attempt = 0; attempt < 12 && !sent && !said(env, key); attempt++) {
+ sent = await emitAppEvent(env.threadId, { type: sayType, key, text, ts: Date.now() });
+ if (!sent) await sleep(150);
+ }
+ // Then wait (passively) for the echo to fold into the transcript so the
+ // chips row below renders under the question it belongs to.
+ for (let i = 0; i < 30 && !said(env, key); i++) await sleep(100);
+ beatOff(); // the bubble is in the feed (or we're falling back) — indicator off AFTER it lands
+ if (!said(env, key)) {
+ // Persistence failed outright (offline?) — the member still sees the
+ // line, as a client-only notice; a later run re-says it for keeps.
+ env.respond({ sender: env.sender, text, actions, ...opts });
+ return;
+ }
+ }
+ if ((actions && actions.length) || opts.hint || opts.attachHint) {
+ env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint });
+ }
+ });
+ return queue;
+ };
+}
+
+// The reducer entry for a say event and its chat-view projection — one line
+// each in the app's spec: `...sayEvents("welcome.say")` under `events`, and
+// `const line = sayBubble(e, "welcome.say"); if (line) return line;` first in
+// chatMessage.
+export function sayEvents(sayType) {
+ return {
+ [sayType]: (s, e) => (e.key ? { ...s, said: { ...(s.said || {}), [e.key]: true } } : s),
+ };
+}
+export function sayBubble(e, sayType) {
+ if (e.type === sayType && e.text) return { text: e.text };
+ return null;
+}
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js
index b4ff709..5278be3 100644
--- a/web/static/apps/setup.js
+++ b/web/static/apps/setup.js
@@ -13,30 +13,36 @@
// events make completed steps part of the encrypted transcript (the ✓ action
// lines you see after a reload), and whose Interface view is a progress card.
import { defineApp, setupBridge, awaitAnswer, cancelAnswer, appStateFor, emitAppEvent, spaceInfo, openAppThread } from "../apps.js";
+import { onceStep, advance, ask, makeSay, sayEvents, sayBubble } from "./eleven.js";
import { startTour } from "./tutorial.js";
export const app = defineApp({
name: "setup",
title: "Eleven",
glyph: "🕚",
+ avatar: "/static/icon-192.png", // Eleven wears the app icon, not a clock glyph
// The sidebar badges this thread BOT, not APP — it presents as a conversation
// with Eleven, and the app machinery underneath is an implementation detail.
senderKind: "bot",
+ chatIntro: "This is the start of your chat with Eleven.",
// Chat-only: the conversation IS the thread — no Interface view, no toggle.
// (The old setup progress card was superseded by the Admin panel.)
chatOnly: true,
// System scope: never offered in the Apps menu — the host mounts it for the
// admin on first run (maybeStartSetup in shell.js), and only for them.
scope: "system",
- initialState: () => ({ steps: {} }),
+ initialState: () => ({ steps: {}, said: {} }),
events: {
// A completed step. `label` is the human line the chat view shows; `value`
// (optional) remembers the answer, for steps that resume on it.
- "setup.step": (s, e) => (e.step ? { steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s),
+ "setup.step": (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s),
+ // Eleven's own lines persist too (via makeSay), so the conversation is
+ // real history after a reload, not just the ✓ action lines.
+ ...sayEvents("setup.say"),
},
- // Completed steps project into the chat view as /me-style action lines, so the
- // transcript survives reloads even though the questions (local notices) don't.
chatMessage(e) {
+ const line = sayBubble(e, "setup.say");
+ if (line) return line;
if (e.type !== "setup.step" || !e.label) return null;
return { text: e.label, action: true };
},
@@ -63,7 +69,7 @@ export function startSetup(env) {
// A resumed run (reload, or the round-trip to the passkey page) gets a brief
// re-greeting instead of the full hello; the steps then skip what's done.
const resumed = !!bridge.myName();
- if (resumed) say(env, "Welcome back — let's pick up where we left off.");
+ if (resumed) say(env, "Welcome back — let's pick up where we left off.", null, { local: true });
spaceInfo()
.then((info) => {
env.space = info || {};
@@ -74,49 +80,10 @@ export function startSetup(env) {
.finally(() => stepName(env, !resumed));
}
-// Replies play through a queue with a brief "Eleven is typing…" beat first —
-// instant answers read as canned, a beat reads as conversation. The beat scales
-// with the reply's length (capped) and the host provides the indicator hook.
-function sleep(ms) {
- return new Promise((r) => setTimeout(r, ms));
-}
-let sayQueue = Promise.resolve();
-let firstSay = true; // the opener greets faster — you just arrived, Eleven was expecting you
-function say(env, text, actions, extra) {
- const opener = firstSay;
- firstSay = false;
- sayQueue = sayQueue.then(async () => {
- if (env.typing) {
- // A beat of "thinking" before the indicator, then typing scaled to the
- // reply's length, with a little jitter — quick enough not to drag, uneven
- // enough to read as natural rather than mechanical.
- await sleep(opener ? 150 : 300 + Math.random() * 250);
- env.typing(true);
- const ms = Math.min(2000, (opener ? 350 : 600) + text.length * 4);
- await sleep(ms * (0.85 + Math.random() * 0.3));
- env.typing(false);
- }
- env.respond({ sender: env.sender, text, actions, ...(extra || {}) });
- });
- return sayQueue;
-}
-
-// The same per-step tap guard as the tour: first advancing tap wins.
-function onceStep() {
- let done = false;
- return (fn) => {
- if (done) return;
- done = true;
- fn();
- };
-}
-const advance = (go, label, fn) => ({ label, run: () => go(fn) });
-
-// Ask a typed question: the next composer message in this thread is the answer.
-// Chips stay live alongside (Keep / Skip), and whichever wins cancels the other.
-function ask(env, go, onText) {
- awaitAnswer(env.threadId, (text) => go(() => onText(text.trim())));
-}
+// The paced reply queue, tap guards, and typed-answer helper are shared with
+// the welcome conversation (eleven.js). say() PERSISTS each line as a
+// setup.say event, so the conversation reads back as real history.
+const say = makeSay("setup", "setup.say");
// ---- steps ------------------------------------------------------------------
@@ -133,7 +100,7 @@ function stepName(env, greet) {
"Let's take two minutes to set things up.",
);
}
- say(env, "First — what should everyone here call you? Reply with your name.");
+ say(env, "First — what should everyone here call you? Reply with your name.", undefined, { hint: "Type your name" });
ask(env, go, async (name) => {
if (!name) return stepName(env, false);
name = name.slice(0, 32); // the same cap the profile rename applies
@@ -153,16 +120,19 @@ function stepGroup(env, ack) {
// re-run) — a fresh instance's compiled-in default is not worth keeping.
const current = env.space && env.space.nameSet ? env.space.name || "" : "";
const chips = [];
- if (current) chips.push(advance(go, `Keep “${current}”`, () => stepEmail(env)));
+ if (current) chips.push(advance(go, `Keep “${current}”`, () => stepEmail(env), "Kept"));
say(
env,
(ack ? ack + "\n\n" : "") +
"Next, this space needs a name. It's the one everyone sees — on the sign-in page, in invites, on the app icon.",
+ undefined,
+ { key: "q-group" },
);
say(
env,
current ? `It's currently “${current}” — keep that, or reply with a new name.` : "What should we call it? Reply with a name.",
chips.length ? chips : undefined,
+ { hint: "Type a name for this space" },
);
ask(env, go, async (name) => {
if (!name) return stepGroup(env);
@@ -194,7 +164,7 @@ function stepEmail(env) {
// question again.
function askEmail(env, text) {
const go = onceStep();
- say(env, text, [advance(go, "Skip for now", () => stepPhoto(env))]);
+ say(env, text, [advance(go, "Skip for now", () => stepPhoto(env), "Skipped")], { hint: "Type a contact email" });
ask(env, go, async (email) => {
const result = await setupBridge().setContactEmail(email);
if (result !== "ok") {
@@ -213,7 +183,7 @@ function askEmail(env, text) {
function stepPhoto(env) {
if (env.done("photo")) return stepInvites(env);
say(env, "Want to give this space a face? The photo shows on the welcome page and when people join — you can always change it later.");
- askPhoto(env, "Send me a photo — tap the + next to the message box and hit send. Type a message with it if you want to include alt text.");
+ askPhoto(env, "Send me a photo — tap the + next to the message box, or just drag a picture into the chat, and hit send. Type a message with it if you want to include alt text.");
}
// The photo arrives THROUGH THE CHAT, like sending any picture: the next
@@ -223,17 +193,27 @@ function askPhoto(env, prompt) {
const go = onceStep();
// Skipping must disarm the photo waiter, or the next thing typed (steps from
// here on are chip-only) would still be treated as a photo attempt.
- say(env, prompt, [
- advance(go, "Skip for now", () => {
- cancelAnswer(env.threadId);
- stepInvites(env);
- }),
- ]);
+ say(
+ env,
+ prompt,
+ [
+ advance(
+ go,
+ "Skip for now",
+ () => {
+ cancelAnswer(env.threadId);
+ stepInvites(env);
+ },
+ "Skipped",
+ ),
+ ],
+ { attachHint: "Optional: type alt text for the photo" },
+ );
const arm = () =>
awaitAnswer(env.threadId, (text, payload) => {
const img = ((payload && payload.attachments) || []).find((a) => a && a.type && String(a.type).startsWith("image/"));
if (!img) {
- say(env, "Send it as a picture — tap the + next to the message box — or tap Skip.");
+ say(env, "Send it as a picture — tap the + next to the message box or drag one in — or tap Skip.");
return arm();
}
// The adopt runs INSIDE the step guard: if Skip won the race, the sent
@@ -269,13 +249,18 @@ function stepPhotoPreview(env) {
: "") +
"\n\nHappy with it, or want to try another?",
[
- advance(go, "Keep this photo", async () => {
- await env.record("photo", "set the space photo", env.photoAlt || "");
- stepInvites(env);
- }),
- advance(go, "Use a different photo", () => askPhoto(env, "Send me another photo, then — with a message for its alt text, if you like.")),
+ advance(
+ go,
+ "Keep this photo",
+ async () => {
+ await env.record("photo", "set the space photo", env.photoAlt || "");
+ stepInvites(env);
+ },
+ "Kept",
+ ),
+ advance(go, "Use a different photo", () => askPhoto(env, "Of course! Send me another one whenever you're ready — and pop a message with it if you'd like alt text. 📷")),
],
- { img: "/api/server-avatar?t=" + Date.now(), imgAlt: env.photoAlt || "The space photo" },
+ { img: "/api/server-avatar?t=" + Date.now(), imgAlt: env.photoAlt || "The space photo", local: true },
);
}
@@ -322,6 +307,7 @@ function stepKeys(env) {
[
{
label: "Save a passkey (recommended)",
+ stale: () => go.taken,
run: () => {
// On the web this navigates away and setup RESUMES here on return.
// In the native app the ceremony opens in the system browser and this
@@ -333,7 +319,7 @@ function stepKeys(env) {
}, 1500);
},
},
- advance(go, "Not now", () => stepKeysConfirm(env)),
+ advance(go, "Not now", () => stepKeysConfirm(env), "Skipped"),
],
);
}
@@ -352,10 +338,15 @@ function stepKeysWaiting(env) {
await env.record("keys", label, "passkey");
stepFinish(env, true);
}),
- advance(go, "It didn't work — skip for now", async () => {
- await env.record("keys", "Skipped the credentials backup for now", "skipped");
- stepFinish(env, false);
- }),
+ advance(
+ go,
+ "It didn't work — skip for now",
+ async () => {
+ await env.record("keys", "Skipped the credentials backup for now", "skipped");
+ stepFinish(env, false);
+ },
+ "Skipped",
+ ),
]);
}
@@ -367,6 +358,7 @@ function stepKeysConfirm(env) {
[
{
label: "Go back and save it",
+ stale: () => go.taken,
run: () => {
setupBridge().savePasskey();
setTimeout(() => {
@@ -374,10 +366,15 @@ function stepKeysConfirm(env) {
}, 1500);
},
},
- advance(go, "I understand the risk", async () => {
- await env.record("keys", "Skipped the credentials backup for now", "skipped");
- stepFinish(env, false);
- }),
+ advance(
+ go,
+ "I understand the risk",
+ async () => {
+ await env.record("keys", "Skipped the credentials backup for now", "skipped");
+ stepFinish(env, false);
+ },
+ "Understood",
+ ),
],
);
}
@@ -397,7 +394,7 @@ async function stepFinish(env, backedUp) {
);
say(env, "Where would you like to start?", [
{ label: "Open admin", run: () => openAppThread("admin") },
- { label: "Take the tour", run: () => startTour(env.respond) },
+ { label: "Take the tour", run: () => startTour(env.respond, env.typing) },
]);
running.delete(env.threadId);
}
diff --git a/web/static/apps/tutorial.js b/web/static/apps/tutorial.js
index 97e9d8f..d7ef9f8 100644
--- a/web/static/apps/tutorial.js
+++ b/web/static/apps/tutorial.js
@@ -24,10 +24,26 @@ registerFloorCommand("/tutorial", {
// startTour(respond): respond(spec) appends one Eleven notice to the current feed.
// Both entry points supply their own respond bound to the thread you're in.
-export function startTour(respond) {
+export function startTour(respond, typing) {
const bot = defaultBot();
+ // With a typing hook (the setup/welcome conversations pass theirs), each tour
+ // reply gets a short beat first — same voice, same rhythm as Eleven's script.
+ let q = Promise.resolve();
+ const respondWithBeat = !typing
+ ? respond
+ : (spec) => {
+ q = q.then(async () => {
+ if (spec && spec.text) {
+ typing(true);
+ await new Promise((r) => setTimeout(r, Math.min(1400, 550 + (spec.text.length || 0) * 3)));
+ typing(false);
+ }
+ respond(spec);
+ });
+ return q;
+ };
const env = {
- respond,
+ respond: respondWithBeat,
// Speak as Eleven (with its AI/BOT badge) when it's a member here; otherwise
// still speak as "Eleven" the guide — the tour is a client feature, not a bot.
sender: bot || { name: "Eleven", kind: "bot" },
@@ -48,17 +64,18 @@ export function startTour(respond) {
// notice (and its chips) are rebuilt on every feed refresh, so without this a
// second tap on a stale "Next" would append the same step twice.
function onceStep() {
- let done = false;
- return (fn) => {
- if (done) return;
- done = true;
+ const go = (fn) => {
+ if (go.taken) return;
+ go.taken = true;
fn();
};
+ go.taken = false;
+ return go;
}
// advance = a chip that moves the tour on (guarded); side = a chip that does
// something without advancing (open an app), so it stays tappable if you return.
-const advance = (go, label, fn) => ({ label, run: () => go(fn) });
+const advance = (go, label, fn) => ({ label, stale: () => go.taken, run: () => go(fn) });
const side = (label, fn) => ({ label, run: fn });
function say(env, text, actions) {
@@ -123,7 +140,7 @@ function stepRemind(env) {
const go = onceStep();
say(
env,
- "And Reminders — for nudging yourself later. Try typing `/remind call mum in 10 minutes` (or `/remind standup at 9:00`). " +
+ "And Reminders — for nudging yourself later. Try typing `/remind me to call mum in 10 minutes` (or `/remind standup at 9:00`). " +
"It'll message you at the right moment, on every device — and even the reminder's text and time stay private to you.\n\n" +
"Same as before: give it a go, then pop back to this chat with me whenever you're ready, or tap “Next”.",
[side("Open Reminders", () => openAppThread("remind")), advance(go, "Next →", () => stepPrivacy(env))],
diff --git a/web/static/apps/welcome.js b/web/static/apps/welcome.js
new file mode 100644
index 0000000..55a00a5
--- /dev/null
+++ b/web/static/apps/welcome.js
@@ -0,0 +1,301 @@
+// Joiner onboarding — Eleven welcomes a NEW member the moment they arrive:
+// saving their access behind a passkey FIRST (so the round-trip back from the
+// ceremony lands mid-conversation with steps still to do, not on "all done"),
+// then name, profile photo, and an invitation to take the tour. Chat-first,
+// like the admin setup conversation (docs/conversational-onboarding.md §4):
+// claiming an invite lands straight in this conversation, no name form.
+//
+// Like setup.js this is a CLIENT-DRIVEN script in Eleven's voice — it must work
+// with no AI and no sidecar (the sidecar's welcome DM stood down in its
+// favour). The "welcome" app gives the conversation a home: a chat-only solo
+// thread whose events are the encrypted transcript — Eleven's lines persist as
+// real bubbles (welcome.say, via makeSay) and completed steps as action lines
+// (welcome.step) — and the resume points across reloads and the passkey
+// round-trip. It talks to the same host bridge as setup (setSetupBridge).
+import { defineApp, setupBridge, cancelAnswer, awaitAnswer, appStateFor, emitAppEvent, spaceInfo } from "../apps.js";
+import { onceStep, advance, ask, makeSay, sayEvents, sayBubble } from "./eleven.js";
+import { startTour } from "./tutorial.js";
+
+export const app = defineApp({
+ name: "welcome",
+ title: "Eleven",
+ glyph: "🕚",
+ avatar: "/static/icon-192.png", // Eleven wears the app icon, not a clock glyph
+ // Presents as a conversation with Eleven (BOT in the sidebar, chat-only
+ // surface) — the app machinery underneath is an implementation detail.
+ senderKind: "bot",
+ chatIntro: "This is the start of your chat with Eleven.",
+ chatOnly: true,
+ // System scope: never offered in the Apps menu — the host mounts it for a
+ // freshly-joined member (maybeStartWelcome in shell.js), exactly once.
+ scope: "system",
+ initialState: () => ({ steps: {}, said: {} }),
+ events: {
+ "welcome.step": (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s),
+ ...sayEvents("welcome.say"),
+ },
+ chatMessage(e) {
+ const line = sayBubble(e, "welcome.say");
+ if (line) return line;
+ if (e.type !== "welcome.step" || !e.label) return null;
+ return { text: e.label, action: true };
+ },
+});
+
+// ---- the conversation -------------------------------------------------------
+
+const running = new Set();
+const say = makeSay("welcome", "welcome.say");
+
+// startWelcome(env): env = { threadId, respond, typing, suggested, inviterName,
+// openPendingThread } — the host supplies the notice sink, the typing beat, the
+// inviter's suggested name (from the invite fragment, never the server), who
+// invited them (decrypted roster label, "" if unknown), and a callback opening
+// the DM the invite created (null if none).
+export function startWelcome(env) {
+ const bridge = setupBridge();
+ if (!bridge || running.has(env.threadId)) return;
+ running.add(env.threadId);
+ env.sender = { name: "Eleven", kind: "bot" };
+ env.done = (step) => {
+ const st = appStateFor(env.threadId, "welcome");
+ return !!(st && st.steps && st.steps[step] && st.steps[step].done);
+ };
+ env.record = (step, label, value) => emitAppEvent(env.threadId, { type: "welcome.step", step, label, value: value || "" });
+ // A resumed run (reload, or the round-trip back from the passkey page): the
+ // history is real bubbles now, so a brief re-greeting is all that's needed —
+ // the steps then skip past whatever's already done. The passkey RETURN gets
+ // its own richer ack in stepAccess, so it skips the generic line here.
+ const resumed = env.done("access") || env.done("name") || !!bridge.myName();
+ const passkeyReturn = bridge.enrolled() && !env.done("access");
+ if (resumed && !passkeyReturn) say(env, "Welcome back — let's pick up where we left off.", null, { local: true });
+ spaceInfo()
+ .then((info) => {
+ env.space = info || {};
+ })
+ .catch(() => {
+ env.space = {};
+ })
+ .finally(() => stepAccess(env, !resumed));
+}
+
+// ---- steps --------------------------------------------------------------------
+
+// The passkey ceremony comes FIRST: it's the one thing that protects everything
+// after it, and the round-trip back from the passkey page re-enters the
+// conversation with the friendlier steps (name, photo) still ahead.
+function stepAccess(env, greet) {
+ const bridge = setupBridge();
+ const space = (env.space && env.space.name) || "this space";
+ if (greet) {
+ // Assume nothing: say what this place IS, who runs it, and what the
+ // encryption means for them — in lock-and-key terms, not jargon.
+ const admin = env.adminName || "";
+ const invited = env.inviterName ? `${env.inviterName} invited you to ${space}` : `You've been invited to ${space}`;
+ const runBy = admin ? `run by ${admin}` : `run by one of its members`;
+ say(
+ env,
+ `Welcome! 👋 I'm Eleven, your guide here.\n\n` +
+ `${invited} — a private, invite-only chat space. It isn't a big platform with accounts and feeds: it's a small server ${runBy}, just for the people in it.\n\n` +
+ `Everything here — messages, photos, even names — is locked with keys only members hold, so nobody outside a chat can ever read it. Not the server, and not whoever runs it.`,
+ );
+ }
+ if (bridge.enrolled() || env.done("access")) {
+ if (!env.done("access")) {
+ // Just back from the passkey ceremony: acknowledge it worked, then move
+ // on — a separate bubble, so the ack lands before the next question.
+ env.record("access", "Saved your access securely with a passkey", "passkey");
+ say(env, "Great — welcome back. 🔒 Now that your access is secure, let's continue.");
+ }
+ return stepName(env);
+ }
+ const go = onceStep();
+ // One bubble, chips attached — context and ask together, so the opening run
+ // is two messages (intro + this), not three.
+ say(env, "Before anything else, let's make sure you can't lose your way in. 🔑\n\n" +
+ "Your access to this space lives in a key that exists only in this browser — there's no username or password, and nobody can reset it for you. If this browser's data is cleared, your access goes with it.\n\n" +
+ "Shall we save it behind a passkey now? One tap — you'll be brought straight back and we'll carry on.", [
+ {
+ label: "Save my access (recommended)",
+ stale: () => go.taken,
+ run: () => {
+ bridge.savePasskey(); // web: navigates away; the welcome resumes on return
+ setTimeout(() => {
+ if (!setupBridge().enrolled()) go(() => stepAccessWaiting(env));
+ }, 1500);
+ },
+ },
+ advance(
+ go,
+ "Not now",
+ async () => {
+ if (bridge.deferBackupReminders) bridge.deferBackupReminders();
+ await env.record("access", "Skipped the access backup for now", "skipped");
+ stepName(env, "No problem — I'll nudge you again in a day or so.");
+ },
+ "Skipped",
+ ),
+ ]);
+}
+
+// The stayed-in-place passkey path (the ceremony opened elsewhere).
+function stepAccessWaiting(env) {
+ const go = onceStep();
+ say(env, "I'll wait here while you finish saving the passkey. When you're done, tap Continue.", [
+ advance(go, "Continue", async () => {
+ const label = setupBridge().enrolled()
+ ? "Saved your access securely with a passkey"
+ : "Saved your access with a passkey (in your browser)";
+ await env.record("access", label, "passkey");
+ stepName(env, "Perfect — that's saved. 🔒");
+ }),
+ advance(
+ go,
+ "It didn't work — skip for now",
+ async () => {
+ const bridge = setupBridge();
+ if (bridge.deferBackupReminders) bridge.deferBackupReminders();
+ await env.record("access", "Skipped the access backup for now", "skipped");
+ stepName(env, "No problem — I'll nudge you again in a day or so.");
+ },
+ "Skipped",
+ ),
+ ]);
+}
+
+function stepName(env, ack) {
+ if (env.done("name") || setupBridge().myName()) return stepPhoto(env);
+ const go = onceStep();
+ const chips = [];
+ if (env.suggested) chips.push(advance(go, `I'm ${env.suggested}`, () => acceptName(env, env.suggested)));
+ say(env, (ack ? ack + "\n\n" : "") + "First things first — what should folks here call you? Reply with your name.", chips.length ? chips : undefined, {
+ hint: "Type your name",
+ key: "q-name",
+ });
+ ask(env, go, (name) => {
+ if (!name) return stepName(env);
+ acceptName(env, name);
+ });
+}
+
+async function acceptName(env, name) {
+ name = name.slice(0, 32); // the same cap the profile rename applies
+ await setupBridge().setMyName(name);
+ await env.record("name", `noted: you're ${name}`, name);
+ stepPhoto(env, `Lovely to meet you, ${name}! 🤝`);
+}
+
+function stepPhoto(env, ack) {
+ if (env.done("photo")) return stepFinish(env);
+ say(
+ env,
+ (ack ? ack + "\n\n" : "") +
+ "Want to add a profile photo? Only members of this space can see it — like everything else here, it's encrypted.",
+ undefined,
+ { key: "q-photo" },
+ );
+ askPhoto(env, "Send me a photo — tap the + next to the message box, or just drag a picture into the chat, and hit send. If you type a message with it, I'll keep that as the photo's description. Or skip for now.");
+}
+
+// The photo arrives through the chat, like sending any picture. Unlike the
+// admin setup's SERVER photo (public branding), a profile photo stays inside
+// the encryption: the host re-encrypts it under the instance key.
+function askPhoto(env, prompt) {
+ const go = onceStep();
+ say(
+ env,
+ prompt,
+ [
+ advance(
+ go,
+ "Skip for now",
+ () => {
+ cancelAnswer(env.threadId);
+ stepFinish(env, true);
+ },
+ "Skipped",
+ ),
+ ],
+ { attachHint: "Optional: describe your photo" },
+ );
+ const arm = () =>
+ awaitAnswer(env.threadId, (text, payload) => {
+ const img = ((payload && payload.attachments) || []).find((a) => a && a.type && String(a.type).startsWith("image/"));
+ if (!img) {
+ say(env, "Send it as a picture — tap the + next to the message box or drag one in — or tap Skip.", null, { local: true });
+ return arm();
+ }
+ go(async () => {
+ // Any text sent with the photo becomes its description (alt text) —
+ // encrypted like everything else here, unlike the admin setup's public
+ // branding photo, so no consent warning is needed.
+ const ok = await setupBridge().adoptProfilePhoto(env.threadId, img, (text || "").trim());
+ if (ok === "nokey") {
+ // The instance key hasn't reached us yet — the photo is fine, we just
+ // can't encrypt it. Say so honestly and let them try again shortly.
+ say(env, "One sec — I'm still finishing your secure setup. Try sending it again in a moment, or skip for now.", null, { local: true });
+ return askPhoto(env, "Ready when you are — send that photo again, or skip it.");
+ }
+ if (!ok) {
+ say(env, "Hmm, I couldn't use that one — try another?", null, { local: true });
+ return askPhoto(env, "Of course! Send me another one whenever you're ready — and pop a message with it if you'd like a description. 📷");
+ }
+ stepPhotoPreview(env);
+ });
+ });
+ arm();
+}
+
+// Show the adopted photo back and let them swap it until it's right — recorded
+// (and moved past) only on an explicit keep, like the admin setup's preview.
+// The preview is a client-only notice: the photo is an encrypted blob, so its
+// decrypted object URL can't ride a persisted event.
+async function stepPhotoPreview(env) {
+ const go = onceStep();
+ let url = null;
+ try {
+ url = await setupBridge().myAvatarURL();
+ } catch {}
+ say(
+ env,
+ "Here's how it looks. Happy with it, or want to try another?",
+ [
+ advance(
+ go,
+ "Keep this photo",
+ async () => {
+ await env.record("photo", "set your profile photo");
+ stepFinish(env);
+ },
+ "Kept",
+ ),
+ advance(go, "Use a different photo", () => askPhoto(env, "Of course! Send me another one whenever you're ready — and pop a message with it if you'd like a description. 📷")),
+ ],
+ url ? { img: url, imgAlt: "Your profile photo", imgRound: true, local: true } : { local: true },
+ );
+}
+
+async function stepFinish(env, skippedPhoto) {
+ const bridge = setupBridge();
+ if (bridge.markWelcomeDone) bridge.markWelcomeDone();
+ const st = appStateFor(env.threadId, "welcome");
+ const backedUp = bridge.enrolled() || !!(st && st.steps && st.steps.access && st.steps.access.value === "passkey");
+ const space = (env.space && env.space.name) || "this space";
+ say(
+ env,
+ (skippedPhoto ? "No problem — you can add one anytime from your profile.\n\n" : "") +
+ `That's it — you're all set. 🎉 Enjoy ${space}!` +
+ (backedUp ? "" : "\n\nAnd do come back to that access backup soon."),
+ undefined,
+ { key: "q-finish" },
+ );
+ const chips = [];
+ if (env.openPendingThread && env.inviterName) {
+ chips.push({ label: `Say hi to ${env.inviterName}`, run: env.openPendingThread });
+ } else if (env.openPendingThread) {
+ chips.push({ label: "Open your first chat", run: env.openPendingThread });
+ }
+ chips.push({ label: "Take the tour", run: () => startTour(env.respond, env.typing) });
+ say(env, "Where would you like to start?", chips);
+ running.delete(env.threadId);
+}
diff --git a/web/static/join.js b/web/static/join.js
index ad0369a..ef3ea5b 100644
--- a/web/static/join.js
+++ b/web/static/join.js
@@ -26,6 +26,17 @@ const isStandalone =
const { T, name: fragName } = parseInviteFragment(location.hash.slice(1));
+// Single-fire guard for the claim: whatever path calls it (button, double tap,
+// Enter), only the first attempt proceeds; the rest are ignored.
+let claimStarted = false;
+function onceOnly(fn) {
+ return (...a) => {
+ if (claimStarted) return;
+ claimStarted = true;
+ return fn(...a);
+ };
+}
+
init();
function init() {
@@ -102,16 +113,27 @@ async function start() {
adoptExisting(id, payload.threadKey, existing);
});
}
- // New person: pick a screen name (pre-filled with the inviter's suggestion),
- // generate a keypair, join into the DM.
- return showWelcome("join", () =>
- showForm(fragName, async (name) => {
- showStatus("Setting up…");
- const kp = await generateUserKeypair();
- const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey);
- finish(id, name, kp.publicKey, kp.privateKey, wrappedSelf);
- }),
- );
+ // New person: chat-first — claim nameless and let Eleven's welcome
+ // conversation ask the name in-chat (the inviter's suggestion rides along
+ // in the pending marker). The native add-chat webview keeps the form: it
+ // hands a NAMED identity straight back to the shell.
+ if (window.__lchatEmbed) {
+ return showWelcome("join", () =>
+ showForm(fragName, async (name) => {
+ showStatus("Setting up…");
+ const kp = await generateUserKeypair();
+ const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey);
+ finish(id, name, kp.publicKey, kp.privateKey, wrappedSelf);
+ }),
+ );
+ }
+ return showWelcome("join", async () => {
+ showStatus("Setting up…");
+ const kp = await generateUserKeypair();
+ const wrappedSelf = await wrapThreadKey(payload.threadKey, kp.publicKey);
+ markWelcomePending(fragName);
+ finish(id, "", kp.publicKey, kp.privateKey, wrappedSelf);
+ });
}
// Setup invite (a fresh instance's first user, who becomes its admin): no name
@@ -125,15 +147,37 @@ async function start() {
});
}
- // New person (or unknown payload shape): pick a screen name (pre-filled if the
- // inviter suggested one), then generate a fresh keypair.
- showWelcome("join", () =>
- showForm(fragName, async (name) => {
- showStatus("Setting up…");
- const kp = await generateUserKeypair();
- finish(id, name, kp.publicKey, kp.privateKey);
- }),
- );
+ // New person (or unknown payload shape): same chat-first dance — the name is
+ // Eleven's first question, not a form (native add-chat keeps its form).
+ if (window.__lchatEmbed) {
+ return showWelcome("join", () =>
+ showForm(fragName, async (name) => {
+ showStatus("Setting up…");
+ const kp = await generateUserKeypair();
+ finish(id, name, kp.publicKey, kp.privateKey);
+ }),
+ );
+ }
+ showWelcome("join", async () => {
+ showStatus("Setting up…");
+ const kp = await generateUserKeypair();
+ markWelcomePending(fragName);
+ finish(id, "", kp.publicKey, kp.privateKey);
+ });
+}
+
+// The pending marker hands the joiner to Eleven's welcome conversation on the
+// app's first load (shell.js maybeStartWelcome), carrying the inviter's
+// suggested name. Local-only — the name suggestion never touches the server.
+// A new invite pasted into an already-open join tab changes only the URL
+// fragment — a same-document navigation, so nothing re-runs and the old
+// card (or error) just sits there. Reload to process the new link.
+addEventListener("hashchange", () => location.reload());
+
+function markWelcomePending(suggested) {
+ try {
+ localStorage.setItem("lchat_welcome_pending", JSON.stringify({ suggested: suggested || "" }));
+ } catch {}
}
// The welcome card: the invite is inspected (a non-consuming GET) but nothing is
@@ -183,7 +227,12 @@ function showWelcome(kind, onContinue) {
const storeBtn = card.querySelector("#welcome-store");
if (storeURL) storeBtn.onclick = () => window.open(storeURL, "_blank", "noopener");
else storeBtn.closest(".row").remove();
- card.querySelector("#welcome-web").onclick = () => onContinue();
+ const webBtn = card.querySelector("#welcome-web");
+ const go = onceOnly(onContinue);
+ webBtn.onclick = () => {
+ webBtn.disabled = true; // instant feedback — no dead-feeling first tap
+ go();
+ };
}
async function finish(id, name, pub, priv, wrappedSelf = "") {
@@ -199,9 +248,11 @@ async function finish(id, name, pub, priv, wrappedSelf = "") {
})
).json();
} catch {
+ localStorage.removeItem("lchat_welcome_pending"); // the claim didn't take — drop the marker
return showError("Couldn't reach the server. Try again.");
}
if (!claim.ok || !claim.token) {
+ localStorage.removeItem("lchat_welcome_pending");
return showError("This invite was just used. Ask for a new one.");
}
try {
@@ -227,6 +278,7 @@ async function finish(id, name, pub, priv, wrappedSelf = "") {
} catch {}
return;
}
+ await plugIn();
location.href = "/"; // drop the fragment from the URL bar
}
@@ -247,6 +299,7 @@ async function adoptExisting(id, threadKeyB64, existing) {
return showError("This invite has been used or is no longer valid. Ask for a new one.");
}
if (r.thread_id) localStorage.setItem("lchat_open_thread", r.thread_id);
+ await plugIn();
location.href = "/";
} catch {
showError("Couldn't reach the server. Try again.");
@@ -301,6 +354,20 @@ function showStatus(_msg) {
showLoader();
}
+// The beat between "claimed" and the app: flash "Plugging in…" under the
+// spinner, fade it out, then navigate — the amp knob fades in on the far side.
+async function plugIn() {
+ if (!loader) return;
+ const msg = document.createElement("div");
+ msg.className = "plugging-in";
+ msg.textContent = "Plugging in…";
+ loader.appendChild(msg);
+ requestAnimationFrame(() => msg.classList.add("show"));
+ await new Promise((r) => setTimeout(r, 1000));
+ msg.classList.remove("show");
+ await new Promise((r) => setTimeout(r, 300));
+}
+
function showError(msg) {
card.classList.remove("onboard", "status");
card.innerHTML = "";
diff --git a/web/static/shell.js b/web/static/shell.js
index 90cba58..403bd9e 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -89,8 +89,9 @@ import "/static/apps/connect.js";
import "/static/apps/dnsimple.js";
import { startTour } from "/static/apps/tutorial.js"; // also registers the /tutorial floor command
import { startSetup } from "/static/apps/setup.js"; // the first-run setup conversation (also registers its app)
+import { startWelcome } from "/static/apps/welcome.js"; // the joiner welcome conversation (also registers its app)
import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admin or setup's chip)
-import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping } from "/static/apps/chat.js";
+import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom } from "/static/apps/chat.js";
import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
import { qrCanvas } from "/static/qrcode.js";
import { initBackupBanner, canRestore, launchRestore, checkRestoreReturn, launchEnrol, isEnrolled, deferBackupNudges } from "/static/backup.js";
@@ -464,12 +465,168 @@ const hidden = {}; // thread id -> bool: closed (hidden from the list), synced f
const pinned = {}; // thread id -> bool: pinned to the top of the list, synced from the server
let chatFilter = ""; // lowercased substring filtering the thread list (search box)
let activeId = null;
+let pendingJoinThread = null; // the DM a fresh invite created — the welcome conversation offers it at the end
let ws = null;
let everConnected = false; // has the socket ever opened? (distinguishes reconnect from first connect)
let wsConnected = false; // is the socket open right now? (gates the send button while reconnecting)
let pingTimer = null; // keepalive ping interval while the socket is open
let reconnectStatusTimer = null; // delays the "Reconnecting…" pill past a brief blip
+// Custom-app import race: this const must initialize BEFORE route() below runs
+// boot() synchronously, or loadCustomApps hits its own const in the temporal
+// dead zone and every custom app silently skips on a same-tick boot.
+const CUSTOM_APPS_TIMEOUT_MS = 4000;
+
+// The boot amp knob (see index.html): labels 1–11 around a 300° pot (1 at 7
+// o'clock, 11 at 5 o'clock, 30° apart). While loading, the fill + pointer ease
+// from 1 towards 10 — and the knob starts to SHAKE as the volume climbs. The
+// finish is choreographed to feel like a human turning it, never instant:
+// hideLoading eases the rest of the way to 10 (a minimum total turn even on a
+// fast load), holds a beat, snaps to eleven, and lets it glow.
+// (All declared above route()'s synchronous boot() call — the TDZ rule.)
+let clockRaf = 0;
+let dialStarted = false; // did this boot show the amp knob? (embed/no-invite/A2HS don't)
+let dialT0 = 0;
+let dialSweep = 0;
+const DIAL_MIN_TURN_MS = 1800; // a fast load still gets a human-speed turn to 10
+// Stage dust: particles resting along the loader's bottom edge that the volume
+// bounces around — still at 1, hopping by 7, popcorn at eleven.
+let dust = null;
+function initDust() {
+ const canvas = document.getElementById("amp-dust");
+ if (!canvas || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+ const dpr = window.devicePixelRatio || 1;
+ const w = window.innerWidth;
+ const h = 140;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ const g = canvas.getContext("2d");
+ if (!g) return;
+ g.scale(dpr, dpr);
+ const css = getComputedStyle(document.documentElement);
+ const ink = (css.getPropertyValue("--muted") || "#888").trim();
+ const accent = (css.getPropertyValue("--accent") || "#7c7cf4").trim();
+ const parts = Array.from({ length: Math.min(110, Math.max(60, Math.round(w / 14))) }, () => ({
+ x: Math.random() * w,
+ y: h - 2 - Math.random() * 2,
+ vx: 0,
+ vy: 0,
+ r: 0.8 + Math.random() * 2.1,
+ c: Math.random() < 0.18 ? accent : ink,
+ a: 0.3 + Math.random() * 0.5,
+ }));
+ dust = { g, w, h, parts };
+}
+function stepDust(level) {
+ if (!dust) return;
+ const { g, w, h, parts } = dust;
+ g.clearRect(0, 0, w, h);
+ for (const p of parts) {
+ // Volume kicks: both the odds and the violence grow with the level. Sharp
+ // impulses + heavy gravity = a BOUNCE, not a float.
+ if (level > 0 && p.vy === 0 && Math.random() < 0.02 + level * 0.2) {
+ p.vy = -(2.2 + Math.random() * 5.5) * level;
+ p.vx += (Math.random() - 0.5) * 2.2 * level;
+ }
+ p.vy += 0.62; // gravity — heavy, so hops are snappy
+ p.y += p.vy;
+ p.x += p.vx;
+ p.vx *= 0.97;
+ if (p.y >= h - 2) {
+ p.y = h - 2;
+ p.vy = p.vy > 1.2 ? p.vy * -0.42 : 0; // crisp rebound, quick rest
+ }
+ if (p.x < -4) p.x += w + 8;
+ else if (p.x > w + 4) p.x -= w + 8;
+ g.globalAlpha = p.a;
+ g.fillStyle = p.c;
+ g.beginPath();
+ g.arc(p.x, p.y, p.r, 0, 7);
+ g.fill();
+ }
+ g.globalAlpha = 1;
+}
+function dialPaint(knob, fill, body, sweep, shakeScale) {
+ fill.style.setProperty("--sweep", sweep.toFixed(1) + "deg");
+ body.style.transform = `rotate(${(-150 + sweep).toFixed(1)}deg)`;
+ // Loudness shake: nothing below ~5, building to a visible tremble by 10–11.
+ const level = Math.max(0, (sweep - 120) / 180) * (shakeScale == null ? 1 : shakeScale);
+ stepDust(level); // the stage dust dances to the same level
+ if (level > 0 && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ const a = 1.7 * level;
+ knob.style.transform = `translate(${((Math.random() - 0.5) * 2 * a).toFixed(2)}px, ${((Math.random() - 0.5) * 2 * a).toFixed(2)}px) rotate(${((Math.random() - 0.5) * 1.6 * level).toFixed(2)}deg)`;
+ } else {
+ knob.style.transform = "";
+ }
+}
+function startLoadingDial() {
+ const knob = document.getElementById("amp-knob");
+ const fill = document.getElementById("amp-fill");
+ const body = knob && knob.querySelector(".amp-body");
+ if (!knob || !fill || !body) return;
+ dialStarted = true;
+ for (let n = 1; n <= 11; n++) {
+ const num = document.createElement("span");
+ num.className = "amp-num";
+ num.dataset.n = String(n);
+ num.textContent = String(n);
+ num.style.setProperty("--a", -150 + (n - 1) * 30 + "deg");
+ knob.appendChild(num);
+ }
+ initDust();
+ // Pose at 1 BEFORE revealing: paint the first frame, then fade the knob in
+ // on the next frame so it never shows unposed.
+ dialPaint(knob, fill, body, 0);
+ requestAnimationFrame(() => knob.classList.add("ready"));
+ dialT0 = Date.now();
+ const tick = () => {
+ const t = (Date.now() - dialT0) / 1000;
+ dialSweep = 270 * (1 - Math.exp(-t / 2.5)); // 0° = volume 1 … 270° = volume 10, never arriving
+ dialPaint(knob, fill, body, dialSweep);
+ clockRaf = requestAnimationFrame(tick);
+ };
+ clockRaf = requestAnimationFrame(tick);
+}
+function clickDialToEleven() {
+ if (!dialStarted) return 0; // boot paths that never showed the knob just fade
+ const knob = document.getElementById("amp-knob");
+ const fill = document.getElementById("amp-fill");
+ const body = knob && knob.querySelector(".amp-body");
+ if (!knob || !fill || !body) return 0;
+ cancelAnimationFrame(clockRaf);
+ // Choreography: finish the turn to 10 at human speed (topping the total turn
+ // up to the minimum), hold a beat, SNAP to eleven, then glow — shaking harder
+ // all the way, easing off as the glow fades.
+ const toTen = Math.max(350, DIAL_MIN_TURN_MS - (Date.now() - dialT0));
+ const beat = 850; // sit at 10, trembling — let the anticipation really build
+ const eleven = 620; // AT eleven the shake goes wild (this IS the loudest it goes)
+ const bow = 330; // …and it fades out still rattling, before the chat appears
+ const CRANKED = 2.3; // shake multiplier at 11 — well past the 10 tremble
+ const from = dialSweep;
+ const t0 = Date.now();
+ const run = () => {
+ const t = Date.now() - t0;
+ if (t < toTen) {
+ const k = t / toTen;
+ dialPaint(knob, fill, body, from + (270 - from) * (1 - (1 - k) * (1 - k)));
+ } else if (t < toTen + beat) {
+ dialPaint(knob, fill, body, 270);
+ } else if (t < toTen + beat + eleven + bow) {
+ // The SNAP: no travel, no ease — one frame it's at 10, the next it's AT
+ // eleven, lit, shaking uncontrollably (and fading, during the bow).
+ dialPaint(knob, fill, body, 300, CRANKED);
+ knob.classList.add("pop");
+ if (t >= toTen + beat + eleven) knob.classList.add("out");
+ } else {
+ knob.classList.add("out");
+ return;
+ }
+ requestAnimationFrame(run);
+ };
+ requestAnimationFrame(run);
+ return toTen + beat + eleven + bow;
+}
+
// If we've just returned from the home server's /restore page, adopt the
// recovered session before routing (checkRestoreReturn writes it to storage).
// Otherwise route straight away — the extra tick only happens on a real return.
@@ -497,8 +654,8 @@ function route() {
// registration (typeOwner, personalApps) is complete when folding starts. A
// broken or slow custom app must never block or white-screen boot: every
// import failure is contained to a console.error, and the whole phase races a
-// short timeout so boot proceeds regardless.
-const CUSTOM_APPS_TIMEOUT_MS = 4000;
+// short timeout so boot proceeds regardless. (Its timeout const is declared up
+// by route()'s invocation — see the TDZ note there.)
async function loadCustomApps() {
try {
const load = (async () => {
@@ -519,6 +676,7 @@ async function loadCustomApps() {
async function boot() {
try {
+ startLoadingDial();
await loadCustomApps();
await ensurePubkey();
// Role + first-run state, fetched in parallel with the heavy loads:
@@ -559,6 +717,9 @@ async function boot() {
// First-run: open the admin's setup conversation before the reveal, so the
// first thing an admin ever sees IS the chat with Eleven (no-op otherwise).
await maybeStartSetup(mePromise);
+ // A freshly-joined member gets Eleven's welcome instead (mutually exclusive
+ // with admin setup — a setup claim never sets the welcome marker).
+ await maybeStartWelcome();
} catch (e) {
console.error(e);
app.innerHTML =
@@ -570,12 +731,16 @@ async function boot() {
}
// Fade out and remove the full-screen loading cover (shown until the app's first
-// meaningful paint). Idempotent — safe to call from any entry path.
+// meaningful paint). Idempotent — safe to call from any entry path. If a loading
+// line is mid-display, the reveal waits until it's been readable (≥1.1s).
function hideLoading() {
const el = document.getElementById("app-loading");
if (!el) return;
- el.classList.add("hidden");
- setTimeout(() => el.remove(), 250); // after the CSS fade
+ const clicked = clickDialToEleven(); // the dial clicks to 11 — let it land
+ setTimeout(() => {
+ el.classList.add("hidden");
+ setTimeout(() => el.remove(), 250); // after the CSS fade
+ }, clicked);
}
// Embed bootstrap: render a single thread's feed for the native shell. Skips the
@@ -589,7 +754,7 @@ async function bootEmbed() {
// embed path needs registration complete before its render too.
await loadCustomApps();
myUserId = embed.myUserId || "";
- setCurrentUser(myUserId, session.name || "Admin");
+ setCurrentUser(myUserId, session.name || "New member");
await loadRoster(); // token-only; gives sender labels + profile photos
if (embed.instanceKey) {
try {
@@ -722,10 +887,10 @@ async function loadRoster() {
const me = roster.find((e) => e.public_key === session.userPub);
myUserId = me ? me.user_id : "";
}
- // The "Admin" fallback (here and on every outbound payload): a setup-invite
- // claimer is signed in nameless until Eleven's first question — if they send
- // anything before answering, recipients see "Admin", never a blank sender.
- setCurrentUser(myUserId, session.name || "Admin"); // let apps do per-user logic (e.g. poll "your vote")
+ // The "New member" fallback (here and on every outbound payload): setup and
+ // welcome claimers are signed in nameless until Eleven's first question — if
+ // they send anything before answering, recipients never see a blank sender.
+ setCurrentUser(myUserId, session.name || "New member"); // let apps do per-user logic (e.g. poll "your vote")
if (instanceKey) await decryptRosterLabels();
}
@@ -742,6 +907,11 @@ async function decryptRosterLabels() {
e.label = await decrypt(instanceKey, e.label_blob);
} catch {}
}
+ if (e.avatar_alt_blob) {
+ try {
+ e.avatarAlt = await decrypt(instanceKey, e.avatar_alt_blob);
+ } catch {}
+ }
}),
);
}
@@ -847,17 +1017,30 @@ async function setupInstanceKey() {
instanceKeyB64 = k.b64;
await grantInstanceKeyTo([]);
} else {
- // It exists but we don't hold a copy yet. Nudge any online holder to wrap it to
- // us, then poll briefly to pick up the grant — otherwise we'd be stuck on
- // "Photo syncing…" until a holder next reboots.
+ // It exists but we don't hold a copy yet. Nudge any online holder to wrap it
+ // to us, then poll to pick up the grant — and KEEP re-nudging (on focus, and
+ // on a slow timer) until it lands: the first holder to see a nudge may come
+ // online long after the first poll would have expired, and a keyless member
+ // renders everyone as "Someone" until this succeeds.
+ const nudge = () => {
+ fetch(`/api/instance-key/nudge?token=${encodeURIComponent(session.token)}`, { method: "POST" }).catch(() => {});
+ pollForInstanceKey(4);
+ };
+ const onFocus = () => (instanceKey ? stopRetry() : nudge());
+ const timer = setInterval(() => (instanceKey ? stopRetry() : nudge()), 60000);
+ const stopRetry = () => {
+ clearInterval(timer);
+ removeEventListener("focus", onFocus);
+ };
+ addEventListener("focus", onFocus);
fetch(`/api/instance-key/nudge?token=${encodeURIComponent(session.token)}`, { method: "POST" }).catch(() => {});
- pollForInstanceKey();
+ pollForInstanceKey(24);
}
}
// After nudging holders, poll for a wrapped instance key; once granted, unwrap it
// and re-render avatars. (The open Edit-profile sheet picks it up on next open.)
-async function pollForInstanceKey(tries = 6) {
+async function pollForInstanceKey(tries = 24) {
for (let i = 0; i < tries && !instanceKey; i++) {
await new Promise((res) => setTimeout(res, 2500));
if (instanceKey) return;
@@ -1355,6 +1538,7 @@ function renderApp() {
// A fresh join (set by the join flow) wins — land straight in that new chat.
const justJoined = localStorage.getItem("lchat_open_thread");
if (justJoined) localStorage.removeItem("lchat_open_thread");
+ pendingJoinThread = justJoined || pendingJoinThread; // the welcome conversation's "Say hi" chip needs it later
if (justJoined && threads.some((t) => t.id === justJoined)) {
target = justJoined;
} else if (deep && threads.some((t) => t.id === deep)) {
@@ -1429,7 +1613,16 @@ function renderThreadList() {
const app = appByName(appName);
const av = document.createElement("span");
av.className = "avatar app-avatar";
- av.textContent = (app && app.glyph) || "▦";
+ // An app can ship an avatar image (Eleven wears the app icon); the glyph
+ // stays the fallback. In-code URLs only — never user content.
+ if (app && app.avatar) {
+ av.style.backgroundImage = `url(${app.avatar})`;
+ av.style.backgroundSize = "cover";
+ av.setAttribute("role", "img");
+ av.setAttribute("aria-label", (app && app.title) || appName);
+ } else {
+ av.textContent = (app && app.glyph) || "▦";
+ }
row.appendChild(av);
} else {
let loader = null;
@@ -1636,8 +1829,10 @@ function paintHeaderAvatar(id) {
const el = document.getElementById("thread-avatar");
if (!el) return;
const t = threads.find((x) => x.id === id);
+ const ownerApp = threadApp[id] && appByName(threadApp[id]);
let loader;
- if (t && t.kind === "group") loader = () => threadAvatarFor(id);
+ if (ownerApp && ownerApp.avatar) loader = () => Promise.resolve(ownerApp.avatar); // e.g. Eleven's app icon
+ else if (t && t.kind === "group") loader = () => threadAvatarFor(id);
else {
const u = t && t.kind === "self" ? myUserId : t && (t.members || []).find((x) => x !== myUserId);
loader = () => avatarFor(u);
@@ -2353,9 +2548,19 @@ setChatAppHost({
// chat (no intro).
chatIntro: (threadId) => {
const owner = threadApp[threadId];
- if (!owner) return null;
- const a = appByName(owner);
- return (a && a.chatIntro) || null;
+ if (owner) {
+ const a = appByName(owner);
+ return (a && a.chatIntro) || null;
+ }
+ // A person-to-person DM gets one too, so a brand-new chat isn't a blank
+ // void before the first message (or while a typing indicator runs).
+ const t = threads.find((x) => x.id === threadId);
+ if (t && t.kind === "dm") {
+ const other = (t.members || []).find((uid) => uid !== myUserId);
+ const name = other && rosterLabel(other);
+ if (name) return `This is the start of your chat with ${name}.`;
+ }
+ return null;
},
icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY },
// Helper-proposed one-tap action chips ("Switch to dark" …): whitelisted,
@@ -2668,7 +2873,7 @@ setSetupBridge({
setMyName: async (name) => {
session.name = name.slice(0, 32);
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
- setCurrentUser(myUserId, session.name || "Admin"); // emitted events carry the new name
+ setCurrentUser(myUserId, session.name || "New member"); // emitted events carry the new name
const me = document.getElementById("me-avatar");
if (me) paintAvatar(me, session.name, () => avatarFor(myUserId));
await publishMyLabel();
@@ -2744,6 +2949,29 @@ setSetupBridge({
// Skipping the key ceremony starts the banner's escalating reminder schedule
// (24h → 72h → 1w) instead of an immediate contradiction of the conversation.
deferBackupReminders: () => deferBackupNudges(),
+ // A joiner's profile photo, sent through the chat (an ordinary E2E
+ // attachment): decrypt, then re-encrypt under the INSTANCE key as their
+ // avatar — unlike the server photo, it never leaves the encryption.
+ adoptProfilePhoto: async (threadId, att, alt) => {
+ if (!instanceKey) return "nokey"; // still waiting on the space key — not a bad photo
+ try {
+ const plain = await loadBlobBytes(att.id, threadId);
+ if (!plain) return false;
+ await uploadAvatar(new Blob([plain], { type: att.type || "image/jpeg" }), (alt || "").trim());
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ // The freshly-adopted profile photo, decrypted, for the welcome preview
+ // (cache-busted: it was uploaded moments ago).
+ myAvatarURL: async () => {
+ delete avatarURLs[myUserId];
+ return avatarFor(myUserId);
+ },
+ // The welcome conversation finished (or was completed enough) — stop
+ // re-opening it on every load.
+ markWelcomeDone: () => localStorage.removeItem("lchat_welcome_pending"),
// ---- the Admin app's reads/writes (role-gated server-side either way) ----
isAdmin: async () => {
try {
@@ -2788,10 +3016,10 @@ setSetupBridge({
async function maybeStartSetup(mePromise) {
const me = await mePromise;
const setupActive = !!me && me.role === "admin" && !me.setup_complete;
- // The sidebar backup nudge defers to the setup conversation entirely; once
- // setup is done (or for everyone else) it renders on its normal rules — which
- // include the escalating 24h/72h/1w schedule a setup-time skip starts.
- initBackupBanner(session, { suppress: setupActive });
+ // The sidebar backup nudge defers to Eleven's conversations entirely (setup
+ // AND the joiner welcome — one voice); afterwards it renders on its normal
+ // rules, which include the escalating 24h/72h/1w schedule a skip starts.
+ initBackupBanner(session, { suppress: setupActive || !!welcomePending() });
if (!setupActive) return;
try {
const id = await resolveAppThread("setup");
@@ -2803,7 +3031,7 @@ async function maybeStartSetup(mePromise) {
const pinFeed = () =>
requestAnimationFrame(() => {
const feed = document.querySelector(".chatapp-feed");
- if (feed) feed.scrollTop = feed.scrollHeight;
+ if (feed) feedGlideToBottom(feed); // intent-aware: follow checks stay true mid-glide
});
startSetup({
threadId: id,
@@ -2816,13 +3044,88 @@ async function maybeStartSetup(mePromise) {
} catch {}
}
+// The joiner's marker, written by the join page when a fresh person claims an
+// invite (chat-first, nameless — Eleven asks). Carries the inviter's suggested
+// name; cleared by markWelcomeDone when the conversation completes.
+function welcomePending() {
+ try {
+ return JSON.parse(localStorage.getItem("lchat_welcome_pending") || "null");
+ } catch {
+ return null;
+ }
+}
+
+// A freshly-joined member's first load: open Eleven's welcome conversation
+// before the reveal (the joiner mirror of maybeStartSetup). Runs until the
+// conversation completes; each load resumes past the answered steps.
+async function maybeStartWelcome() {
+ const pending = welcomePending();
+ if (!pending) return; // the marker is set only by a SUCCESSFUL member-invite
+ // claim (join.js clears it on failure), and cleared at stepFinish — so a stale
+ // one can't hijack an add-device sign-in (that path never sets it).
+ try {
+ const id = await resolveAppThread("welcome");
+ if (!id) return;
+ await openThread(id);
+ const pinFeed = () =>
+ requestAnimationFrame(() => {
+ const feed = document.querySelector(".chatapp-feed");
+ if (feed) feedGlideToBottom(feed); // intent-aware: follow checks stay true mid-glide
+ });
+ // The greeting names who runs the space AND who invited them — both are
+ // encrypted labels, so they need the instance key: a fresh joiner's key
+ // grant usually lands within a couple of seconds of the boot-time nudge.
+ // Wait briefly rather than open with "one of its members" when we're
+ // about to know better. (Everything label-derived reads AFTER this.)
+ for (let i = 0; i < 12 && !instanceKey; i++) await new Promise((r) => setTimeout(r, 250));
+ if (instanceKey) await decryptRosterLabels();
+ // The DM the invite created (captured before renderApp consumed its
+ // marker): its other member is the inviter, for the greeting and the
+ // "Say hi" chip.
+ let inviterName = "";
+ let openPendingThread = null;
+ // The captured marker survives one load only — on a mid-welcome reload,
+ // fall back to the joiner's (sole) person-DM, which is the same thread.
+ const dm =
+ (pendingJoinThread && threads.find((t) => t.id === pendingJoinThread)) ||
+ threads.find((t) => t.kind === "dm" && !threadApp[t.id] && (t.members || []).some((uid) => uid !== myUserId));
+ if (dm) {
+ const other = (dm.members || []).find((uid) => uid !== myUserId);
+ const entry = other && roster.find((x) => x.user_id === other);
+ inviterName = (entry && entry.label) || "";
+ const dmId = dm.id;
+ openPendingThread = async () => {
+ await openThreadById(dmId);
+ // They tapped "Say hi" — put the cursor where the hi goes.
+ requestAnimationFrame(() => {
+ const ta = document.querySelector(".chatapp-root .composer textarea");
+ if (ta) ta.focus();
+ });
+ };
+ }
+ const adminEntry = roster.find((e) => !e.is_agent && e.role === "admin");
+ startWelcome({
+ threadId: id,
+ suggested: (pending.suggested || "").slice(0, 32),
+ inviterName,
+ adminName: (adminEntry && adminEntry.label) || "",
+ openPendingThread,
+ typing: (on) => {
+ setNoticeTyping(id, "Eleven", on);
+ if (on) pinFeed();
+ },
+ respond: (spec) => appendNotice(id, spec),
+ });
+ } catch {}
+}
+
// Send a normal chat message (name + text) into a thread, as if typed. Same
// envelope + encryption as the composer and the app-event transport; used by the
// tour to ask Eleven a question on the member's behalf.
async function sendChatText(threadId, text) {
const key = threadKeys[threadId];
if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false;
- const payload = { name: session.name || "Admin", text, ts: Date.now() };
+ const payload = { name: session.name || "New member", text, ts: Date.now() };
if (myUserId) payload.uid = myUserId;
const blob = await encrypt(key, payload);
ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
@@ -2868,7 +3171,7 @@ function sendAndConfirm(threadId, blob) {
async function announceJoin(threadId, uids) {
const key = threadKeys[threadId];
if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return;
- const payload = { name: session.name || "Admin", text: `${session.name || "Admin"} added ${nameList(uids.map(rosterLabel))}`, join: uids.slice(), ts: Date.now(), action: true };
+ const payload = { name: session.name || "New member", text: `${session.name || "New member"} added ${nameList(uids.map(rosterLabel))}`, join: uids.slice(), ts: Date.now(), action: true };
if (myUserId) payload.uid = myUserId;
const blob = await encrypt(key, payload);
ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
@@ -2883,7 +3186,7 @@ async function announceJoin(threadId, uids) {
function announceLeave(threadId) {
const key = threadKeys[threadId];
if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve();
- const payload = { name: session.name || "Admin", text: `${session.name || "Admin"} left`, left: true, ts: Date.now(), action: true };
+ const payload = { name: session.name || "New member", text: `${session.name || "New member"} left`, left: true, ts: Date.now(), action: true };
if (myUserId) payload.uid = myUserId;
return encrypt(key, payload).then((blob) => sendAndConfirm(threadId, blob));
}
@@ -2896,7 +3199,7 @@ function announceLeave(threadId) {
async function announceRemove(threadId, uids) {
const key = threadKeys[threadId];
if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return;
- const payload = { name: session.name || "Admin", text: `${session.name || "Admin"} removed ${nameList(uids.map(rosterLabel))}`, removed: uids.slice(), ts: Date.now(), action: true };
+ const payload = { name: session.name || "New member", text: `${session.name || "New member"} removed ${nameList(uids.map(rosterLabel))}`, removed: uids.slice(), ts: Date.now(), action: true };
if (myUserId) payload.uid = myUserId;
const blob = await encrypt(key, payload);
ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
@@ -4038,17 +4341,42 @@ function connectWS() {
} catch {
return;
}
+ if (msg.type === "joined") {
+ // Someone claimed an invite: swap any pending-invite card for the live
+ // DM and pick up the new roster entry — no reload needed on this side.
+ await loadRoster();
+ await loadThreads();
+ await refreshNamesUI();
+ renderThreadList();
+ const t = activeId && threads.find((x) => x.id === activeId);
+ // openThread(activeId) would no-op (the already-open guard), so refresh
+ // the pending card directly: the joiner now has a key, so it clears and
+ // the feed + composer come back.
+ if (t && (t.members || []).includes(msg.user_id)) updatePendingBanner(t);
+ return;
+ }
if (msg.type === "instance_key_nudge") {
- // A member lacks the instance key. If we hold it, (re-)wrap it to whoever's
- // missing it so they can decrypt names + photos (and publish their own).
- // Reload the roster first: the nudger is often a brand-new joiner who isn't
- // in our cached roster yet, so without this we'd skip the very person asking.
- if (instanceKeyB64) {
+ // A member lacks the instance key. The nudge names WHO needs it, but we
+ // never wrap to a key handed to us in a raw frame — a compromised server
+ // could forge one and harvest members' names/photos. Reload the roster and
+ // wrap only to the public key the server has PUBLISHED for that user (a
+ // claimed member). The inviter shares the joiner's pending DM, so their
+ // roster includes the joiner and the grant flows; an unseen/forged nudge
+ // is simply ignored (the joiner re-nudges until a holder who sees them is
+ // online).
+ if (instanceKeyB64 && msg.user_id) {
try {
await loadRoster();
- const ik = await (await fetch(`/api/instance-key?token=${encodeURIComponent(session.token)}`)).json();
- await grantInstanceKeyTo(ik.have || []);
- await refreshNamesUI(); // the reload may have added the new joiner to our roster
+ const target = roster.find((e) => e.user_id === msg.user_id && e.public_key);
+ if (target) {
+ const wrapped = await wrapThreadKey(instanceKeyB64, target.public_key);
+ await fetch("/api/instance-key", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, grants: [{ user_id: target.user_id, wrapped }] }),
+ });
+ }
+ await refreshNamesUI(); // the joiner may be new to our roster too
} catch {}
}
return;
@@ -4078,7 +4406,17 @@ function connectWS() {
}
if (msg.type === "avatar") {
const e = roster.find((x) => x.user_id === msg.user_id);
- if (e) e.avatar = msg.blob_id;
+ if (e) {
+ e.avatar = msg.blob_id;
+ e.avatarAlt = "";
+ if (msg.alt_blob && instanceKey) {
+ decrypt(instanceKey, msg.alt_blob)
+ .then((alt) => {
+ e.avatarAlt = alt;
+ })
+ .catch(() => {});
+ }
+ }
delete avatarURLs[msg.user_id];
refreshAvatars();
return;
@@ -5461,7 +5799,11 @@ function avatarColor(name) {
// Paints initials immediately, then the photo (if any) when its loader resolves.
// Returns a promise of the photo URL (or null), so callers can react to it —
// e.g. the header avatar wires a tap-to-zoom only when there's a real photo.
-function paintAvatar(el, name, loader) {
+function paintAvatar(el, name, loader, alt) {
+ // Avatars are background-image spans, invisible to screen readers without
+ // this: the member's own (encrypted) photo description wins over their name.
+ el.setAttribute("role", "img");
+ el.setAttribute("aria-label", alt || name || "");
el.style.backgroundImage = "";
el.textContent = ((name || "?").trim().charAt(0) || "?").toUpperCase();
el.style.background = avatarColor(name);
@@ -5492,6 +5834,10 @@ function editProfile() {
card.appendChild(h);
const meAvatar = () => avatarFor(myUserId);
+ const meAlt = () => {
+ const e = roster.find((x) => x.user_id === myUserId);
+ return (e && e.avatarAlt) || "";
+ };
const av = avatarEl(session.name, meAvatar);
av.classList.add("avatar-lg");
const photoBtn = document.createElement("button");
@@ -5518,7 +5864,7 @@ function editProfile() {
photoBtn.disabled = true;
photoBtn.textContent = "Uploading…";
try {
- await uploadAvatar(f);
+ await uploadAvatar(f, meAlt()); // keep the existing description on a photo swap
delete avatarURLs[myUserId];
paintAvatar(av, session.name, meAvatar);
const me = document.getElementById("me-avatar");
@@ -5546,6 +5892,12 @@ function editProfile() {
input.value = session.name;
input.maxLength = 32;
input.addEventListener("input", () => paintAvatar(av, input.value || "?", meAvatar));
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ save.click(); // Enter saves, like any single-field form
+ }
+ });
card.appendChild(label);
card.appendChild(input);
@@ -5562,8 +5914,13 @@ function editProfile() {
if (next && next !== session.name) {
session.name = next.slice(0, 32);
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
+ setCurrentUser(myUserId, session.name); // emitted events carry the new name
const me = document.getElementById("me-avatar");
if (me) paintAvatar(me, session.name, meAvatar);
+ // Publish the (encrypted) label NOW — without this, everyone else keeps
+ // seeing "Someone" until this browser's next full reload happens to
+ // republish it. The welcome/setup bridge already does the same.
+ publishMyLabel();
}
backdrop.remove();
};
@@ -5575,7 +5932,7 @@ function editProfile() {
// Encrypt a profile photo with the instance key, upload it, and point the user's
// avatar at the blob. Downscaled small; readable by every member, never the server.
-async function uploadAvatar(file) {
+async function uploadAvatar(file, alt = "") {
if (!instanceKey) throw new Error("no instance key");
const { bytes } = await processImage(file, 400);
if (bytes.length > MAX_UPLOAD) throw new Error("too big");
@@ -5583,13 +5940,23 @@ async function uploadAvatar(file) {
const res = await fetch(`/api/upload?token=${encodeURIComponent(session.token)}`, { method: "POST", body: enc });
if (!res.ok) throw new Error("upload");
const { id } = await res.json();
- await fetch("/api/avatar", {
+ // The alt text is user content: it ships encrypted under the instance key,
+ // like the display name — the server stores an opaque blob. Cap the plaintext
+ // so the base64 blob stays well under the server's 4096-char limit (else the
+ // avatar POST 400s and — before this — failed silently).
+ alt = (alt || "").slice(0, 280);
+ const altBlob = alt ? await encrypt(instanceKey, alt) : "";
+ const av = await fetch("/api/avatar", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, blob_id: id }),
+ body: JSON.stringify({ token: session.token, blob_id: id, alt_blob: altBlob }),
});
+ if (!av.ok) throw new Error("avatar save failed"); // don't let the UI show it as kept
const me = roster.find((e) => e.user_id === myUserId);
- if (me) me.avatar = id; // so avatarFor() picks it up immediately
+ if (me) {
+ me.avatar = id; // so avatarFor() picks it up immediately
+ me.avatarAlt = alt;
+ }
}
async function leave() {
diff --git a/web/static/style.css b/web/static/style.css
index 3efe90e..2de37b9 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -146,10 +146,93 @@ html, body {
z-index: 100;
display: grid;
place-items: center;
+ align-content: center;
+ gap: 20px;
background: var(--bg);
transition: opacity 0.25s ease;
}
.app-loading.hidden { opacity: 0; pointer-events: none; }
+/* The join page's "Plugging in…" flash between claiming and the app. */
+.plugging-in {
+ min-height: 22px;
+ color: var(--muted);
+ font-size: 15px;
+ text-align: center;
+ opacity: 0;
+ transform: translateY(6px);
+ transition: opacity 0.3s ease, transform 0.3s ease;
+}
+.plugging-in.show { opacity: 1; transform: none; }
+/* The loader's stage dust: a strip of particles along the bottom edge. */
+.amp-dust {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100%;
+ height: 140px;
+ pointer-events: none;
+}
+/* The boot amp knob (labelled 1–11): the pie arc fills and the pointer turns
+ from 1 towards 10 while loading; on ready it goes to eleven, then glows.
+ The pot sweeps 300° — 1 at 7 o'clock round to 11 at 5 o'clock. */
+.amp-knob {
+ width: 92px;
+ height: 92px;
+ border-radius: 50%;
+ position: relative;
+ /* Hidden until the engine has posed it at 1 (labels built, pointer turned) —
+ the raw pre-JS knob would flash pointing at nothing, mid-dial. */
+ opacity: 0;
+}
+.amp-knob.ready { animation: amp-in 0.35s ease both; }
+/* After eleven: the knob takes its bow — fades out on the still-solid cover
+ BEFORE the chat is revealed. (Declared after .ready so it wins.) */
+.amp-knob.out { animation: amp-out 0.32s ease both; }
+@keyframes amp-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes amp-out {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+.amp-fill {
+ position: absolute;
+ inset: 14px;
+ border-radius: 50%;
+ background: conic-gradient(from 210deg, var(--accent) var(--sweep, 0deg), color-mix(in srgb, var(--border) 55%, transparent) var(--sweep, 0deg) 300deg, transparent 300deg);
+}
+.amp-body {
+ position: absolute;
+ inset: 24px;
+ border-radius: 50%;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ box-shadow: inset 0 -2px 4px rgba(0, 0, 0, 0.18);
+}
+.amp-pointer {
+ position: absolute;
+ left: calc(50% - 1.5px);
+ top: 2px;
+ width: 3px;
+ height: 11px;
+ border-radius: 2px;
+ background: var(--ink);
+}
+.amp-num {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--muted);
+ transform: translate(-50%, -50%) rotate(var(--a)) translateY(-41px) rotate(calc(var(--a) * -1));
+}
+/* Hitting eleven: the 11 lights up, a breath, and the whole cover fades fast
+ (hideLoading's fade — no theatrics). */
+.amp-knob.pop .amp-num[data-n="11"] { color: var(--accent); }
+
/* The join page's card yields entirely to the full-screen loader while a claim
is in flight (see showLoader/showCard in join.js). */
.card.hidden { display: none; }
@@ -687,23 +770,18 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.lp-host { font-size: 11px; color: var(--muted); margin-top: 5px; }
-/* composer attachment preview — floats over the feed instead of taking its own
- band. .composer-float is a zero-height in-flow anchor at the top of the
- composer stack; the chip row is absolutely anchored to its bottom, so it
- paints upward over the messages and rises with any banners (reply/edit)
- that grow the stack. Wrapped rows grow upward too (bottom-anchored). */
-.composer-float { position: relative; height: 0; z-index: 5; }
+/* composer attachment preview. .composer-float is the in-flow anchor at the top
+ of the composer stack; the typeahead drop-up absolutely anchors to it. */
+.composer-float { position: relative; z-index: 5; }
+/* The staging strip occupies real layout space (the feed shrinks above it) —
+ floating it over the feed covered whatever sat at the bottom, including
+ tappable action chips ("Skip for now" became dead text under a thumbnail). */
.composer-preview {
- position: absolute;
- bottom: 8px;
- left: 16px;
- right: 16px;
+ padding: 8px 16px 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
- pointer-events: none; /* the empty row lets feed clicks/scroll through */
}
-.composer-preview > * { pointer-events: auto; }
.composer-preview.hidden { display: none; }
.preview-spinner {
width: 16px; height: 16px; border-radius: 50%;
@@ -779,6 +857,9 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.action-chip:hover:not(:disabled) { background: var(--bubble-them); }
.action-chip:disabled { color: var(--muted); cursor: default; }
+/* A spent chip (its step was answered/skipped): a quiet part of the record,
+ not something that still looks tappable. */
+.action-chip--spent { background: transparent; border-color: transparent; padding-left: 6px; padding-right: 6px; }
/* Local notice: a command's private "only visible to you" reply — a client-only
feed row (never sent, gone on reload). It lives IN the feed and PERSISTS (no
@@ -822,12 +903,19 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
border-radius: 12px; border: 1px solid var(--border);
background: color-mix(in srgb, var(--ink) 6%, transparent);
}
+/* An avatar preview renders as the circle it will actually be. */
+.local-notice-img--round { border-radius: 50%; object-fit: cover; }
/* Notices ease in like live messages (see .msg.appear). */
.local-notice.appear { animation: msg-appear 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); }
@media (prefers-reduced-motion: reduce) {
.local-notice.appear { animation: none; }
}
.local-notice .chip-row { margin-top: 8px; }
+/* A chips-only notice row (the live actions under a persisted bubble): tuck it
+ right up under the bubble it belongs to instead of floating a full row lower.
+ (Doubled class so it outranks the base rules above regardless of order.) */
+.local-notice.local-notice--chips { margin-top: 2px; } /* + the feed's 4px gap = a 6px breath */
+.local-notice.local-notice--chips .chip-row { margin-top: 0; }
/* A confirmation is loud only for a while: after ~30s it "blinds up" (its height
animates down — see collapseNotice) to this compact one-line record
(glyph · who · what happened · action). */
@@ -847,7 +935,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.local-notice--compact .lc-action::before { content: "·"; margin-right: 5px; color: var(--muted); font-weight: 400; }
/* App-thread action line: a centered, muted /me-style record of an interaction
("added 'milk'") — deliberately unlike a chat bubble. */
-.chatapp-action { align-self: center; text-align: center; color: var(--muted); font-size: 13px; padding: 3px 14px; max-width: 90%; }
+.chatapp-action { align-self: center; text-align: center; color: var(--muted); font-size: 13px; padding: 3px 14px; margin: 10px 0; max-width: 90%; }
/* The "start of your chat history" intro: a calm, centered explainer pinned at
the top of a bot's chat (a persistent first row that scrolls up with history). */
.chatapp-intro {
@@ -1402,8 +1490,10 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
width: 72px; height: 72px; border-radius: 18px; object-fit: cover;
margin: 0 auto 12px; display: block;
}
-/* the browser-only caveat on the welcome card: present but quiet */
-.welcome-caveat { font-size: 13px; margin: 12px 0 0; }
+/* the browser-only caveat on the welcome card: present but quiet, with clear
+ air between it and the button it annotates. (.card selectors so this outranks
+ the generic .card p margin rule.) */
+.card .welcome-caveat { font-size: 13px; color: var(--muted); margin: 20px auto 4px; max-width: 42ch; }
.steps {
text-align: left;
color: var(--muted);