Compare commits

7 Commits

Author SHA1 Message Date
claude-owner
0a09540e9f a11y(prediction): add aria-pressed + type=button to period presets
PredictionPanel's 3-segment 예측 기간 control (15일/30일/1년) was the last
segmented control without aria-pressed after slice (n)'s audit. It carried
the visual "pressed" state via className branch but no ARIA attribute, so
screen-reader users got no announcement of the current selection.

Also tighten type="button" to prevent default form submission semantics —
the buttons live outside <form> here, but explicit type is canonical for
non-submit buttons.

Visual styling kept as-is. The slightly heavier active token (bg-zinc-700
solid, px-3 py-1) vs the chart-toolbar canonical (border-zinc-600 bg-zinc-
800/80, px-2 py-0.5) is intentional: these presets live in the card body
next to a primary rose-600 execute button and need more visual weight than
toolbar toggles. Comment documents the reasoning.
2026-06-01 11:17:11 +09:00
claude-owner
c172f244e3 ui(chart): unify fullscreen toggle aria-pressed visual with toolbar siblings
The fullscreen button in the chart toolbar carried aria-pressed={fullscreen}
but had no active-state visual differentiation — only its label flipped
("⛶ 전체화면" ↔ "⛶ 해제"). Its sibling toggles in the same row (SMA presets,
RSI, MACD) all use the canonical zinc token (border-zinc-600 bg-zinc-800/80
text-zinc-100 when active). Sighted users got an inconsistent press-feedback
within a single toolbar.

Switch fullscreen to the same conditional className pattern. CSV button next
to it is intentionally not a toggle so stays unchanged.

Other aria-pressed carriers audited and left as-is:
- PeriodTabs: intentionally rose-500 (primary control must stand out from
  zinc toolbar buttons, comment already documents this)
- StarButton: intentionally amber (favorite/star semantic)
- InvestorCumulative / StockChart SMA presets / RSI / MACD: already on the
  canonical zinc token
2026-06-01 11:14:27 +09:00
claude-owner
0aebdc9bdb ui(collapsible): unify subtitle text size with CardHeader (10px → 11px)
본문/사이드바 헤더 보조 텍스트 토큰 통일.

Collapsible subtitle 이 text-[10px] 였는데, 같은 페이지 좌우(좌: 본문 카드의
CardHeader, 우: 사이드바의 Collapsible) 에서 헤더 우측 metadata 슬롯이 1px 다른
크기로 노출되고 있었음. 두 슬롯은 시각적으로 같은 역할(title 옆 보조 정보) 인데
토큰이 분기돼 있어 미세한 시각 점프 발생.

해결: Collapsible subtitle 을 text-[11px] 로 bump — CardHeader subtitle 과 동일
토큰. 사이드바는 280px 로 좁지만 기존 truncate 안전망(min-w-0 truncate) 이 그대로
overflow 를 흡수하므로 1px 키워도 wrap/clip 위험 없음.

유지: 헤더 chevron(▶) 은 text-[10px] 그대로 — 순수 장식 글리프라 subtitle 텍스트
크기와 별개 토큰. CardHeader 측은 chevron 자체가 없어 비교 대상이 아님.

다른 text-[10px] 사용처는 의도된 토큰으로 유지:
- StockChart 툴바 버튼/SMA preset/수평선 chips — 액션 컨트롤군 자체 토큰.
- AlertsPanel 발화 뱃지/조용히 버튼 — 보조 액션 토큰.
- InvestorCumulative window 버튼 그룹 — aria-pressed 컨트롤 토큰.
- ShortcutsHelp kbd — 키 글리프 토큰.
- CompositeScoreCard "/ 100" / SubScore hint — footnote 캡션 토큰.

검증: tsc/next lint 통과.
2026-06-01 11:10:00 +09:00
claude-owner
2b2d1ea661 ui(collapsible): fix header hover tone inversion under sticky parent
Collapsible 헤더 button 의 hover bg 를 zinc-900/60 → zinc-800/50 으로 교체.

문제: hover 톤이 부모의 bg 불투명도에 의존해서 방향이 뒤집힘.
- 비-sticky 부모 bg-zinc-900/40 + hover bg-zinc-900/60 → hover 영역이 더 불투명
  (어두워짐) = highlight. OK.
- OrderbookPanel sticky 부모 bg-zinc-900/80 + hover bg-zinc-900/60 → hover 영역이
  덜 불투명(더 옅어짐) = hover 가 "뒤로 가는" 인상. 톤 역전.

원인: 둘 다 같은 zinc-900 색이라 hover 가 불투명도 차이만으로 highlight 를
표시. 부모 opacity 가 hover opacity 보다 높으면 신호가 뒤집힘.

해결: hover 를 zinc-800 (zinc-900 보다 한 단계 밝은 색) 으로 옮김. 색 자체가
밝아 /50 opacity 라도 두 부모 케이스 모두에서 highlight 방향이 유지됨. 사이드바
list-row hover 톤(RelatedStocks bg-zinc-800/40 · PeerList bg-zinc-800/50)과도
통일 — 클릭 가능 요소의 hover 톤이 앱 전반에서 동일 색족.

검증: tsc/next lint 통과.
2026-06-01 11:05:30 +09:00
claude-owner
be1f8870b4 refactor(card): extend CardHeader with right slot, absorb InvestorCumulative
본문 카드 헤더 단일 source of truth 한 단계 확장.

CardHeader:
- 옵셔널 right?: ReactNode 슬롯 추가. 제공되면 subtitle 대신 렌더. subtitle 은
  텍스트 케이스(text-[11px] text-zinc-500 자동) 편의 prop 으로 유지.
- 컨테이너에 gap-2 추가. 기존 8개 caller 는 justify-between 으로 충분히 떨어져
  있어 시각 변화 없고, 좁은 폭에서 title↔우측 슬롯 충돌 방지 효과.
- 주석: 적용 카드 8 → 9 (InvestorCumulative 추가). 제외 카드 PredictionPanel
  유지 — title 이 제목+설명 2줄 stack 이고 큰 button 정렬을 위해 items-center
  필요해 wrapper API 확장 비용보다 자체 헤더 유지가 surgical.

InvestorCumulative:
- 자체 헤더 markup 을 <CardHeader title=... right={...} /> 로 교체. window
  선택 button 그룹(20/60/120일 · aria-pressed) 은 right slot 으로 전달, 기존
  inline className/동작 그대로 보존.
- 시각 결과 완전 동일 — 기존 헤더가 character-by-character 로 CardHeader 와
  같은 mb-3 flex items-baseline justify-between gap-2 였음을 확인.

검증: tsc/next lint 통과.
2026-06-01 11:02:20 +09:00
claude-owner
fabc21e262 ui(chart): harmonize StockChart container tone to detail-page body-card palette
상세 페이지 본문 카드 톤 통일 마무리. StockChart 컨테이너는 본문 영역의
top-level 카드 슬롯인데 마지막 남은 분기 톤이었음:

  before:  w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2
  after:   w-full rounded-md border border-zinc-800 bg-zinc-900/40 p-2

- rounded-lg → rounded-md : 3b06b95 에서 PredictionPanel 도 같은 통일을 했고,
  같은 좌측 컬럼에서 차트 바로 아래에 PredictionPanel 이 붙어있어 corner radius
  분기는 시각적 톤 점프.
- bg-zinc-900/30 → bg-zinc-900/40 : /30 은 ReturnBucket 같은 nested sub-card
  보조 톤. top-level 본문 카드는 일관되게 /40 이어야 함.
- p-2 유지 : 차트 캔버스가 자체 inner padding 을 들이므로 p-4 는 빈 가장자리
  낭비. 이건 의도된 분기라 주석으로 근거 명시.
- fullscreen 모드는 viewport 전체 차지라 별개 — 손대지 않음.

전체화면 외 모든 종목 페이지에서 StockChart → PredictionPanel 톤 일치 확인.
tsc/next lint 통과.
2026-06-01 10:57:50 +09:00
claude-owner
9e8c32e70c docs(card): fix CardHeader comment — exclude InvestorCumulative from adopters
리뷰어 비차단 노트 후속. 959e9af 의 CardHeader.tsx 주석은 본문 카드 9개 목록에
InvestorCumulative 를 포함시켰지만, 실제로는 우측이 window 선택 button 그룹
(aria-pressed) 인 커스텀 헤더라 wrapper 적용 대상이 아님.

- 적용 카드 9 → 8 (Metrics / IntradayReturns / PeriodReturns / CompositeScoreCard
  / PeerComparePanel / TradingValuePanel / DisclosuresPanel / NewsList).
- 제외 카드 섹션에 InvestorCumulative 추가, PredictionPanel 옆에 나란히 명시.
  근거 — 둘 다 우측이 단순 subtitle 텍스트가 아닌 인터랙티브 컨트롤이라
  CardHeader 의 'subtitle 우측' 패턴에 안 맞음.

주석 한정 변경 — 런타임/타입 영향 없음. tsc/next lint 통과.
2026-06-01 10:57:37 +09:00
5 changed files with 100 additions and 51 deletions

View File

@@ -1,30 +1,40 @@
// 상세 페이지 본문 카드의 표준 헤더 — title 좌, 옵셔널 subtitle 우. // 상세 페이지 본문 카드의 표준 헤더 — title 좌, 옵셔널 우측 슬롯 우.
// 작은 wrapper 지만 본문 카드 9개(Metrics / IntradayReturns / PeriodReturns / // 작은 wrapper 지만 본문 카드 9개(Metrics / IntradayReturns / PeriodReturns /
// CompositeScoreCard / PeerComparePanel / TradingValuePanel / InvestorCumulative / // CompositeScoreCard / PeerComparePanel / TradingValuePanel / DisclosuresPanel /
// DisclosuresPanel / NewsList) 가 같은 mb-3 flex items-baseline justify-between // NewsList / InvestorCumulative) 가 같은 mb-3 flex items-baseline justify-between
// 패턴을 character-by-character 로 중복 정의하고 있어, ReturnBucket 분리와 같은 // 패턴을 character-by-character 로 중복 정의하고 있어, ReturnBucket 분리와 같은
// 이유로 단일 source of truth 로 묶는다 — 한쪽만 마진/폰트 바꾸면 본문 카드들 // 이유로 단일 source of truth 로 묶는다 — 한쪽만 마진/폰트 바꾸면 본문 카드들
// 사이에 톤 점프가 생기는 future drift 리스크 제거. // 사이에 톤 점프가 생기는 future drift 리스크 제거.
// //
// PredictionPanel 은 우측이 '예측 실행' button 인 커스텀 레이아웃이라 이 wrapper // 우측 슬롯은 두 형태:
// 대상이 아님. 해당 카드는 자체 헤더 유지. // - subtitle: 단순 보조 텍스트 (text-[11px] text-zinc-500 자동 적용). 8개 카드 사용.
// - right : 임의 ReactNode (버튼 그룹 등 인터랙티브). InvestorCumulative 가 사용.
// 둘 중 하나만 지정. 동시 지정 시 right 우선 (호출자 의도 명확화는 호출 시점에서).
//
// 제외 카드 — PredictionPanel: title 자체가 제목 + 설명 2줄 stack 이고 우측 큰
// button 이라 flex 정렬이 items-center 필요. 별도 패턴이 한 곳뿐이라 wrapper 의
// API 를 확장하기보다는 자체 헤더 유지하는 게 surgical.
import type { ReactNode } from "react"; import type { ReactNode } from "react";
type Props = { type Props = {
title: ReactNode; title: ReactNode;
// 우측 보조 정보. 없으면 헤더에 title 만 남음 (이 경우에도 mb-3 flex 컨테이너 // 우측 보조 텍스트. 없으면 헤더에 title 만 남음 (이 경우에도 mb-3 flex 컨테이너
// 는 유지 — MetricsPanel 같은 단일-제목 카드도 같은 라인 높이로 정렬). // 는 유지 — MetricsPanel 같은 단일-제목 카드도 같은 라인 높이로 정렬).
subtitle?: ReactNode; subtitle?: ReactNode;
// 우측 임의 노드 (버튼 그룹 등). 제공되면 subtitle 대신 렌더.
right?: ReactNode;
}; };
export function CardHeader({ title, subtitle }: Props) { export function CardHeader({ title, subtitle, right }: Props) {
return ( return (
<div className="mb-3 flex items-baseline justify-between"> <div className="mb-3 flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-zinc-200">{title}</div> <div className="text-sm font-medium text-zinc-200">{title}</div>
{subtitle != null && ( {right != null ? (
right
) : subtitle != null ? (
<div className="text-[11px] text-zinc-500">{subtitle}</div> <div className="text-[11px] text-zinc-500">{subtitle}</div>
)} ) : null}
</div> </div>
); );
} }

View File

@@ -54,7 +54,14 @@ export function Collapsible({
type="button" type="button"
onClick={toggle} onClick={toggle}
aria-expanded={open} aria-expanded={open}
className="flex w-full items-baseline justify-between gap-2 px-4 py-3 text-left transition hover:bg-zinc-900/60" // hover 톤은 zinc-800/50 — zinc-900 보다 한 단계 밝은 색으로 잡아 부모의
// bg 불투명도와 무관하게 항상 highlight 방향으로 시각 신호. zinc-900/60
// 였을 땐 비-sticky 부모(/40) 에선 더 어두워져 highlight 가 됐지만,
// OrderbookPanel sticky 부모(/80) 에선 오히려 더 옅어져 hover 가 "뒤로
// 가는" 인상이 되는 톤 역전 발생. zinc-800 으로 한 단계 색을 올리면
// /50 opacity 라도 색 자체가 밝아 두 부모 모두에서 highlight 로 읽힘.
// 사이드바 list-row hover 톤(RelatedStocks/PeerList)과도 통일.
className="flex w-full items-baseline justify-between gap-2 px-4 py-3 text-left transition hover:bg-zinc-800/50"
> >
<span className="flex min-w-0 items-center gap-2 text-sm font-medium text-zinc-200"> <span className="flex min-w-0 items-center gap-2 text-sm font-medium text-zinc-200">
<span <span
@@ -72,7 +79,12 @@ export function Collapsible({
// 손절 987,654") 사이드바 폭을 넘기는 대신 말줄임표로 끝나도록. shrink-0 였을 땐 // 손절 987,654") 사이드바 폭을 넘기는 대신 말줄임표로 끝나도록. shrink-0 였을 땐
// title 을 밀어내며 헤더가 두 줄로 깨질 수 있었음. title 쪽도 min-w-0 을 줘 flex // title 을 밀어내며 헤더가 두 줄로 깨질 수 있었음. title 쪽도 min-w-0 을 줘 flex
// shrink 계산이 올바르게 되도록. // shrink 계산이 올바르게 되도록.
<span className="min-w-0 truncate text-[10px] text-zinc-500"> //
// 크기 토큰: text-[11px] — CardHeader 의 subtitle 과 동일 토큰. 사이드바 패널
// 헤더(Collapsible) 와 본문 카드 헤더(CardHeader) 가 같은 페이지 좌우에 나란히
// 놓이므로 헤더 우측 보조 텍스트 크기를 통일. 280px 좁은 사이드바에서도 truncate
// 가 overflow 를 흡수해 1px 키워도 wrap 위험 없음.
<span className="min-w-0 truncate text-[11px] text-zinc-500">
{subtitle} {subtitle}
</span> </span>
)} )}

View File

@@ -14,6 +14,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { TradingValuePoint } from "../lib/api"; import type { TradingValuePoint } from "../lib/api";
import { CardHeader } from "./CardHeader";
type Window = 20 | 60 | 120; type Window = 20 | 60 | 120;
@@ -66,8 +67,9 @@ export function InvestorCumulative({ data }: { data: TradingValuePoint[] }) {
return ( return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4"> <div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-baseline justify-between gap-2"> <CardHeader
<div className="text-sm font-medium text-zinc-200"> </div> title="투자자별 누적 순매수"
right={
<div className="flex items-center gap-1.5 text-[10px]"> <div className="flex items-center gap-1.5 text-[10px]">
{WINDOW_OPTIONS.map((w) => { {WINDOW_OPTIONS.map((w) => {
const on = win === w.v; const on = win === w.v;
@@ -88,7 +90,8 @@ export function InvestorCumulative({ data }: { data: TradingValuePoint[] }) {
); );
})} })}
</div> </div>
</div> }
/>
<div className="space-y-2.5"> <div className="space-y-2.5">
{SUBJECTS.map((s) => ( {SUBJECTS.map((s) => (
<SubjectRow key={s.key} label={s.label} color={s.color} values={series.out[s.key]} /> <SubjectRow key={s.key} label={s.label} color={s.color} values={series.out[s.key]} />

View File

@@ -94,19 +94,30 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs"> <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
<span className="text-zinc-500"> :</span> <span className="text-zinc-500"> :</span>
{PREDICT_PRESETS.map((p, i) => ( {PREDICT_PRESETS.map((p, i) => {
const on = presetIdx === i;
return (
// segmented control — PeriodTabs / InvestorCumulative window / SMA·RSI·MACD 토글과
// 같은 패턴. type="button" 으로 폼 submit 기본값 사고 차단, aria-pressed 로 스크린
// 리더에 현재 선택 노출. 시각 토큰은 PredictionPanel 자체 톤(bg-zinc-700 solid)
// 유지 — 본문 카드 영역 안 primary 보조 컨트롤이라 툴바용 zinc-800/80 보다 한
// 단계 도드라지는 게 의도. 패딩(px-3 py-1) 도 옆 rose-600 실행 버튼과 시각
// 무게 균형을 위해 툴바 토글(px-2 py-0.5) 보다 큼.
<button <button
key={p.id} key={p.id}
type="button"
onClick={() => setPresetIdx(i)} onClick={() => setPresetIdx(i)}
aria-pressed={on}
className={ className={
presetIdx === i on
? "rounded-full bg-zinc-700 px-3 py-1 font-medium text-zinc-50" ? "rounded-full bg-zinc-700 px-3 py-1 font-medium text-zinc-50"
: "rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500" : "rounded-full border border-zinc-700 px-3 py-1 text-zinc-300 hover:border-zinc-500"
} }
> >
{p.label} {p.label}
</button> </button>
))} );
})}
{presetIdx === 2 && ( {presetIdx === 2 && (
<span className="ml-1 text-[11px] text-amber-400"> <span className="ml-1 text-[11px] text-amber-400">
30 30

View File

@@ -918,10 +918,14 @@ export function StockChart({ chart, prediction, targets }: Props) {
}; };
// 전체화면(native or 폴백) 일 때는 viewport 를 꽉 채워서 차트 면적 최대화. // 전체화면(native or 폴백) 일 때는 viewport 를 꽉 채워서 차트 면적 최대화.
// 일반 모드는 기존 카드 스타일 유지. // 일반 모드는 상세 페이지 본문 카드 표준 톤 (rounded-md / border-zinc-800 /
// bg-zinc-900/40) 채택 — 같은 좌측 컬럼 바로 아래의 PredictionPanel 과 톤 일치.
// padding 만 p-2 유지: 차트 캔버스가 자체 inner padding 을 들이므로 p-4 는 빈
// 가장자리 낭비. (참고: bg-zinc-900/30 은 ReturnBucket 같은 nested sub-card 용
// 보조 톤 — top-level 본문 카드는 /40 로 통일.)
const wrapperCls = fullscreen const wrapperCls = fullscreen
? "fixed inset-0 z-50 flex w-full flex-col overflow-auto bg-zinc-950 p-3" ? "fixed inset-0 z-50 flex w-full flex-col overflow-auto bg-zinc-950 p-3"
: "w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2"; : "w-full rounded-md border border-zinc-800 bg-zinc-900/40 p-2";
// 차트 본체 높이 — 전체화면이면 가용 공간을 차지하도록 flex-1. // 차트 본체 높이 — 전체화면이면 가용 공간을 차지하도록 flex-1.
const chartBoxCls = fullscreen const chartBoxCls = fullscreen
? "relative min-h-[300px] flex-1 w-full" ? "relative min-h-[300px] flex-1 w-full"
@@ -1046,7 +1050,16 @@ export function StockChart({ chart, prediction, targets }: Props) {
<button <button
type="button" type="button"
onClick={() => void toggleFullscreen()} onClick={() => void toggleFullscreen()}
className="flex items-center gap-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200" // aria-pressed 시각 피드백 — 같은 툴바의 SMA/RSI/MACD 토글과 동일한 canonical
// 토큰(border-zinc-600 bg-zinc-800/80 text-zinc-100) 으로 active 톤 맞춤.
// 이전엔 라벨 텍스트("⛶ 전체화면" → "⛶ 해제") 만 바뀌고 border/bg 변화가 없어,
// 시각적으로 옆 토글들과 "눌렸음" 신호의 강도가 달랐음. fullscreen 모드에서도
// 툴바는 그대로 노출되므로 (overlay 안에 같은 차트 + 툴바) 누른 상태 표시가 의미 있음.
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
fullscreen
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
: "border-zinc-800 bg-transparent text-zinc-400 hover:border-zinc-600 hover:text-zinc-200"
}`}
title={fullscreen ? "전체화면 해제 (ESC / F)" : "전체화면 (F)"} title={fullscreen ? "전체화면 해제 (ESC / F)" : "전체화면 (F)"}
aria-pressed={fullscreen} aria-pressed={fullscreen}
> >