feat(composer): tighten action-row padding, extract button JSX, and rotate placeholder across 12 hour-keyed variants
This commit is contained in:
parent
3fb5f012e5
commit
c96d0061f0
5 changed files with 235 additions and 95 deletions
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "Перетащите файлы сюда или нажмите для выбора",
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue