d992442b8194be5e9c48204332c7243d9587b4ca
55
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
94639e8420 |
Hang every folder off one edge, and give the drawer a way out
Two things the drill-down got wrong, both found on a phone-width window. The folders did not line up. The rule that drops the twisty's 30px gutter was hung on the rows offering a drill, so only folders with children lost it -- they sat 18px left of every folder without any, and the column of icons came apart. Whether a folder has children is not a reason to hang it somewhere else. The class moves to the list, which is what the indent is a property of; icons now share one column and labels another, at every level and on the back row too. There was no obvious way back out of the drawer. It covers the top bar -- it is taller than it -- so the hamburger that opened it is underneath, and pressing the same place again did nothing at all, since that handler only ever set the drawer open. The dimmed strip beside the drawer was the only exit, and nothing says so. There is now a close where the hamburger was, moved by the same rule so it lands on exactly the same pixels, and the hamburger itself toggles rather than only opening. Escape closes it too, for a tablet with a keyboard. Raising the top bar over the drawer instead would have been the smaller change and is not available: the drawer is at 950 and a full-screen composer at 800, so a top bar above the first is also above the second. |
||
|
|
6c958b8609 |
Folders one level at a time on a phone, and targets a thumb can hit
Three things the mobile interface got wrong, all of them measurable. The folder tree spent its width on depth. Four levels down, the 16px indent steps and the 18px twisty left a folder 85px of a 300px drawer to print its name in, and the twisty had walked far enough right that hitting it was luck -- a miss landed on the row, which is a link, so the wrong tap also cost a navigation. Under 768px the tree is now a drill-down: one level at a time, no indent, a back row above it, and a chevron at the right edge that is the same size in the same place on every row. Tapping the row still opens the folder; only the chevron changes what the list shows. The tree is untouched above 768px, where a wide sidebar can afford the indent and where dragging a folder onto another folder -- still the only way to reparent one -- needs both of them on screen at once. Every control in the top bar was under the 44px a fingertip covers: the icons at 36, the search filter at 30, the row menu at 24, and the hamburger 6px from the bezel in the corner a thumb is worst at. They keep the size they draw at and gain a transparent hit area, since growing the boxes would reflow a bar with no room to give; rows grow for real, because a 44px target inside a 36px row reaches into its neighbours. The one exception is the row menu, held to 36px wide: at a full 44 it overlapped the drill chevron by 4px, so its right edge silently drilled instead. Pinch was dead on the message list. `.msg-row` sets `touch-action: pan-y` to feed the swipe gesture the horizontal movement the browser is not using -- but naming any value drops every gesture not named, zoom included. It worked on an open message and died on the list, which reads as the zoom being broken at random rather than as a rule about rows. `pan-y pinch-zoom` keeps the swipe and gives the zoom back. |
||
|
|
a6863e98cc |
Second extraction pass: the strings the codemod could not see
`i18n:coverage` reported 100% while a hundred-odd strings rendered
English in every language. It was not wrong about what it measured: it
reads JSX text, and none of these were JSX text. They were toast
arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and
`aria-label=` attributes, and template literals — every one built from an
expression the codemod cannot read.
176 source strings and 15 plural sets now go through t() and plural(),
translated into all nine languages. Where English put a word in a slot,
the sentence is spelled out per branch instead: `Filter ${verb}` became
"Filter saved" and "Filter created", because which word agrees with what,
and where it sits, is not a property English gets to decide for everyone.
Counts that were `${n} message${n === 1 ? "" : "s"}` are plural() calls,
so Russian and Ukrainian get three forms and Japanese and Chinese get the
one they actually have.
Two of the catalogue's own conventions were worth learning the hard way.
Plural entries are keyed on the English *other* form, not `one` — `one`
is a form English happens to have and Japanese does not. And a constant
table holding English that is translated at the render site is fine: the
literal is a key, not a leak.
Which is what the new check encodes. `scripts/i18n-literals.mjs` accepts
a string that is wrapped where it is written or is a catalogue key
somewhere, and refuses one that is neither — a string no catalogue can
translate, however many languages ship. It found twenty more than my own
sweep had, including the stale-folder toast seen in production. It runs
as part of `npm run i18n:check`.
Also fixed: the catalogue is now awaited before the first paint. The
tree is rebuilt when a catalogue lands, so components recover on their
own, but a string computed in an effect does not — a toast fired in that
gap is emitted in English and stays English. The wait costs nothing
visible, since the session bootstrap already shows a spinner and English
resolves immediately.
And the Japanese agenda title loses a space Japanese does not use:
"{date} からの予定" was written with the English habit of spacing around
a placeholder.
|
||
|
|
f94cc2ce51 |
Translate the labels the extractor could not see
Three places built user-visible English out of expressions rather than
writing it as JSX text, so the extraction codemod never found them and
they stayed English in all nine languages — including the five that have
been in production for weeks.
The calendar view switcher was the worst of them: it spelled its labels
as `v[0].toUpperCase() + v.slice(1)`, which is correct English and
untranslatable anywhere else. Day, Week, Month and Agenda were already in
every catalogue, sitting unused, because the buttons never asked for
them. They now come from a Record<View, () => string>, so TypeScript
makes the map exhaustive and adding a view forces adding its label. The
labels are functions rather than values: a module-level object would
capture whichever language happened to load first and keep it.
The other two needed new source strings, added to all nine catalogues:
the composer's title for an untitled draft ("New message"), its status
line ("Sending…", "Saving…", "Error", "Saved {when}", "Unsaved"), and the
agenda view's own title ("Agenda from {date}").
This is not the whole of it. A sweep for the same shape — template
literals, toast arguments, and dialog props rather than JSX text — turns
up roughly a hundred more strings, mostly toasts and confirmation
dialogs. Those are a second extraction pass rather than a fix, and are
left for one.
|
||
|
|
53c38ed3c3 |
French, on the same terms as German
Second of Phase 1. Generated by AI, unreviewed, marked Beta, with the report link in Settings doing the job a native speaker would otherwise do. 781 of 796 strings; the fifteen left are product names, bare URLs and example addresses, which should stay English. Register is "vous", following the "Sie" decision and for the same reason: a mail client a workplace deployed has no business addressing anybody as "tu". One terminology decision goes the opposite way to German, deliberately. "Label" stays English in German because no German client translates it, and becomes "Libellé" in French because Gmail did and a French reader will meet it there. The rule being followed is "use what the reader will find elsewhere", not "always translate" or "never" -- which only looks inconsistent if the rule is mistaken for the outcome. French typography: the narrow no-break space before ? ! and : is the rule and is deliberately not used. It is invisible in a diff, trivially lost in an editor, and no French webmail actually ships it. Guillemets are used, because those are visible and do read as wrong when missing. Two things worth recording from doing this a second time. The folder-context separator arrived as a raw U+0004 byte rather than the \\u0004 escape the German file uses. It would have worked -- TypeScript accepts it -- and it is invisible in an editor and in a diff, which is exactly why the German catalogue writes it as an escape. Converted, so both files say the same thing in the same way. And the language tests named a real language as their example of one that is not shipped, so shipping German broke them, and shipping French broke them again. Both times the failure was the test being out of date rather than anything wrong. They derive an unshipped tag now, and assert over every shipped language rather than a hardcoded pair, so the third and fourth languages will not repeat it. |