Skip to content

feat(chat): the model and the level in one quiet pill, with a readable name where the id has one - #467

Merged
devswha merged 6 commits into
mainfrom
feat/chat-model-pill
Oct 5, 2026
Merged

devswha merged 6 commits into
mainfrom
feat/chat-model-pill

Conversation

@devswha

@devswha devswha commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Change

승인하신 채팅 모드 목업(final-working-dark.html 등)을 순서대로 올리는 작업의 한 조각입니다. 앞선 조각은 #456 ~ #466으로 main에 들어가 있습니다. 이번 차례는 두 조각으로, 이 PR(모델 pill)과 입력 카드에 붙는 승인 카드(다른 PR)입니다. 마지막으로 글자 크기 단계와 글꼴이 남습니다. 그래서 이 PR만으로는 최종 목업과 똑같아 보이지 않습니다.

싣는 목업 규칙은 h.css / h.js(입력창의 모델 표시)의 공통 규칙(모델과 effort가 한 덩어리, effort는 가운뎃점 뒤), mono(이름이 없는 id는 식별자 글꼴), name(읽히는 이름), pill(마크와 함께 조용한 chip 하나)입니다.

사용자가 보는 것

  • 입력 카드 아래 줄에서 에이전트 마크, 모델, reasoning 단계, 컨텍스트 링이 조용한 pill 하나(--bg-hover 바탕, --radius-pill, 컨트롤 높이)에 모입니다. DONE은 pill 밖, pill 앞에 그대로 그려집니다.
  • 모델은 id가 출처가 있는 패턴과 통째로 맞을 때만 이름으로 읽힙니다: claude-opus-5-5 → Opus 5.5, claude-sonnet-5 → Sonnet 5, gpt-5.6 → GPT-5.6, gpt-5.6-sol → GPT-5.6-Sol, glm-5.3 → GLM-5.3. 그 밖의 id(날짜가 붙은 스냅숏, gpt-5.6-sol-max 같은 접미사, 다른 provider 접두사, 다른 벤더)는 받은 그대로 mono 글꼴로 그립니다. 이름을 추측하지 않습니다.
  • 이름 뒤에 가려진 원래 id는 title(툴팁)에 있고, 스크린 리더도 읽습니다(visually-hidden 글자). 터치 화면에서는 아직 보이지 않습니다.
  • 단계는 --text-dim 가운뎃점 뒤에 첫 글자만 대문자로 나옵니다(High, Extra high). 글자 자체는 그대로이고 대문자는 CSS입니다.
  • 단계를 기록하지 않는 pane은 이름과 링만 보입니다(main은 대시를 그렸습니다). 단계는 있는데 모델이 없는 pane은 pill 없이 줄에 흐린 "Model — · High"만 그립니다.
  • pill은 표시만 합니다. 버튼이 아니고, role, tabindex, hover, chevron이 없습니다.
  • 모델 표시가 Queue 옆에 들어가지 않아 물러날 때(#465의 규칙)는 pill도 함께 물러나고 링은 남습니다.

새 번역 문구는 없습니다. HTTP/WS 계약과 서버는 바뀌지 않습니다. .conn-live, 사이드바의 READY / RUN / INPUT, Queue와 "Send now", 프롬프트 응답 경로는 건드리지 않았습니다. composerModelDraw와 fitStatus도 그대로입니다. 바뀐 파일은 8개이고 site/, server/, shared/ 아래에는 없습니다. 변경 이력은 Unreleased → Changed에 적었습니다.

목업과 다른 점

  1. gpt-5.6-sol은 GPT-5.6-Sol(하이픈)로 그립니다. 목업 h.js는 "GPT-5.6 Sol"입니다. 이 tier 단어의 출처가 저장소에는 Codex 상태 줄 fixture "GPT-6-Sol"(server/prompt.test.ts)뿐이라 그 표기를 따랐습니다. 목업의 공백으로 바꾸는 것은 src/lib/modelName.ts 한 줄과 테스트입니다.
  2. glm-5.3은 GLM-5.3(벤더가 쓰는 하이픈 표기)입니다. h.js는 "GLM 5.3"입니다.
  3. 이름으로 바꾸는 패턴은 다섯 개뿐입니다: claude-(opus|sonnet|haiku|fable|mythos)-N[-M], 같은 것의 anthropic/ 접두사, gpt-N[.M], gpt-N[.M]-sol, glm-N[.M]. "Claude"라는 단어를 빼는 것은 Claude Code 상태 줄 "[Opus 5.5 (1M context)]"(server/prompt.test.ts)을 따랐습니다.
  4. 날짜나 접미사가 붙은 id는 이름 + title이 아니라 통째로 mono로 그립니다(claude-haiku-4-5-20251001). 접미사가 가려지는 일이 없게 하려는 것입니다.
  5. 이름 옆에 원래 id를 visually-hidden 글자로 한 번 더 넣었습니다(목업에 없음).
  6. 모델 없이 단계만 있는 pane은 pill을 그리지 않습니다(목업에 없음).
  7. pill 안의 글자 크기는 --fs-xs 그대로입니다(h.css는 --fs-sm). 크기는 마지막 조각의 몫입니다.
  8. 업로드 / 재연결 문장이 따로 한 줄을 쓸 때 pill 높이는 --chip-h입니다. 컨트롤 높이로 두면 카드가 8px 커지고, 기존 폭 맞춤 검사가 이를 막습니다.
  9. pill 바탕 위에서 컨텍스트 링의 트랙은 --border-strong입니다. charcoal dark와 Catppuccin Mocha에서 --border가 --bg-hover와 같은 값이어서입니다. 카드 위에 혼자 남은 링은 --border 그대로입니다.
  10. DONE은 pill 앞에 --space-1 여백으로 그립니다(전에는 마크 뒤였고, 마크가 이제 pill 안에 있습니다).
  11. src/AGENTS.md의 Chat composer 줄에 lib/modelName.ts와 "이름을 추측하지 않는다"는 규칙을 적었습니다.

결정이 필요한 것

  • 390px 폰에서 작업 chip, 링, Queue가 모두 있으면 pill은 항상 물러납니다. 남는 폭이 140px이고 pill은 약 161px이 필요합니다. main은 이 자리에서 짧은 모델 표시를 그립니다. 마이크를 켠 800px도 같습니다. 선택지: 지금처럼 문서화한 대로 둔다 / 좁은 카드에서는 물러나기 전에 pill의 바탕과 padding을 먼저 뺀다 / Queue가 보일 때 단계가 먼저 물러나게 한다(#465의 규칙을 뒤집음). DESIGN.md에 적고 스크립트로 고정해 두었습니다.
  • "GPT-5.6-Sol"과 목업의 "GPT-5.6 Sol", "GLM-5.3"과 "GLM 5.3". Sol의 출처는 Codex 상태 줄을 담은 테스트 fixture입니다. 그 줄이 실제 캡처가 아니라 손으로 쓴 것이라면 이 패턴은 다시 빼야 합니다.
  • 이름으로 바뀐 모델의 원래 id는 터치로 닿지 않습니다(title과 visually-hidden 글자뿐). pill이 모델 전환기가 되면 풀립니다. 그때까지 둘지 결정해 주세요.

리뷰할 때 볼 곳과 위험

  • 이름이 없는 id는 mono라서 전의 sans 표시보다 넓고, main보다 일찍 물러납니다(긴 id, 1024px, 초안이 있을 때 full → out).
  • 모델 없는 줄은 "Model — · High"로 읽히고 점 앞뒤 간격이 조금 다릅니다(앞 4px, 뒤 8px). 드문 상태라 그대로 두었습니다.
  • charcoal dark에서 pill 위 흐린 단계 글자의 대비는 4.54:1로 4.5를 겨우 넘습니다.
  • 폭 맞춤 스크립트는 잰 결과를 고정합니다. 두 칸(긴 id, 1440, 컨텍스트 숫자 열림)이 경계에 가까워 글꼴 스택이 다르면 뒤집힐 수 있습니다.
  • 스크립트의 단계 검사는 Chrome의 innerText가 ::first-letter의 text-transform을 반영한다는 데 기대고 있습니다.
  • 업로드 문장이 따로 한 줄을 쓰는 상태에서 짧은 pill이 키보드 focus 링(2px + 2px offset)을 자릅니다. origin/main도 같은 상태에서 .composer-status[data-hint] > .composer-status-meta { overflow: hidden }으로 자르고 있어 이 PR이 만든 것은 아닙니다.
  • 브랜치에 origin/main 병합 커밋(feat(chat): held messages are quiet rows on the message box's column #466)과 후속 커밋이 있습니다. squash 머지에는 문제가 없습니다.

다른 PR과의 충돌

외부 PR #436(상태 줄)은 Composer.tsx, Composer.css에서 충돌할 것으로 봅니다. #409, #438(채팅)도 같은 파일이나 CHANGELOG.md, DESIGN.md에서 충돌할 수 있습니다. #441, #421(사이드바)은 충돌하지 않을 것으로 봅니다. 이 PR들은 건드리지 않았습니다. 같은 차례의 승인 카드 조각과는 CHANGELOG.md, DESIGN.md, Composer.css에서 겹칠 수 있습니다.

Validation

검증 환경: Linux, herdr 0.9.3, Bun 1.4.2, Chrome(/usr/bin/google-chrome). 브라우저 검사는 별도 세션 herdr-web-ui-test에서만 돌렸습니다. 아래는 451c9dd 기준이고, 이때 origin/main은 #466(9153e60)이며 브랜치에 이미 병합되어 있습니다.

  • bun run typecheck: 통과
  • bun run test:unit: 1,269건 통과, 4건 건너뜀, 실패 없음. src/lib/modelName.test.ts가 새로 들어갔습니다(이름이 붙는 id, 받은 그대로 돌아와야 하는 id).
  • bun run build: 통과
  • scripts/ui-regression.ts 전체 순서(test:ui의 스크립트, 위 빌드로 1회): PASS 36개 뒤 checkAlertSound의 .droplet-card 대기(scripts/alert-sound-regression.ts:79)에서 중단, 종료 코드 1. 이 실패는 이 기기에서 origin/main도 전체 순서 안에서만 똑같이 내는 것으로 알려진 것이고, 이 PR이 만든 것이 아닙니다. 그 앞의 검사는 모두 통과했고 checkComposerReconnect도 그 안에 있습니다.
  • 같은 ui-regression.ts에서 이 기기의 알려진 실패 두 개(checkAlertSound, checkUpdateNotice: 둘 다 origin/main에서도 전체 순서 안에서만 실패하고 CI는 통과)만 뺀 임시 복사본(커밋하지 않고 지웠습니다): PASS 64개, 종료 코드 0.
  • bun scripts/composer-fit-demo-regression.ts: PASS 8개. 이 PR이 바꾼 스크립트입니다: pill과 그 안의 마크, 이름, 단계, 링을 모든 경우에 쌍으로 겹침 검사, 이름 붙이기(gpt-5.6-sol → GPT-5.6-Sol, gpt-5.6-sol-max는 그대로), 가려진 id가 이름 뒤에만 있음, extra high → Extra high, 단계 없음, 모델 없음, 390 / 800 / 1024 / 1440 표(짧은 이름과 긴 id, 마이크 유무, 컨텍스트 숫자 닫힘과 열림), 폰에서 Queue가 보이는 동안 물러났다가 초안을 지우면 돌아옴.
  • bun scripts/chat-browser-qa.ts: PASS 7개(getByText로 찾는 테스트 모델 id와 xhigh가 그대로 맞습니다).
  • 각 1회, 모두 종료 코드 0: chat-history-browser-qa.ts PASS 5개, chat-greeting-demo-regression.ts PASS 10개, held-rows-demo-regression.ts PASS 10개, droplet-demo-regression.ts PASS 5개, keyboard-viewport-regression.ts PASS 1개, file-viewer-regression.ts PASS 6개, file-viewer-mobile-regression.ts PASS 16개.
  • flake로 기록할 것은 없었습니다.

돌리지 않은 것:

  • checkAlertSound, checkUpdateNotice는 위 이유로 이 기기에서 통과를 확인하지 못했습니다. CI가 돌립니다.
  • bun run test:integration과 *.contract.test.ts.
  • scripts/machines-browser-qa.ts(SSH fixture 필요). .composer-status[data-status]만 읽고, 이 속성은 바뀌지 않았습니다.
  • scripts/film/capture.ts. textContent의 /RUN|DONE/만 읽습니다.
  • 실제 iOS / Android 기기. 터치는 Chromium 에뮬레이션입니다.
  • 수정마다 되돌려서 새 검사가 실패하는지 보는 확인은 하지 않았습니다.

그 밖에 리뷰어가 커밋되지 않은 하네스로 24개 상태를 그려 확인했습니다: 390 터치 dark와 light, Queue가 있는 초안, 긴 id, 단계 없음, 모델 없음, 두 단어 단계, 390과 1440의 DONE, 따로 한 줄을 쓰는 업로드 문장, light의 amber / report / charcoal / catppuccin, dark charcoal. 이것은 리뷰 수정(451c9dd) 전의 트리에서였습니다. 수정 뒤에 만든 사람이 다시 본 것은 dark amber뿐입니다(GPT-5.6-Sol · Xhigh, Opus 5.5 · Extra high, 단계 없음 1440과 390 터치, 모델 없음). light와 다른 팔레트는 수정 뒤에 다시 찍지 않았습니다. 두 단어 단계와 모델 없음 상태는 실제 에이전트가 아니라 스크립트의 fixture 패치로 만들었습니다.

Codex 적대적 리뷰(gpt-6.1-sol, xhigh, 읽기 전용): 4건 보고, 3건을 고쳤고(느슨해진 겹침 검사, hover로만 닿던 원래 id, 단계만 있을 때의 "Model —" pill) 1건(focus 링 잘림)은 origin/main에도 있는 것이라 뺐습니다.

PR을 연 뒤의 변경

  • 이름 규칙에 대한 메인테이너의 결정을 반영했습니다(d79e335). 이름은 계속 벤더의 정규 id 문법으로 붙입니다. 출시된 버전의 목록이 아니므로 새 모델이 나와도 코드를 고칠 필요가 없고, 그 대가로 모양만 맞고 실제로는 없는 버전의 id(claude-opus-9-9, gpt-10)에도 이름이 붙습니다. 이 점은 src/lib/modelName.ts의 머리 주석에 적었고, 누가 실수로 "고치지" 않도록 테스트 하나로 고정했습니다.
  • 대신 벤더의 정규 형태가 아닌 id는 받은 그대로, mono 글꼴로 나옵니다. 버전 자리에 앞자리 0이 있는 id(claude-sonnet-5-05, gpt-05.6, glm-5.03)와 major가 0인 id가 그렇습니다. minor 0은 Anthropic만 쓰는 형태라 claude-opus-4-0은 Opus 4.0으로 이름이 붙고, gpt-5.0과 glm-5.0은 그대로 나옵니다. 이 minor 0의 구분은 결정문에 없던 것을 제가 정한 것이니 확인이 필요합니다.
  • 빈 자리, 세 자리 이상, 끝이나 중간의 잘못된 구분자, 대문자, 공백과 줄바꿈, ASCII가 아닌 숫자는 전에도 그대로 나왔습니다. 코드는 바뀌지 않았고 테스트로 고정했습니다.
  • DESIGN.md와 CHANGELOG의 문장을 고쳤습니다. 폰에서 작업 칩과 Queue 옆의 pill이 "항상" 물러난다고 썼지만 사실이 아니었습니다. pill은 들어가지 않을 때 물러나고, 390px 폰에서 작업 칩, 링, Queue가 함께 있으면 단계가 있는 이름 붙은 모델은 모두 그 경우입니다. 단계가 없는 짧은 id는 그대로 그려집니다. 두 문서의 "그대로 나오는 id" 목록에 정규 형태가 아닌 표기도 넣었습니다.
  • scripts/composer-fit-demo-regression.ts에 390px 경우 두 개를 넣었습니다. 작업 칩, 링, Queue가 있을 때 단계 없는 o3는 온전히 그려지고, 가장 짧은 이름인 gpt-6 + low는 물러납니다.
  • 이 커밋에서 돌린 것: bun run typecheck 통과, bun run test:unit 1,272건 통과 / 4건 건너뜀 / 실패 없음, bun run build 통과, bun run scripts/composer-fit-demo-regression.ts PASS 8개(종료 코드 0).
  • 확인하지 못한 것: bun run test:ui, test:integration, *.contract.test.ts는 이 커밋에서 돌리지 않았습니다. "단계가 있는 이름 붙은 모델은 모두 물러난다"는 문장은 gpt-6 + low, gpt-5.6 + xhigh, claude-opus-5-5 + xhigh로만 쟀고, 그보다 짧은 단계 낱말은 재지 않았습니다. 실제 기기에서는 보지 않았습니다(Chromium 에뮬레이션).

🤖 Generated with Claude Code

devswha and others added 5 commits October 5, 2026 19:32
…e name where the id has one

The agent mark, the model, the reasoning level and the context ring sit in one
pill in the composer's controls row. It is display only: no role, no focus, no
hover. DONE stays drawn outside it.

modelLabel (src/lib/modelName.ts) names an id only when it matches a vendor's
regular pattern whole: claude-<family>-<major>[-<minor>] (also behind
anthropic/), gpt-<version>, glm-<version>. Every other id is drawn as received
in the mono face, so no suffix is dropped and no name is guessed.

When the label steps out (composerModelDraw, unchanged) the pill goes with it
and the ring stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o box

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…me, no pill or dash for what a pane does not record

- The fit check compares the pill's parts with each other again, in every draw, and the
  level is one of the parts the pill must hold.
- Behind a name the id as received is repeated as visually hidden text (a touch cannot
  reach the title).
- A pane that records a level and no model draws no pill: a dim placeholder and the level
  stand in the row. A pane that records no level draws the name alone, no dot and no dash.
- The level's first letter alone is a capital (::first-letter), so "extra high" keeps its words.
- The ring's stronger track is used only on the pill's fill, not after the label stepped out.
- gpt-<version>-sol is named GPT-<version>-Sol, as Codex's own status line writes it
  (server/prompt.test.ts); no other tier word is named.

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: 16902193-9621-4059-b310-3267e9d571a0
📥 Commits

Reviewing files that changed from the base of the PR and between 4f6d873 and d79e335.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • DESIGN.md
  • scripts/composer-fit-demo-regression.ts
  • src/lib/modelName.test.ts
  • src/lib/modelName.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • CHANGELOG.md

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
    • The chat composer groups the agent mark, model name, reasoning level, and context indicator in a compact status pill.
    • Recognized Claude, GPT, and GLM model IDs display as readable names; other IDs remain unchanged.
    • Reasoning details are omitted when unavailable.
  • Improvements
    • The composer adapts its status details to available space. When Queue is visible and space is limited, the model label and pill step out while the context indicator remains visible.

Walkthrough

The composer displays the agent mark, model, reasoning level, and context ring in a status pill. It formats recognized Claude, GPT, and GLM model IDs, while leaving other IDs unchanged. The pill’s contents and visibility respond to available space and Queue state.

Changes

Composer Status Pill

Layer / File(s) Summary
Model label formatting
src/lib/modelName.ts, src/lib/modelName.test.ts, src/AGENTS.md
Adds modelLabel to format supported Claude, GPT, and GLM model IDs. Other IDs remain unchanged. Tests cover recognized IDs and fallback cases; the Chat composer documentation describes the matching rule.
Composer status pill
src/components/Composer.tsx, src/components/Composer.css, DESIGN.md, CHANGELOG.md
The composer groups the agent mark, model, reasoning level, and context ring in a display-only pill. Styling and design details define accessibility text, tooltips, and layout behavior when space is limited or Queue is shown.
Composer fit regressions
scripts/composer-fit-demo-regression.ts
Expands browser cases and assertions for pill contents, model and effort variants, ring styling, Queue state, and viewport widths.

Priority: ⬇️ Low

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

Change: Feature

Merge Risk: ⚪ Minimal · up to d79e3

The model pill and labeling changes have no established merge-blocking issue in the supplied evidence. Complete normal validation before merging.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 4 files. (2 skipped: 2… 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 summarizes the main change: a display-only pill that combines the model and reasoning level and uses readable names when IDs match supported patterns.
Description check ✅ Passed The description includes both required sections. It explains the behavior and scope, and it lists validation results, known failures, and checks that were not run.
Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 4 files. (2 skipped: 2 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.

…hown as received

The pill still names a model by the vendor's id syntax, not from a list of
released versions, so a new model needs no change. The syntax is now read
strictly: a version part with a leading zero (claude-sonnet-5-05, gpt-05.6,
glm-5.03), a major of 0, and a ".0" minor on a GPT or GLM id are not named.
The header comment and a test state the accepted consequence: a well-formed
id of a version nobody released is named.

DESIGN.md and the changelog said the pill always steps out beside a task
chip and Queue on a phone. It steps out when it does not fit: the fit check
now pins a short raw id with no level drawn whole at 390px, and the
shortest named label with a level stepped out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@devswha
devswha merged commit caae185 into main Oct 5, 2026
4 checks passed
@devswha
devswha deleted the feat/chat-model-pill branch October 5, 2026 12:04
devswha added a commit that referenced this pull request Oct 5, 2026
…ranscript (#468)

## 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(#467) merge.

확인하지 못한 것:

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

🤖 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
…orean only

Pretendard's chunks hold kana and 435 ideographs: kanji and Chinese text are drawn in the device's font. Also corrects when the model pill steps aside (#467).

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
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>
devswha added a commit that referenced this pull request Oct 8, 2026
…icks for this session only (#594)

## Change

`/effort` sent from a Claude Code chat was a dead end. It was not in the
chat's slash list, and typed by hand it opened Claude Code's effort
slider in the pane, where no reader knew the screen. herdr reports the
pane idle while the slider waits, so not even the fallback card
appeared, and the level had to be set in the terminal.

This adds a reader for that slider (`claude-effort` in
`server/prompt.ts`), so the existing prompt card shows it, the same way
#501 did for `/model`. Nothing changes on the client or on the wire.

- **What becomes a card.** The slider is recognised by its hint, `←/→ to
adjust · Enter to confirm · s for this session only · Esc to cancel`,
with a line of level names above it, the track with one `▲` above that,
and the `Effort` title. The options are the levels Claude draws (low,
medium, high, xhigh, max on Opus 5.5 and Fable 5.1). `▲` is read as over
the level whose name it is centred on, within 3 columns, else no card.
- **A pick is for this session only.** The answer moves the slider with
`←` / `→` and types `s`. It never sends Enter, which saves the level as
the default for new sessions (per model, in `modelSettings`). Typing
`/effort high` by hand still saves a default, as it does in the
terminal: that is Claude Code's own behaviour for a level typed after
the command.
- **Same guards as the model list.** The slider is read again before the
`s`, also when the pick needs no move, and the letter goes only while
`▲` stands over the chosen level. Each move after the first waits for
the slider to show the one before it. A slider the reader cannot take,
for example level names a narrow pane wrapped, gets no fallback card and
holds pending delivery back (`modelListWaits`), because both end in
Enter.
- **Moves.** `←` / `→` count as moves for this responder only. Elsewhere
they stay a menu's own keys (Claude's question tabs).
- **One card wherever `▲` is.** The card's id does not depend on where
the slider stands, so moving it in the terminal does not replace the
card.
- `/effort` joins Claude's built-in slash list, described in Claude
Code's own words.
- Ultracode (the slider's Tab toggle) is left to the terminal.

Related:

- #523 by @suho-han, closed today, read Claude's effort slider as part
of a composer control for model and effort. Its close comment said the
slider reading would feed into the maintainer's own change, with credit.
This PR is written fresh against Claude Code 2.1.294's screen (#523
could not capture a real `/effort` screen), but it is the same feature,
so the changelog credits it.
- #501 and #503: the `/model` cards this follows (letter `s`, never
Enter, look before the letter).
- #456 and #467: the model and effort shown in the composer's pill. The
pill picks the new level up with Claude's next answer, as it does for a
model.

Known limits:

- #542 applies here too. The guard reads this slider's hint with
`claudeModelListWaits`, so a hint split inside a word, or over more than
six lines, is missed in the same way.
- #469 applies here too. The slider closing between the last read and
the `s` still lets the letter into Claude's prompt.
- A narrow pane's slider was not captured live. A wrapped line of levels
is left to the terminal, and that path is covered by unit fixtures only.
- Models with four levels (Opus 4.6, Sonnet 4.6) were not captured. The
reader takes whatever level names are drawn, but `▲`'s spacing there is
unverified.

## Validation

- `bun run typecheck`: passed. `bun run test:unit`: 1780 passed, 0
failed (before the rebase on 6993563). After the rebase: typecheck
passed and `server/prompt.test.ts` 188 passed, 0 failed.
- `server/prompt.test.ts`: the fixture drawing is checked against a
capture of Claude Code 2.1.294's slider at 120 columns. Tests cover
reading, one id across `▲` positions, keys (`←` / `→` then `s`), no card
and no hold once the slider is answered or under later output, no card
without the session-only key, a wrapped level line (no card, still
holds), `▲` between two levels, and two route cases: a pick two levels
left (`left, left, s`), and `▲` moved in the terminal mid-pick (one
arrow, no letter, 409).
- Real Claude Code 2.1.294 in an isolated herdr 0.9.3 session
(`herdr-web-ui-test-effortprobe`), through this branch's `createServer`.
`GET /api/pane/commands` lists `effort`. `/effort` then `GET
/api/pane/prompt` gave the card with five levels. Answering xhigh and
then low over `POST /api/pane/prompt/answer` printed `Set effort level
to xhigh (this session only)` and `Set effort level to low (this session
only)`, and the banner read `with low effort`. `~/.claude/settings.json`
kept its modification time through every probe and pick.
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