Compare commits

11 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
claude-owner
959e9afa80 refactor(card): extract shared CardHeader for detail-page body cards
상세 페이지 본문 카드 8개가 character-by-character 로 동일한 헤더
markup 을 중복 정의하고 있었음 — ReturnBucket 분리와 같은 future
drift 리스크. 단일 source of truth 로 묶음.

신규 web/components/CardHeader.tsx:
- 표준 패턴 캡슐화: mb-3 flex items-baseline justify-between 컨테이너
  + text-sm font-medium text-zinc-200 title + 옵셔널 text-[11px]
  text-zinc-500 subtitle.
- subtitle 은 옵셔널 — MetricsPanel 처럼 제목만 있는 카드도 같은
  컨테이너로 정렬돼 본문 카드 라인 높이 통일.

적용한 카드 8개:
- IntradayReturns / PeriodReturns / CompositeScoreCard /
  PeerComparePanel / TradingValuePanel / DisclosuresPanel / NewsList
  — 기존 markup 과 시각 결과 완전 동일.
- MetricsPanel — 기존 mb-2 standalone div → CardHeader 로 흡수,
  마진이 mb-2 → mb-3 으로 정렬됨. 다른 본문 카드들과 헤더 아래 공간
  통일.

적용 제외:
- PredictionPanel: 우측 슬롯이 button (예측 실행), 좌측이 title+
  subtitle 두 줄 중첩. 단순 text subtitle 모델에 안 맞음.
- InvestorCumulative: 우측 슬롯이 window 선택 button 그룹 (1M/3M/...)
  + aria-pressed. 위와 같은 이유.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:52:32 +09:00
claude-owner
232b94755a refactor(returns): extract shared ReturnBucket from Intraday/PeriodReturns
IntradayReturns(1D 10분봉 모드)와 PeriodReturns(일봉 이상)는 페이지의
같은 슬롯에서 모드 전환으로 갈아끼우는 한 쌍이다. 두 컴포넌트의 Bucket
서브컴포넌트는 className 문자열까지 완전히 동일했지만 각 파일에 별도
정의돼 있었다 — 한쪽 톤만 바꾸면 모드 전환 시 시각적 점프가 생기는
드리프트 리스크.

신규 web/components/ReturnBucket.tsx 로 단일 source of truth 분리:
- 색 규칙(rose/sky/zinc), 카드 톤(rounded-md border-zinc-800
  bg-zinc-900/30 px-2 py-2 text-center), 라벨/값 타이포 일괄 캡슐화.
- 두 패널은 각자 horizon 라벨/계산 로직만 유지하고 렌더는 ReturnBucket
  으로 위임.

기존 외부 동작/시각 결과 동일 — 동일한 className 을 한 곳으로 묶은
순수 리팩토링.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:47:44 +09:00
claude-owner
3b06b95586 ui(prediction): rounded-lg → rounded-md to match detail-page palette
종목 상세 페이지(/[code]) 본문에서 유일하게 남아 있던 rounded-lg
divergence. 다른 본문 카드(MetricsPanel / IntradayReturns /
PeriodReturns / CompositeScoreCard / PeerComparePanel /
TradingValuePanel / InvestorCumulative / DisclosuresPanel /
NewsList / SymbolSidebar) 가 모두 rounded-md border-zinc-800
bg-zinc-900/40 톤이라 PredictionPanel 만 모서리가 살짝 더 둥글어
시각적으로 튀었음.

홈 페이지 쪽(IndicesPanel / SeedTiles / RecentTiles) 의 rounded-lg
+ bg-zinc-900/30 묶음은 별도 슬라이스 — 홈 페이지는 카드가 더 크고
타일 그리드라 다른 톤 묶음을 의도적으로 유지할 수 있음. 이번 턴은
상세 페이지 본문 톤 완전 통일만 노림.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:44:29 +09:00
claude-owner
bd6e1dac6d fix(collapsible): truncate long subtitle instead of pushing title
리뷰어 b46f6f1 non-blocking 노트 반영. 기존 subtitle 은 shrink-0 이라
값이 길어지면 헤더 폭을 넘기며 title 을 두 줄로 밀어낼 수 있었음.
실제 트리거 예: PriceTargets 의 "목표 1,234,567 · 손절 987,654"
같은 큰 주가 범위.

변경:
- subtitle: shrink-0 제거 + min-w-0 truncate → 폭 부족 시 말줄임표.
- title flex span: min-w-0 추가 → flex shrink 계산이 양쪽 모두에서
  정상 동작하도록 (min-w-0 없으면 자식 텍스트의 min-content 가 부모
  shrink 를 막아 truncate 가 발동 안 됨).

title 텍스트 자체는 늘 짧음(호가/내 목표가/관련 종목/투자 메모/가격
알람) — title 쪽 truncate 는 불필요, min-w-0 만으로 충분.

검증:
- npx tsc --noEmit clean
- npx next lint "✔ No ESLint warnings or errors"
2026-06-01 10:44:04 +09:00
14 changed files with 187 additions and 119 deletions

View File

@@ -0,0 +1,40 @@
// 상세 페이지 본문 카드의 표준 헤더 — title 좌, 옵셔널 우측 슬롯 우.
// 작은 wrapper 지만 본문 카드 9개(Metrics / IntradayReturns / PeriodReturns /
// CompositeScoreCard / PeerComparePanel / TradingValuePanel / DisclosuresPanel /
// NewsList / InvestorCumulative) 가 같은 mb-3 flex items-baseline justify-between
// 패턴을 character-by-character 로 중복 정의하고 있어, ReturnBucket 분리와 같은
// 이유로 단일 source of truth 로 묶는다 — 한쪽만 마진/폰트 바꾸면 본문 카드들
// 사이에 톤 점프가 생기는 future drift 리스크 제거.
//
// 우측 슬롯은 두 형태:
// - 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";
type Props = {
title: ReactNode;
// 우측 보조 텍스트. 없으면 헤더에 title 만 남음 (이 경우에도 mb-3 flex 컨테이너
// 는 유지 — MetricsPanel 같은 단일-제목 카드도 같은 라인 높이로 정렬).
subtitle?: ReactNode;
// 우측 임의 노드 (버튼 그룹 등). 제공되면 subtitle 대신 렌더.
right?: ReactNode;
};
export function CardHeader({ title, subtitle, right }: Props) {
return (
<div className="mb-3 flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-zinc-200">{title}</div>
{right != null ? (
right
) : subtitle != null ? (
<div className="text-[11px] text-zinc-500">{subtitle}</div>
) : null}
</div>
);
}

View File

@@ -54,9 +54,16 @@ export function Collapsible({
type="button"
onClick={toggle}
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 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
className={`inline-block text-[10px] text-zinc-500 transition-transform ${
open ? "rotate-90" : ""
@@ -68,7 +75,18 @@ export function Collapsible({
{title}
</span>
{subtitle != null && (
<span className="shrink-0 text-[10px] text-zinc-500">{subtitle}</span>
// min-w-0 + truncate: subtitle 이 길어지면(예: PriceTargets 의 "목표 1,234,567 ·
// 손절 987,654") 사이드바 폭을 넘기는 대신 말줄임표로 끝나도록. shrink-0 였을 땐
// title 을 밀어내며 헤더가 두 줄로 깨질 수 있었음. title 쪽도 min-w-0 을 줘 flex
// shrink 계산이 올바르게 되도록.
//
// 크기 토큰: text-[11px] — CardHeader 의 subtitle 과 동일 토큰. 사이드바 패널
// 헤더(Collapsible) 와 본문 카드 헤더(CardHeader) 가 같은 페이지 좌우에 나란히
// 놓이므로 헤더 우측 보조 텍스트 크기를 통일. 280px 좁은 사이드바에서도 truncate
// 가 overflow 를 흡수해 1px 키워도 wrap 위험 없음.
<span className="min-w-0 truncate text-[11px] text-zinc-500">
{subtitle}
</span>
)}
</button>
<div className={open ? "border-t border-zinc-800 px-4 pb-4 pt-3" : "hidden"}>

View File

@@ -12,6 +12,7 @@
// 종합 = 평균. 50 미만 = 약세(파랑), 50 초과 = 강세(빨강).
import type { ChartPayload } from "../lib/api";
import { CardHeader } from "./CardHeader";
type Scores = {
momentum: number | null;
@@ -111,12 +112,7 @@ export function CompositeScoreCard({ chart }: { chart: ChartPayload }) {
const s = computeScores(chart);
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-baseline justify-between">
<div className="text-sm font-medium text-zinc-200"> (AI)</div>
<div className="text-[11px] text-zinc-500">
···
</div>
</div>
<CardHeader title="종합 점수 (AI)" subtitle="모멘텀·수급·감성·추세 평균" />
<div className="grid grid-cols-[auto_1fr] items-center gap-5">
<ScoreDial value={s.composite} />
<div className="grid grid-cols-2 gap-2">

View File

@@ -5,6 +5,7 @@
import { useEffect, useState } from "react";
import { api, type NewsResponse } from "../lib/api";
import { CardHeader } from "./CardHeader";
export function DisclosuresPanel({ code }: { code: string }) {
const [data, setData] = useState<NewsResponse | null>(null);
@@ -27,12 +28,10 @@ export function DisclosuresPanel({ code }: { code: string }) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-baseline justify-between">
<div className="text-sm font-medium text-zinc-200"> (DART)</div>
<div className="text-[11px] text-zinc-500">
{data ? `최근 ${data.items.length}` : ""}
</div>
</div>
<CardHeader
title="공시 (DART)"
subtitle={data ? `최근 ${data.items.length}` : ""}
/>
{err && <div className="text-xs text-red-400"> : {err}</div>}
{!err && !data && <div className="text-xs text-zinc-500"> </div>}

View File

@@ -13,6 +13,8 @@
// 추가 백엔드 호출 없음 — chart.ohlcv 슬라이싱만.
import type { OhlcvPoint } from "../lib/api";
import { CardHeader } from "./CardHeader";
import { ReturnBucket } from "./ReturnBucket";
const BUCKETS: { label: string; bars: number }[] = [
{ label: "10분", bars: 1 },
@@ -49,33 +51,12 @@ export function IntradayReturns({ ohlcv }: { ohlcv: OhlcvPoint[] }) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-baseline justify-between">
<div className="text-sm font-medium text-zinc-200"> </div>
<div className="text-[11px] text-zinc-500">10 · 60 </div>
</div>
<CardHeader title="단기 수익률" subtitle="10분봉 기준 · 60초 갱신" />
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{buckets.map((b) => (
<Bucket key={b.label} label={b.label} pct={b.pct} />
<ReturnBucket key={b.label} label={b.label} pct={b.pct} />
))}
<Bucket label="시가대비" pct={todayPct} />
</div>
</div>
);
}
function Bucket({ label, pct }: { label: string; pct: number | null }) {
// 한국식 컬러 — 상승=rose, 하락=sky, 0/null=zinc.
const tone =
pct == null || pct === 0
? "text-zinc-400"
: pct > 0
? "text-rose-400"
: "text-sky-400";
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2 text-center">
<div className="text-[11px] text-zinc-500">{label}</div>
<div className={`mt-1 text-sm font-medium tabular-nums ${tone}`}>
{pct == null ? "—" : `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`}
<ReturnBucket label="시가대비" pct={todayPct} />
</div>
</div>
);

View File

@@ -14,6 +14,7 @@
import { useMemo, useState } from "react";
import type { TradingValuePoint } from "../lib/api";
import { CardHeader } from "./CardHeader";
type Window = 20 | 60 | 120;
@@ -66,29 +67,31 @@ export function InvestorCumulative({ data }: { data: TradingValuePoint[] }) {
return (
<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">
<div className="text-sm font-medium text-zinc-200"> </div>
<div className="flex items-center gap-1.5 text-[10px]">
{WINDOW_OPTIONS.map((w) => {
const on = win === w.v;
return (
<button
key={w.v}
type="button"
onClick={() => setWin(w.v)}
className={`rounded-full border px-2 py-0.5 transition ${
on
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
aria-pressed={on}
>
{w.label}
</button>
);
})}
</div>
</div>
<CardHeader
title="투자자별 누적 순매수"
right={
<div className="flex items-center gap-1.5 text-[10px]">
{WINDOW_OPTIONS.map((w) => {
const on = win === w.v;
return (
<button
key={w.v}
type="button"
onClick={() => setWin(w.v)}
className={`rounded-full border px-2 py-0.5 transition ${
on
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
aria-pressed={on}
>
{w.label}
</button>
);
})}
</div>
}
/>
<div className="space-y-2.5">
{SUBJECTS.map((s) => (
<SubjectRow key={s.key} label={s.label} color={s.color} values={series.out[s.key]} />

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { api, type MetricsResponse } from "../lib/api";
import { CardHeader } from "./CardHeader";
export function MetricsPanel({ code }: { code: string }) {
const [m, setM] = useState<MetricsResponse | null>(null);
@@ -36,7 +37,7 @@ export function MetricsPanel({ code }: { code: string }) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-2 text-sm font-medium text-zinc-200"> 30 </div>
<CardHeader title="최근 30일 모델 성능" />
<table className="w-full text-left text-sm">
<thead className="text-xs text-zinc-500">
<tr>

View File

@@ -7,6 +7,7 @@
import { useEffect, useState } from "react";
import { api, type NewsItem, type NewsResponse } from "../lib/api";
import { CardHeader } from "./CardHeader";
export function NewsList({ code }: { code: string }) {
const [data, setData] = useState<NewsResponse | null>(null);
@@ -34,10 +35,7 @@ export function NewsList({ code }: { code: string }) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-baseline justify-between">
<div className="text-sm font-medium text-zinc-200"> · </div>
<div className="text-[11px] text-zinc-500"> {data.items.length}</div>
</div>
<CardHeader title="뉴스 · 공시" subtitle={`최근 ${data.items.length}`} />
<ul className="space-y-2">
{data.items.map((n, i) => (
<NewsCard key={i} n={n} />

View File

@@ -7,6 +7,7 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { api, type PeerCompareItem, type PeerCompareResponse } from "../lib/api";
import { CardHeader } from "./CardHeader";
export function PeerComparePanel({ code }: { code: string }) {
const [data, setData] = useState<PeerCompareResponse | null>(null);
@@ -50,12 +51,10 @@ export function PeerComparePanel({ code }: { code: string }) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-baseline justify-between">
<div className="text-sm font-medium text-zinc-200"> </div>
<div className="text-[11px] text-zinc-500">
{data.window} · peer {data.peer_count ?? 0}
</div>
</div>
<CardHeader
title="동종업종 비교"
subtitle={`최근 ${data.window}거래일 · peer ${data.peer_count ?? 0}종목`}
/>
<div className="mb-3 flex flex-wrap gap-1.5">
{data.themes.map((t) => (

View File

@@ -5,6 +5,8 @@
// 거래일 기준 근사: 1주≈5, 1개월≈21, 3개월≈63, 6개월≈126, 1년≈252.
import type { OhlcvPoint } from "../lib/api";
import { CardHeader } from "./CardHeader";
import { ReturnBucket } from "./ReturnBucket";
const BUCKETS: { label: string; days: number }[] = [
{ label: "1주", days: 5 },
@@ -39,34 +41,12 @@ export function PeriodReturns({ ohlcv }: { ohlcv: OhlcvPoint[] }) {
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-baseline justify-between">
<div className="text-sm font-medium text-zinc-200"> </div>
<div className="text-[11px] text-zinc-500"> </div>
</div>
<CardHeader title="기간 수익률" subtitle="차트 구간 기준" />
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{buckets.map((b) => (
<Bucket key={b.label} label={b.label} pct={b.pct} />
<ReturnBucket key={b.label} label={b.label} pct={b.pct} />
))}
<Bucket label="전체" pct={all} />
</div>
</div>
);
}
function Bucket({ label, pct }: { label: string; pct: number | null }) {
const tone =
pct == null || pct === 0
? "text-zinc-400"
: pct > 0
? "text-rose-400"
: "text-sky-400";
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2 text-center">
<div className="text-[11px] text-zinc-500">{label}</div>
<div className={`mt-1 text-sm font-medium tabular-nums ${tone}`}>
{pct == null
? "—"
: `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`}
<ReturnBucket label="전체" pct={all} />
</div>
</div>
);

View File

@@ -75,7 +75,7 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
const steps = pred?.steps ?? [];
return (
<div className="rounded-lg 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-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-zinc-200"> (Chronos + LightGBM )</div>
@@ -94,19 +94,30 @@ export function PredictionPanel({ code, initial, onResult }: Props) {
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
<span className="text-zinc-500"> :</span>
{PREDICT_PRESETS.map((p, i) => (
<button
key={p.id}
onClick={() => setPresetIdx(i)}
className={
presetIdx === i
? "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"
}
>
{p.label}
</button>
))}
{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
key={p.id}
type="button"
onClick={() => setPresetIdx(i)}
aria-pressed={on}
className={
on
? "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"
}
>
{p.label}
</button>
);
})}
{presetIdx === 2 && (
<span className="ml-1 text-[11px] text-amber-400">
30

View File

@@ -0,0 +1,28 @@
// 수익률 미니 셀 — IntradayReturns(1D 10분봉 모드) 와 PeriodReturns(일봉 이상) 가 같은
// 슬롯에서 모드 전환으로 갈아끼우므로 두 패널의 Bucket 톤/색 규칙은 반드시 동일해야 한다.
// 예전엔 각 파일에 동일한 Bucket 이 중복 정의돼 있어 한쪽만 톤을 바꾸면 모드 전환 시
// 시각적 점프가 생길 위험이 있었다. 한 곳으로 묶어 단일 source of truth 로 둔다.
//
// 한국식 컬러 규칙: 상승 = rose, 하락 = sky, 0/null = zinc (Toss 톤).
type Props = {
label: string;
pct: number | null;
};
export function ReturnBucket({ label, pct }: Props) {
const tone =
pct == null || pct === 0
? "text-zinc-400"
: pct > 0
? "text-rose-400"
: "text-sky-400";
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2 text-center">
<div className="text-[11px] text-zinc-500">{label}</div>
<div className={`mt-1 text-sm font-medium tabular-nums ${tone}`}>
{pct == null ? "—" : `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`}
</div>
</div>
);
}

View File

@@ -918,10 +918,14 @@ export function StockChart({ chart, prediction, targets }: Props) {
};
// 전체화면(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
? "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.
const chartBoxCls = fullscreen
? "relative min-h-[300px] flex-1 w-full"
@@ -1046,7 +1050,16 @@ export function StockChart({ chart, prediction, targets }: Props) {
<button
type="button"
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)"}
aria-pressed={fullscreen}
>

View File

@@ -6,6 +6,7 @@
// 색: KR 관행 — 매수 우위(+) 빨강, 매도 우위(-) 파랑.
import type { TradingValuePoint } from "../lib/api";
import { CardHeader } from "./CardHeader";
export function TradingValuePanel({
data,
@@ -24,10 +25,10 @@ export function TradingValuePanel({
return (
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
<div className="mb-3 flex items-baseline justify-between">
<div className="text-sm font-medium text-zinc-200"> </div>
<div className="text-[11px] text-zinc-500"> {recent.length} · 단위: 억원</div>
</div>
<CardHeader
title="거래주체 순매수"
subtitle={`최근 ${recent.length}일 · 단위: 억원`}
/>
<div className="overflow-hidden rounded-md border border-zinc-800">
<table className="w-full text-xs tabular-nums">
<thead className="bg-zinc-900/60 text-[11px] text-zinc-500">