mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
main
76
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
13303b247e |
v0.3.9 milestone (#283)
* Run the end-to-end suite on pull requests into milestone and wip branches
A release's work is now collected on a milestone branch (milestone/v0.4.0)
before it reaches main, and the suite only ran on pull requests into main,
so nothing proposed for the milestone was tested. `milestone/**` joins
`main`, and `wip/**` comes along from wip/28, where it already runs the
suite on the phases of #28 — the same block on both, so they merge
without a conflict.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit
|
||
|
|
5550727888 |
AppLocale : proposer le français
Ajoute 'fr' à AppLocale et à framework.enabled_locales, qui doivent rester synchronisés (le commentaire de translation.yaml le dit). Le sélecteur de langue des réglages et celui de l'installation lisent AppLocale::cases() : « 🇫🇷 Français » y apparaît sans autre modification. Deux réglages suivent la langue et devaient connaître le français : - ClockFormat::forLocale() : le français écrit 14 h 00, donc 24 heures par défaut comme l'allemand. Sans ça, un utilisateur francophone qui n'a rien choisi verrait « 2 PM ». Testé dans ClockFormatResolverTest. - Le sélecteur d'emoji charge ses noms et mots-clés par langue (la recherche « cœur » ne trouve rien dans les données anglaises). assets/emoji/fr/data.json vient de emoji-picker-element-data, comme en/ et de/. J'ai vérifié que ces deux fichiers sont identiques octet pour octet à ceux publiés par le paquet, donc fr/ vient bien de la même source. Le gabarit choisit maintenant de, fr ou en d'après la langue au lieu du seul test « starts with 'de' ». Laissé tel quel : DeterministicDateDetector, qui lit les dates dans le contenu des mails selon leur langue, et non la langue de l'interface. Apprendre les dates françaises (« le 4 août ») est un autre chantier. Docs : README, features/appearance.md (et sa traduction allemande, avec le hash mis à jour), et une entrée de CHANGELOG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni |
||
|
|
8f7d2cb073 |
Work through the first-impression feedback in #37
Eleven of the twelve items. The twelfth, a deleted label coming back after the next sync on IMAP, is left for an issue of its own: there a label is a folder, and what deleting one should do to the mail in it needs deciding first. A selection survives the pager. It was nothing but the ticked checkboxes, so "next page" replaced the rows and the selection with them. The rows that are not on screen are now remembered in assets/mail_selection.js, per list, and the toolbar, the label menu, the move menu and a drag all read the selection from there. The checkbox stays the truth for anything visible. The whole view can be selected from the select menu. It was only offered by a banner that appears once a page is fully ticked. And a whole-view selection no longer survives a row being unticked: the action still ran on everything. A sync no longer logs a stored message as one it could not build. MessageSyncer reads the UIDs a folder holds once, before it fetches, and a move made meanwhile files rows under UIDs that list does not have. The INSERT was refused on (mailbox, uid), rightly, and each message was then held back as a failure. The list is read again when a batch is refused. A pairing code and the JMAP address under App passwords carry the configured public address, and the browser's only when none is set. This reverses the earlier choice in DevicePairingController, which handed a phone paired at home a LAN address. Hiding quoted text gives the height back. scrollHeight cannot report less than the frame it is measured in, so the collapse reports the root element's box instead and holds other reports until the parent has resized. Mail in Sent shows its images without asking. Read off the Sent label and not the From line, which a forged mail fills in as it likes. Spam still wins. Smaller: placeholders are lighter than typed text, the actions on a connected integration are buttons on one baseline, Save signature and four composer buttons no longer hover to white on white, the Spam badge loses the accent, and German says "vor 12s". Refs #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
219fc9e283 |
Keep a mail's styles from drawing over the app around it
Inline styles survive the sanitiser on purpose: in mail they are the design. In the reading pane that costs nothing, because the message is in a sandboxed frame. The quote under a reply and the print page are the sender's HTML in the app's own document, and there `position: fixed` on a full-size element covered the composer — or the whole window — with a page of the sender's choosing the moment somebody pressed Reply. Reported in #34. Two layers, since either alone has a hole: the sanitiser drops `position: fixed` and `sticky` from every body, and `absolute` and `z-index` too from one being composed. Absolute stays for mail that is only read: real newsletters use it, and in the frame it can only rearrange the message. the stylesheet the composer's editor and the print page's body are containing blocks that clip. This is what holds for a body stored before the sanitiser changed, or a declaration written some way the pattern does not anticipate. Refs #34 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
09600802db |
Let a message be written once and inserted as a template
Settings → Templates is a new section: a tree on the left, one editor
beside it. The tree opens with one folder per mail account, and those
are not stored — an account folder is the account, drawn as a folder,
so the mirror cannot drift. Folders the user makes go inside an
account, inside one another or beside the accounts. Removing an
account or a folder keeps the templates in it and moves them up.
A template holds variables: the recipient's first name, name and
address, the sender's name and address, a signature, and dates with an
offset and a format of their own. They are stored as text tokens in
the body, because the body goes through the inbound-mail sanitiser and
a marked element would not survive it; the editor draws them as chips
and turns them back before it posts.
The compose window gets a button beside the signature one. Dates,
sender and signature are filled in by the server when a template is
inserted. The recipient is not: a template is usually picked before
anyone is addressed, so recipient variables arrive as markers and the
window fills them when To has somebody in it, and asks before sending
with one still open. A template's signature replaces the window's
rather than joining it, and one the template names is pinned so a
change of From leaves it alone.
TemplateTokens the notation, read the same way in PHP and in
assets/compose/template_variables.js
TemplateRenderer what each variable becomes, and who fills what
TemplateLibrary the tree, and the only writer of where a
template or folder sits
One bug fell out of running it in a browser. Chromium will not put a
caret between a non-editable element and the edge of its block, so a
template ending in a variable could not be given its full stop — the
keystrokes were dropped. The editor keeps a zero-width space beside
each chip and strips them before saving.
Two new tables, mail_template and template_folder. Web only so far:
nothing is published over JMAP yet.
Refs #26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
8ad3e83c52 |
fix(ui): the last hard-coded colours in the templates use the theme's tokens
Four kinds, each of which ignored the theme it was drawn in: - Checkboxes and radios asked for the accent through `text-accent`. A native box is coloured by accent-color; `color` reaches it only through @tailwindcss/forms, which this app does not load, so every one of them was ticked in the browser's own blue. They use `accent-accent`, and modal_form_theme says why. - Icons and icon buttons in admin, settings and the thread row's hover actions were Tailwind's zinc-400 and gray-400, with dark: variants and grey hover washes. They are ink-faint, with hover:text-ink and hover:bg-hover, or hover:bg-danger-soft for the destructive ones. - The state pills on bg-raised wrote ink-faint, a tier held to 4.5:1 on the surface, which the raised tint under them takes as low as 3.85:1 (14 of 39 themes under 4.5). They use ink-muted. - Accent buttons wrote text-white, which a light accent cannot carry. They use the accent's own ink. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
dd6bd5a62b |
fix(ui): drop the compose window's undeclared formErrors target
compose--compose never declared or read it, so the attribute promised a hook that did not exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3bff24340a |
feat: the picker for send-later and booking hours; month arrows
calendar--when now takes three shapes: start and end with days (the event editor), one date and time (compose's send later, bounded by the field's min and max), and times only (a booking page's daily hours). The shared attributes and words live in calendar/_when.html.twig. Also: - the month view has previous/next arrows, stopping at the bounds - "Quarter hours" rounds a :05 to the nearest quarter and closes; it used to reopen at once because the minute sat between quarters - the all-day label hugs its text, so the empty row under the picker no longer ticks it - "Today" becomes the weekday when the strip is narrow, asked of the strip's own width - ui--datetime, the interim text time field, is gone with its last use Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6ec93e098a |
fix: time fields follow the 12/24-hour setting, not the browser's locale
The event editor, the custom send-later time and the booking page's
hours used native datetime-local / time inputs, which print the time in
the browser's locale whatever the page says: "03:00 PM" for someone
who chose the 24-hour clock.
ui--datetime keeps the native input as a hidden value carrier (same
name, id, ISO value, targets and actions) and draws a native date field
plus a text time field printed by the clock setting and read liberally
("15", "1530", "3pm", "3:30 p"). Edits fire input/change on the
carrier; writes to it redraw the fields; focus and aria-invalid are
passed through; the label is re-pointed and the time field is named
"<label>, time".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
aebae962a3 |
Release v0.2.24
Give a joined control's halves the group's own corners. v0.2.23 made the focus ring inset, which stopped the clip deleting it, and stopped there: an inset ring on a square box is still a square ring, and the group's 7px inner radius erased 3.39px of the 2px band at each outer corner. The highlight arrived with its corners bitten off. The halves are rounded to match the group now — chip face and save button, both halves of the Send pill, the first and last segment of the calendar view switcher, the last of those read off the segments actually rendered rather than off loop.first, which counts candidates a caller may not publish. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d681830fc5 |
Release v0.2.23
Draw a focus ring inside a clipped control, not around it. Reported on an attachment chip, where the highlight read as a second border with the wrong corners. The buttons carry no radius: the rounding lives on the group, which also clips, so the browser's default outline was a rectangle around a square half with its corners sliced off. An inset ring — the idiom the settings segmented controls already used — is painted inside the padding box, and the clip rounds it instead of removing it. Six sites: the attachment chip, the Send pill and its chevron, the calendar view switcher, the select and account menus, and a block on the time grid, which clipped its chip's ring away in full. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d0d5d017a3 |
Let the writing assistant's instructions be changed from the composer
Tuning a prompt is a loop — change a clause, generate, read it, change it back — and the only place to change one was Settings → AI. That means abandoning a half-written mail, saving, coming back and starting the draft again, so in practice nobody did it and the note stayed whatever it was on the day it was written. The assistant's panel carries the note itself now, prefilled with the saved one and editable in place, with "Try again with this" beside it. Nothing is saved. The box lives as long as the composer does, and Settings → AI is still where a lasting instruction is written: trying a sentence out and adopting it should take different gestures, or you are left with whichever experiment you happened to stop on. Absent and empty are different answers, and the plumbing keeps them apart. ComposeAssistController::note() reads presence rather than emptiness, and the controller sends the field only once the box has been opened — so a composer nobody has touched behaves exactly as it did before, and an emptied box is a person asking for this attempt to carry no standing instruction at all. That second case is the half of the feature that cannot be reached any other way: working out why a draft keeps coming out wrong means being able to take an instruction AWAY, not only to pile another one on top. It replaces the writer's own note and reaches nothing above it. The task instructions, the language rule and the administrator's own prompts belong to the application, and a box in the composer that could switch those off would be doing it in the one place nobody would think to look for them. The disclosure sits in the panel's header rather than in its action row, and that is the difference between offering it after a good draft and offering it after a bad one. The action row only exists once there is something to accept, and the moment somebody wants to change the instruction is the moment the answer came back wrong — "no answer" being the wrongest it gets. The browser test asserts on the request body, because that is the only place the claim is legible: with the model host answering nothing, a field that was sent and a field that was not look identical on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dfe27b7219 |
fix(compose): the keyboard covered the window on an iPad and left a gap on an iPhone
Two reports, one misunderstanding. While the on-screen keyboard is open, iOS
goes on telling a page that the screen is its full height — only the *visual*
viewport shrinks — and a `fixed` element is placed against the layout one. So
anything anchored to the bottom of the screen sits behind the keyboard and
believes it does not. Android shrinks the layout viewport too
(`interactive-widget=resizes-content`), which is the whole reason the same
window looked right there.
The phone window already answered this: below md it sizes itself from
`visualViewport`, so the keyboard ends up below it. Above md nothing ran at
all — `_applyMobile` called `_unwatchViewport()` on that branch — so the dock
card kept its `bottom: 1rem` from an edge that was now keyboard, with the
action bar and Send underneath it and no gesture that brought them back.
The controller now tracks the viewport at every width and publishes
`--keyboard-inset` on <html>; the dock wrapper and the card's height cap read
it from app.css. The card moves up AND caps against the room left, because
lifting a 600px card on a screen with a keyboard on it otherwise pushes its
title bar off the top.
The iPhone gap was the other half of the same thing.
`env(safe-area-inset-bottom)` also describes the layout viewport, so it went on
reserving the home indicator's 34px against a window whose bottom edge had
become the top of the keyboard: clearance turned into a dead band of window
between the action bar and the keys. It is dropped while the keyboard is up.
Three things an adversarial review of the first draft found, all of them real:
• Pinch zoom read as a keyboard. `innerHeight` ignores the scale and
`visualViewport.height` does not, so zooming to 2x reported half the screen
as keyboard with none on it — and `scroll` is subscribed as well as
`resize`, so panning resized the card continuously under the user's finger.
Zoom is deliberately available here (WCAG 1.4.4), so this was reachable. No
arithmetic separates keyboard from zoom; at any scale but 1 the correction
is withheld.
• `--keyboard-inset` is one property written by every compose window, and
above md a dock window and an inline reply are open at once by design. The
first draft deleted it on any disconnect and argued in a comment that the
survivor would re-assert it — which only holds when a blur caused the
teardown. `_settleSend()` empties the inline frame from a timer eight
seconds after a send, keyboard untouched, and would have dropped a dock
window back under it mid-sentence. Cleared only by the last one out.
• The height cap had no floor and the lift no clamp. An iPhone in landscape
is 844x390 — wider than md, so it gets the dock card — and `90vh` less a
200px keyboard computes to 151px against ~210px of header and action bar. A
box shorter than its `shrink-0` children does not shrink them: it spills
them out of its bottom edge while the scroller between them, which carries
From, To, Subject and the message, collapses to nothing. Nothing fits a
composer and a keyboard into 390px, and this does not pretend otherwise —
it stops the fix from breaking the case it cannot fix.
tests/e2e/support/keyboard.ts stages what a keyboard leaves behind, since
Chromium's visual viewport never shrinks: the real VisualViewport with its
height, scale and offsetTop overridden, and a resize to announce it. Each of
the four faults above has a spec that goes red without its fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
af42afce31 |
fix(ai): write in the language of the mail, and stop offering a reply to nothing
Three small things, all reported from use. The writing help answered German mail in English, and proofreading a German draft quietly translated it. The instructions are written in English and a model reads that as evidence of the language it is meant to answer in — so the rule has to name that pull and refuse it, not merely ask for the right language. The proofread case is the serious one: a task asked to fix punctuation returned a different message in a different language, and the writer would have had to notice before sending it. Verified against the real host: a German mail now gets a German reply, and proofreading fixes the umlauts without touching a word. "Draft a reply" was offered on a new message, which has nothing to reply to. The server already refused it, so the option was always there and never worked — which reads as a broken feature rather than an inapplicable one. The rewriting tasks stay, because they act on whatever is in the body. And the last force-click goes. `force` skips the hit-target check, and skipping it is how the snooze button came to be pressed while it was invisible: the event went to its coordinates, landed on the ROW, and the failure said "the menu did not open" — true, and silent about why. The shared helper now re-hovers on every attempt (a row replaced under a stationary pointer never receives :hover, so retrying alone re-reads the same nothing) and clicks honestly. snoozed-list stops carrying its own copy of that helper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2d1586cb68 |
fix(compose): a draft that died at thirty seconds, under the quote, in silence
Three faults in the writing help, all of them things that only appear once it is
actually being used.
PHP's max_execution_time is 30 seconds for the web SAPI while the model call is
allowed 120, so the limit always belonged to the wrong side of the request. A
writing model that has fallen out of the keep-alive window needs about thirteen
seconds to be read off disk BEFORE the first token exists, and a reply worth
reading is another twenty or thirty after it — so the limit fired mid-generation
and the reader watched the text stop halfway:
MaxExecutionTimeError: Maximum execution time of 30 seconds exceeded
at CurlResponse.php line 370
The worst possible place for it to land, too: inside the HTTP client's read, so
the unwinding that cancels the model call and records the row never happened and
Ollama carried on generating for a reader who had already been shown an error.
The bound now comes from the pieces that can actually enforce it — the upstream
timeout, and connection_aborted() on the next write.
The answer went in at the end of the editor, which in a reply is UNDERNEATH the
quoted original: an answer nobody scrolls to, sitting below the message it was
answering. It goes above the quote now, which is where a person's cursor already
is when they start typing a reply. Plain text too, where the boundary is the
first `>` line less its attribution line — inserting between "On … wrote:" and
the quotation splits it in half.
And the wait had nothing moving in it. There is nothing honest to put in a
progress bar, because the host reports no progress at all while it loads, so a
still sentence was all there was and it read as a window that had hung. Three
dots that breathe say "working" and claim nothing about how far along it is,
which is exactly as much as we know. They stop at the first token, where
arriving text says it better, and they hold still but stay visible under
prefers-reduced-motion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
377d6026b8 |
feat(ai): make the language model observable, and the composer honest
"I click the AI button in the composer and nothing happens" turned out not to be about the model at all. #editor() queried data-compose--toolbar-target while the composer renders data-compose--compose-toolbar-target, so run() returned at its first guard: no request, no status, no error. The feature had never worked once. #subject() was wrong the same way and quieter — every prompt went out with an empty subject. And the status element lived INSIDE the dropdown that the same click closes, so even once working it would have written into a hidden subtree. The rest of this is what makes the next such thing findable. RECORDING One row per model call, tagged by the workload that caused it, and nothing about what was in it — no prompt, no completion, no message id, no subject, no address. error_kind is a category from a closed set for the same reason: an HTTP client's exception message routinely quotes the request body back, and here that is somebody's mail. OllamaClient was discarding Ollama's whole timing block; it now returns it, and AiAssistant — the only place that knows both the feature and the model — writes the row. DBAL and deliberately no entity, following EmbeddingStore: BackfillEmbeddings clears the EntityManager after every chunk, so an unflushed ORM row would be detached and silently dropped, fifty at a time, for as long as a backfill runs — which is exactly the window the panel exists to show. AiFeature could not carry this. A four-character search query somebody is waiting on and two thousand characters of mail body written unattended a hundred thousand times both went through embed() with no discriminator, and averaged together the second buries the first — the panel would report backfill throughput under the heading "search". AiCallFeature splits them. THE PANEL Live state from /api/ps, which is passive and safe to poll. Six states that look different, and the one that justifies the work is "partly on the CPU": size_vram < size means layers spilled, throughput collapses, and the model still answers correctly — invisible everywhere else, so everything is simply slow forever. THE COMPOSER Tokens stream into a provisional surface and commit in one operation, because hundreds of per-token insertions destroy the undo stack and Ctrl+Z is the first thing anyone tries. Waiting, generating, done and failed are distinct states with reasons: outside the keep-alive window the first thirteen seconds produce nothing while 20 GiB loads, and a featureless spinner for that IS the bug. Cancellation propagates to the host — an abandoned draft otherwise holds the GPU and everything else queues behind it. SEARCH Silent degradation to keyword-only is replaced by the reason it degraded, and rows found by meaning say so. The semantic arm and its join are now matched on model AND width: vectors from different models are not comparable, and after somebody changes the search model every existing vector is dead weight. On a pgvector install comparing different widths raises an error — a 500 on /mail/search caused by an admin dropdown — and on the shipped loop it silently compares the overlap, which is worse. Coverage is stated where results are shown, because semantic search over an 8%-indexed mailbox is a trap. BACKFILL Progress is counted from the database, never accumulated, so a killed worker resumes. It stands aside while anyone is using the AI and comes back on its own: both compete for one integrated GPU, and a batch in flight when somebody presses the button puts them straight back to "nothing happens". Also carries the cron line for app:jobs:reap from the commit before this one. Verified against the real host: reachable, both configured models installed, timings parsed, 1024-dimension embeddings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
469a2e8107 |
feat(ai): semantic search, writing help, and the pass that embeds a mailbox
The three remaining pieces. Written to the seams the survey found, and
keeping the traps it named.
SEARCH — ANOTHER ARM OF THE SAME UNION
A vector hit is a candidate on exactly the same footing as a lexical one:
it lands in COUNT(*) OVER (), it pages correctly, and it needs no second
statement to reconcile with. Four things had to hold and each is written
down where it applies:
· The query is embedded ONCE, in the controller. buildSearchSql() runs
up to four times per search — the cheap pass, the body rescue, and
twice more when a page past the end recovers its total — and one of
those is inside a statement-timeout transaction, where a slow model
would be reported as a database fault.
· The candidate set is BOUNDED to the most recent 2,000 messages. The
distance function costs 0.11ms a row; over a whole mailbox it is
eleven seconds, worse than the pathology the UNION shape exists to
escape.
· A THRESHOLD, not a top-k. The total rides on the same statement, and
"lexical matches plus up to k" is a number that is only true on page
one.
· The inner LIMIT is parenthesised, because these arms are joined with
a bare ' UNION ' and an unparenthesised LIMIT binds to the whole
union rather than to one arm.
The rank join is 1:1, which is the property the label joins lost and had
to be given back in v0.1.40. Three tests: a message found by meaning that
no lexical arm can reach, one rejected by the threshold, and one with no
embedding still found by words — that last is what proves the LEFT JOIN
does not drop rows.
WRITING HELP — THE SERVER MAKES THE CALL
connect-src 'self' is enforced, so a browser could not reach the model
host even if it should — and it should not: that address is on the
operator's private network, and putting it in a page hands it to every
script the page loads.
The route carries NO draft id. A composer opens before one exists, which
is exactly when somebody wants a first draft; a URL built at render time
with an id in it is stale in that window. Nothing is saved as a side
effect either.
Four tasks, a closed set. A free-form instruction parameter would be a
prompt an attacker could write, with the user's own mail as context and
the answer pasted into the user's own draft. The message being replied to
is read from the DATABASE by id, ownership checked — never from the page.
Generated text is INSERTED, never substituted. A rewrite that overwrote
the original would be a model deleting somebody's words with no undo they
can see. It goes in as a plain text node at the end of the body: a styled
wrapper would be subtracted by the composer's typed-length calculation
and could stop the draft autosaving, and "at the caret" is a position the
writer did not choose, because opening the menu moved focus.
The menu is behind a Twig FUNCTION, not a variable — this window is
included from four places, two of them undo streams that include it
`only`, and strict_variables makes a variable threaded through three of
four a 500 on the undo path rather than a missing button.
EMBEDDING — ARRIVALS ON INGEST, THE REST ON DEMAND
A post-ingest step for new mail, and a command for everything already
there. The backfill re-dispatches itself with a cursor rather than
looping: a hundred thousand messages at a round trip each is hours, and a
single job holding that is killed by any worker restart with nothing to
show for it.
Ascending id, because it is the one ordering nothing can change under a
walk that long — mail arriving during it gets a higher id and is met by
the pass still coming. The vectors already stored ARE the progress
record, so there is no second one to keep in step, and the cursor
advances past everything LOOKED AT rather than everything stored, so one
message the model cannot answer for does not become a wall it restarts at
forever.
On the maintenance transport, not ingest: a backfill running for hours
would otherwise sit in front of every arriving message, and new mail
would stop appearing until an old mailbox had finished being catalogued.
KNOWN FAILING, AND NOT FROM THIS
compose-send-optimistic.spec.ts:124 fails in the FULL e2e run and passes
in isolation and in pairs — verified with the AI menu both present and
removed, 24 passed either way. It is the same spec that broke in the
earlier seed race, and the row it cannot click is present but still
mid-entrance. Cross-file interference, pre-existing, and left for the
debugging pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7065d2a192 |
fix(compose): "Message sent." was said two seconds before the send was attempted
A send is held for ten seconds so it can be called off. The browser marked the end of the CANCEL window — eight seconds — by asking the server to tidy up, and that answer carried "Message sent." So the confirmation preceded the attempt by two seconds, every time, for everybody. And nothing ever corrected it. All three senders turn a failure into a bare `false`, which SendMessageHandler treated as handled: no retry, no failed-transport row, nothing. SmtpMailSender goes further and binds the transport exception without using it, so a hard SMTP rejection left zero trace anywhere in the system. The user was told the mail had gone, and it had not. Now the eight-second mark only tidies, and the outcome is published from the worker when there is one — success or failure, over the live-updates stream that is already there. The failure toast is the case that had no surface at all. It publishes RENDERED html, because the alternative is a toast built in JavaScript, which is a second copy of _toast.html.twig drifting from the first. Best effort, and honestly so: with the hub down there is no toast, which is a confirmation missing rather than a confirmation that lies. WHAT THE E2E SUITE CAN AND CANNOT SHOW It cannot show the toast. The test app runs MESSENGER_TRANSPORT_DSN=in-memory://, so a send dispatched by a browser request is discarded when the request ends and no worker can pick it up — which also means those specs were only ever asserting the premature claim. They now pin the half that was wrong: that nothing announces an outcome it does not have. The publishing side is covered by SendOutcomeNotifierTest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
24e27b9d50 |
fix: false|default(true) is true, in five templates that relied on false
Not a demo issue — every install, every send.
Twig's `default` filter substitutes when a value is undefined OR EMPTY,
and `false` is empty. So `false|default(true)` is `true`, and every
caller passing `false` to switch something off was ignored.
The one that was reported: sending a message showed "Message sent." and
"Sending… 8 · Undo" in two toasts an inch apart — the app announcing the
send was finished beside its own offer to call it off. The optimistic
response includes the settle template with `notify: false` for exactly
this reason, and that suppression had never once worked.
Four more, all the same mistake:
- compose/_attachments — `live: false` means "these chips are already on
screen, do not animate them in", so re-rendering the compose window
replayed the entrance stagger.
- calendar/_event_chip — the time-grid asks for no start time because
its own axis already shows one. Every chip printed it anyway.
- _partials/_thread_row — the per-account list asks not to be treated as
a merged one and got the account disambiguation regardless.
- settings/_appearance — the setup wizard hides the export/import block,
which its own comment calls "not worth showing someone who has been in
the app for ninety seconds". It showed.
`?? true` is what was meant: the null-coalescing operator defaults on
null or undefined and leaves false alone.
A guard test refuses `|default(true)` anywhere under templates/, with
the five cases written down. It blanks `{# … #}` before scanning rather
than skipping lines that look like comments — the first version reported
the comments explaining this trap, which necessarily quote it.
THE ASSERTION THAT DID NOT ASSERT
The E2E check went in as `expect(locator).toHaveCount(1)` and passed
against the unfixed code. toHaveCount RETRIES: it watched two toasts,
waited the few seconds for the stray one to auto-hide, and reported
success. Counted once, with no retry, it fails 2 against 1 — which is
the only reason this commit's fix is known to be a fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c848bc5474 |
fix(compose): the suggestion panel stops moving the dialog
The reported bug, fixed the other way round from the obvious one. The dock window was content-sized under a 90vh cap, and the dock is anchored bottom-right — so the suggestion list appearing grew the window and the whole thing climbed the screen, then dropped back when the list closed. Typing a recipient made the dialog jump. Taking the list out of the flow is one line and is wrong. Subject sits directly under To, so an overlaid list covers it, and a click aimed at Subject lands on whichever suggestion is under the pointer and enrols it — which once silently added a third party to a message addressed to someone else. Three specs exist to keep that fixed and they caught the attempt. So the list stays in the flow and the WINDOW stops resizing: a fixed height, with the scrollable region absorbing the panel. The list pushes the body down inside an outline that does not move, which is what the report was actually about. Gmail's dock window is a fixed size for the same reason, and 90vh still caps it on a short screen. The new test asserts both halves together, because either alone is satisfiable by breaking the other: a fixed window with an overlaid list passes "does not move" and covers Subject, and an in-flow list in a growing window passes "does not cover Subject" and moves the dialog. Verified it fails with the right message when the height is taken away. Full Playwright suite: 564 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
67b3a93013 |
feat(compose): send gives the screen back, and the undo moves to a toast
"Das warten beim Absenden ist pain." Pressing Send kept the composer on screen for the whole cancel window and made its own pill the cancel — nothing moved, and the way back was exactly where the way forward had been. That shape is defensible and it has the one problem that got reported: eight seconds of a window that will not close is a long time when you have finished writing. So the default is the other trade. The composer closes at once, the mail is already where it will live, and the cancel moves to a toast that counts down beside it. The held shape stays, one setting away — User::SETTING_COMPOSE_SEND_FEEDBACK, on the compose behaviour panel next to the forward-quote choice. Almost nothing new was needed, which is the interesting part. The toast partial has supported an undo button and a countdown all along, ui--toast already renders the timer, and compose--undo-send was sitting in the tree marked UNWIRED "while the replacement is reviewed" — with _send_toast.html.twig beside it, kept for the same reason. This is that review coming back the other way. The template is gone, because the toast is emitted by the stream that needs it now, and the controller's docblock says what it is for again. The tidying is deliberately the SAME template the end of the cancel window renders, with its toast suppressed. A send must not settle differently depending on which shape rendered it, and two copies of "put the message where it belongs" is exactly how the forward bug that template documents got in. `hide_after` on the toast IS the cancel window rather than a duration somebody picked: the Undo goes when the toast goes, so a toast that outlived the window would offer a cancel that silently fails, and one that died early would take away a cancel that still worked. Both settings post their own form. The controller writes only the key it is sent — an absent `sendFeedback` read as its default would have stored the default and silently undone the other setting on every save. Also fixed here, and found by the specs rather than by me: **Three destructive forms had stopped confirming entirely.** Revoking a trusted device, revoking every remembered device and switching off two-factor authentication carry `data-turbo="false"` AND `data-turbo-confirm`. Opting out of Turbo means setConfirmMethod is never consulted, so the attribute is inert — they had been `onsubmit="return confirm(…)"`, which worked regardless, and the conversion silently removed the guard. templates/admin/_live_frame.html.twig already warned about this exact combination in a comment: "an attribute that looks like a guard but is not is worse than none." Fixed with a capture-phase submit listener rather than by editing the three templates, because the next form to combine the two is then covered by having done nothing. It re-issues through requestSubmit() so validation still runs, and the flag that stops it asking twice is compared as a string — dataset values are strings, and `true ===` would have asked forever. compose-send-cancel and compose-undo-reopen now put their user on "hold" first and back to the default afterwards, because they describe that shape; compose-send-optimistic covers the new default on both surfaces. Verified: 4009 unit tests, PHPStan clean, both compose suites and the security, label and confirm specs green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0f80d2c43b |
fix(auth): a subresource is not a destination, and logging out is a full load
Three reports, one story: the application kept treating a dead session as an ordinary navigation. **Landing on your own profile picture after signing in.** Symfony saves the login target in ExceptionListener::setTargetPath(), whose test is `isMethodSafe() && !isXmlHttpRequest()` — and an `<img src="/…/avatar">` is both. So the last unauthenticated thing the browser happened to fetch became the place the next login went. The session ends, the page is still on screen, the browser re-requests the avatar, that 302 saves an image URL, and login redirects to it. Whichever subresource lost the race decided the destination, which is why it looked intermittent rather than broken. The entry point is where to undo it, because it runs after the save. Anything that is not a document navigation has the saved path removed again. The test is two-sided rather than a list of extensions: `Sec-Fetch-Dest: document` is what a browser says about a real navigation, and an Accept mentioning text/html covers a Turbo visit — which is a fetch, and reports `Sec-Fetch-Dest: empty`. Both halves are pinned, so the fix cannot degrade into "never remember anything": asking for /settings while signed out still returns you to /settings. **Logout went through Turbo.** The shell survived it — controllers still mounted and polling with a cookie that no longer existed, the Mercure subscription still open, and Turbo's page cache able to put a signed-in snapshot back with the back button. data-turbo="false". **A session ending underneath an open page** made Turbo swap the login form into the application shell: a password box wearing the sidebar, with every controller on the page still running and failing. There is a bar now. The interesting decision is in that bar. "Reload the page" is the obvious response and it is wrong exactly when it matters: a half-written mail lives in a contenteditable, and the autosave that would preserve it is a POST — the thing that has just stopped working. So with nothing being written it offers a straightforward sign-in in this tab, and with something being written it offers a new tab instead and leaves this one untouched, text and all. The cookie is shared across tabs, so coming back here just works and the next autosave lands. Being wrong in that direction costs a tab; being wrong the other way costs what somebody wrote. Also: the compose window's close button gets an aria-label (CODE-REVIEW U-01). Its only content is an icon, so with `title` alone the accessible name depended on a screen reader choosing to fall back to it, and on touch it was unreachable — while above md it is the only way out of the window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1e09f36afb |
feat(ui): the interface stops appearing out of nowhere
Every animated surface, against the contract in the previous commit. Four
agents worked in parallel on the mail list, compose, the shared chrome and the
calendar; what follows is what survived review.
**The rule everything obeys: motion marks an ARRIVAL, never a redraw.** The
mail list is the case that decides the design. It is re-rendered wholesale —
`live.innerHTML = incoming.innerHTML` in mail--mail-pane#_swapRegions — on every
background sync, as often as every fifteen seconds, so every row becomes a new
node carrying the same mail. A row that animated on insertion would pulse four
times a minute on an inbox nobody is touching, and a list that shimmers at rest
is the fastest possible way to make somebody switch this off.
What tells the two apart is the id. `thread_1234` is the same conversation
whoever rendered it, so motion.js animates an element the first time it sees a
given id and never again. New mail rises into place; a redraw of the same mail
is silent; and a whole new list — a folder, a search, the next page — plays ONE
fade on the <ul> rather than fifty on the rows, because a child whose ancestor
is announcing it stays quiet.
That needed a second attribute, `data-enter-new`, and the reason is worth
stating because it was learned the hard way: a CSS animation starts the instant
an element carrying one is inserted, before any observer can object. Suppression
therefore cannot be a decision taken after insertion — an element whose entrance
is conditional has to arrive with no animation attached at all and be given one
only once its id proves new. The first implementation recorded ids inside
play() after an early return, so rows suppressed by their list's fade were never
recorded and animated in a slab on the next sync. A test caught it.
Elsewhere the agents mostly declined, which is the right ratio:
• Search suggestions animate ONCE when the panel opens, not per keystroke —
five characters produce one animation.
• The calendar animates the grid WRAPPER on a view change: one gesture at one
duration however many events are on screen, and nothing on the positioned
blocks, where an animation would win over the inline transform the drag
controller writes and corrupt its geometry reads. Proven: every block
reports animation-name: none at all three tiers.
• Compose declines minimise, expand, close, discard and pop-out. The first
two are size changes; the rest are frequent actions where an exit is time
between the ask and the answer.
• The compose window uses `fade` where the contract said `pop`. Below md it
IS the screen, so scaling draws a hairline of the mail list around all four
edges, and a transform on it is inherited by everything measured inside it
— the emoji picker's fit-to-window pass reads exactly that. The contract
was wrong; motion.css now says so.
Two exits, both earned: a toast dismissing itself, and a modal, which is most
of the screen and whose backdrop is all of it.
Fixed along the way: closing a modal stopped returning focus to its trigger,
because the hide is now deferred through leave() and Turbo's
focusFirstAutofocusableElement was landing after the focus call — harmless when
the dialog was display:none by then, not harmless when it is still on screen for
a fade. Focus returns before the animation now, and the dialog goes `inert` on
its way out. And the motion setting's radiogroup labelled itself with an
sr-only span, which is 1px-clipped by definition and which preview-pane.spec.ts
correctly read as 53px of overflow; the heading is the label.
Verified with a getAnimations() harness rather than by eye: every entrance is
180ms at Full, opacity and transform only, no layout property animated
anywhere, and the three tiers resolve to 180/120/0ms with 6/0/0px of travel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
097f6ca943 |
fix(compose): an autosave the server refused no longer says "Draft saved"
`response.ok` was the wrong question. A form the server REFUSES — a stale CSRF token, a validation failure — does not come back as an error status: it comes back as the window re-rendered with the errors on it, HTTP 200. The autosave read that as success, printed "Draft saved", and cleared the unsaved flag that the unload guard depends on. So the one path where writing is actually lost was also the one path that claimed everything was fine. ComposeController::draft() has passed `saved => true` on the accepted branch since it was written, and the template has never rendered it. It does now, and the client requires it. A refusal takes the same road as a network failure: "Could not save the draft", and `_unsaved` stays standing so leaving the page still warns. This is the half of the CSRF bug that made it invisible — the composer was telling the user their reply was safe while the server discarded it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
35cf17f05d |
feat(compose): the send pill is the cancel, in every editor
A send used to answer by taking the window away and offering the way back
somewhere else — an Undo in a toast for the dock, a countdown bar in the
thread for an inline reply — plus a "Cancel" link that appeared beside the
pill while the request was out. Three surfaces for one act, none of them where
the user had just clicked, two of them in a window that no longer existed.
The window now stays exactly where it is for the cancel window. Its Send
button reads "Sending…" with living dots and "click to cancel" under it, and a
second click on that same button calls the send off. Both faces are stacked in
one grid cell and only toggle visibility, so the button cannot change size when
a send starts, when it ends, or on any frame of the animation.
The server's answer no longer re-renders anything: it hands the live window the
two URLs it cannot address by itself (compose/_sending.stream), and when the
offer expires the window asks ComposeController::sent() to file the message and
close it. A forward starts its own conversation, so the settle streams close the
frame unconditionally and touch the thread only when it is the thread on screen
— without that a forwarded mail left the composer reading "Sending…" for ever,
addressing ids that were nowhere in the page.
A toast now says the message went, because a composer that simply vanished was
the one moment in the flow with no feedback at all. Cancelling says nothing: the
draft coming back with everything in it is the answer, and the dock's old "Send
cancelled" toast was the last asymmetry between the two composers.
Also here, because they are the same window:
• The inline composer gets a reduced header — the To field itself, typable,
with From/Cc/Bcc/Subject folded behind the chevron. A forward opens needing
exactly one thing and used to open with a label where that thing goes.
• The suggestion panel opens on typing and on nothing else. `openOnFocus` is
read at focus time and a forward's caret beats the code that sets it, so an
empty query is refused at the moment the panel opens instead.
• It overlays the body rather than reserving room, which in a header meant
growing the header and shoving the editor down on every keystroke.
• `mode` moved onto ComposeContext. As a render-time extra it was set by one
action and lost by every other render, so a cancelled forward came back as a
plain new message and asked whether you meant to send an empty mail.
compose/_send_toast, compose/_inline_send.stream, compose--undo-send and
compose--inline-send are kept, unwired and documented as such, while the
replacement is reviewed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2828dc31dc |
feat(compose): sending with a pulse and a way out, and forwards that behave
The send button's "Sending…" becomes a living one-two-three dot cycle — a static label reads as a hang — and a Cancel appears beside it for the in-flight moment: every send lands in a cancel window, so a click here is remembered and the window's own control is pressed the instant it renders. The watcher lives at module scope because the stream that renders it replaces the very element a controller-held watcher would die with. A forward opens unsigned (its content is the mail being passed on), with the address rows unfolded and a blinking caret in a typable To — Tom Select's enhancement is awaited, not hoped for — and the quoted original counts as content, so no forward is questioned as an empty mail. The "show quoted text" fold stays default but becomes a choice: Settings → General → Composing, stored only when switched off, absent key meaning default as the settings bag always reads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
85cc9e7166 |
refactor(mail): compose gives up the parts that were never about HTTP
1,130 lines and 93 methods, with sixteen section headers the author had already
drawn — decomposed in the head, not on disk. Four pieces come out.
ComposeAttachmentController takes the three upload actions, and they are the
one genuine seam in that class. Everything else — new, reply, forward, draft,
send, schedule, undo — is a single pipeline building the same ComposeType form
from the same request context and answering with the same window, so pulling
"sending" away from "composing" would only have moved nine shared private
helpers into a shared parent and called the result two classes. These three
share none of it. MAX_ATTACHMENT_BYTES goes with them, which is where the file
picker and the window now read it.
ComposeContext replaces `array{inline, frame, thread, replyTo, urlParams}`, a
shape written out in EIGHT docblocks because every method touching it had to
redeclare it for PHPStan — so a key added to it meant eight edits, and a key
misspelled at a read site meant null rather than an error. `urlParams` was also
a STORED key derived from the other four, so the array could carry a value that
disagreed with the fields it came from; it is a method now and cannot. A value
resolver builds it, so actions type-hint it and four of them stop taking a
Request they no longer need.
ComposeWindow and ComposeFormFactory take the rest: the default From account,
the signature seed, the picker's services, the unsendable-address check, the
discard's row arithmetic, and the one place the form is built. All of it was
reachable only through a route before, so "which account is the default From"
could not be asked without building a request.
Two template assumptions fell out of it. `ctx|merge({thread: …})` was the last
thing standing between this shape and being a type — merge is an array filter —
and is `ctx.withThread()` now. And _window.html.twig defaulted every field it
read, with fallbacks that had DRIFTED from the controller's: it defaulted
`frame` to 'compose_dock', so a window could render as dock while the server
thought it was inline. A guaranteed type needs no fallbacks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 801639173c4c5017bfa0852791eb97306ee9db39)
(cherry picked from commit 2e1d713897de293c5e7623f95e661286dd245951)
|
||
|
|
d737fec583 |
fix(compose): make a confirmed cancellation a real one
Undo send could confirm a cancellation and deliver the mail anyway.
Reproduced in a browser against a real worker, a real ten-second
DelayStamp and a real SMTP server: cancel an inline reply at 9.9s and
the endpoint answers HTTP 200 "send cancelled" while the message
arrives at the SMTP server. 3/3 runs. The row was left holding both
statements at once — cancelled = true beside a populated sent_at.
The mechanism is a lost update, not a broken flag. SendMessageHandler
READ `cancelled` and then sent; the cancel's UPDATE committed between
those two steps. Neither side could discover what the order had been,
so the composer confirmed unconditionally. Two things were needed:
* A condition the handler cannot lose. message.send_claimed_at, with
the guard and the claim in ONE statement (claimForSend), and the
cancel as its mirror (cancelSend). Postgres decides which UPDATE
matched a row, and both sides learn whether they won. `cancelled`
survives as the one-shot signal it always was — it is no longer
what arbitrates.
* The composer to stop lying. A cancel that loses now answers
_undo_too_late.html.twig — "Too late, that message had already been
sent" — instead of "send cancelled" plus a restored window over a
delivered mail. The window is deliberately NOT restored: an
editable copy of a sent mail would be autosaved into a draft
standing beside the sent copy.
The reachable trigger was an inline reply bar counting the full ten
seconds, so it accepted a click at the instant the worker took the
message. The dock's toast was accidentally safe at eight. Both now
take CANCEL_WINDOW_MS from the controller, which is kept below the
send delay; the margin makes losing rare, the claim makes losing safe.
A claim expires after 15 minutes so a hard-killed worker cannot wedge
a draft that nothing may ever send again, and a failed send releases
its claim so the messenger retry is a real second attempt.
Also fixed, both reproduced first:
* Scheduling forked a permanent duplicate draft. The send pill's
buttons carry `formaction`, rendered once when the window had no
id; autosave read the new draft/send URLs out of its response but
never the schedule one, so scheduling posted to the id-less route
and ComposeController::schedule() built a second Message. Measured:
one composition, two rows in Drafts, the plain one stranded for
good. The window now publishes its schedule URL alongside the other
two and _adoptSavedUrls() rewrites every formaction.
* The schedule picker kept a stale "that time has already passed"
under a corrected date, beside an enabled confirm button — the
dialog calling the input invalid and offering to proceed with it at
the same time. Editing the field now withdraws the complaint.
NOT changed, because it did not reproduce: the send/undo toast is not
clipped. Measured at 1280x800 and 414x851 in both themes — 16px clear
of the bottom, fully on screen, undo link comfortably hittable. The
only overlap is the Symfony debug toolbar in dev, which the report
itself attributed to dev.
Regression cover in tests/Controller/Mail/UndoSendTest.php ends at a
recording sender, not at the flag: the old tests asserted the composer
reopened and `cancelled` was set, both of which were true while a mail
was going out. Two of the five fail against the pre-fix code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ea43b1fe30 |
fix(compose): the schedule that went nowhere, and eight other things a person hit
Scheduled send did nothing when a time was picked by hand, and it did it
silently. ScheduledSendResolver refuses anything closer than MIN_SECONDS (60s);
the picker only checked whether a time had PASSED, so the entire band between
now and a minute from now sailed through the browser. The server then reported
the refusal with $form->addError() — a ROOT error — and _window.html.twig
rendered only form.toAddresses's errors, so the message went nowhere at all.
Turbo replaced the compose frame with a re-render identical to what was already
on screen: menu closed, composer unchanged, no toast, one request in the
profiler, console clean.
Which is exactly the click a person makes to test the feature — they type the
next whole minute. Every green test used a PRESET; the custom picker appeared
in one test that drives it to a refusal, so "pick a time and have it actually
schedule" was asserted nowhere.
* the picker mirrors MIN_SECONDS and refuses below it, locally and in words
that are true of the time chosen ("past" and "too soon" are now two
different things to be told, in three catalogues)
* root-level form errors have somewhere to render, so no refusal from
schedule() can be swallowed again
* the spec drives the custom picker to a real schedule, all the way to a
persisted badge in Drafts — and pins the floor case as a local refusal
with no round trip at all
And the rest of the pass:
* the signature no longer eats the first paragraph. Clicking the empty space
BELOW it put the caret at the end of the sign-off, so typing welded onto
it; those clicks now land in the writing space above. The emptiness check
counted the signature as not-the-user's-writing unconditionally, so text
that had landed inside it was subtracted before the count and Send claimed
a visibly full message had none — the block is skipped only while it is
untouched.
* the subject/body confirmations are the in-app panel the plain-text switch
already uses, not window.confirm(), which froze a test session three times.
Both reasons are one question asked once, and the retry goes back through
the same submitter so a scheduled send stays scheduled.
* the To field: the refusal clears when a recipient is added, names the
invalid address rather than blaming the count, and keeps what was typed
(Tom Select wiped the box on blur before submit ever saw it). The
suggestion panel no longer reopens over the Subject row on every focus.
* aria-invalid and aria-describedby tie the refusal to the input; the From
select and the Tom Select original are named; the picker's refusal is a
role="alert".
* a subject typed on its own is worth autosaving — the threshold counted the
body alone, so "TEST-RELOAD halbfertig" was lost on reload with no warning
and nothing in Drafts. Below the threshold, beforeunload interrupts.
* the emoji panel is capped against the compose WINDOW rather than the
viewport (its top edge sat 33px outside it), the grid's scrollbar is thin
rather than a 15px native bar with arrows, and the skin-tone button is off
the panel outline.
* vergrößern, Schriftgröße, groß — the compose labels were the only corner
of the UI spelling it the Swiss way, and a spec was asserting it.
phpunit OK (3427 tests, 8849 assertions); composer stan [OK] No errors;
120 compose e2e passed, plus 48 adjacent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
263785f20a |
fix(compose): the send pill in the header on a phone, where the old button was
Below md the Send pill now sits in the compose window's header — the spot the
plain Send ICON held two rounds ago — instead of at the bottom of the screen.
Above md nothing moves: it stays first in the action bar.
One partial, `compose/_send_pill.html.twig`, included twice: `md:hidden` in the
header, `hidden md:flex` in the action bar. Not copy-pasted, because the shape
this replaces was exactly a copy that drifted — a header icon that could only
send beside a bar pill that could also schedule, two Send buttons disagreeing
about what Send meant. Exactly one is ever in the accessibility tree, which is
what the specs assert at each width.
Three things the second placement needed:
• The menu flips. The bar's opens upward and anchors `left-0`, because it is
the first thing in the bar. In the header the pill is the LAST thing on the
row, so the menu opens DOWNWARD (`top-full` — `bottom-full` would clear the
top of the phone) and anchors `right-0` (a left-anchored `w-64` runs off a
320px screen). Both are a `placement` parameter, and both are measured.
• Two `schedule_at` fields, one form. They share a name and `display: none`
does not keep a control out of a submission, so PHP would have kept the
LAST in document order — the pill nobody touched — and every schedule set
from the header would have posted an empty time. Both ship `disabled`;
compose--schedule arms only its own as it writes to it. No ids were
involved: the block had none to collide.
• The header at 320px. The right-hand group lost its `shrink-0` and the save
status is capped at 30vw below md, so the title yields and the pill does
not. Measured: header 61px tall, pill 115px wide ending 8px from the right
edge, no horizontal overflow, back arrow still at x=0.
compose--compose#_handleSubmit already read `sendBtnTargets` plural, so both
copies are disabled and relabelled on send; its comment now says why there are
two rather than claiming there is one.
Tests: ScheduledSendTest counts the pair and asserts the two placements carry
opposite breakpoint classes, opposite menu directions and disabled fields;
compose-mobile and compose-scheduled-send assert the pill is in the header,
that the menu opens below it and fits at 393px and 320px, and that the count of
Sends on screen is still one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9c37bc3a8f |
fix(compose): a toolbar that wraps, a pill on the phone, and a way back out of plain text
Five reports from one window, and four of them turn out to be the same
mistake: the two toolbar rows were sideways scrollers.
• The emoji panel opened off screen on a desktop. It was anchored to its
button, which sits near the right edge of a 520px dock. It is centred on
the compose WINDOW now, at every width and in both compose modes — the
same treatment a phone already got by being pinned to the viewport, and
one rule instead of two.
• Leaving plain-text mode did not bring the formatting bar back. Going
plain wrote `display: none`; coming back wrote nothing, so a rich-mode
window sat there with no bar and no reason why. The switch is symmetric
now, and restores the bar to what it WAS rather than simply opening it —
a bar folded on purpose stays folded across the round trip. Same for the
link, emoji, image and signature buttons, which are greyed while plain
(they write into an editor that is hidden) and restored on the way out.
• Neither toolbar row scrolls horizontally any more; both wrap onto a
second row. `overflow-x: auto` computes `overflow-y: auto` with it, which
is what clipped the more-options menu to nothing on a phone last round —
so removing the scrollers also let the `max-md:fixed` viewport-pinning
workarounds be deleted rather than kept. Every popover now anchors to the
toolbar block, which spans the window. Two rows at 393px and at 320px:
the group dividers go `hidden md:block` below md, which is the 25px
between two rows and a third holding nothing but Redo.
• The send pill is rendered at every width, so scheduling exists on a phone
for the first time; the header's separate Send icon is gone rather than
left to make a second, less capable Send button. Both halves of the pill
take a 44px minimum below md — the chevron was an 11px hit area.
• The formatting bar is shown by default on a phone as well as a desktop.
"Aa" still folds it away for whoever wants the room.
Measured at 320x568 with all of it up: 62px of format bar, 84px of action
bar, 349px left for the body. At 393x851, 632px.
Tests: compose-mobile gains the no-sideways-scroll assertion, a
both-ends-on-screen assertion and a body-height floor; compose-scheduled-send
inverts its "the pill is desktop-only" block into measured pill-and-menu
geometry at 393px and 320px plus a preset set from the phone itself;
compose-signature-and-options gains the round trip that would have caught
this — a one-way test is what let it through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
cf9ac15e35 |
Merge fix/compose-editor-polish: in-app popovers, and a picker that fits
Replaces the native prompt and confirm with popovers: a link editor with URL and text prefilled from the selection, a link viewer with Open/Change/Remove, and a plain-text warning. Preset times now honour the user's 12/24 setting, which formatWallClock never consulted. Two real display bugs behind the emoji picker's black borders: --rgb-line was spent without its alpha, so every hairline resolved to pure black, and the component set color-scheme from the OS rather than the app theme. Also fixes a more-options menu that was clipped to nothing on a phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
832050b304 |
fix(compose): a link popover, a themed emoji picker, and clocks that follow the setting
Five reported defects in the compose editor, and one the mobile specs were
never going to catch.
The clock. formatWallClock() asked Intl for an hour and never said which
kind, so the send-later presets followed the LOCALE's default rather than the
user's twelve-or-twenty-four-hour setting — "8:00 AM" beside server-rendered
timestamps reading 08:00. The setting is now stamped on <html> once per render
and read back through assets/clock_format.js, which is a module rather than a
Stimulus value because the snooze menu had the identical bug and shares no
template with compose. Both pass hour12 explicitly now.
The link button. It raised prompt('Enter URL:') — one unthemed field, no way
to say what the link should read as, and a bare "example.com" going in as a
relative link to a path inside the app. It is now a popover with a URL field
and a text field, the text prefilled from the selection. The selection
survives because the toolbar's saved range is FROZEN while the popover is
open: focusing the URL field blurs the editor, and a blur allowed to re-read
the selection would record the collapsed one. URLs are normalised and screened
against MailBodySanitizer's own allowLinkSchemes list, so nothing the editor
accepts is silently dropped on save, and "example.com" becomes https://.
Links in the editor. A click in a contenteditable places a caret and nothing
else, so a link just written was unreachable. Clicking one now shows the same
popover's other face — the address, Open, Change, Remove — and it dismisses on
Escape, on an outside click, and when the caret moves off the link. It does
not take focus and does not restore any range, because that face appears
beside a caret the user placed themselves. Anchors in the editor are also
styled as links again: Tailwind's preflight resets `a` to inherit, which left
a freshly inserted link indistinguishable from the words around it.
The emoji picker's "weird black borders". --rgb-line is `0 0 0` in every light
theme and `255 255 255` in every dark one — a hue whose weight lives in
--line-alpha — and the picker was handed it with no alpha, so its hairlines
resolved to pure black. Its search field and scrollbar were black for a second
reason: emoji-picker-element declares color-scheme on its own :host from
prefers-color-scheme, the OS setting this app uses for nothing. Both fixed,
the second with `color-scheme: inherit` so a runtime theme switch carries the
picker with it.
The plain-text warning. window.confirm() becomes a popover over the editor,
with the same rule (only when there is formatting to lose) and the same
reversibility. Refusable by Cancel, Escape or an outside click.
And the one nobody reported: the More-options menu was UNREACHABLE on a phone.
Its button sits in .compose-scroll-x, whose overflow-x:auto computes
overflow-y:auto too, so a menu opening upwards out of a 36px strip was clipped
to nothing — plain text, priority and read receipt could be tapped open and
never seen. Pinned to the viewport below md, as the emoji panel already was.
compose-mobile.spec.ts now measures every one of these against the viewport at
393px AND 320px, in both axes and with a minimum size, because the last round
shipped an off-screen emoji panel past a spec that only asserted the panel
existed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a28f9da47c |
feat(compose): call a scheduled send off from the row it sits on
The hold was already announced — the composer closes and a toast names the
time — but calling it off meant opening the draft and then opening the send
menu, and the Drafts row itself offered no way out. On a phone that was the
only way out at all: the send pill is desktop-only, so the row is the sole
surface a scheduled draft has there.
• The scheduled badge grows a cancel. Badge and × are one pill rather than
a badge with a button parked beside it — the × means nothing on its own.
It sits ON TOP of the row's overlay anchor exactly as the star and the
hover actions do (z-10 on a flex item, plus stopPropagation in
mail--message-row), so no nested <form> in a shared partial and no click
reaching the link underneath.
• POST /compose/unschedule/{id} answers with the row redrawn, not with the
composer reopened: undo() exists to catch a send you regretted while you
were still looking at it, and a hold set on Friday is called off on
Monday from a list of forty rows. Both now share callOffSend(); the
sentAt guard is in one place instead of two. A message that has already
gone is left entirely alone — submissionSendAt is by then the record of
when it was due — and the row is redrawn anyway, because what was on
screen was stale.
• The toast's action is relabelled "Cancel send". "Undo" is the wrong verb
for something that has not happened yet, and the long form pushed the
message onto four lines; _toast.html.twig takes an optional undo_label.
• On a stacked row a scheduled draft shows the badge instead of the
preview, which at 393px was two characters and an ellipsis. `hidden
@xl:inline-block`, not `@xl:block`: Tailwind emits `.hidden` after
`.block`, so the container variant does not outrank it.
Every string translated (en/de/en_PI), including the sr-only label. The
mobile spec measures the pill's bounding box and the tap target rather than
asserting presence, after the target came out 11px tall.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0620de10ab |
feat(compose): signatures, a more-options menu, and an honest encrypt button
The last three placeholder controls in the compose toolbar. The `placeholderTools` loop is gone; nothing in that window is a picture of a button any more. SIGNATURES. Per account and per alias, in the existing jsonb settings bag — no migration. The two levels are read by the key's PRESENCE, not its value, exactly as the read-receipt setting next to it: an alias with no key inherits the account signature, and an alias holding the empty string signs with nothing. Collapsing those would make an unsigned personal address on a signed work mailbox impossible, which is the case the per-alias setting exists for. Written through MailBodySanitizer on every path in — the settings panel and "Identity/set" — because a signature is HTML injected verbatim into every outgoing message. The allow-list drops class and data- attributes, so the `data-pl-signature` marker is added around the sanitised value and a signature can never smuggle in a `data-cid` for InlineImageRewriter to mistake for one of its own inline images. Auto-inserted above the quote on new, reply and forward, with an empty paragraph in front of it: the composer puts the caret in the body's first editable node, so a body starting with the signature block starts with the caret INSIDE the signature. The toolbar button replaces the block in place rather than appending a second, and so does the From switch, which swaps only the block — a paragraph already typed survives changing sender. Exposed through Identity/get and accepted by Identity/set, so a phone and the browser sign with the same thing. `plMail stores no signature` is no longer true and no longer says so. MORE OPTIONS. A ui--dropdown rendering the priority and readReceiptRequested fields ComposeType already mapped — until now the window rendered with render_rest:false and bound them to null and false on every autosave. Plus plain-text mode, which needs no schema either: a message with text and no HTML IS a plain-text message, which is the same pair MessageSendService branches on, so the mode survives a reload with nothing storing it. Switching to it warns first and is reversible until the draft is saved that way. ENCRYPT. Disabled, named, and it says why. There is no encryption yet and a live-looking lock is the one lie a mail client must not tell. Every string in EN, DE and en_PI; the JS ones through the i18n value. |
||
|
|
c47eef8b5e |
Merge feat/compose-emoji-inline-image: a real emoji picker and inline images
Wires two of the placeholder toolbar buttons. The emoji picker is the full Unicode set with search, skin tones and recents, vendored locally so nothing is fetched from a CDN, and it never interprets typed text -- emoji arrive only by an explicit click. Inline images upload as cid: parts the send path already knew how to embed, including paste and drag-drop. Conflicts in ComposeController were additive on both sides (a constructor dependency and an import each) and both were kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
81fa3d6aa0 |
feat(compose): wire the send-options chevron to scheduled send
The chevron beside Send was a type="button" with no data-action at all —
named for the accessibility audit, inert to the click. It now opens a menu:
Tomorrow morning / Tomorrow afternoon / Monday morning, a separator, and
"Pick date & time" revealing an inline <input type="datetime-local">. Same
ui--dropdown shape as the integration picker, so outside-click and Escape
come free.
Message::$submissionSendAt and MessageSendService already honoured a hold,
and JMAP has implemented delayed send end to end for a while. The web
composer was the only surface that never set it.
POST /compose/schedule[/{id}] runs the same path as send() — recipient
validation, firstUnsendableAddress(), DraftPersister::save() — then records
the hold and dispatches SendMessageMessage with a DelayStamp for its
distance rather than the fixed ten seconds that IS the undo window. Each
menu item is a real submit button with `formaction`, so a schedule goes
through the form exactly as a send does, guards and CSRF included, rather
than down a second quieter path.
Timezone: the menu computes wall clocks against the user's *configured*
zone (ClockGlobal::getTimezone, resolved exactly as TwigTimezoneSubscriber
resolves it) and posts "2026-08-13T09:00"; ScheduledSendResolver turns that
into an instant in PHP, where the tz database knows what to do with a
spring-forward Sunday. No offset arithmetic in JavaScript at all. The
browser's own zone is deliberately not consulted — a laptop still on New
York time must not move a Berlin user's morning.
Ceiling: ScheduledSendResolver::MAX_SECONDS imports
SubmissionEnvelope::MAX_HOLD_SECONDS, the value the session advertises as
maxDelayedSend, so web and JMAP cannot disagree about what a client was
told. A floor of one minute as well: below it "schedule" is "send" with a
worse undo.
Cancel reuses the undo endpoint — `cancelled = true`, which
SendMessageHandler reads and clears — and now also clears submissionSendAt,
because that column with sentAt null is exactly what EmailSubmission/get
reports as `pending`; leaving it made a cancel invisible on every other
device, and permanent.
Surfaced in the Drafts list and on a draft's row inside a thread, since the
toast has faded by the time anyone goes looking. The schedule menu also
carries "Scheduled to send … / Cancel scheduled send" on a draft already
held, which is the affordance that is still there tomorrow.
While in ComposeController: adopt RendersTurboStreams and delete the
byte-for-byte private turboStream() copy.
Tests: ScheduledSendResolverTest (zone, spring-forward pair, floor,
ceiling, delay), ScheduledSendTest (hold recorded in the user's zone and
demonstrably not the server's, delay, cancel, past/over-ceiling/unsendable
refused), DraftRowRenderTest (badge), and compose-scheduled-send.spec.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
07253c8bb7 |
feat(compose): wire the emoji picker and inline images
Two of the five inert buttons in the compose toolbar's placeholder loop now do what they have always claimed to: the smiley opens a real picker, and the image button puts a picture in the body rather than beside it. The other three are left in the loop untouched. Emoji — the full Unicode set, via `emoji-picker-element` in a `ui--dropdown` shaped like the integration picker beside it, so Escape and outside-click come free. Two things the component does by default are not acceptable here and are configured out: its data source is a jsdelivr URL, replaced by emojibase JSON vendored per locale under assets/emoji (so search works in German); and its labels are English, replaced by the same Twig-side i18n object the rest of the window uses. A Playwright assertion pins the rule the whole feature is built around — emoji enter the body when somebody picks one and at no other time, so `:)` and `:smile:` survive typing byte for byte. Noto Color Emoji (OFL) ships with it, COLRv1, as the ten unicode-range woff2 subsets Google Fonts itself serves: 2.0 MB on disk, but a body with three smileys fetches only the ~140 KB slice they live in. Scoped to the picker and the compose body, so the grid matches the phone keyboard people know it by instead of changing shape per operating system. Inline images — the read and send sides already existed (MessagePart carries contentId/isInline; buildEmail() already embeds), so this adds the two missing halves. DraftAttachmentService::attachInline() stores the bytes down the same path as an attachment and describes them differently: inline disposition, a generated content id, isInline — which keeps syncFlag() from putting a paperclip on a mail whose only "attachment" is a picture the reader can already see. InlineImageRewriter is the inverse of resolveCids(): the editor holds the attachment route because a browser cannot render `cid:`, the wire holds `cid:` because a recipient cannot resolve anything else, and `data-cid` is the identity that bridges them. It runs inside DraftPersister::save(), so autosave, send and plainTextBody() all see the same body. Paste and drag-drop go through the same endpoint. Without them a pasted screenshot becomes a data: URI that every later autosave posts back as base64. Refusal messages from DraftAttachmentService were spelled in English in the method that produced them — the one thing a German compose window said in English. They go through the translator now, in all three locales. |
||
|
|
c60c57131a |
fix(compose): undo reopens the window where the send came from
Cancelling a send has to put the compose window back — and the floating dock has to survive it. Two symptoms, one cause. The dock's placement rode on the <turbo-frame id="compose_dock"> element itself. But the undo stream reopens the window with action="replace" on that frame, swapping the whole element and throwing its `fixed bottom-4 right-6 z-50` away with it. The reopened window then rendered in normal document flow, below the fold — so the send was cancelled but the window looked like it never came back. Worse, that classless frame stayed in the page, and it is the very frame the Compose button and every draft row navigate into: every later dock window landed below the fold too, until a full page reload restored the markup. That is the "draft won't open until I reload" report — the same bug, one step on. Move the dock's positioning onto a stable wrapper div, so replacing the frame can no longer move the dock. The inline reply had its own copy of the older failure: _inline_undo.stream renders the same compose window with `only`, and never passed pickerIntegrations — which under strict_variables is a 500. The inline cancel flushed and then failed to reopen, leaving the draft with no way back to it, exactly as the dock undo used to. Pass it through. Pinned by tests/e2e/compose-undo-reopen.spec.ts: a dock send reopens on screen with its fields intact and the next Compose still opens on screen; an inline reply reopens inline, not in the dock; and a drafts-list row opens on the first click when it arrived by a turbo navigation. The placement assertions check where the window actually is — an element in normal flow below the fold is still "visible" to a toBeVisible() check, which is why the existing undo test passed through the regression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
110cfb298c |
Merge fix/search-routing-i18n
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # migrations/Version20260811160000.php # templates/calendar/_event_modal.html.twig # templates/mail/_message_details.html.twig # templates/mail/print.html.twig |
||
|
|
17c21df702 |
Merge fix/compose-safety
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1836e673e7 |
fix(a11y): name account controls per account, stop hardcoding white on accent
A-04: every account rendered a folder toggle labelled "Toggle account folders", so three accounts gave three controls a screen reader reads identically. The address is in the label now, and the account link is named explicitly rather than by a `truncate` span that CSS can cut mid-address. A-07 (second half): computing --rgb-accent-ink by luminance achieves nothing while 63 call sites paint `text-white` on `bg-accent` directly. They now use text-accent-ink and inherit the measured choice. The two left behind are white over a black gradient and over a danger fill, which are not the accent. H-05: the search box kept a stale query when a folder was clicked — the input survives the Turbo visit and a folder page has no `q` to overwrite it with, so the box claimed a filter the list below it had not applied. It now tracks `?q=` in the address bar, which is also right on the way back in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
af5439fe3f |
Stop Enter sending mail, and make undo bring the window back
Six items from an external bug report against v0.0.30, all in compose. Enter in the To field sent the message. The mechanism is implicit form submission: Tom Select calls preventDefault on Enter only when it has a use for the key — a suggestion to commit, or a createItem() that succeeds. Type something with no "@" and neither applies, so the keydown reached the <form>, whose action is the send URL. The mail went out to nobody, with no subject and no body. Every existing test missed it because they all type valid addresses, which Tom Select always swallows. Enter is now claimed unconditionally and then asked what it should have meant: a chip in an address row, a move to the body elsewhere, and a refusal that says so when the text cannot be an address. Ctrl/Cmd+Enter is the one deliberate send. Undo was a 500. The undo response rendered the compose window without pickerIntegrations, and the cancel flag was flushed before that render — so the send was called off, the toast faded, and the draft was filed with no way back to it. It now renders as a turbo-stream with every variable the window needs and replaces the frame, so cancelling reopens what was being written, with recipients, subject and body intact. Recipients are validated at the chip, at the send, and on the server: a Count in the form's existing `send` group (so a draft may still have none), plus an address-format check on what is about to go on the wire. An empty subject or body asks first, Gmail-style. The From default and the PRIMARY badge named different accounts. isPrimary is positional on the user's own arrangement (AccountCreator::resequence), but the settings page listed alphabetically and badged loop.first — so the badge was a statement about the alphabet. The badge reads the flag now, the list is ordered the way the reorder endpoint already re-renders it, and compose's fallback for installs with no flag at all is ordered rather than arbitrary. The window was English inside a German UI. Every string is translated in all three catalogues, including the ones the Stimulus controller writes, which travel as an i18n value the way ui--select's already do. Accessible names: the recipient comboboxes, the typeface and size pickers, and the six unnamed icon buttons. The text-colour "A" was a label wrapping a 0x0, pointer-events:none, tabindex="-1" input — a picture of a button, in the accessibility tree not at all; the input now covers the control and is focusable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
917a508566 |
fix(mail): two attachment clicks that did nothing and said nothing
Both are in the message view, and neither left a trace anywhere. The download link was a plain same-origin anchor, so Turbo Drive intercepted the click, fetched the URL itself and dropped the response for not being HTML. The request went out, the app answered 200 with the file, the access log looked perfect, and the browser saved nothing — no console error, no exception, no failed request to find. `download` is what tells Turbo to leave a link to the browser; the backup export, the ICS subscribe link and the appearance export all already carry it for exactly this reason, and these two call sites were missed. The draft attachment list had the same link and the same silence. "Save to…" opened a menu positioned `top-full` against an element carrying `overflow-hidden` — the clipping that rounds the chip's two halves inside one border. The menu opens below that box, so it was clipped away entirely: it had a bounding rect and no paint, and hit-testing at its own centre found whatever happened to be behind it. Clipping now belongs to an inner box and positioning to the outer one, which is the only arrangement where both are true at once. The spec covering save-to passed throughout, which is the more useful half of this: toBeVisible() is satisfied by a rect, and Playwright clicks at coordinates rather than at what a person can reach, so a menu painted nowhere tested exactly like a menu on screen. Both specs now assert what someone would actually notice — a download event carrying the right filename and bytes, and a menu whose own centre hit-tests to itself — plus the POST that the save reaches the server with. The upload beyond that boundary is not proven here: the test stack's Nextcloud is unreachable by design, so what is pinned is that the click gets out and that the answer gets back to the reader. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e453cc9516 |
feat(ui): every select is a Tom Select, themed from the palette tokens
`select-field` got the closed control right — appearance:none, the app's fill, the app's chevron — and could never get the popup, because the popup is the one part of a form control CSS does not reach. color-scheme was the last thing left to try and it only picks light or dark: the list is still the platform's font, the platform's row height and the platform's highlight, sitting in front of a themed app in five of the six themes. Compose already had the answer for its address fields. This is that same widget on every other single-select in the app, so there is one dropdown here instead of two. - assets/controllers/ui/select_controller.js progressively enhances a native <select>. The element stays in the DOM as the source of truth, so forms post identically, `onchange="…requestSubmit()"` and `data-action="change->…"` keep firing, and no-JS renders the native control the stylesheet still styles. - The panel is parented to <body>: every call site is inside something that clips. It flips above the control when there is no room below and caps itself to the room there is, which Tom Select does not do on its own, and closes on scroll rather than being left behind by the pane it was anchored to. - Short lists lose the search box entirely, so a five-item picker behaves like the native control it replaced instead of blinking a text caret. - tom-select.css now skins .single as the twin of `select-field` — same fill, border, radius, chevron and height, the last one measured per call site so h-8 rows stay h-8. No per-theme rules: the six themes differ only in what the tokens resolve to. - The rule editor builds five selects in JavaScript and was missed by the last sweep; it stamps the attributes itself now. - Compose's font pickers are converted too, frameless so the format bar keeps its character. Their labels were the lookup keys, which is why they were English in a German UI; the values carry the keys now. - "No results found" arrives from Twig |trans (EN/DE/en_PI). Left native, deliberately: compose's multi-select address fields, which have a richer Tom Select of their own; the provider preset, enhanced by UX Autocomplete because it fetches its choices; and compose's sr-only account select, which is never shown. PageRendersTest now sweeps every page for a select that is none of those and carries no widget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9bc3cf0097 |
Send the compose context whole instead of unpacking it four times
The undo action listed seven template variables, five of which were the same context every other render already had, and urlParams was recomputed at three call sites from the same input. Now composeContext carries urlParams and the renders pass ctx. The templates read ctx.frame and ctx.inline rather than taking them apart again. _window keeps its defaults, so it still renders standalone. One trap in doing it: _window already had a local named ctx meaning the query params, and four route calls used it. Renaming the incoming variable to ctx without noticing would have put inline, replyTo and a nested array into every draft and send URL -- a break with no error, since path() takes extra parameters as a query string. The local is params now. Also fixes the From alias, which never survived a save. The composer wrote the address the user picked onto the message and markAsDraft replaced it with the account's own one line later, so every mail sent from an alias went out as the primary address. The sender is a parameter now, defaulting to the account. That one is worth reading with the Android notes open: SERVER_REQUESTS.md files the identity gap as JMAP-only, reasoning that "the web composer does honour the choice, through ComposeController::resolveFromAddress()". That method does not exist anywhere in src/. Both surfaces ignored the alias, so the ask is wider than it is filed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
122006460d | onboarding. this is a lot | ||
|
|
f514bea724 | mobile compose rework | ||
|
|
92593a756a | small fixes. | ||
|
|
54e3ae3b3b | stimulus controller reorder |