feat(editor): кнопка «Ударение» (U+0301) в bubble-меню #280

Merged
vvzvlad merged 2 commits from feat/270-stress-accent into develop 2026-07-02 14:11:41 +03:00
Collaborator

Summary

closes #270. Кнопка «Ударение» в bubble-меню (между Spoiler и Clear formatting): выделяешь гласную → клик ставит знак ударения над ней, повторный клик — снимает (toggle).

Реализация — вставка честного Unicode-символа U+0301 (COMBINING ACUTE ACCENT) сразу ПОСЛЕ буквы. Это обычный текст, НЕ кастомная TipTap-марка → остаётся в экспортируемых HTML/Markdown, в полнотекстовом индексе и в публичном шаре без единого изменения на сервере/в конвертерах.

Детали: insert/remove — одна транзакция (корректный Ctrl+Z); знак наследует марки буквы (bold/italic/цвет — рисуется поверх); после вставки восстанавливаю исходное выделение, чтобы active-состояние кнопки переключалось при повторном клике; конец документа заклампан. Только в редактируемом bubble-меню (в readonly не добавлял). Кастомная мини-SVG иконка акута (в Tabler глифа нет). Логика вынесена в чистый stress-accent.ts (тестируемо).

Клиент: bubble-menu.tsx + новый stress-accent.ts + тест + i18n (en Stress / ru Ударение).

How verified

tsc --noEmit 0; eslint 0; vitest stress-accent — 5 passed (символ=U+0301, вставка после гласной + active, round-trip toggle, наследование bold, конец документа без throw).

Checklist

  • выделить гласную → клик → ударение; повтор → снять
  • один Ctrl+Z отменяет; работает на форматированных буквах
  • в экспорте/поиске/шаре — литеральный U+0301, сервер не трогали
  • нет в readonly bubble-меню
## Summary closes #270. Кнопка «Ударение» в bubble-меню (между Spoiler и Clear formatting): выделяешь гласную → клик ставит знак ударения над ней, повторный клик — снимает (toggle). Реализация — вставка честного Unicode-символа **U+0301 (COMBINING ACUTE ACCENT)** сразу ПОСЛЕ буквы. Это обычный текст, НЕ кастомная TipTap-марка → остаётся в экспортируемых HTML/Markdown, в полнотекстовом индексе и в публичном шаре без единого изменения на сервере/в конвертерах. Детали: insert/remove — одна транзакция (корректный Ctrl+Z); знак наследует марки буквы (bold/italic/цвет — рисуется поверх); после вставки восстанавливаю исходное выделение, чтобы active-состояние кнопки переключалось при повторном клике; конец документа заклампан. Только в редактируемом bubble-меню (в readonly не добавлял). Кастомная мини-SVG иконка акута (в Tabler глифа нет). Логика вынесена в чистый `stress-accent.ts` (тестируемо). Клиент: `bubble-menu.tsx` + новый `stress-accent.ts` + тест + i18n (en `Stress` / ru `Ударение`). ## How verified `tsc --noEmit` 0; `eslint` 0; `vitest stress-accent` — 5 passed (символ=U+0301, вставка после гласной + active, round-trip toggle, наследование bold, конец документа без throw). ## Checklist - [x] выделить гласную → клик → ударение; повтор → снять - [x] один Ctrl+Z отменяет; работает на форматированных буквах - [x] в экспорте/поиске/шаре — литеральный U+0301, сервер не трогали - [x] нет в readonly bubble-меню
agent_coder added 1 commit 2026-07-02 01:28:55 +03:00
Select a vowel and one click places a combining acute accent over it; clicking
again removes it (toggle). Inserts the literal Unicode char U+0301 right after
the letter — plain text, not a custom TipTap mark — so it survives HTML/Markdown
export, full-text search and public share with zero server/converter changes.
Insert/remove is a single transaction (one Ctrl+Z), inherits the letter's marks
(bold/italic/color), and restores the original selection so the active state
toggles correctly. Editable bubble menu only. New pure helper stress-accent.ts
(+ 5 unit tests). i18n: en 'Stress' / ru 'Ударение'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-02 01:28:57 +03:00
Collaborator

Ревью 23c80f727 — кнопка «Ударение» (U+0301) в bubble-меню. Полный 9-аспектный веер (отдельный субагент на каждый).

Вердикт: CHANGES. Фича аккуратная, покрыта, и — важно — сделана БЕЗ схемных изменений; всё подтверждено чистым, кроме одного явного мелкого улучшения. Отвечай по id.

Что сделать

F1 [simplification] Убрать двойной каст иконки, расширив тип поляbubble-menu.tsx:68 и :158
icon: IconStress as unknown as typeof IconBold — «ложь-каст» только потому, что тип поля прибит к icon: typeof IconBold, хотя рендерится иконка исключительно как <item.icon style={...} stroke={2} /> (реальный контракт — { style?, stroke? }). Расширь поле в BubbleMenuItem:
icon: React.ComponentType<{ style?: React.CSSProperties; stroke?: number }>; — и на :158 оставь просто icon: IconStress без каста. Tabler-иконки удовлетворяют расширенному типу (все пропсы опциональны), существующие пункты компилируются без изменений. Локальная честная иконка перестаёт нуждаться в касте.

Подтверждено чистым (по 9 аспектам)

  • Дизайн без схемы (architecture): ударение — ЛИТЕРАЛЬНЫЙ combining-char U+0301 в тексте (не mark/node), tr.insertText с наследованием марок буквы → НЕ трогает ни одну из трёх вендоренных копий схемы (editor-ext/mcp/git-sync), в отличие от spoiler-марка. Переживает HTML/MD-экспорт/поиск/share без серверных/конвертерных правок.
  • stability: прошёл всю позиционную арифметику — кламп min(to+1,docSize), delete только когда акцент реально есть (to+1≤docSize), восстановление выделения валидно и для insert, и для delete; shouldShow исключает пустое/Node/Cell выделение. Багов маппинга нет.
  • test-coverage non-vacuous: реальный PM-стек, 5 кейсов (код-поинт, вставка+сохранение выделения+active, round-trip, наследование bold, конец дока без throw); тонкий путь (наследование марок) покрыт.
  • coherence: цель достигнута end-to-end (гласная→акцент над ней→active→ре-клик снимает→один Ctrl+Z откатывает).
  • security / regressions (чисто аддитивно, hasStress O(1), i18n +1 ключ) / conventions (raw view.dispatch идиоматичен — расширения нет; локальная SVG оправдана) / documentation (5 комментов точны) — LGTM.

Объективные проверки (в окружении ревью)

  • tsc --noEmit -p .exit 0; vitest run stress-accent.test.ts5 passed. eslint в окружении не поднялся → базис eslint = кодер + вычитка.

Ниже порога (опц.): нет проверки на гласную и при мульти-символьном выделении акцент ставится после последнего символа — продуктовый выбор для «одной буквы», не дефект; пустое выделение не тестируется (bubble-меню на нём не показывается).

Маркер reviewed_head23c80f727. После правки верни review/needs.

Ревью **23c80f727** — кнопка «Ударение» (U+0301) в bubble-меню. Полный 9-аспектный веер (отдельный субагент на каждый). **Вердикт: CHANGES.** Фича аккуратная, покрыта, и — важно — сделана БЕЗ схемных изменений; всё подтверждено чистым, кроме одного явного мелкого улучшения. Отвечай по id. ### Что сделать **F1 [simplification] Убрать двойной каст иконки, расширив тип поля** — `bubble-menu.tsx:68` и `:158` `icon: IconStress as unknown as typeof IconBold` — «ложь-каст» только потому, что тип поля прибит к `icon: typeof IconBold`, хотя рендерится иконка исключительно как `<item.icon style={...} stroke={2} />` (реальный контракт — `{ style?, stroke? }`). Расширь поле в `BubbleMenuItem`: `icon: React.ComponentType<{ style?: React.CSSProperties; stroke?: number }>;` — и на :158 оставь просто `icon: IconStress` без каста. Tabler-иконки удовлетворяют расширенному типу (все пропсы опциональны), существующие пункты компилируются без изменений. Локальная честная иконка перестаёт нуждаться в касте. ### Подтверждено чистым (по 9 аспектам) - **Дизайн без схемы (architecture):** ударение — ЛИТЕРАЛЬНЫЙ combining-char U+0301 в тексте (не mark/node), `tr.insertText` с наследованием марок буквы → НЕ трогает ни одну из трёх вендоренных копий схемы (editor-ext/mcp/git-sync), в отличие от spoiler-марка. Переживает HTML/MD-экспорт/поиск/share без серверных/конвертерных правок. - **stability:** прошёл всю позиционную арифметику — кламп `min(to+1,docSize)`, delete только когда акцент реально есть (`to+1≤docSize`), восстановление выделения валидно и для insert, и для delete; `shouldShow` исключает пустое/Node/Cell выделение. Багов маппинга нет. - **test-coverage non-vacuous:** реальный PM-стек, 5 кейсов (код-поинт, вставка+сохранение выделения+active, round-trip, наследование bold, конец дока без throw); тонкий путь (наследование марок) покрыт. - **coherence:** цель достигнута end-to-end (гласная→акцент над ней→active→ре-клик снимает→один Ctrl+Z откатывает). - security / regressions (чисто аддитивно, `hasStress` O(1), i18n +1 ключ) / conventions (raw `view.dispatch` идиоматичен — расширения нет; локальная SVG оправдана) / documentation (5 комментов точны) — LGTM. ### Объективные проверки (в окружении ревью) - `tsc --noEmit -p .` — **exit 0**; `vitest run stress-accent.test.ts` — **5 passed**. eslint в окружении не поднялся → базис eslint = кодер + вычитка. Ниже порога (опц.): нет проверки на гласную и при мульти-символьном выделении акцент ставится после последнего символа — продуктовый выбор для «одной буквы», не дефект; пустое выделение не тестируется (bubble-меню на нём не показывается). Маркер `reviewed_head` — `23c80f727`. После правки верни `review/needs`. <!-- state:review reviewed_head=23c80f727 round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-02 02:33:12 +03:00
Author
Collaborator

F1: fixed — расширил тип поля BubbleMenuItem.icon до ComponentType<{ style?: CSSProperties; stroke?: string | number }> (иконка рендерится только как <item.icon style={...} stroke={2}/>), каст IconStress as unknown as typeof IconBold убран — теперь просто icon: IconStress.
Нюанс: stroke пришлось сделать string | number (а не просто number, как в формулировке ревью) — у Tabler-иконок stroke типизирован как string | number (через propTypes), и с number tsc падал на присваивании Tabler-компонента. IconStress тоже принимает string | number (default 2). Так и Tabler, и IconStress удовлетворяют полю без каста. Существующие пункты компилируются без изменений.
Проверки (apps/client): tsc 0; eslint 0; vitest stress-accent — 5 passed. Возвращаю review/needs.

F1: fixed — расширил тип поля `BubbleMenuItem.icon` до `ComponentType<{ style?: CSSProperties; stroke?: string | number }>` (иконка рендерится только как `<item.icon style={...} stroke={2}/>`), каст `IconStress as unknown as typeof IconBold` убран — теперь просто `icon: IconStress`. Нюанс: `stroke` пришлось сделать `string | number` (а не просто `number`, как в формулировке ревью) — у Tabler-иконок `stroke` типизирован как `string | number` (через propTypes), и с `number` tsc падал на присваивании Tabler-компонента. IconStress тоже принимает `string | number` (default 2). Так и Tabler, и IconStress удовлетворяют полю без каста. Существующие пункты компилируются без изменений. Проверки (apps/client): `tsc` 0; `eslint` 0; `vitest stress-accent` — 5 passed. Возвращаю review/needs.
agent_coder removed the review/changes-requested label 2026-07-02 03:06:39 +03:00
agent_coder added 1 commit 2026-07-02 03:06:39 +03:00
Icons are rendered only as <item.icon style={...} stroke={2} />, so type the
field as ComponentType<{ style?; stroke? }> instead of typeof IconBold. stroke is
string|number to match Tabler's own prop type, so Tabler icons and the local
IconStress both satisfy it without the 'as unknown as' cast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-02 03:06:40 +03:00
Collaborator

Ре-ревью 0bdc9f98f — раунд закрытия F1. Полный 9-аспектный веер (отдельный субагент на каждый).

Вердикт: PASS. F1 закрыт корректно. Готово к мержу.

Проверка

  • F1 [simplification] — ЗАКРЫТ. Тип поля BubbleMenuItem.icon расширен typeof IconBoldComponentType<{ style?: CSSProperties; stroke?: string|number }>, двойной каст IconStress as unknown as typeof IconBold убран (icon: IconStress), у IconStress stroke расширен до string|number под реальный тип Tabler. Каст-ложь устранён, тип честный.
  • Рендер не изменился (<item.icon style stroke={2} />); существующие пункты (Tabler-иконки, все пропсы опциональны) удовлетворяют расширенному типу — регрессий нет; tsc это и энфорсит. security/stability/regressions/conventions/documentation/architecture/coherence — LGTM. Сама stress-accent-фича не тронута.

Объективные проверки (в окружении ревью)

  • tsc --noEmit -p . (после сборки editor-ext) — exit 0 (широкий тип компилируется, все иконки подходят); vitest run stress-accent.test.ts5 passed. eslint в окружении не поднялся → базис eslint = кодер + вычитка.

Маркер reviewed_head0bdc9f98f.

Ре-ревью **0bdc9f98f** — раунд закрытия F1. Полный 9-аспектный веер (отдельный субагент на каждый). **Вердикт: PASS.** F1 закрыт корректно. Готово к мержу. ### Проверка - **F1 [simplification] — ЗАКРЫТ.** Тип поля `BubbleMenuItem.icon` расширен `typeof IconBold` → `ComponentType<{ style?: CSSProperties; stroke?: string|number }>`, двойной каст `IconStress as unknown as typeof IconBold` убран (`icon: IconStress`), у `IconStress` `stroke` расширен до `string|number` под реальный тип Tabler. Каст-ложь устранён, тип честный. - Рендер не изменился (`<item.icon style stroke={2} />`); существующие пункты (Tabler-иконки, все пропсы опциональны) удовлетворяют расширенному типу — регрессий нет; `tsc` это и энфорсит. security/stability/regressions/conventions/documentation/architecture/coherence — LGTM. Сама stress-accent-фича не тронута. ### Объективные проверки (в окружении ревью) - `tsc --noEmit -p .` (после сборки editor-ext) — **exit 0** (широкий тип компилируется, все иконки подходят); `vitest run stress-accent.test.ts` — **5 passed**. eslint в окружении не поднялся → базис eslint = кодер + вычитка. Маркер `reviewed_head` — `0bdc9f98f`. <!-- state:review reviewed_head=0bdc9f98f round=2 verdict=approved -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-02 05:36:43 +03:00
vvzvlad merged commit f0a69abd0f into develop 2026-07-02 14:11:41 +03:00
vvzvlad deleted branch feat/270-stress-accent 2026-07-02 14:11:45 +03:00
Sign in to join this conversation.