Skip to content

feat(chat): the prompt card docks over the message box, outside the transcript - #468

Merged
devswha merged 8 commits into
mainfrom
feat/chat-approval-dock
Oct 5, 2026
Merged

devswha merged 8 commits into
mainfrom
feat/chat-approval-dock

Conversation

@devswha

@devswha devswha commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Change

승인하신 채팅 모드 재설계(목업 mockups/chat-mode)의 조각 하나입니다. 앞선 조각은 #456 ~ #466으로 머지되었습니다. 이번 세 번째 묶음은 모델 표시(pill)와 이 PR, 곧 입력 카드 위에 붙는 승인 카드입니다. 마지막 묶음은 글자 크기 체계와 글꼴입니다.

이 PR은 목업 규칙 D1(approval dock)과 D2(waiting head)를 옮깁니다(variants/d.css, d.js).

토론 판정 R1-D3(카드는 대화 안에 둔다)를 뒤집습니다. 2026-10-05에 승인하신 내용이며 첫 커밋 본문에도 적었습니다.

사용자가 보는 것

  • 에이전트의 질문, 승인, 계획 카드가 대화 끝에 붙어 있지 않고 입력 카드 바로 위, 같은 열에 놓입니다. 위에서부터 대화, 보류 메시지(한 줄로 접힘), 승인 카드, 입력 카드 순서입니다. 대화는 카드 뒤에서 자유롭게 스크롤됩니다.
  • 카드는 그림자가 없고 --border-strong 테두리에 --bg-elevated 바탕입니다. 빨간색은 제목 하나뿐입니다. "INPUT NEEDED" 배지는 그리지 않지만 스크린 리더는 읽습니다.
  • 선택지는 숫자 키캡이 붙은 납작한 줄이며 무게가 모두 같습니다. 키캡에는 숫자만 그리고, 접근성 이름은 "1. Yes"를 유지합니다.
  • 카드 높이는 앱 높이의 60%와 240px(터치 줄 여섯 개) 중 큰 값까지입니다. 줄어드는 것은 참고 텍스트(코드 상자)뿐이고(여섯 줄, 최소 두 줄), 그래도 넘치면 카드 전체가 스크롤되며 Confirm은 항상 보입니다.
  • 메시지 상자의 안내는 "Type 1–3 to choose…" 또는 "Type 1–3 or your own reply…"입니다. 폰에서 잘리던 "Answer above:"를 뺐습니다.
  • 에이전트가 사용자를 기다리는 동안 열린 작업 블록은 "Working…" 대신 "Needs you"로 읽히고, 빨간 점은 움직이지 않습니다. pane 상태(blocked)로 판단합니다.
  • 답하는 방법은 그대로입니다. 선택지를 누르거나, 숫자를 입력하고 Confirm을 누르거나, 직접 답을 씁니다. 빈 상자에서 Enter는 아무것도 보내지 않습니다. 답 경로(POST /api/pane/prompt/answer)와 HTTP/WS 계약은 바뀌지 않습니다.

구현

PaneTerminal이 .prompt-dock 요소로 순서를 정하고, 프롬프트와 폴링, 답을 여전히 가진 ChatView가 포털로 카드를 그 안에 그립니다. 새 문구 4개는 ko, ja, zh에 모두 넣었습니다. 변경 이력은 Unreleased에 적었습니다.

목업과 다른 점

  • 카드는 목업처럼 .composer의 자식이 아니라, 그 앞 형제인 .prompt-dock 안에 있습니다(보류 목록과 같은 방식). dock이 포털 대상이자 aria-live="polite" 영역이고, 카드는 "Message composer" 그룹 밖에 남습니다. 새 카드가 읽히도록 dock은 비어 있을 때도 높이 0으로 렌더링됩니다.
  • is-command 클래스와 질문의 고정폭 상자는 만들지 않았습니다. 서버 파서(Codex, omp, Claude 승인)가 이미 명령을 body에 넣고, 그것이 카드의 고정폭 상자입니다. pi의 confirm은 메시지를 question에 넣지만 명령임을 알리는 신호가 없습니다. 데모 fixture는 Codex의 실제 형태(title = question, body = 명령)로 바꿨습니다.
  • 카드와 입력 카드 사이 간격은 목업의 --space-2가 아니라 --composer-grip-h(12px, 터치 20px)입니다. 보류 목록 아래와 같은 크기 조절 손잡이 영역을 그대로 둡니다.
  • 열 너비는 목업의 --content-w가 아니라 #463의 --chat-w입니다.
  • 한 줄짜리 참고 텍스트는 두 줄이 아니라 한 줄까지 줄어듭니다. 본문 페이드 띠는 --space-3이 아니라 --space-2입니다.
  • 카드 글자는 대화 안에 있을 때처럼 설정의 Chat font size와 Chat font를 따릅니다(본문, 키캡, 단계 표시는 고정폭 유지). 목업에는 이 설정이 없습니다. 높이 상한은 글자 크기에 따라 커지지 않습니다.
  • .terminal-stack.is-chat > .terminal-surface { overflow: hidden }을 추가했습니다. 390x500에서 대화 스크롤 영역이 카드 윗부분을 덮어 그렸기 때문입니다.
  • 새 동작: 카드에서 답을 누른 뒤 포커스가 메시지 상자로 갑니다. 정밀 포인터에서만, 그리고 답이 돌아왔을 때 카드가 아직 있고 포커스가 카드 안이나 페이지 본문에 있을 때만 옮깁니다(focusFollowsAnswer).
  • 새 동작: 폰 키보드가 올라와 있을 때 카드의 글자를 탭하거나, 카드가 맨 위에 있을 때 아래로 끌면 키보드가 내려갑니다(dismissKeyboardOn의 atTopOnly). 버튼, 라벨, 입력란은 예외입니다.
  • 과제는 안내 문구 두 개를 지목했지만, 프롬프트 안내 네 개 모두에서 "Answer above:"를 뺐습니다.

리뷰하실 때 볼 곳

  • 판정이 필요한 것: 60% 상한과 본문 두 줄 하한에서 나오는 결과입니다. 800x600에서 긴 폼에 숫자를 입력하면 대화는 64px(반쯤 흐려진 한 줄)만 남습니다. 390x500에서 숫자를 입력하면 Confirm이 두 줄로 접히고 선택지는 좁은 틈으로 보입니다. 390x500에 채팅 글자 24px이면 질문과 고정된 Confirm이 카드를 채우고 선택지는 스크롤해야 보입니다. 상한을 바꾸지 않으면 고칠 수 없어 그대로 두었고, 새 스크립트의 대화 높이 하한은 48px입니다.
  • overflow: hidden은 공용 요소(.terminal-surface)에 채팅 렌즈일 때만 거는 새 클립입니다. 인사말과 보류 줄 회귀는 통과합니다.
  • dock은 항상 렌더링되는 polite 라이브 영역이라 카드 안의 변화("Submit (2)", 오류)도 읽힙니다. 실제 스크린 리더로는 확인하지 못했습니다.
  • 배지를 숨기고 #456에서 상태 줄의 INPUT도 숨겼으므로, 작업 블록이 없는 blocked pane은 채팅 안에서 카드와 사이드바의 INPUT만으로 상태를 알립니다.
  • 스크린샷 한 장(긴 폼, 1440x900, 채팅 글자 24px, Confirm 고정)에서 카드 아래 모서리에 불투명하지 않은 픽셀 32개가 있었습니다. 원인을 찾지 못했고 다른 화면에서는 나오지 않았습니다.
  • .prompt-card가 .chat-view 안에 있다고 가정하는 외부 스크립트나 PR은 깨집니다. 저장소 안의 scripts/film, scripts/readme-media, site/demo는 선택자가 그대로 맞습니다.
  • docs/guide.md의 media/look-prompt.png와 site/index.html의 정적 목업은 예전 카드를 보여 줍니다. 릴리스 때 다시 찍어야 합니다. 바뀐 fixture가 이미 새 모습을 만듭니다.
  • "Confirm your answer in the card above, or type another…"는 390px과, 사이드바가 열린 800px에서 여전히 두 줄입니다. 범위 밖이라 두었습니다.
  • 대기 중인(queued) Codex 질문은 제목이 빨갛지만 작업 블록은 "Working…"으로 남습니다. 목업대로입니다.

열려 있는 외부 PR과의 충돌

#436(Composer.css, PaneTerminal.tsx), #409와 #438(ChatView.tsx, ChatView.css, PromptCard)과 충돌이 예상됩니다. #441과 #421(사이드바)은 겹치지 않을 것으로 봅니다.

Validation

검증 환경: Linux, herdr 0.9.3, Bun 1.4.2, Chrome(/usr/bin/google-chrome). origin/main은 #466(9153e60)이며 브랜치가 그 위에 있어 병합할 것이 없었습니다.

  • bun run typecheck: 통과
  • bun run test:unit: 1,272건 통과, 4건 건너뜀, 실패 없음(116개 파일)
  • bun run build: 통과
  • bun run test:ui(별도 세션 herdr-web-ui-test) 1회: PASS 36개 뒤 checkAlertSound의 .droplet-card 대기(scripts/alert-sound-regression.ts:79)에서 30초 타임아웃으로 종료 코드 1. 이 장비에서는 origin/main에서도 전체 순서로 돌릴 때 여기서 실패하는 것으로 알려져 있으며(CI는 통과), 이번에 main에서 다시 확인하지는 않았습니다. 그 앞에서 실패한 검사는 없습니다.
  • 그래서 checkAlertSound와 checkUpdateNotice 두 호출만 뺀 ui-regression.ts 임시 사본(커밋하지 않고 지움)으로 나머지를 돌렸습니다: PASS 64개, 종료 코드 0. 카드가 .chat-view 밖에 있고 dock의 다음 형제가 .composer이며 카드가 입력 카드 위에서 끝나는지 보는 새 단언이 들어간 "startup prompt appears and accepts an answer while the agent status is idle"을 포함합니다.
  • herdr가 필요한 다른 스크립트: chat-browser-qa.ts PASS 7개, file-viewer-regression.ts PASS 6개, directory-groups-regression.ts PASS 7개. 모두 종료 코드 0.
  • herdr 없이 도는 스크립트(모두 종료 코드 0): prompt-dock-demo-regression.ts(이 PR의 새 스크립트, ci-browser.sh에 등록) PASS 15개, held-rows-demo-regression.ts 10개, composer-fit-demo-regression.ts 5개, chat-greeting-demo-regression.ts 10개, droplet-demo-regression.ts 5개, keyboard-viewport-demo-regression.ts 1개, keyboard-viewport-regression.ts 1개, chat-history-browser-qa.ts 5개, skill-activity-browser-qa.ts 2개, file-viewer-mobile-regression.ts 16개, pi-image-browser-qa.ts("browser OK" 한 줄).

고친 테스트: promptAnswer.test.ts(안내 문구 기대값, focusFollowsAnswer), keyboard.test.ts(atTopOnly, scrolledDown), workBlocks.test.ts(isWaitingWorkTurn), ui-regression.ts(카드 위치 단언). 지우거나 느슨하게 한 테스트는 없습니다.

Codex 적대적 리뷰(gpt-6.1-sol, xhigh, 읽기 전용): 지적 4건(빈 라이브 영역이 숨겨짐, 채팅 글자 설정을 잃음, 늦은 답이 포커스를 가져감, 카드의 overscroll)을 모두 확인했고, 별도 리뷰에서 나온 폰 키보드 내리기 1건과 함께 202e9da에서 고쳤습니다.

확인하지 못한 것

  • 실제 iPhone/Android 기기. 키보드 내리기 검사는 합성 클릭/터치 이벤트와 가짜 navigator.virtualKeyboard를 씁니다.
  • 실제 스크린 리더.
  • 다중 선택, 대기 중인 Codex, fallback 메뉴, 보기 전용 pane의 카드를 그려 본 것. 같은 컴포넌트와 CSS를 씁니다.
  • bun run test:integration과 *.contract.test.ts. 서버와 계약은 건드리지 않았습니다.
  • 전체 순서 안의 checkAlertSound, checkUpdateNotice(위 설명).

PR을 연 뒤의 변경

리뷰에서 나온 Medium 네 건과 문서 한 건을 고쳤고, 그 사이 움직인 main을 merge로 합쳤습니다(rebase, force push 없음).

  • 85a2cc2: 늦게 돌아온 답이 다음 프롬프트를 지우지 않습니다. 프롬프트가 나타날 때마다 카드를 새로 만들고(id는 내용 해시라서 id가 아니라 폴링이 유지하는 프롬프트 객체로 구분합니다), 사라진 카드의 답은 아무것도 바꾸지 않습니다. 채팅은 그 카드의 프롬프트만 지우고, 입력해 둔 선택은 pane과 프롬프트가 맞을 때만 지웁니다. 새 카드는 맨 위에서 열리므로 이전 카드의 스크롤 위치도 남지 않습니다.
  • c529e0e: Confirm 줄의 질문은 최대 두 줄만 보이고 그 안에서 스크롤됩니다. Confirm과 Cancel은 항상 한 줄에 함께 있습니다. 2,166자 라벨, 390x500, 채팅 글자 24px에서도 옵션이 가려지지 않습니다.
  • e5615f0: 답한 뒤 메시지 입력란으로 포커스를 넘길지는 누른 방식으로 정합니다. 탭(터치, 펜)이면 넘기지 않고, 키보드와 마우스일 때만 넘깁니다. 클릭에 pointerType이 없는 브라우저를 위해 기존의 (pointer: coarse) 확인도 그대로 두었습니다. 그래서 coarse 포인터 기기에서는 마우스로 눌러도 이전처럼 포커스가 넘어가지 않습니다. Composer의 Queue는 touch만 보지만 여기서는 pen도 탭으로 봅니다.
  • 9890fe8: DESIGN.md가 입력 상자를 유일한 카드라고 하던 문장을 고쳤고, 위 동작을 적었습니다. 데모 회귀 스크립트의 프롬프트를 pane별로 나누고, 늦은 답(같은 pane, pane 전환), 교체된 카드의 스크롤, 긴 라벨, 한 줄 버튼, 탭 케이스를 추가했습니다. 추가한 단언은 모두 고치기 전 코드에서 실패하는 것을 확인했습니다.
  • 256e95d: origin/main(feat(chat): the model and the level in one quiet pill, with a readable name where the id has one #467) merge.

확인하지 못한 것:

  • 터치스크린 노트북은 흉내 낸 것입니다. Chromium은 터치가 있는 컨텍스트를 항상 coarse로 보고하므로 스크립트가 그 질의의 matchMedia만 바꿉니다. 실제 기기에서는 보지 않았습니다.
  • 카드를 새로 만든 뒤의 라이브 영역은 DOM으로만 확인했습니다(polite 영역 안에 카드 하나가 추가됨). 스크린 리더로 들어 보지는 않았습니다.
  • 같은 내용의 프롬프트가 폴링 한 번 사이에 연달아 나오면 id와 객체가 같아 구분할 수 없습니다. 서버 쪽 문제로 따로 등록되어 있고 여기서는 건드리지 않았습니다.
  • 중간 커밋 c529e0e, e5615f0은 따로 typecheck하지 않았습니다. 85a2cc2의 스테이징 상태와 최종 head에서만 돌렸습니다.
  • bun run test:ui, test:integration, contract 테스트는 돌리지 않았습니다.

🤖 Generated with Claude Code

devswha and others added 3 commits October 5, 2026 19:33
…ranscript

An agent's question, approval or plan was the sticky last element of the
transcript, with the popover shadow. It now sits on the composer's column,
directly above the input card: transcript, held messages (folded to their
caption), prompt card, input card. PaneTerminal owns that order
(.prompt-dock) and ChatView, which still owns the prompt, its poll and the
answer, renders the card into it through a portal. No answer path changes.

This reverses debate ruling R1-D3 (the card stays in the transcript); the
maintainer approved the move on 2026-10-05.

- Look: --bg-elevated, a --border-strong hairline, no shadow. The title is
  the card's one red; the "input needed" badge is read, not drawn. Options
  are flat rows with a keycap number, all of equal weight (the option's
  name keeps "1."). A command stays in the body's mono box, where the
  parsers already put it; the demo's approval fixture takes that shape.
- Height: the larger of 60% of the app height and six touch rows. Only the
  reference text gives way (six lines, two at the least); a card still too
  tall scrolls as a whole with a fade, the confirm row pinned.
- The composer keeps the grip's hit strip for itself under the card.
- Placeholders lose "Answer above:" ("Type 1–3 to choose…").
- A blocked agent's open work block reads "Needs you" behind a still red
  dot, by the pane's status.
- The dock is a polite live region; after an answer pressed in the card,
  focus goes to the message box (not on a coarse pointer).
- The transcript's box is clipped under the chat lens, so its scroller's
  padding cannot paint over the card when the stack is short.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and puts a phone's keyboard away

Review findings on the prompt dock:

- The empty dock was display: none, so the live region appeared together with
  its card and a screen reader announced nothing. It stays rendered at zero
  height; its two margins apply only with a card in it.
- Outside the transcript the card had lost Settings -> Chat font size and Chat
  font. The dock scales the type tokens and the card uses them; the reference
  text and keycaps stay mono. The tall form is checked again at 24px.
- On a short phone the card stands where the transcript was, and the keyboard
  could not be put away. A tap on the card's text, or a drag down it once it is
  at its top, blurs the message box as on the transcript (dismissKeyboardOn
  takes atTopOnly).
- Focus after an answer is decided when the answer comes back: not if the user
  moved it meanwhile or the card is gone (focusFollowsAnswer).
- The card contains its overscroll, so a drag past its top is not Android's
  pull-to-refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: devswha/herdr-web-ui/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: dd480018-0c65-4354-828f-a5e083b5ef7b
📥 Commits

Reviewing files that changed from the base of the PR and between be5ec0f and 256e95d.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • DESIGN.md
  • scripts/prompt-dock-demo-regression.ts
  • src/AGENTS.md
  • src/components/ChatView.tsx
  • src/components/Composer.css
  • src/components/PaneTerminal.tsx
  • src/components/PromptCard.css
  • src/components/PromptCard.tsx
  • src/lib/promptAnswer.test.ts
  • src/lib/promptAnswer.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/AGENTS.md
  • CHANGELOG.md
  • src/components/Composer.css

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.


📝 Summary

Summary by CodeRabbit

  • New Features
    • Approval, question, and plan cards now appear above the message box, separate from chat history.
    • On phones, tapping or dragging a prompt card can dismiss the keyboard without losing a draft.
  • Improvements
    • Prompt cards have bounded scrolling, more compact options, clearer waiting indicators, and updated focus behavior after answering.
    • Answer placeholders use more direct wording, including in Japanese, Korean, and Chinese.
    • Blocked agents are labeled “Needs you.”

Walkthrough

The chat prompt card now renders in a dock above the composer and outside the transcript. The update changes card layout, answer focus, phone keyboard dismissal, and blocked-work status display. It also adds browser regression checks and updates prompt translations.

Changes

Chat prompt dock and interactions

Layer / File(s) Summary
Dock integration and placement
src/components/PaneTerminal.tsx, src/components/ChatView.tsx, src/components/Composer.css, src/components/PaneTerminal.css, DESIGN.md, src/AGENTS.md
PaneTerminal provides a polite live-region dock above the composer. ChatView portals the prompt card into it. Composer spacing accounts for the dock and held messages.
Prompt card layout and answer handling
src/components/PromptCard.css, src/components/PromptCard.tsx, src/lib/promptAnswer.ts, src/lib/promptAnswer.test.ts, src/lib/i18n.*.ts, CHANGELOG.md
The card has bounded scrolling, sticky fades, and a persistent confirmation row. Option rows and number markers use updated styling. Answer completion is scoped to the current card, with focus restoration for eligible answers. Prompt placeholders no longer use the “Answer above:” prefix.
Phone keyboard dismissal
src/components/PromptCard.tsx, src/lib/keyboard.ts, src/lib/keyboard.test.ts, DESIGN.md
Touch dismissal checks whether the card or touched reference text can still scroll upward. Tests cover the card at its top and scrolled content.
Blocked work waiting state
src/components/ChatView.tsx, src/components/ChatView.css, src/lib/workBlocks.ts, src/lib/workBlocks.test.ts, DESIGN.md
A live turn with blocked status displays “Needs you” and waiting styles. The predicate tests cover blocked and non-blocked statuses.
Regression coverage and documentation
scripts/prompt-dock-demo-regression.ts, scripts/ui-regression.ts, scripts/ci-browser.sh, docs/development.md, site/demo/fixtures.ts
The browser regression harness checks dock placement, card layout, answer behavior, pane isolation, and phone interactions. Browser CI runs the harness, and the development guide documents the command.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Suggested reviewers: yoonwoo-ha, haeminway1

Merge Risk: ⚪ Minimal · up to 256e9

Prompt cards now appear in a dock above the message box. Their layout, focus, and phone keyboard behavior are updated, and the answer API is unchanged. No concrete defects were found in the change. Mergeability depends mainly on browser CI passing, including the new prompt-dock regression check.

Security Architecture Review

Security architecture risk: ⚪ Minimal · up to 256e9

The change rearranges approval controls and tightens delayed-response isolation. The reviewed behavior does not introduce new privileges, weaken approval checks, or broaden access.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The reviewed change retains the existing pane-targeted answer capability. Moving the card does not add a production endpoint or broaden the authority of the terminal-input path.

Trust Boundaries and Controls

  • observed — Externally supplied title, question, body, and option labels are rendered as React text. Option indices or custom text still pass through the existing answer API and server-side validation before reaching pane input.

Resilience and Maintainability Implications

  • observed — Pane-keyed ChatView instances and prompt-occurrence ownership isolate stale completions. Failed card answers preserve the visible prompt with an error; late unmounted-card responses are ignored. Focus restoration changes focus only and does not submit another answer.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.57% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 16 files. (5 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: prompt cards now dock above the message box, outside the transcript.
Description check ✅ Passed The description includes both required sections. It explains the problem and resulting behavior in detail, and lists validation results, failures, and untested areas.
Full details: Docstring Coverage

Explanation

Docstring coverage is 78.57% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 16 files. (5 skipped: 5 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

devswha and others added 5 commits October 5, 2026 21:04
…ped pick alone

Each prompt that appears gets a card of its own (a key per occurrence, told
by the prompt object the read keeps, since the id is a hash of the text). A
card that is gone reports nothing when its answer comes back, the chat clears
only the prompt the card was for, and a typed pick is cleared by its pane and
prompt. The new card also opens at its top, with no scroll left over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…buttons on one line

The row is pinned over the options, and an option's label can run to pages:
its question now scrolls in itself, and Confirm and Cancel are one group that
never breaks, beside the question when there is room and under it when not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The handoff asked the device's pointer, so a touch-screen laptop (a fine
pointer) raised its on-screen keyboard after a tap. The press itself now says
what made it; a coarse pointer still rules the handoff out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DESIGN.md no longer calls the input box the only card. The regression's
prompts are per pane, and it covers a late answer (same pane and across a
pane switch), a replaced card's scroll, a 2,166-character label in the
Confirm row, its buttons on one line, and a tap on a fine-pointer screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@devswha
devswha merged commit 8378a95 into main Oct 5, 2026
4 checks passed
@devswha
devswha deleted the feat/chat-approval-dock branch October 5, 2026 12:21
devswha added a commit that referenced this pull request Oct 5, 2026
…n Wide at another root font size (#474)

## Change

승인된 채팅 모드 재설계의 한 조각입니다(앞서 #456 ~ #468이 머지되었고, 이번이 마지막 묶음입니다). 이 PR에 해당하는
목업 규칙은 없습니다. #463(960px 채팅 레인과 Chat width 설정) 리뷰에서 남은 후속 수정입니다.

Chat width가 Default일 때 레인의 최대 폭이 고정 `960px`이었습니다. Wide는 `72rem`이라서, 브라우저
글꼴 크기가 16px이 아니면 Default가 Wide보다 넓어질 수 있었습니다(13px 루트에서 Default 960px,
Wide 936px).

이제 Default의 최대 폭은 `60rem`입니다.

- 16px 루트: 지금과 같습니다(820px ~ 960px).
- 20px 루트: 큰 pane에서 Default가 1200px까지 넓어집니다(Wide는 1440px).
- 13px 루트: Default는 pane 폭과 상관없이 820px이고(Narrow와 같음), Wide(936px)가 다시 더
넓습니다.
- 탭을 열어 둔 채 글꼴 크기를 바꿔도 pane 크기 변경이나 새로고침 없이 바로 적용됩니다.

구현: `PaneTerminal`이 `--chat-w`를 px 숫자가 아니라 CSS 길이 하나로 씁니다.
`min(max(820px, 60rem), <pane 몫>px)` 형태이고 `chatLaneLength`가 만듭니다. 루트 글꼴
크기를 JS에서 읽지 않습니다. `CHAT_LANE_MAX`(960)는 `CHAT_LANE_MAX_REM`(60)으로 바뀌었고,
`chatLaneWidth(pane, root)`는 같은 규칙의 숫자 형태로 남아 테스트에서만 씁니다. `DESIGN.md`와
`src/styles.css`의 주석을 함께 고쳤습니다. 새 문구, HTTP/WS 계약, 클래스 이름 변경은 없습니다.

## 목업과 다른 점

- 대응하는 목업 규칙이 없어서 목업 렌더와 비교하지 않았습니다. 검증은 측정한 폭입니다.
- 루트가 13.67px보다 작으면 `60rem`이 820px보다 작아지는데, 이때는 바닥값 820px이 이깁니다.
Default가 Narrow보다 좁아지지 않게 하기 위해서입니다. 그래서 "Default ≤ Wide"는 루트 11.39px
이상에서 성립합니다. 그보다 작으면 Narrow의 820px 자체가 Wide의 72rem보다 넓은데, 이 PR은 그 부분을 바꾸지
않습니다.
- `chatLaneWidth`는 rem 최대 폭을 정수 px로 반올림하지 않습니다(CSS가 계산하는 값과 같게). 루트가 소수
px이면 레인도 소수 px 폭이 될 수 있습니다.

## 위험과 리뷰할 곳

- `.terminal-stack`의 `--chat-w`가 px 값이 아니라 `min()/max()` 식입니다.
`ChatView.css`와 `Composer.css`(`calc()` 안에서 쓰는 곳 포함)에서 Chrome은 올바르게
계산했습니다. Chrome에서만 확인했습니다. `src`와 `scripts`에 이 인라인 값을 숫자로 파싱하는 코드는 없습니다.
- 루트 글꼴이 13.67px보다 작고 모니터가 큰 사용자는 Default가 960px에서 820px으로 줄어듭니다. 의도한
수정이지만 눈에 보이는 변화입니다.
- `src/lib/settings.test.ts`가 `PaneTerminal`이 `setProperty("--chat-w",
chatLaneLength(`를 쓰고 `chatLaneWidth(`를 호출하지 않는지 소스 문자열로 확인합니다. JS에서 px로
계산한 최대 폭이 되돌아오는 것을 막기 위한 것입니다.

## Validation

검증 환경: Linux, Bun 1.4.2, Chrome. `origin/main`(8378a95, #468 포함)을 머지한 뒤
실행했습니다.

- `bun run typecheck`: 통과
- `bun run test:unit`: 1,284건 통과, 4건 건너뜀, 실패 없음
- `bun run build`: 통과(`test:ui` 안에서)
- `bun run test:ui`(별도 세션 `herdr-web-ui-test`): PASS 36개 뒤
`checkAlertSound`의 `.droplet-card`
대기(`scripts/alert-sound-regression.ts:79`)에서 실패했습니다. 이 실패는 이 장비에서
`origin/main`도 전체 순서로 돌릴 때 똑같이 나는 것으로 알려져 있고, CI는 통과합니다. 그 앞에서 실패한 검사는
없습니다.
- 같은 스크립트를 `checkAlertSound`와 `checkUpdateNotice` 두 호출만 뺀 임시 사본(커밋하지
않음)으로 다시 실행: PASS 64개, 종료 코드 0. 1920 창에서 레인이 960px, 1280 창에서 820px인 기존
검사가 여기에 들어 있고 그대로 통과합니다(검사 값은 바꾸지 않았고 주석만 고쳤습니다).
- 레인 폭에 기대는 데모 스크립트: `composer-fit-demo-regression`(8),
`held-rows-demo-regression`(10), `prompt-dock-demo-regression`(22),
`chat-greeting-demo-regression`(10) 모두 통과.
- 단위 테스트(`src/lib/settings.test.ts`): 루트 13, 14, 16, 17.6, 20, 24px에서
Default가 820px 이상 72rem 이하인지, 쓸 수 없는 루트 값(0, 음수, NaN, Infinity)이 16px로
처리되는지, `chatLaneLength`가 쓰는 문자열 형태, 그리고 그 길이가 각 루트에서 `chatLaneWidth`와 같은
값으로 계산되는지. 16px 기대값은 그대로입니다.
- 빌더가 데모 전송 계층에 올린 실제 클라이언트를 Chrome에서 측정했습니다. 열린 페이지에서 pane 크기를 바꾸지 않고
루트 글꼴만 바꿨습니다: 2200x900 다크(pane 1880px)에서 16px 960 → 13px 820 → 20px 1200
→ 16px 960, 1440x900 라이트에서 세 크기 모두 820, 390x844 터치에서 세 크기 모두 366. 대화 열과
입력 카드 폭이 모든 상태에서 같았고 가로 스크롤과 페이지 오류는 없었습니다.

하지 않은 것: `test:integration`, 계약 테스트, `chat-history-browser-qa`,
`file-viewer-regression`, `keyboard-viewport-regression`,
`file-viewer-mobile-regression`, `droplet-demo-regression`은 실행하지
않았습니다(CI가 실행합니다). Chrome 외 브라우저는 확인하지 않았습니다. 이번 단계에서 스크린샷을 새로 찍지 않았습니다.

Codex 적대적 리뷰: 작은 조각이라 실행하지 않았습니다. 자체 리뷰에서 막는 지적은 없었습니다.

## 열린 외부 PR과의 충돌

#436, #409, #438, #441, #421은 살펴보지 않았습니다. 이 PR은 `settings.ts`의 채팅 레인 함수,
`PaneTerminal.tsx`의 effect 하나, 주석, `DESIGN.md`, `CHANGELOG.md`만 건드립니다.
겹칠 만한 곳은 `CHANGELOG.md`의 Unreleased → Fixed입니다.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
devswha added a commit that referenced this pull request Oct 5, 2026
… reads a step larger (#473)

## Change

승인하신 채팅 모드 목업을 순서대로 올리는 작업의 마지막 묶음 중 하나입니다. 앞선 조각은 #456 ~ #468이고, 이 PR은
목업의 G 규칙(`mockups/chat-mode/variants/g.css`: 서체와 읽기 크기)만 담습니다. 다른 조각은
건드리지 않습니다.

**사용자가 보는 것**

- 앱이 서체를 직접 가져옵니다. 화면과 채팅은 Pretendard, 채팅과 화면의 코드는 JetBrains Mono로 그립니다.
서체가 설치되지 않은 휴대폰이나 PC에서도 같은 글자가 나옵니다.
- 인터넷에서 받아 오는 것은 없습니다. 파일은 앱과 함께 사용자 PC에서 나갑니다. Pretendard는 92개 조각으로 나뉘어
있어서 페이지는 그 글에 필요한 조각만 받습니다(영어 채팅은 3개 조각과 JetBrains Mono, 한국어 UI는 9개 조각과
JetBrains Mono). 한 번 받은 조각은 서비스 워커의 기존 `/assets/` 규칙이 보관합니다.
- 채팅에서 답변, 내 메시지 말풍선, 작업 중 나오는 말은 15px에 줄 높이 1.65입니다. 굵은 글씨는
semibold(600), 인라인 코드는 0.87em, 코드 블록은 13px에 줄 높이 1.6, 빈 채팅의 인사말은 22px
semibold입니다.
- 메시지 입력란은 마우스에서는 15px, 터치에서는 그대로 16px입니다(iOS 확대 방지).
- 도구 행, 시각, 작은 라벨은 크기가 그대로입니다.
- 터미널은 바뀌지 않습니다. 터미널 글꼴, **Terminal font** 설정, 격자가 그대로이고, 직접 고른 **Chat
font**가 여전히 맨 앞에 옵니다.
- 느린 연결에서 서체가 늦게 도착해도, 채팅 맨 끝을 보던 사람은 맨 끝에 그대로 있습니다. 입력 카드는 서체가 하나 도착할
때마다 다시 맞춥니다.

**코드**

- `src/fonts/`: Pretendard 스타일시트와 92개 woff2 조각(패키지 그대로),
`JetBrainsMono-Regular.woff2`, `fonts.css`. `src/main.tsx`가 불러오고 Vite가
`dist/assets/`에 지문을 붙여 넣습니다.
- `src/styles.css`와 `DESIGN.md`: 새 토큰 `--fs-chat`(15 / compact 14),
`--fs-display`(22 / compact 21), `--lh-prose` 1.65, `--lh-code` 1.6,
`--tracking-display` -0.02em, 그리고 `--font-sans`, `--font-mono` 스택. 두 파일은
함께 바꿨습니다.
- `src/lib/fontFaces.ts`(새 파일): 서체 도착 횟수를 세는 모듈 저장소와 `useFacesArrived`
훅. `ChatView.tsx`는 `stickToBottom`일 때 도착마다 끝으로 스크롤하고, `Composer.tsx`는 기존
`loadingdone` 효과 대신 이 훅으로 다시 맞춥니다.
- `public/sw.js`: `CACHE_NAME`을 올렸습니다.
- `THIRD_PARTY_NOTICES.md`: 두 서체의 OFL 전문.
- 새 `t("…")` 문구, HTTP/WS 계약 변경은 없습니다.

## Why

지금은 기기에 깔린 서체에 기대기 때문에 기기마다 글자 모양과 폭이 다릅니다. 입력 카드의 모델 라벨 맞춤(#465,
#467)처럼 폭을 재는 코드는 서체가 같아야 결과가 같습니다. 그리고 채팅 본문은 14px에 좁은 줄 간격이라 긴 답변을 읽기에
빽빽했습니다. 목업에서 승인하신 크기가 15px / 1.65입니다.

## 목업과 다른 점

- 서체는 의존성으로 추가하지 않고 `src/fonts/`에 넣었습니다(기존 Nerd Font와 같은 방식).
`pretendard` 패키지는 풀면 97 MB에 파일 1,826개이고, `build-remote-bundle.ts`가
`node_modules`를 원격 번들마다 복사합니다.
- JetBrains Mono는 `"JetBrains Mono Web"`이라는 이름으로 등록했습니다(설치된 사본이 있으면
`local()`로 먼저 씁니다). g.css는 `"JetBrains Mono"`인데, `lib/fontFamily.ts`의
터미널 스택이 그 이름을 쓰고 있어서 같은 이름이면 xterm이 웹 서체로 칸 크기를 재기 시작합니다. `--font-mono`의
맨 앞은 여전히 `"Symbols Nerd Font Mono"`입니다.
- G6(pane 제목)은 #461에서, G5(조용한 행과 상태 줄의 작은 크기)는 이미 main에 있어서 바꾸지 않았습니다.
- G2의 본문 자간 -0.003em은 넣지 않았습니다(토큰이 없고 눈에 보이지 않습니다). 문단 `margin-block`
규칙도 넣지 않았습니다. 기존 아래 여백으로 같은 간격이 나옵니다. `li + li` 간격은 넣었습니다.
- Markdown의 h3 ~ h6을 `--fs-md`에서 본문 크기로 올렸습니다. 그대로 두면 제목이 본문보다 작아집니다.
g.css에는 없는 변경입니다.
- 압축 / 중단된 턴 요약(`.chat-compact-text`)은 이전의 14px / 1.5로 고정했습니다. 완료된 뒤 접힌
말(settled narration)도 13px 그대로입니다. 크기를 올리는 대상은 본문, 말풍선, 작업 중인 말뿐입니다.
- 목업에 없는 제품 코드: `src/lib/fontFaces.ts`. `font-display: swap`으로 서체가 바뀔 때
스크롤 위치와 입력 카드 맞춤이 어긋나는 것을 막습니다.

## 리뷰할 때 볼 것

- **용량**: 저장소와 릴리스, 원격 번들이 약 3.1 MB 늘어납니다(woff2 93개). `dist/`는 4.57 MB에서
7.68 MB가 됩니다.
- **설정 표기**: Settings는 기본값을 "Chat font size 14px"로 보여 주는데 본문은 15px로
그려집니다. 설정은 기준 크기이고 본문은 그보다 한 단계 위이며, 설정을 바꾸면 여전히 함께 커집니다. 스테퍼가 15로 읽히길
원하시면 `lib/settings.ts`의 `CHAT_BASE_FONT`를 바꿔야 하는데, 여기서는 하지 않았습니다.
- **입력란 15px 조건**: `(hover: hover) and (pointer: fine)`입니다(g.css 그대로).
트랙패드를 붙인 iPad도 여기에 들어갑니다.
- **다시 그리기**: 서체가 하나 도착할 때마다 입력 카드와 열려 있는 ChatView가 한 번씩 다시 그려집니다. 한국어
페이지 첫 방문에 열 번 남짓입니다. 훅은 93개 face의 `loaded` 약속을 지켜보지만 내려받기를 시작하지는 않습니다.
- **끝으로 스크롤**: 서체가 도착하면 끝에서 48px 안에 있던 사람만 끝으로 옮깁니다. resize observer가 쓰는
기존 기준과 같습니다.
- **`CACHE_NAME`**: 올렸기 때문에 기기마다 기존 캐시가 활성화 때 한 번 비워집니다.
- **코드 안의 한글**: JetBrains Mono에 한글이 없어 시스템 고정폭 서체로 넘어갑니다. regular 굵기만
넣었으므로 굵은 코드는 합성됩니다.
- `scripts/film/capture.ts`는 여전히 `"JetBrains Mono"`라는 이름을 찾아 기존 경고를 냅니다.
이 조각 밖이라 두었습니다.

## Validation

검증 환경: Linux, herdr 0.9.3, Bun 1.4.2, Chromium. 브랜치는
`origin/main`(8378a95, #467과 #468 포함)을 병합한 상태이고 뒤처진 커밋이 없습니다.

- `bun run typecheck`: 통과
- `bun run test:unit`: 1,286건 통과, 4건 건너뜀, 실패 없음
- `bun run build`: 통과
- `bun run test:ui`(별도 세션 `herdr-web-ui-test`), 첫 실행: `checkDroplet`의
"no in-app alert for the pane already open"에서 멈췄습니다(PASS 31개 뒤).
- 같은 `ui-regression.ts`를 `checkAlertSound`와 `checkUpdateNotice` 두 호출만 뺀
임시 사본(커밋하지 않음)으로 다시 실행: PASS 64개, 종료 코드 0. `checkDroplet`도 통과했으므로 첫 실행의
실패는 타이밍 flake로 적습니다. 뺀 두 검사는 이 기기에서 `origin/main`도 전체 순서 안에서만 실패하는 것으로
알려진 것이고 CI에서는 통과합니다. 이 PR에서는 그 둘을 이 기기에서 실행하지 않았습니다.
- CI의 나머지 브라우저 스크립트: `chat-history-browser-qa` 5/5,
`file-viewer-regression` 6/6, `keyboard-viewport-regression` 1/1,
`file-viewer-mobile-regression` 16/16, `droplet-demo-regression` 5/5,
`chat-greeting-demo-regression` 10/10, `composer-fit-demo-regression`
8/8, `held-rows-demo-regression` 10/10, `prompt-dock-demo-regression`
22/22
- CI에 없는 스크립트: `font-swap-demo-regression`(새 파일) 3/3, `chat-browser-qa`
7/7, `skill-activity-browser-qa` 2/2, `output-browser-qa` 종료 코드 0

**테스트 변경**

- `src/lib/fontFaces.test.ts`(새 파일, 4건): 불러오는 중이거나 아직 요청되지 않은 face만 따라감,
여러 번 훑어도 한 번만 따라감, 다른 face가 아직 오는 중이어도 각자 도착을 알림, 실패한 로드는 도착도 아니고 처리되지
않은 거절도 아님.
- `src/lib/fontFamily.test.ts`: 번들 서체 이름, 그리고 터미널 스택이 그 이름을 쓰지 않는다는 것을
고정.
- `scripts/font-swap-demo-regression.ts`(새 파일, 390px 터치, woff2 응답을 1.5초
늦춤): 끝을 보던 사람은 서체가 바뀌어도 끝에 있음, JetBrains Mono 파일이 끝내 오지 않아도 같음, 위로 올려 보던
사람은 끌려 내려가지 않음. 첫 검사는 ChatView 효과가 없으면 실패합니다(간격 18px).
- `scripts/app-faces.ts`: 두 서체가 로드될 때까지 기다리는 도우미. `composer-fit`,
`chat-greeting`, `held-rows`, `prompt-dock` 데모 스크립트가 측정 전에 씁니다.
- 기대값을 바꾸거나 지우거나 느슨하게 한 것은 없습니다. 고정된 폭, 높이, 한 줄 검사는 서체가 로드된 상태에서 그대로
통과합니다.

**눈으로 본 것**: 데모 transport의 실제 클라이언트를 1440과 390 터치, 다크와 라이트, 영어와 한국어 UI에서
봤습니다. 본문과 말풍선 15px / 24.75px, strong 600, 인라인 코드 13.05px, 코드 블록 13px /
20.8px, 도구 행 12px, 제목 14px / 600, 입력란은 마우스 15px / 24px, 터치 16px / 24px,
가로 스크롤 없음. Chat font size 20 + compact에서도 본문이 따라 커집니다(21.5px). g.css와
맞습니다.

**하지 않은 것**

- `bun run test:integration`, `*.contract` 테스트, `bun run build:site`.
- 서비스 워커가 조각을 오프라인에서 돌려주는지는 실행해 보지 않았습니다. 바뀌지 않은 `/assets/` cache-first
규칙을 읽고 판단했습니다.
- 늦게 온 고정폭 서체로 모델 라벨을 다시 맞추는 경우(Windows / Consolas)는 단위 테스트로만 덮었고,
브라우저에서 라벨 폭을 확인하지는 않았습니다.
- herdr를 쓰는 브라우저 스크립트(`ui-regression` 등)는 서체 로드를 기다리지 않습니다. 폭을 한 번만 읽는
검사가 서체 교체와 겹칠 수 있습니다. CI에서 흔들리면 `appFaces`를 넣으면 됩니다.

**Codex 적대적 리뷰**: gpt-6.1-sol xhigh가 끝까지 돌았고 지적은 하나였습니다(Composer가
`loadingdone`에서만 다시 맞춤). 브라우저에서 재현한 뒤 `useFacesArrived`로 고쳤고, 두 OFL 전문이
원본과 같다는 것도 확인받았습니다.

## 다른 PR과의 충돌

열려 있는 외부 PR과 겹칠 수 있습니다. #436(상태 줄: `Composer.css`, `Composer.tsx`),
#409와 #438(`ChatView.css`의 `.markdown` 규칙, `ChatView.tsx`), 그리고 어느 쪽이든
`CHANGELOG.md`와 `DESIGN.md`. #441과 #421(사이드바)은 겹치지 않을 것으로 봅니다. 그 PR들은
건드리지 않았습니다.

## PR을 연 뒤의 변경

적대적 리뷰의 지적을 하나씩 확인하고 고쳤습니다(`62cf511`..`c7ed562`).

- **서비스 워커 (`public/sw.js`)**: `CACHE_NAME`이 바뀌면 새 워커가 켜지는 순간 옛 캐시를 지웠고,
미리 담아 두는 파일이 없어서 그 뒤 온라인으로 한 번 더 열기 전에는 오프라인 새로고침이 브라우저 오류 화면이었습니다. 이제
켜질 때는 아무것도 지우지 않고, 서버에 닿은 탐색이 새 캐시에 셸(`/`)을 넣은 다음에 다른 이름의 캐시를 지웁니다. 옮겨
담는 방식 대신 이쪽을 고른 이유: 켜지는 시점에는 새 셸이 어느 캐시에도 없어서 "새 셸이 가리키는 파일"을 알 수 없고, 옛
캐시에는 그 페이지를 띄운 셸, 번들, 이미 받은 서체 조각이 그대로 있으며, 아무것도 복사하지 않으니 옛 빌드의 해시 파일이 첫
온라인 탐색 뒤까지 남지 않습니다. 서체 조각은 여전히 받을 때만 캐시합니다. 대가: 옛 캐시에 있던 서체 조각은 그 탐색 뒤 한
번 다시 받습니다.
- 검증: `src/pwa.test.ts`에 실제 `sw.js`를 가짜 CacheStorage 위에서 돌리는 테스트 4개(고치기
전 워커에서는 2개 실패). 실제 Chrome에서도 한 번 확인했습니다(main의 v3 워커로 연 뒤 새 워커로 교체하고 서버를
내린 채 새로고침: 고치기 전 `net::ERR_FAILED`, 고친 뒤 앱이 그려짐. 다음 온라인 탐색 뒤 v4만 남고 다시
오프라인으로 열림). 이 Chrome 확인은 일회용 스크립트였고 커밋하지 않았습니다.
- **압축 요약 (`ChatView.css`)**: `.chat-compact-text` 안의 h3~h6은 요약 글자 크기를
물려받고, 코드는 `--fs-xs` / `--lh-base`로 되돌렸습니다(버려진 턴의 요약도 같은 상자라 함께 적용). 빌드한
CSS로 잰 값(기본 크기): main h3 14px, 인라인 코드 12px, 코드 블록 12px/1.5 → 고치기 전 15px,
12.18px(제목 안 13.05px), 13px/1.6 → 고친 뒤 14px, 12px, 12px/1.5. 채팅 글자 크기
1.25배에서도 main과 같고, 요약 밖의 답변은 그대로입니다.
- **문서 (CHANGELOG, DESIGN.md)**: "어느 기기에서나 같은 글자"를 라틴 문자와 한글로 한정했습니다.
리뷰는 한자가 전혀 없다고 했지만 조각의 `unicode-range`를 세어 보니 한글 11,172자, 히라가나 83, 가타카나
88, 한자 435자가 들어 있습니다. 그래서 "가나와 수백 자의 한자만 있고, 나머지 한자와 중국어는 기기의 서체로 그려지며
일본어 한 줄에는 두 서체가 섞인다"고 적었습니다. README, docs, site에는 같은 주장이 없었습니다. 오프라인 수정은
Fixed 항목으로 추가했습니다.
- **서체 도착 횟수 (`src/lib/fontFaces.ts`)**: 서체 하나의 도착이 두 번(자기 promise, 이어서
`loadingdone`) 세어졌고 전부 실패한 묶음도 한 번으로 세어졌습니다. 서체마다 한 번만 세고 실패는 세지 않습니다.
단위 테스트 7개.
- **#467 항목 (CHANGELOG)**: 모델 알약이 비켜나는 조건의 괄호를 "390px 휴대폰에서 확인"으로 고쳤습니다.

검증하지 못한 것:
- 서비스 워커 교체를 Safari와 Firefox에서, 그리고 설치된 앱의 실제 업데이트 경로에서 확인하지 않았습니다.
- `mobile-tabs-regression.ts`(서체 도착을 쓰는 탭 줄)와 `test:ui`,
`test:integration`, contract 테스트는 공유 herdr 세션이 쓰이는 중이라 돌리지 않았습니다. CI에
맡깁니다.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
devswha added a commit that referenced this pull request Oct 5, 2026
… message box on a coarse-pointer device too (#475)

## Change

머지된 #468(입력 카드 위에 붙는 승인 카드)의 리뷰에서 나온 후속 수정입니다. #468은 아직 릴리스되지 않았습니다.

### 무엇이 잘못되었나

카드에서 답을 누르면 카드가 사라지므로, 탭이 아닌 경우 포커스를 메시지 상자로 넘깁니다. 카드는 클릭의
`pointerType`으로 탭을 가려냈지만, `PaneTerminal`이 기기가 `(pointer: coarse)`이면 넘기지
않는 조건을 하나 더 걸고 있었습니다. 원래는 클릭이 무엇으로 눌렸는지 알려 주지 않는 브라우저를 위한 대비책이었는데, 모든
누름에 적용되었습니다.

재현: 키보드를 붙인 태블릿(또는 터치 화면이 주 포인터인 노트북, 키보드를 연결한 폰)에서 Tab으로 선택지에 가서 Enter를
누릅니다. 답은 전달되고 카드가 사라지는데, 포커스는 메시지 상자로 가지 않고 `document.body`로 떨어집니다. 키보드
사용자는 자리를 잃고 Tab을 처음부터 다시 눌러야 합니다. 같은 기기에서 마우스로 눌러도 같았습니다.

### 무엇을 바꿨나

- 누름의 출처를 `keyboard` / `mouse` / `touch` / `pen` / `unknown` 다섯 가지로
구분합니다(`pressOrigin`, `src/lib/promptAnswer.ts`).
- 규칙(`focusFollowsAnswer`): 키보드와 마우스는 어떤 기기에서든 포커스를 넘깁니다. 터치와 펜은 넘기지
않습니다. `(pointer: coarse)`는 `unknown`일 때만 봅니다. 답이 돌아왔을 때 카드가 아직 있고 포커스가
카드 안이나 페이지에 있어야 한다는 기존 조건은 그대로입니다.
- `PaneTerminal`의 `!coarseRef.current` 조건을 뺐습니다. 판단은 이제 순수 함수 한 곳에 있습니다.
- 답 경로는 바뀌지 않았습니다. 누르지 않으면 아무것도 보내지 않고, 탭은 화면 키보드를 올리지 않습니다. CSS 변경은
없습니다.

### 누름의 출처를 알아내는 방법

클릭의 `pointerType`과, 그 버튼에서 직전에 있었던 pointerdown의 `pointerType`을 함께 봅니다. 둘
중 하나라도 `touch`나 `pen`이면 그것이고, 아니면 `mouse`입니다. pointerdown을 함께 보는 이유는 iOS
Safari가 탭의 클릭을 `mouse`로 보고한 적이 있기 때문입니다(WebKit bug 282988, CodeRabbit
지적). coarse 기기에서도 마우스가 포커스를 넘기게 되었으므로, 이를 믿으면 폰에서 탭이 화면 키보드를 올릴 수 있습니다.
클릭에 `pointerType`이 없는 브라우저도 pointerdown으로 구분됩니다. 포인터가 없으면 `detail ===
0`이고 **같은 버튼에서 Enter나 Space의 keydown이 먼저 있었을 때만** 키보드로 봅니다. 카드가 keydown을
기억해 두고, 포인터가 눌리거나 답이 나가면 지웁니다.

`detail === 0`만 쓰지 않은 이유: 스크립트의 `click()`과 보조 기술의 활성화도 `detail`이 0입니다.
폰의 화면 낭독기로 선택지를 활성화했을 때 키보드로 판정하면 메시지 상자에 포커스가 가서 화면 키보드가 올라옵니다.
keydown까지 요구하면 이런 클릭은 `unknown`이 되어 정밀 포인터에서는 넘기고 coarse에서는 넘기지 않습니다.
`pointerType === ""`만 보지 않은 이유: 클릭이 `MouseEvent`인 브라우저에는 이 값이 아예 없어서 탭과
키를 구분할 수 없습니다. 카드의 입력란에서 Enter로 보낸 답은 클릭이 없으므로 그대로 키보드입니다.

### 고치지 않은 것: 카드가 사라진 뒤 돌아온 실패

답이 돌아왔을 때 카드가 이미 없으면 성공이든 실패든 아무것도 알리지 않습니다(`PromptCard.tsx`의 `if
(!shown.current) return false`). 실패에 대해서는 알리지 않는 채로 두었습니다. 이유는 다음과 같습니다.

- 메시지 상자의 알림 줄(`composer-note`)은 `Composer` 안의 상태라서, 밖에서 쓰려면
`PromptCard` → `ChatView` → `PaneTerminal` → `Composer`로 새 prop을 이어야 하고
pane별 범위와 지우는 시점도 정해야 합니다. 문구는 기존 "Not sent: {message}"로 되지만 배선이 이 PR의
크기를 넘습니다.
- 카드가 사라지는 경우는 둘입니다. (1) 같은 pane에서 프롬프트가 바뀌었거나 없어진 경우: 서버는 대개 409
`prompt_changed`로 답하고, 화면에는 이미 지금의 프롬프트가 있습니다. 그 밖의 실패(네트워크 등)는 키가
전달되었는지 알 수 없어서 "보내지 못했다"는 말이 틀릴 수 있습니다. (2) 다른 pane으로 옮긴 경우: 지금 보는 메시지
상자에 알림을 띄우면 다른 pane의 이야기가 됩니다. 원래 pane은 답이 전달되지 않았다면 사이드바에 INPUT으로 남고,
돌아가면 카드가 다시 보입니다.

알려진 한계: (1)에서 409가 아닌 실패는 사용자에게 아무 말도 가지 않습니다.

### 그 밖의 한계

- 답이 가는 동안 카드가 다른 프롬프트로 바뀌면 키보드 사용자의 포커스는 여전히 페이지로 떨어집니다. 새 카드로 옮길지는 따로
정해야 해서 손대지 않았습니다.

## Validation

검증 환경: Linux, Bun 1.4.2, Chrome(`/usr/bin/google-chrome`).
`origin/main`(#474, ac5b066) 위에 있습니다.

- `bun run typecheck`: 통과
- `bun run test:unit`: 1,302건 통과, 실패 없음. `src/lib/promptAnswer.test.ts`에
`pressOrigin` 5건과, 다섯 출처 각각을 정밀 포인터와 coarse 포인터에서 본 10건을 넣었습니다.
- `bun run build`: 통과
- `scripts/prompt-dock-demo-regression.ts`: PASS 23개, 종료 코드 0(두 번). 새
블록은 `(pointer: coarse)`인 1024x768 터치 페이지에서 다음을 봅니다. 선택지에서 Enter와 Space,
카드 입력란에서 Enter, 400ms 걸리는 답, 마우스 클릭이 모두 메시지 상자로 포커스를 넘깁니다. 카드가 dock에서
빠지는 순간마다 포커스가 `document.body`가 아니었습니다. 탭, keydown 없는 `click()`,
pointerdown은 `touch`인데 클릭은 `mouse`라고 하는 누름은 넘기지 않습니다(마지막 것은 pointerdown을
보지 않으면 실패합니다). `PaneTerminal`의 예전 조건을 되돌리면 이 블록은 `actual: "page"`로
실패합니다.
- 같은 스크립트의 기존 폰 검사 하나는 Send를 Playwright의 `click()`(마우스)으로 누르고 포커스가 가지
않기를 기대했습니다. 새 규칙에서는 마우스가 포커스를 넘기므로 의도대로 `tap()`으로 바꿨습니다.
- `scripts/held-rows-demo-regression.ts`: PASS 10개, 종료 코드 0.
`scripts/composer-fit-demo-regression.ts`: PASS 8개, 종료 코드 0.
- `DESIGN.md`의 Prompt card 문장을 새 규칙으로 고쳤고, 변경 이력은 #468 항목의 문장에
덧붙였습니다(릴리스되지 않은 기능의 수정).

돌리지 않은 것: `bun run test:ui`, `bun run test:integration`, `*.contract`
테스트(CI에 맡김). 확인하지 못한 것: 실제 태블릿과 폰, Safari와 Firefox, 실제 화면 낭독기(TalkBack,
VoiceOver)의 활성화가 어떤 클릭을 만드는지. Chromium에서만 실행했습니다.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
devswha added a commit that referenced this pull request Oct 5, 2026
Bumps package.json and herdr-plugin.toml to 0.3.50 and moves the
Unreleased notes under [0.3.50] - 2026-10-06, with the compare link.
Adds the PR link and credit the #471 entry was missing; every other
entry already carries them. Raises the remote PC bundle to 17: #470
changes the prompt answer route on the bridge, so a remote PC on bundle
16 keeps the old behaviour until its bridge is updated. Push the
`remote-v17` tag and let the bundle workflow publish before running
release.yml.

Known limits carried into the notes: Japanese and Chinese text uses the
device's fonts beside Pretendard; #469 stays open for what #470 does not
cover (answers after the first committing key, identical twin prompts,
screens no reader knows).

Carries #422 #423 #424 #425 #426 #435 #437 #439 #446 #447 #448 #452 #454
#455 #456 #457 #458 #459 #460 #461 #462 #463 #465 #466 #467 #468 #471
#473 #474 #475

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant