Conversation
The expanded composer needs a composer, and a composer needs h(), icon() and the 55 SVGs behind it. Sidecar has no build step, so sharing means a second script tag rather than an import: composer-core.js now holds show, hide, ICONS, FILLED_ICONS, icon and h, lifted unchanged. The panel destructures them straight back into its own scope on its first line, so its several thousand call sites did not move and nothing about how it runs has changed. The alternative was a second copy of the icon set in the new page, which is the kind of duplicate that looks free on the day and drifts by the third icon somebody adds. Six test files read sidepanel.js as text and assert against it. They now read both files joined, which is what the panel actually loads.
The expanded composer needs the editor, not just the icons: @ mention autocomplete, the pills, and the serialize/hydrate round trip the draft store depends on. So createMentionEditor, serializeEditor, hydrateEditorFromText and the tracking-parameter family follow the DOM toolkit into composer-core.js. The code itself reads as it did in the panel. What changed is where its collaborators come from: profile lookups, the follow list, the global name search and its consent ask are page-level services, so the page hands them in through installComposer and this file stays ignorant of which one it is drawing into. One mutable binding rather than an argument threaded through thirty functions, since a document only ever runs one page and keeping the moved code otherwise identical was worth more than the purity. AND THE SEAM IS NOW TESTED, because the extraction already broke once in a way nothing could see. The follow-list cache was read straight off followListCache and state.activePubkey, names that are simply not in scope in this file. Every test passed. It would have thrown on the first @ typed in the new page. composer-core.test.js now checks that every name the core reaches for through deps is one the panel passes, that everything the panel destructures is something the core returns, and that the core mentions none of the panel's own globals.
The panel is 360px wide. That is the right size for approving a signature and the wrong size for writing anything you would want to read back, so the composer now has a corner button that opens the same draft in a tab at 17px over a 720px measure. THE SAME DRAFT, NOT A COPY. Both ends read and write the one slot in the background's encrypted draft store, keyed by account, so the text is already there before the tab exists and whatever is typed in the tab is what the panel finds afterwards. Nothing rides in the URL, because a handover has a moment where the note lives in one place only and that is the moment a tab gets closed. WHERE IT PUBLISHES IS DECIDED IN THE PANEL. An account's write set means its NIP-65 list, the configured relays, or the declared set alone when the account asked for NIP-65 only, and that last case is why the page must not re-derive it: publishing a NIP-65-only account to the configured list is precisely what the setting exists to stop. The panel works it out on expand and leaves the answer beside the draft. A page opened cold falls back to the configured list, which is the panel's own fallback too. The page gets its own small SimplePool rather than a share of the panel's. That one carries reconnect handling, NIP-42 auth, relay health and the notification subscriptions, none of which a composer needs, and sharing it would have meant the new page could destabilize posting from the old one. Replies and polls do not offer the button. The page composes a top-level note, and a reply that arrived there would publish as one, silently, which is the exact failure the draft store learned to carry replyTo to avoid. Not built yet, and next: threads and long-form, both of which only make sense at this size.
The corner already belongs to the close box, so the icon went straight on top of it. And the outward arrow that means "expand" in most apps reads here as leaving the browser, which is the one thing this does not do. It is a word at the right margin of the Write / Preview row instead. Three things you can do with what you are writing, and the third one says what it is. Same metrics as a tab so the baseline lines up, no bottom border so it cannot read as a third tab that could be selected.
A tab has no panel chrome around it to say whose page it is. The guide and the app directory already settled the shape, so this is the same bar at the same height: 12px of padding around a 22px wordmark, sticky at the top. IN THE THEME, which is where it parts company with the guide. That bar's background is a hardcoded rgba of Speakeasy's velvet, which would read as a black stripe above a marble page on any of the six light themes. This one comes from the theme variables, and the wordmark swaps with the theme too, since the default is baked lavender and vanishes on a light field. LIGHT_THEMES, logoSrcFor and avatarPhSrc moved to composer-core.js to make that possible. Their own comment counts the places a new theme has to be registered and warns that the forgotten one is the failure mode; this page would have been one more. It also fixes an avatar placeholder on the page that was appending an img with no src at all. The move broke the panel in a way nothing could see: it kept calling logoSrcFor and avatarPhSrc while no longer importing them, which is a ReferenceError on every theme apply. The seam test now checks that half too. Any name the core exports and the panel still uses, the panel must also take.
A tab is a lot of empty field, and text laid straight onto it has nothing holding it. The note being written is one object, so it gets one surface with an edge: solid velvet-1 from the theme, a strong border, 18px corners and a shadow under it. Centered horizontally by the flex container and vertically by auto margins rather than align-items. Both center while the card fits; when it does not, auto margins resolve to zero and the overflow stays reachable, where align-items pushes the top of the card off the top of the window with nothing able to scroll back to it. The editor inside moved off velvet-1 onto the same field every other input in the app sits on. It was the card's own color, so the two would have rendered as one flat plane with a caret in it. The bar's height is written once as --compose-topbar-h, because the card subtracts it to know what a full window is, and two literals drift.
Media, proof of work and Write / Preview, all of it the panel's own code rather than a second set. renderNotePreview, renderNoteText, the embed and mention resolvers, the link-card fetch, the Blossom uploader and the proof-of-work miner all moved into composer-core.js: 436 lines across 22 ranges, leaving the panel importing what it still calls. A second preview renderer would have been the worst of them. It would disagree with the panel's about a mention, an embed or a link card, and the whole point of a preview is that it is what will be published. Their relay reads are injected, so the core never learns which sockets it is using. The page hands in its own poolGet and poolQuerySync and the preview resolves through the pool this page already owns, which is what lets the panel keep its reconnect handling and its NIP-42 auth without this page inheriting any of it. Mining happens before signing, because the event id commits to the pubkey. Post becomes Stop while it runs, since it is the only button on screen and ten seconds at 22 bits is long enough to want out of. The close box moved to the card's own top-right corner, where every sheet in the panel puts one. Cancel is a word rather than a button: leaving is not an action with the same weight as publishing, and two filled controls side by side say it is. Post is the largest thing in the row, because it is the only thing on the page that cannot be taken back. Media counts as content on its own, for the draft and for the Post button, the same as in the panel. Polls are still panel-only. The editor for them lives inside openComposer and closes over the composer's own state, so it is a different kind of move from these.
The panel drops a banner because its composer is a modal that closes onto a whole app underneath. A tab has nothing underneath it: the card IS the page, so emptying the editor and leaving it there reads as a note lost rather than published. So the card becomes the receipt. A mark, "Your note is live.", how many relays took it, a link to read it, and Write another beside it. THE LINK IS THE POINT. Sidecar is a companion and not a client: it cannot show you the note in a thread with its replies, and the client this account already chose can. Resolved against the account that SIGNED it rather than whatever is active by the time it paints, since the panel can switch while the tab is open, and the nevent carries relay hints or the client has nowhere to look it up. A failure to work that out leaves the receipt without a link rather than with a broken one. VIEW_CLIENTS, DEFAULT_CLIENT and resolveClient moved to the core for it. The panel's banner and this receipt are the same question asked twice, and two directories would answer it differently the first time a client changes its routes. The mark's wash is mixed from its own glyph color rather than from --accent-rgb, so the two cannot disagree on a theme that moves one and not the other.
Write and Preview are two views of one thing, so switching between them must not change the type scale or the height of the card. The pane was inheriting the panel's 14px and its 120px minimum, which made the preview read as a thumbnail of the 17px being written above it. That is the opposite of what a preview is for. Same field, same size: 17px on a 340px pane with the editor's padding and corners. Embedded media scales with it too, since 220px is sized for a 360px column and looks like a stamp on a 720px one.
The expanded composer is the same draft in a tab, and both ends autosave on a 400ms debounce. Two of them open is last-writer-wins on every keystroke, and it gets worse than that: posting from one clears the slot while the other still holds the text in memory, so the next keystroke there republishes a note that already went out as a fresh draft. Start fresh in the panel's chooser would delete what the tab is editing, and the tab would put it straight back. So the panel stands down for a live tab and focuses it instead of opening its own. Focused rather than refused: the tab can be in another window, and a panel that does nothing when you tap Compose is indistinguishable from a broken one. Expand reuses an open tab for the same reason rather than making a second one on the same draft. A reply is a different slot, since draftKey appends the id it answers, so only the main composer collides and only it is held back. getContexts rather than tabs.query, and the difference is not style. The manifest asks for https://*/* and nothing else, so tab.url is blank for a chrome-extension:// page: a query filtered on compose.html would match nothing, and adding the tabs permission to see a document we own would widen what Sidecar can read across every tab the user has open.
minePow works on a snapshot of the template taken when Post was pressed. The editor stayed live through it, so anything typed during the mine was not in the note that published: at 22 bits that is ten seconds and sometimes a minute of writing into a box whose contents no longer matter, and the note goes out as the old text while the screen shows the new one. The panel cannot reach this state, because its mining pane takes over the screen and every route back into the composer is disabled. Here the editor is simply still there, so it is turned off for the duration, along with the toolbar and the tabs. Cancel and the close box stay live: leaving is always allowed, and it takes the worker with the page. It looks disabled too. A caret blinking in a field whose contents are already spent is the worst kind of lie a composer can tell. Also gives the receipt's check mark its Werkstätte exception. That theme squares everything by design and the audit caught the new disc, which is exactly the job that test exists to do.
Cancel and the close box chain their close off the save, so those were never at risk. Closing from the browser's own tab strip was: the only thing covering it was beforeunload, which fires as the document is being torn down, and persistDraft is an async round trip through the worker. The page can be gone before the write lands, which is why the guidance everywhere is not to start async work there. What was at stake is the 400ms of debounce, which is the last sentence written. visibilitychange is the one that arrives. Hiding a tab fires it first and the document stays alive afterwards, so the write completes. Switching tabs saves too, which costs one storage write and means the draft is already safe before anything closes. beforeunload stays as a backstop rather than as the mechanism. The flush drops the pending debounce as well as writing, so a timer that had already been scheduled cannot land afterwards with older text in it.
state was read once at boot and never again, and the panel is a different document. Switch accounts there and this page went on showing the old name and avatar, went on writing the old account's draft slot, and then failed at Post with a bare error, because owner-sign refuses when expectedPubkey is not the account it would sign with. Failing closed is right. Failing closed with no explanation, after the note is already written, is not. The panel disables its own account switcher while a mine runs for this reason. It cannot disable it on this page's behalf, so the page watches instead, on focus and on becoming visible. The draft follows the account, because the slot is keyed by it. What is on screen belongs to the account it was typed as, so it is written back there BEFORE the key moves rather than carried into someone else's slot, and the new account's own draft is loaded in its place. The relay set and the follow cache go with it too, since a different account publishes somewhere else and follows different people. Never mid-publish: a swap underneath a running post is the one moment where reacting is worse than not.
It fires on every input event. The panel wrapped its own in a 20s guard and the expanded composer page passed the bare message, so a keystroke each woke the service worker for as long as somebody was writing. Only one of the two was ever going to be remembered, so neither is asked to: the guard moved into the editor, which is the one place both of them go through. A page whose noteActivity throws no longer takes a keystroke down with it either.
…ault The worker answers "Keystore is locked" or "Sidecar is locked" depending on which guard refused, and either one arrived here as a bare error toast: a note already written, a failure with no cause named, and nothing saying what to do next. The panel has an unlock flow and this page cannot host one, so the least it can do is name where it is. The draft is still there, which is the other half of why this is survivable rather than a dead end: the text is only cleared after a publish that landed.
noteCountdown defaults on and the panel has honored it since it existed. This page published the instant Post was pressed, which is a setting somebody turned on and one of two composers quietly ignoring it. showPostCountdown was already parameterized on its container and its preview, because the note composer and the page-comment sheet had different things worth a second look. The last thing in it that knew which document it was drawing into was the identity strip, read off the panel's own state, so that became a parameter as well and moved to the core with it. Both panel call sites pass one, including the comment sheet, which drew a strip before the change and would have silently stopped. On the page it renders into its own container rather than over the card. Taking over the sheet the way the panel takes over its modal would mean rebuilding the editor on cancel around a lost caret. The editor and the toolbar go inert while it is up, for the same reason they do while a mine runs: what is being reviewed was decided when Post was pressed. A settings read that fails still posts. Refusing to publish because we could not find out whether to wait five seconds first would be the wrong way round.
_ownNoteIds is what the notification bell filters replies against, and it is the panel's own memory. A note written in the expanded composer was not in it, so replies to that note stayed out of notifications until the panel next re-queried its own notes from relays. The tab says so instead of waiting to be found. SIDECAR_EVENT already exists as a broadcast from the worker to whichever extension pages are open, and a page's own broadcast reaches the panel the same way. The worker now ignores that type rather than answering it. A page emitting one reached the control switch, fell through, and came back as "Unknown control message" with a dev-log line for company, which is not what a broadcast should cost. Only after the publish landed: telling the bell about a note the relays refused would put an id in that filter for an event nobody can reply to.
The Profile tab rendered a banner, an avatar and a status balloon and
then stopped, because renderAbout calls resolveMentions and resolveMentions
had moved to the core without being handed back. Six more went with it:
IMG_EXT in firstQuoteImage, embedRef in resolveQuotePreviews,
renderNoteText in the reply context strip, renderLinkCard in the
web-comment sheet, paintCountdownNum in the unlock cooldown, and
tryBlossomFirst in the profile-picture uploader. Every one a
ReferenceError the moment that surface drew.
They were never exported at all, which is why the seam test missed them.
It checked the names the core RETURNS against the names the panel takes,
so a name that moved and was never returned was outside the question it
asked.
It asks the right one now: everything the core DECLARES, against what
the panel either takes or declares for itself. Both of its scans were
also finding the first `const {` in the file and matching the whole way
to the call site, which is how the check came back clean while the panel
was broken; they are anchored now, and the installComposer return is
guarded too, since dropping a name from that one hands back undefined
rather than throwing.
Mutation-checked in both directions. This class has cost three breakages
in a day and every one of them was invisible to a green suite.
The gap before Media and PoW was an empty thumbnail row. It is still a flex item when it holds nothing, so it charged 14px of its own margin plus the sheet's gap on either side of it: 42px of nothing between the editor and the toolbar, at every window height. The panel pays a smaller version of the same bill, so that fix is not scoped to this page. The rows inside the sheet no longer add a margin on top of the gap the sheet already sets either. Under 820px of viewport the card gives back its padding and takes the two panes down to 240px, which is still twice what the panel offers and therefore still the reason to be on this page. The two panes read one variable rather than two equal numbers, because them agreeing is the point: switching Write to Preview must not move the card, and that has to survive an override that shrinks both at once. Post gets a 184px floor rather than more padding, so its width is the same whatever the label says. It becomes Stop mining for as long as a mine runs, and a button that changes size when it changes job reads as two buttons.
getState already carries `locked`, so the page can say so at boot and on every focus instead of discovering it when Post fails with a note already written. The lock can also land while the tab sits open: fifteen idle minutes is shorter than a long note. The unlock is asked for, not hosted. SIDECAR_UNLOCK enumerates its three callers in a comment because the throttle and the 21st-strike wipe are enforced behind it, and a fourth surface taking a PIN is a change that deserves a security look rather than a paragraph in a layout commit. So the button opens the side panel, where the unlock already lives. chrome.sidePanel.open wants a user gesture and routing it through the worker loses one: a message handler is not the click. It is called from the click itself, on a page that already holds the sidePanel permission and needs no new one. If it throws anyway, on an older Chrome or in a context the API declines, the words that were always the fallback are still there. Post stays enabled behind the notice. The draft is safe either way, and refusing to let somebody finish writing because the store happens to be locked is the wrong way round.
Leaving Post lit behind the locked notice was worse than I thought. Pressing it sat through the whole review countdown and then a full proof-of-work mine, which at 22 bits is as much as a minute, before the signer refused and a toast explained. A note vanishing for a minute into no feedback at all is worse than anything that could follow it. There is one primary control on this page, so it says which of three things it is doing rather than saying Post and doing something else: it becomes Unlock to post while the store is locked, the same way it becomes Stop mining while one runs. All three states are decided in one function now, because three of them setting label, class and disabled independently is how a button ends up saying Post while a mine is running. reviewThenPost refuses too, for the route the button no longer offers. The editor stays writable behind it. The draft is safe either way. And the worker broadcasts the unlock, which it never did. It already broadcast the lock so an open panel would drop to its unlock screen rather than discover it on the next action; the mirror of that matters more here, because the panel this page sends you to opens BESIDE the tab and never takes its focus. Without it the page would go on saying Unlock to post until something else happened to it.
Posting did nothing. The review countdown paints its digits through
splitGlyphs, splitGlyphs calls ironDiceStyle, and ironDiceStyle had
stayed in the panel: a ReferenceError thrown after setReviewing had
already hidden the editor to make room for the countdown. The card went
blank, the note looked lost, and the draft was fine all along. The
panel's own balance figures strike through the same call, so they were
broken too.
Nine more were hiding behind it: _blossomServerCache, BLOSSOM_CACHE_TTL,
sha256Hex, resolveQuotePreviews, quoteSnippet, firstQuoteImage, relTime,
glyphBeat with its strike constants, and two bare applyAvatar calls that
should always have gone through the page.
THE GUARD IS THE REAL CHANGE. It was a denylist of panel globals, which
only ever catches what somebody thought to list, and the scan behind it
stripped strings and comments with regexes. An apostrophe in a trailing
comment ("calls don't double-fetch") opened a string that ran hundreds
of lines and swallowed the declarations the scan was looking for, which
is how it came back clean twice while the panel was broken.
So there is a tokenizer now, and the check is general: every call and
every member base in composer-core.js must be a name the file declares,
a name injected through deps, or the browser's. Nothing else, because
anything else is a scope this file cannot see. Mutation-checked in both
halves.
Four breakages of this one shape today. This is the version that would
have caught all four.
It started every note at off regardless. An account that had asked for 20 bits in Settings got none of them the moment it wrote in a tab, and silently, which is the difficulty with a setting that quietly does not apply: the note goes out, it is just not the note that was asked for. powSetting moved to the core rather than being read a second time here. powBy is keyed by pubkey and the default is off because a mine spends the user's own time; two copies of that judgment would drift, and this is the second setting today that one composer honored and the other did not. Re-seeded when the account moves under the tab, with everything else that account decides.
It defaulted to five seconds here and fifteen in the panel, so the same account got three times less time to catch a mistake depending on which composer it was in. This page had its own copy of the reader, with its own default and no check that a stored value is one of the presets. That is the third setting today that one composer honored and the other read for itself, so postCountdownSetting moved to the core with them. The presets are a list rather than a range because a value outside them is a hand-edited settings file, not a choice, and falling back to the default beats honoring it. Settings draws its picker from the same list. And the pane it reviews in was inheriting a 42vh cap sized for a 360px panel. On this page that sits under a note written at 17px with its images at full width: tall enough to push its own ring and its own buttons off the screen, at the one moment everything in it matters. A third of the window, scrolling inside itself, images down to something takeable at a glance, and the ring holding its size while the rest gives way.
Two mistakes with one shape. The expanded composer started every note at off, so an account that had asked for 20 bits in Settings got none of them in a tab. Seeding from Settings instead then threw away a rung chosen in the panel a second before pressing Expand. The difficulty is a decision about THIS note, the same as the text is. So it rides in the draft, next to replyTo and the media, and the account's standing setting is the fallback rather than the answer. Both composers write it the moment the button cycles rather than at the next keystroke, and the panel restores it when resuming a draft, for the reason it already restores replyTo: finding a resumed note's difficulty reset is the same surprise as finding its target reset. Written even when off, because off can be the deliberate choice against an account that mines by default.
…ns back The panel passes an identity strip to the countdown because its countdown replaces the whole modal, header and all, so nothing else on screen says who is posting. This card keeps its own header up, so the strip put a second face and a second name six lines under the first. And its buttons had fallen to the left. `.actions` is only styled under `.modal`, where it is a column with the primary on top; with no modal above it here the two buttons came out inline and left-aligned, at the opposite end of the card from where this page puts Cancel and Post every other second of its life. They are a right-aligned row now, in the footer's order and at the footer's sizes. row-reverse rather than reordering the shared builder, which appends the primary first because the panel's column wants it on top, and is right to. The whole footer hides during the review rather than its two buttons, which had left the character count dangling underneath attached to nothing.
BLOSSOM_SERVER_LIST_KIND stayed in the panel when the uploader moved to the core, so fetchBlossomServers threw a ReferenceError inside its own try, returned an empty list, and every upload in BOTH composers went to nostr.build instead of the account's own server. Two more constants had gone the same way. The catch says something now: an empty list and a failed lookup are not the same thing and must not read the same. The page was also answering every relay question with the PUBLISH set. relayUrls(false) means all configured relays, read-only ones included, and it is what the core asks for when it looks up a profile, an embed or the 10063 server list; targetRelays is where a note goes and has no business deciding where a lookup happens. Conflating them meant the server list was looked for on two or three write relays. THE GUARD IS THE POINT. Four extractions in a day left callers behind, and the suite was green through every one, because these tests read source as text and cannot tell you a screen still draws. There is a real tokenizer now and two symmetric checks: every identifier in composer-core.js and every identifier in sidepanel.js must be declared there, taken off SidecarCore, or a browser global. Both mutation-checked. On its first run the panel half found a live bug that predates this branch: `const title = global` in paintSearchModeIcon, a ReferenceError swallowed by an uncaught then(), so the search-mode button has never had a tooltip. Fixed here.
Both composers gain an ALT chip on every image thumb — "+ ALT" until the picture is described, "✓ ALT" after — and a full-width editor row under the strip: thumbnail and explainer on top, the multiline field beneath, the count and Save stretched under that. Stacked, per the only grammar a 360px column has; an inline row rather than a second modal, because the panel's composer IS a modal and rebuilding it around a saved description would cost the caret. On publish the note carries one NIP-92 imeta tag per DESCRIBED attachment, written the way zap.cooking writes them (PRs #746/#749, and Amethyst and Gossip before that): the alt slot's value is everything after its first space, so spaces, quotes and line breaks ride as themselves — JSON escapes them on the wire and the event id hashes the same bytes. Breaks are normalized rather than flattened: CRLF to LF, each line trimmed, runs of blank lines capped at one paragraph gap, which is the correction #749 itself had to make after shipping the flattened version. Empty means nothing: no alt slot, no imeta tag, and an image with no description publishes byte-identical to what it published before this existed. Sidecar renders no images in a note, so this is the write side only — the reader's client does the saying. The tag builder, the break normalizer and the editor row live in composer-core so the two composers share one copy of the wire format, and the parity spec's vectors run against it in test/imeta-alt.test.js. Alt rides in the draft's media array, which has always been serialized whole, so a description survives the tab, the account switch and the resume.
The ALT row's explainer named the screen reader, which is the mechanism rather than the person. The row is asking for words about a picture, and the reason to write them is a reader who may not be able to see it — which is also closer to how the row reads to somebody who has never deliberately written alt text before.
The foot row's counter was text beside a flex: 1 button, and text changes width with its digits: "1998 left" is a different-sized neighbor from "12 left", so Save readjusted on every keystroke — a control changing size under a moving hand. The meter is now the review countdown's ring at row size: one fixed 20px footprint in the row from the moment it opens, empty track included, filling by dash offset and turning amber for the last tenth of the cap. Nothing in the row says a number, so nothing in the row moves.
An upload wrote its URL into the editor as a line of text, and the editor was where it lived: removing a thumb went looking through the text nodes for the line and cut it out, a 100-character CDN URL pushed every real sentence down the panel, and the composer showed two copies of the same thing. The URLs live in the media slot alone now. The composer shows the prose the user wrote and nothing else, and one function puts the two back together — at publish, where the attachments are appended to the end after a blank line, each URL on its own line, and in the preview and the review window, which show the same composed string. Nothing about the wire format changes: the URL in the content is still what most clients read, and the imeta tags describe those same URLs in the same order. A drawer under the thumbnails replaces what the editor stopped showing: one collapsed line — "2 attachments — added to the end of your post" — opening to one row per attachment, the URL truncated with a copy button in the icon slot, for whoever wants to see or take a link. Drafts saved before the move carry the URLs in their text, the same URLs the media slot holds; restoring strips them, or publishing would append the note's images a second time. And because an upload no longer fires an editor input, the autosave and the Post button are told by hand at every media change — media alone is a postable note, so a first upload into an empty composer must enable Post and a last removal must leave it lit.
The order the URLs are appended in is the order the attachments sit in the draft, and until now that order was whatever order the uploads happened to finish in — no way to put the second picture first. With more than one attachment on the strip, the thumbs drag: the one being carried fades, the one it would land on takes an edge, and the drop splices the draft into the new order. Everything downstream follows the array — the appended URLs, the imeta tags, the drawer rows — because the strip was already the single source of that order; the drag just makes it editable. The images' own native drag is disabled so the cell, not the browser, owns the gesture, and an open ALT row closes on the drop since the slot it edits may have moved under it.
The base textarea rule hands every field a resize handle, and the description field grew one it had no use for: two rows are the shape of a description, and a corner grip on a 300px row read as chrome borrowed from a screen that has room to drag around in.
Save description was a primary — the orange fill, the glow, stretched across the whole foot row — which is a louder presence than saving one description earns on a row that is itself a footnote to a thumbnail. It is a ghost now, at its natural width at the row's end. The count comes back beside the ring, as the bare number and nothing else, held in a fixed box of four tabular digits right-aligned: "2000" and "12" occupy the same space, so the quieting of the button is not paid for by the resizing it was meant to fix. The ring stays the sentence the number sits in, and turns amber with it for the last tenth of the cap.
Hitting publish left half the card up underneath: the preview pane kept rendering the note, the thumbnails kept their strip, and the drawer kept its line — while the review window above them rendered the same note again, so a post with an image showed that image twice on one screen and the eye had two versions to choose between. setReviewing now puts one class on the card, and the preview pane, the thumbnail strip and the attachments' drawer all stand down while the review is up. A class rather than a handful of hidden toggles, because the drawer and the preview pane manage their own .hidden — renderThumbs re-opens the drawer on every sync, and the two would have argued about who closed it. Cancel puts the card back exactly as it was, tab and all.
A draft save attempted while the keystore is locked fails, and the tab swallows the failure — defensibly, since a locked store answers nothing and the typing continues. But everything edited since the lock landed exists only in the page's memory, and the page never retried: the unlock toast told the user their draft was still here while the last stretch of it had never reached the store. Close the tab the wrong way — a crash, a discarded tab, a browser quit — and the earlier text, saved before the lock, looks fine while the description set afterwards is gone. The unlock message now flushes the draft, which is the first moment the write can land. It rides the toast rather than replacing it.
The save chain had only source scans — text reading text — which is how a whole session of refactors stayed green while the reader's own test draft lost its description in the browser. These lift the actual onSave handler and the actual persistDraft out of compose.js, and the panel's pair out of sidepanel.js, run them against a mock of the encrypted store, and check what a reopen gets: the description in the slot, the prose beside it, one JSON round trip each way. The typing path is covered separately from the commit path — the row's debounced autosave and the Save button land in the same slot through different calls, and either one alone losing the write would read, from the outside, as the field refusing to save.
The composer's promise has always been that closing is safe: the text, the attachments, the difficulty — each lands in the draft as it is made, and nothing asks to be saved on the way out. The description field broke that promise. It committed only on Save description, so typing the words and walking away — closing the tab, Escape, anything short of the one button — threw them away while the draft beside them survived, and the reopened draft showed exactly what it looks like when a thing "doesn't save". The field autosaves now: half a second after the typing stops, the description is in the draft, and the chip flips to ✓ ALT with it. The exits keep their jobs — Save description closes, the trash still removes — but all three commit what is in the field first, so the last half second never depends on the timer. Escape no longer means "throw the words away"; nothing here means that any more.
A paste whose whole content is one image URL used to land as a line of text — a bare CDN link sitting in the middle of the note the user was writing, rendering as an image wherever the client decides a bare URL is an image and as a text line everywhere else. The strip never heard about it, so no thumbnail, no ALT chip, no slot in the drawer, and no way to give it a description. Now the composer offers. The paste itself is what is judged — the whole clipboard content has to be exactly one image URL, and it has to have landed as its own line, so a URL riding inside a sentence or a chunk of copied text stays prose. Accepting cuts the line back out of the editor (text-node surgery, caret untouched), puts the image in the strip, and saves: from there it is indistinguishable from an upload, appended to the note's end at publish, draggable, describable. Declining costs nothing, and the next keystroke withdraws the offer on its own — the same rules the tracking-tags offer has always lived by, and the same row grammar. The detection and the offer live in the shared editor, so both composers get them at once; the conversion is handed in per page, because only the composers with a draft have somewhere to put it. Video stays out of scope: the ALT side of this feature is images.
The tab handed the shared editor a stub — follows only — on the theory that the consent ask was panel UI and this page should not ask its own question. What that bought was two composers with two memories: a name the sidebar found in a keystroke did not exist in the tab unless the account followed it, and nothing on the tab said why. The tab now runs the real client: same tri-state setting read straight from storage so keystrokes cannot wake the worker, same one-time ask rendered in the dropdown when the decision has never been made, same no-fetch-until-consent gate, same endpoint and backoff, same decision write. One addition the panel cannot make for itself: the tab memoizes its first read like the panel does, but the decision is often changed in a different document, so the memo listens on storage.onChanged — flip the toggle in the panel's Settings and the open tab follows without a reload.
Two things made the paste offer easy to lose. It sat below the editor, which on a note of any length is below the fold; and it vanished on the next keystroke — which is exactly what a user who intends to accept is about to do, since the caption that goes with the picture is typed into the very box whose input event was erasing the offer. The offer now sits above the editor, where the eye starts, and wears the accent — lav text on a lav hairline with a faint tint — so it reads as a live action rather than faint chrome. And it no longer withdraws on typing: every input re-checks instead, and the offer stands for as long as the URL still sits on its own line. It goes only when that line goes — which keeps the honest part of the old behavior, since an offer about a line that no longer exists is an offer about nothing.
The offer required the pasted URL to be exactly a whole line, which missed the most natural flow going: paste right after the paragraph you just wrote, no return pressed. The URL glued itself to the last word, the line no longer equaled the URL, and the one case a user was most likely to produce got no offer at all. The rule is a line boundary now, not a whole line: alone on the line, or glued to either end of one, all qualify — accepting cuts just the URL substring out, leaving the paragraph intact. A URL with words on both sides of it is inside a sentence and stays in the sentence, and a URL twice on one line declines as ambiguous. The boundary test is a pure function on the core beside loneImageUrl, so the vectors for all of it run against the real code.
The 2000 limit was enforced with a code-unit slice, and an astral character — an emoji, and descriptions of pictures love an emoji — is two code units for one character. A description whose 2000th unit fell inside a pair shipped half of one down the wire: valid JSON, a lone surrogate in the tag, and a replacement character in every client that parses it. The cut goes by code points now, at publish and in the editor's save path alike.
This was referenced Sep 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Three related changes to both composers (the panel's and the expanded tab), built one portable feature per commit:
1. Image descriptions, written the way other clients read them (NIP-92
imeta). Every image thumb carries an + ALT chip; the editor row (thumbnail, multiline field, a ring meter with a bare count, a quiet Save) autosaves like the rest of the composer. On publish, oneimetatag per described image, in zap.cooking's wire shape (PRs zapcooking/frontend#746 and #749): thealtslot's value is everything after the first space, line breaks preserved and capped at one paragraph gap, never empty metadata. Undescribed images emit nothing, so a note of bare URLs publishes byte-identical to before. The cap counts characters, never mid-emoji.2. Attachment URLs leave the prose. An upload's URL no longer lands in the editor as a line of text — it lives in the draft's media slot and is appended to the content at publish (own line, after a blank line), so the review window and Preview show the exact composed string. A collapsed drawer under the thumbnails says where they went and expands to copyable URLs; with more than one attachment the thumbs drag to reorder (the strip's order is the publish order). Old drafts migrate: URLs the old format left in the text are stripped on restore so nothing is appended twice.
3. A pasted image URL is offered a life as an attachment. Paste one image URL on its own — or glued to the end of a paragraph, no return needed — and an accented offer appears above the editor. Accepting cuts just the URL out of the prose and puts the image in the strip, indistinguishable from an upload. Offered, never done; it stands while the URL line stands and follows the line out.
Also here: the expanded composer gains the sidebar's full name search (same Nostr Archives gate, same one-time ask, same endpoint — plus a storage listener so a decision made in Settings reaches an open tab), and the tab flushes its draft the moment the keystore unlocks.
Testing
onSavehandlers andpersistDraft/saveComposeDraftagainst a mock store.Porting notes
Each commit is standalone: the keystone is
008b754(write side + chips + editor row in composer-core.js), and later commits refine it — cherry-pick in order.