From c96d0061f016411aea21f403af8f4d82ee1f8989 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Fri, 15 May 2026 00:13:51 +0300 Subject: [PATCH] feat(composer): tighten action-row padding, extract button JSX, and rotate placeholder across 12 hour-keyed variants --- docs/ai/architecture.md | 45 ++++++ public/locales/en.json | 11 ++ public/locales/ru.json | 11 ++ src/app/features/room/RoomInput.tsx | 206 ++++++++++++++++---------- src/app/features/room/RoomView.css.ts | 57 ++++--- 5 files changed, 235 insertions(+), 95 deletions(-) diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index 1ccc7337..31d6ffa9 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -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`: diff --git a/public/locales/en.json b/public/locales/en.json index 478a2c1b..6e1193d9 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/public/locales/ru.json b/public/locales/ru.json index 7b4c6e99..20ea9ff7 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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": "Перетащите файлы сюда или нажмите для выбора", diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index fd45d467..9c046abd 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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 ``, @@ -513,6 +537,107 @@ export const RoomInput = forwardRef( }); }; + // 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 = ( + pickFile('*')} + variant="SurfaceVariant" + fill="None" + size="300" + radii="300" + > + + + ); + + const emojiButton = ( + + {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( + { + setEmojiBoardTab((tab) => { + if (tab) { + if (!mobileOrTablet()) ReactEditor.focus(editor); + return undefined; + } + return tab; + }); + }} + /> + } + > + setEmojiBoardTab(EmojiBoardTab.Emoji)} + variant="SurfaceVariant" + fill="None" + size="300" + radii="300" + > + + + + )} + + ); + + const sendButton = ( + ) => { + 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" + > + + + ); + + // 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 (
{selectedFiles.length > 0 && ( @@ -606,7 +731,7 @@ export const RoomInput = forwardRef( ( - pickFile('*')} - variant="SurfaceVariant" - fill="None" - size="300" - radii="300" - > - - + {plusButton} - - {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( - { - setEmojiBoardTab((tab) => { - if (tab) { - if (!mobileOrTablet()) ReactEditor.focus(editor); - return undefined; - } - return tab; - }); - }} - /> - } - > - setEmojiBoardTab(EmojiBoardTab.Emoji)} - variant="SurfaceVariant" - fill="None" - size="300" - radii="300" - > - - - - )} - - { - 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" - > - - + {emojiButton} + {sendButton} } /> diff --git a/src/app/features/room/RoomView.css.ts b/src/app/features/room/RoomView.css.ts index 66cee962..03085920 100644 --- a/src/app/features/room/RoomView.css.ts +++ b/src/app/features/room/RoomView.css.ts @@ -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