feat(composer): tighten action-row padding, extract button JSX, and rotate placeholder across 12 hour-keyed variants

This commit is contained in:
v.lagerev 2026-05-15 00:13:51 +03:00
parent 3fb5f012e5
commit c96d0061f0
5 changed files with 235 additions and 95 deletions

View file

@ -167,6 +167,51 @@ Stock Cinny had multiple themes; vojo simplified to System / Light / Dark (commi
- Folds tokens (`color.*`, `config.space`, `config.radii`, `config.borderWidth`) are read-only inside folds compiled CSS. Re-skinning colours via `createTheme()` works; re-skinning radii or spacing requires CSS overrides outside folds.
- Brand accent in v4.11.x: `Primary.Main = #BDB6EC` (lavender) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe.
## Composer card geometry
Load-bearing pixel values for the main chat composer + thread-drawer composer (both wrap `RoomInput` with the `ChatComposer` class). The composer is a floating rounded card with **32px corner radius** (`VOJO_HORSESHOE_RADIUS_PX`); all paddings are tuned so the visible glyphs (text, IconButton icons) stay outside the curve clip. Source of truth: [`src/app/features/room/RoomView.css.ts`](../../src/app/features/room/RoomView.css.ts), [`src/app/features/room/RoomInput.tsx`](../../src/app/features/room/RoomInput.tsx) (action-row padding).
| Element | Value | Where |
|---|---|---|
| Card corner radius | 32px | `VOJO_HORSESHOE_RADIUS_PX` |
| Card outer padding | `6px / 16px` (vertical / horizontal) | `RoomView.css.ts``.ChatComposer .Editor` |
| Textarea vertical padding | 13px (folds default — do NOT override) | `Editor.css.ts``EditorTextarea` |
| Textarea horizontal padding | 12px left, 12px right | `RoomView.css.ts``:first-child` / `:last-child` rules |
| Placeholder paddingTop | 13px (folds default — must match textarea padding) | `Editor.css.ts``EditorPlaceholderTextVisual` |
| Action-row padding | `2px / 8px / 4px` (top / sides / bottom) | `RoomInput.tsx` `bottom` slot |
| IconButton size | 32×32 (folds `size="300"`, `fill="None"`) | `RoomInput.tsx` |
| IconButton internal padding | 4px (SVG 24×24 centered) | folds default |
| Empty-state composer height (single-line, no reply) | ~93px | derived |
**Don't override the textarea's vertical padding (13px) without also retuning `EditorPlaceholderTextVisual.paddingTop` in lockstep**: folds tuned the pair so Slate's placeholder span and the typed-text caret land on the same y inside the contenteditable content-box. Diverging the two breaks vertical alignment — typed text and the «Send a message…» placeholder appear at different baselines.
**Visual alignment goal** — text glyph and Plus icon-glyph sit on the same vertical column at 28px from the card edge (mirrored on the right for Send):
- `text-glyph-x = outer (16) + textarea paddingLeft (12) = 28`
- `icon-glyph-x = outer (16) + row paddingLeft (8) + button-internal-pad (4) = 28`
**Bottom-left curve clearance** (Plus IconButton container vs the 32px corner):
- `button-bottom y = 6 (outer) + 4 (row pad-bot) = 10`
- `curve-x at y=10 = 32 √(32² 22²) ≈ 8.76px`
- `button-left = 16 (outer) + 8 (row pad-left) = 24`
- **clearance ≈ 15.24px** — comfortable for the hit-box; the visible glyph clears the curve by ~23px
**Top-left curve clearance** (placeholder text glyph):
- `text-glyph-y = 6 (outer) + 13 (textarea pad-top) = 19`
- `curve-x at y=19 = 32 √(32² 13²) ≈ 2.76px`
- `text-glyph-x = 28`
- **clearance ≈ 25.24px** — very generous; supports multi-line growth
**Future compactness levers** (if needed without breaking alignment):
- Outer card vertical padding (currently 6px) — drop to 4px saves 4px
- Action-row padding (currently 2/4) — drop to 0/2 saves 4px
- IconButton size (currently 300 / 32px) — already smallest in folds; no further reduction available
Avoid touching textarea or placeholder vertical padding unless you re-tune both in matched pairs and visually verify glyph alignment.
**Don't apply these to other composers**: the textarea-padding compact override is scoped to `.ChatComposer`. The message-edit overlay, `Editor.preview.tsx`, and any future `CustomEditor` consumer outside the chat composer keep the folds-default `padding: 13px 1px` (`Editor.css.ts:24-42`).
If you re-tune any number here, update both the CSS comments in `RoomView.css.ts` and this table — they're cross-referenced.
## Responsive design
**No CSS media queries** for layout. Responsive behaviour goes through `hooks/useScreenSize.ts`:

View file

@ -510,6 +510,17 @@
"leave_room": "Leave Room",
"send_message": "Send a message...",
"send_message_alt_1": "One line or many...",
"send_message_alt_2": "Write something right now...",
"send_message_alt_3": "Don't keep me waiting, type...",
"send_message_alt_4": "This line won't fill itself...",
"send_message_alt_5": "So... what's it gonna be?..",
"send_message_alt_6": "Nobody reads placeholders. But you did...",
"send_message_alt_7": "Letters here, please...",
"send_message_alt_8": "You stare at the placeholder. The placeholder stares back...",
"send_message_alt_9": "Congrats, you're in the 3% who read placeholders...",
"send_message_alt_10": "Fine, I'll wait... and wait...",
"send_message_alt_11": "After you...",
"drop_files": "Drop Files in \"{{name}}\"",
"drag_drop_desc": "Drag and drop files here or click for selection dialog",

View file

@ -518,6 +518,17 @@
"leave_room": "Покинуть комнату",
"send_message": "Написать сообщение...",
"send_message_alt_1": "В одну строку или несколько...",
"send_message_alt_2": "Написать в эту минуту...",
"send_message_alt_3": "Не томи, пиши...",
"send_message_alt_4": "Эта строка сама себя не заполнит...",
"send_message_alt_5": "Ну так что?..",
"send_message_alt_6": "Никто не читает плейсхолдеры. Но вы прочитали...",
"send_message_alt_7": "Сюда буквы, пожалуйста...",
"send_message_alt_8": "Вы смотрите на плейсхолдер. Плейсхолдер смотрит на вас...",
"send_message_alt_9": "Поздравляю, вы в 3% людей, читающих плейсхолдеры...",
"send_message_alt_10": "Ну я подожду, подожду...",
"send_message_alt_11": "Только после вас...",
"drop_files": "Перетащите файлы в \"{{name}}\"",
"drag_drop_desc": "Перетащите файлы сюда или нажмите для выбора",

View file

@ -1,9 +1,11 @@
import React, {
KeyboardEventHandler,
MouseEvent as ReactMouseEvent,
RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@ -117,6 +119,28 @@ import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
// Placeholder rotation set — 12 i18n keys (1 default + 11 alternates) under
// the `Room` namespace; the hour-of-day slot picks one. With 12 variants
// over 24 hours each variant occupies two specific hour slots per day, so
// the placeholder is stable within an hour and a user is most likely to
// notice the change when they navigate to another room across an hour
// boundary. Keep this list in sync with `public/locales/{en,ru}.json`
// `Room.send_message*` entries.
const COMPOSER_PLACEHOLDER_KEYS = [
'Room.send_message',
'Room.send_message_alt_1',
'Room.send_message_alt_2',
'Room.send_message_alt_3',
'Room.send_message_alt_4',
'Room.send_message_alt_5',
'Room.send_message_alt_6',
'Room.send_message_alt_7',
'Room.send_message_alt_8',
'Room.send_message_alt_9',
'Room.send_message_alt_10',
'Room.send_message_alt_11',
] as const;
// Composer action-row icons — stroke-based outline style from the Dawn
// canon (docs/design/new-direct-messages-design/project/shared.jsx line
// 3-22). folds Icon wraps these in `<svg viewBox="0 0 24 24" fill="none">`,
@ -513,6 +537,107 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
};
// Action-row buttons extracted into JSX consts so the bottom-slot
// markup stays readable. Single source for each button across whatever
// layout the composer evolves into; today the layout is a two-row
// strip — textarea on top, this trio below — on every platform.
const plusButton = (
<IconButton
onClick={() => pickFile('*')}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Plus} />
</IconButton>
);
const emojiButton = (
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
emojiBoardTab === undefined
? undefined
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
}
content={
<EmojiBoard
tab={emojiBoardTab ?? EmojiBoardTab.Emoji}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((tab) => {
if (tab) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return tab;
});
}}
/>
}
>
<IconButton
ref={emojiBtnRef}
aria-pressed={!!emojiBoardTab}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Smile} />
</IconButton>
</PopOut>
)}
</UseStateProvider>
);
const sendButton = (
<IconButton
onClick={(evt: ReactMouseEvent<HTMLButtonElement>) => {
submit();
// Defense-in-depth against the Android WebView's synthetic
// :hover/:focus-visible persistence (the CSS gate in
// RoomView.css.ts handles the same class of bug, but
// explicitly blurring the tap target also clears any
// lingering :focus state regardless of input-mode
// attribution).
evt.currentTarget.blur();
}}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Send} />
</IconButton>
);
// Hour-of-day picks the placeholder variant. Memoised against
// (roomId, threadId) so the placeholder stays stable while the user is
// in one chat/thread and only re-rolls when they navigate elsewhere —
// and even then it's stable within the same wall-clock hour, so the
// change is most likely to surface to the user when they cross an
// hour boundary AND switch rooms (or reload the app). That cadence
// keeps the joke surprising without flipping mid-session.
const placeholderKey = useMemo(() => {
const idx = new Date().getHours() % COMPOSER_PLACEHOLDER_KEYS.length;
return COMPOSER_PLACEHOLDER_KEYS[idx];
// roomId and threadId are intentional re-roll triggers; the
// exhaustive-deps lint is happy with them too.
}, [roomId, threadId]);
return (
<div ref={ref}>
{selectedFiles.length > 0 && (
@ -606,7 +731,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<CustomEditor
editableName="RoomInput"
editor={editor}
placeholder={t('Room.send_message')}
placeholder={t(placeholderKey)}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
@ -653,83 +778,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Box
alignItems="Center"
gap="200"
style={{ padding: `${toRem(4)} ${toRem(8)} ${toRem(6)}` }}
style={{ padding: `${toRem(2)} ${toRem(8)} ${toRem(4)}` }}
>
<IconButton
onClick={() => pickFile('*')}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Plus} />
</IconButton>
{plusButton}
<Box grow="Yes" />
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
emojiBoardTab === undefined
? undefined
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
}
content={
<EmojiBoard
tab={emojiBoardTab ?? EmojiBoardTab.Emoji}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((tab) => {
if (tab) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return tab;
});
}}
/>
}
>
<IconButton
ref={emojiBtnRef}
aria-pressed={!!emojiBoardTab}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Smile} />
</IconButton>
</PopOut>
)}
</UseStateProvider>
<IconButton
onClick={(evt) => {
submit();
// Defense-in-depth against the Android WebView's synthetic
// :hover/:focus-visible persistence (the CSS gate in
// RoomView.css.ts handles the same class of bug, but
// explicitly blurring the tap target also clears any
// lingering :focus state regardless of input-mode
// attribution).
evt.currentTarget.blur();
}}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Send} />
</IconButton>
{emojiButton}
{sendButton}
</Box>
}
/>

View file

@ -8,16 +8,20 @@ import {
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Main chat composer — Dawn canon (stream-v2-dawn.jsx line 285-307): a
// floating dark card with all-corners rounded geometry and a two-row layout
// (input on top, action buttons below). Radius matches the silhouette
// horseshoe at the top of the chat (32px) for visual harmony — top and
// bottom of the chat surface mirror the same curvature. The default Editor
// styling (pill R400, lighter SurfaceVariant fill) is preserved for the
// thread-drawer composer, message-edit overlay and preview cases — none of
// them mount inside `ChatComposer`. The touch-hover gate at the bottom of
// this file also covers `RoomTombstone` / `RoomInputPlaceholder` since they
// share the wrap, which is intentional: their action buttons benefit from
// the same Android-WebView stuck-:hover suppression.
// floating dark card with all-corners rounded geometry. Two-row layout
// (textarea on top, action strip with `+` / emoji / send below) — same
// shape on every platform. Radius matches the silhouette horseshoe at the
// top of the chat (32px) for visual harmony — top and bottom of the chat
// surface mirror the same curvature. The thread-drawer composer in
// `ThreadDrawer.tsx` also wraps `RoomInput` with this class
// (`${ThreadComposer} ${ChatComposer}`), so it inherits both the dark
// card chrome and the compact two-row geometry. The message-edit overlay
// and `Editor.preview.tsx` mount `CustomEditor` directly without the
// `ChatComposer` wrap, so they keep the folds-default pill R400 +
// SurfaceVariant fill. The touch-hover gate at the bottom of this file
// also covers `RoomTombstone` / `RoomInputPlaceholder` since they share
// the wrap, which is intentional: their action buttons benefit from the
// same Android-WebView stuck-:hover suppression.
export const ChatComposer = style({});
// Outer absolute-positioned wrapper for the composer overlay. Carries the
@ -55,29 +59,44 @@ globalStyle(`${ChatComposer} .${Editor}`, {
// clear of the side curves.
// * Vertical 6px — minimum that still leaves comfortable clearance
// from the top/bottom curves while keeping the card compact.
// Bottom-left geometry check (radius=32, centre at (32, card_bottom-32)):
// * button-bottom = card_bottom - (6 outer + 6 row) = card_bottom - 12
// → curve x at y=12 from card bottom is ~7px from card left
// * button-left = 16 (outer) + 8 (row paddingLeft) = 24
// → ~17px clearance between visible button edge and the curve
// Bottom-left geometry check against the 32px corner curve (centre at
// (32, card_bottom-32)) with action-row `padding: 2 8 4` (RoomInput.tsx):
// * button-bottom y-from-card-bottom = 6 (outer) + 4 (row pad-bot) = 10
// * curve-x at y=10 = 32 √(32² 22²) ≈ 8.76px
// * button-left from card-left = 16 (outer) + 8 (row pad-left) = 24
// → ~15px clearance between the button hit-box and the curve.
// Top-left mirror (curve at y=19 from top is ~2.8px from left, text-left
// at 28px → ~25px clearance) is even more generous, which fits the
// single-line composer that grows downward when wrapping.
// The same math is consolidated in docs/ai/architecture.md (Composer
// card geometry section) — keep that table in sync if you tune these.
padding: `${toRem(6)} ${toRem(16)}`,
});
// Visual alignment goal: typed-text glyph-start and Plus-icon glyph-start
// sit on the same vertical column at 28px from the card edge (mirrored on
// the right for Send).
// text-glyph-start = outer (16) + textarea paddingLeft (12) = 28
// icon-glyph-left = outer (16) + row paddingLeft (8) + button pad (4) = 28
// One column line through "Написать сообщение…" and the leading Plus.
// the right for Send). One column line through "Написать сообщение…" and
// the leading Plus.
// text-glyph-start = outer (16) + textarea paddingLeft (12) = 28
// icon-glyph-left = outer (16) + row paddingLeft (8) + button pad (4) = 28
globalStyle(`${ChatComposer} ${EditorTextareaScroll}:first-child ${EditorTextarea}`, {
paddingLeft: toRem(12),
});
globalStyle(`${ChatComposer} ${EditorTextareaScroll}:last-child ${EditorTextarea}`, {
paddingRight: toRem(12),
});
// NB: do NOT override `EditorTextarea.paddingTop` / `paddingBottom` here.
// Folds tuned the textarea's 13px vertical padding to MATCH the
// `EditorPlaceholderTextVisual.paddingTop` (also 13px) so the placeholder
// span and the typed-text glyph land on the same y inside the editable
// content-box. Changing only the textarea padding without retuning the
// placeholder visual produces a visible mismatch — the caret/typed text
// drifts vertically relative to the «Send a message…» placeholder. If
// height compactness is needed in the future, the safer levers are:
// * outer card vertical padding (the `padding: 6 16` above)
// * action-row padding (set in RoomInput.tsx `bottom` slot)
// — both untie cleanly from Slate's placeholder positioning.
// NB: do NOT override `EditorPlaceholderTextVisual.paddingLeft` here. Slate
// renders the placeholder span absolutely-positioned (`position: absolute;
// top: 0`) inside the leaf text node — its origin is the contenteditable