diff --git a/docs/conventions/component-convention.md b/docs/conventions/component-convention.md
index b9eedfe..6d048bf 100644
--- a/docs/conventions/component-convention.md
+++ b/docs/conventions/component-convention.md
@@ -40,6 +40,7 @@ interface RentalItemCardProps {
- 예: Figma `Chip/Chip` → `import { Chip } from '@wanteddev/wds'`
- 아이콘은 `@wanteddev/wds-icon`에서 가져온다.
- **WDS 컴포넌트 내부를 임의로 오버라이드하지 않는다.** 간격·배치 같은 레이아웃 조정은 감싸는 wrapper에서 한다.
+- **화면 헤더는 화면에서 `TopNavigation`을 직접 새로 조립하지 않고 `src/components/ui/ScreenHeader.tsx`를 거친다.** Figma의 Top Navigation 패턴(타이틀 정렬, leading/trailing 조합, 게시판류의 토글형 타이틀 등)을 `ScreenHeader`의 props(`variant`/`title`/`leading`/`trailing`)로 고르게 돼 있다 — 화면마다 손으로 다시 조립하면 컨벤션이 흩어진다. 새 헤더 패턴이 필요하면 `ScreenHeader`부터 확장한다(`docs/plans/unified-screen-header.md` 참고).
## 4. Stream 고유 UI (신규 컴포넌트)
@@ -50,6 +51,26 @@ interface RentalItemCardProps {
- 색은 `text-[#171719]`처럼 hex를 직접 박지 않는다. `src/index.css`의 `@theme` 블록에 있는 시맨틱 토큰(`text-label-normal`, `bg-background-alternative`, `border-line-solid-neutral`, `text-primary`, `bg-primary-subtle` 등)을 쓴다. 이 토큰들은 `@wanteddev/wds/global.css`가 심어둔 `--semantic-*`/`--atomic-*` CSS 변수를 그대로 별칭 연결한 것이라 다크 테마 전환도 자동으로 따라간다.
- 필요한 색이 아직 토큰으로 없으면, hex를 추측해서 쓰지 말고 `get_variable_defs(fileKey, nodeId)`로 해당 노드의 실제 Figma 변수명·값을 확인한 뒤 `index.css`의 `@theme`에 새 토큰을 추가한다. `Line/Normal/Neutral`(반투명 `#70737c29`)과 `Line/Solid/Neutral`(불투명 `#eaebec`)처럼 이름이 비슷해도 값이 다른 토큰이 있으니 이름만 보고 넘겨짚지 않는다.
- 컴포넌트 인스턴스가 없는 화면 배경/외곽선처럼 Figma 값이 실제로는 안 보이는 경우(예: Bottom Nav 상단 border가 바로 위 배경과 같은 색이라 안 보였던 사례)도 있다 — 이럴 땐 왜 다른 토큰으로 바꿨는지 주석으로 남긴다.
+### 타이포그래피
+
+- 글자 크기·굵기를 `text-xs`/`text-[17px]`/`font-semibold`처럼 Tailwind로 직접 짓지 않는다. `@wanteddev/wds`의 `Typography` 컴포넌트를 쓴다 — Figma의 이름 있는 타입 스타일(Headline 2/Bold 등)과 `variant`+`weight` 조합이 1:1로 대응한다(`node_modules/@wanteddev/wds/dist/components/typography/style.js`에서 실측 확인 가능).
+- Tailwind에는 11px(Caption 2) 같은 값이 기본 스케일에 없어서, 대충 가까운 `text-xs`(12px)를 썼다가 실제로 다른 크기가 되는 사고가 실제로 있었다(Bottom Nav 탭 라벨). `Typography`를 쓰면 이 스케일 자체가 WDS 값이라 이런 어긋남이 구조적으로 없어진다.
+- 색은 `className`의 Tailwind 색상 토큰이 아니라 `Typography`의 `color` prop(예: `color="semantic.label.normal"`)으로 준다. `Typography`가 `color` 미지정 시 `color: inherit`을 자체 CSS-in-JS로 주입하는데, 이 스타일이 Tailwind 유틸리티 레이어보다 우선순위가 높아서(`@wanteddev/wds/global.css`를 `layer(base)`로 감싸야 했던 것과 같은 이유) `className="text-label-normal"`을 같이 줘도 씹힐 수 있다.
+- `variant`/`weight` → Figma 이름 대응표(자주 쓰는 것만):
+
+ | `variant` | `weight` | 실제 크기 | Figma 이름 |
+ |---|---|---|---|
+ | `headline1` | `bold` | 18px / SemiBold | Headline 1/Bold |
+ | `headline2` | `bold` | 17px / SemiBold | Headline 2/Bold |
+ | `headline2` | `medium` | 17px / Medium | Headline 2/Medium |
+ | `label1` | `bold` | 14px / SemiBold | Label 1/Normal - Bold |
+ | `caption1` | `bold` | 12px / SemiBold | Caption 1/Bold |
+ | `caption1` | `medium` | 12px / Medium | Caption 1/Medium |
+ | `caption1` | `regular` | 12px / Regular | Caption 1/Regular |
+ | `caption2` | `medium` | 11px / Medium | Caption 2/Medium |
+
+- **예외**: 서드파티 라이브러리가 `className` 문자열만 받아서 자기 DOM에 그대로 꽂는 자리(예: `@ncdai/react-wheel-picker`의 `classNames` prop)는 `Typography`로 감쌀 수 없다 — 이럴 땐 Tailwind `text-[17px]` 같은 값을 그대로 쓰되, 어느 Figma 타입 스타일을 옮긴 값인지 주석을 남긴다(`BililgeRentalSheet.tsx`의 `WHEEL_CLASS_NAMES` 참고).
+
- Stream 자체 이미지·아이콘(일러스트, 물품 아이콘 등)은 `download_assets`로 받아 `src/assets/`에 커밋한다. Figma asset URL은 **7일 후 만료**되므로 절대 코드에 그대로 참조하지 않는다.
- `data-node-id` 같은 Figma 추적용 속성은 컴포넌트 마크업에 남기지 않는다. 대신 파일 최상단에 원본 Figma 노드를 알 수 있는 주석 한 줄만 남긴다 — 나중에 디자인이 바뀌었을 때 다시 대조할 수 있도록:
@@ -60,6 +81,7 @@ interface RentalItemCardProps {
## 5. 완료 기준 체크리스트
- [ ] WDS로 확인된 요소는 전부 import로 대체했다 (raw JSX 없음)
+- [ ] 텍스트는 `Typography`(`variant`+`weight`)로 썼다 — 서드파티가 className만 받는 자리가 아닌 이상 `text-xs`/`text-[Npx]` 직접 사용 없음
- [ ] Stream 고유 요소만 새 컴포넌트로 작성했다
- [ ] Props가 Figma variant를 유니온 타입으로 반영한다
- [ ] 이미지/아이콘 asset을 다운로드해 커밋했다 (만료되는 Figma URL 미참조)
diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md
index fd28c06..a76a9bd 100644
--- a/docs/conventions/wds-component-usage.md
+++ b/docs/conventions/wds-component-usage.md
@@ -66,6 +66,12 @@ WDS 컴포넌트만 합산하면 파일 안에서 **약 350회 이상**의 인
코드에서는 `@wanteddev/wds-icon`의 `IconSearch`/`IconBell`/`IconHome`/`IconTicket`/`IconList`로 대응된다(각각 default export를 `index.d.ts`에서 named export로 재노출). `Segmented Control`은 `@wanteddev/wds`의 `SegmentedControl`/`SegmentedControlItem`으로 대응된다.
+### `Typography` — 텍스트 스타일은 Figma 인스턴스 스캔에 안 잡혀서 뒤늦게 확인됨
+
+위 표들은 Figma의 인스턴스(컴포넌트) 이름을 스캔해서 만든 거라, Figma에서 텍스트 스타일(Text Style)로만 적용되고 별도 컴포넌트 인스턴스가 아닌 타이포그래피는 이 방식으로는 안 잡힌다. 그래서 지금까지 `text-xs`/`text-[17px]` 같은 Tailwind 값을 화면마다 손으로 맞춰왔는데, `@wanteddev/wds`에 Figma의 이름 있는 타입 스타일(Headline 2/Bold 등)과 정확히 대응하는 `Typography` 컴포넌트가 있다는 걸 뒤늦게 확인했다(`node_modules/@wanteddev/wds/dist/components/typography/style.js`). Bottom Nav 탭 라벨을 `text-xs`(12px)로 잘못 만들었던 것도 실제 Figma 값(Caption 2/Medium, 11px)과 어긋난 채로 남아있다가 이번에 확인됐다.
+
+`variant`+`weight` 조합과 색상 `color` prop 사용법은 `docs/conventions/component-convention.md`의 "타이포그래피" 절 참고. 이후 새 화면을 만들 때는 텍스트 크기를 짐작하지 말고 이 컴포넌트부터 확인한다.
+
### 반례 — 빌릴게 필터 Chip은 WDS `Chip/Chip`이 아니었다
위 "WDS 컴포넌트로 확인됨" 표에 `Chip/Chip`이 파일 전체 기준 24개 인스턴스로 확정돼 있다고 해서, **다른 화면의 비슷하게 생긴 칩도 자동으로 WDS라고 가정하면 안 된다.** 빌릴게 화면의 카테고리 필터(전체/전자기기/생활잡화/상비약/위생용품)를 처음 구현할 때 이 표만 보고 재조사 없이 WDS `Chip`을 그대로 썼는데, 실제 Figma 스타일(활성 = 연한 파랑 배경 + 파랑 outline, 비활성 = 회색 outline)이 WDS Chip의 기본 활성 스타일(검정 배경)과 달랐다 — Stream이 로컬로 새로 만든 칩이었다. **스타일이 눈에 띄게 다르면, 이름이 같아 보여도 그 인스턴스는 따로 `get_design_context`로 열어 확인한다.** 코드는 `src/features/rental/components/RentalCategoryFilter.tsx` 참고 (plain `
```
-hex를 하드코딩하지 않고 `index.css`의 색상 토큰을 그대로 참조했고, `Button` 자체(접근성 속성, `disabled`/`loading` 상태 처리 등)는 그대로 재사용한다. `src/features/rental/components/RentalItemCard.tsx` 참고.
+hex를 하드코딩하지 않고 `index.css`의 색상 토큰을 그대로 참조했고, `Button` 자체(접근성 속성, `disabled`/`loading` 상태 처리 등)는 그대로 재사용한다. `src/features/bililge/components/BililgeItemCard.tsx` 참고.
+
+### 빌릴게 대여 바텀시트 — `Action Area/Action Area`, `Icon/Normal/Circle Info`, 그리고 Time Picker는 의도적으로 WDS를 안 씀
+
+`/component`로 빌릴게 대여 바텀시트(Figma nodeId `1422:57155`, 실제 시트 콘텐츠는 `1422:57176`)를 구현하며 확인된 내용.
+
+- **`Action Area/Action Area` + `ActionAreaButton`**: 버튼 하나(대여 신청하기)만 있는 액션 영역도 `@wanteddev/wds`의 `ActionArea`(기본 `variant="strong"`) + `ActionAreaButton`(기본 `variant="main"`)으로 그대로 재현된다. `ActionAreaButton`의 `main` variant가 내부적으로 `Button`을 `size="large"` `fullWidth` `variant="solid"` `color="primary"`로 렌더링해서 Figma의 파란 통 너비 버튼과 정확히 일치했다(`node_modules/@wanteddev/wds/dist/components/action-area/index.js` 확인). `src/features/bililge/components/BililgeRentalSheet.tsx` 참고.
+- **`Icon/Normal/Circle Info`**: 안내 문구("대여 시작 시간은 최소 5분 뒤부터...") 앞 아이콘. 위 표에서 이미 확정된 매핑 재사용(`@wanteddev/wds-icon`의 `IconCircleInfo`).
+- **Time Picker(휠 피커)는 예외적으로 WDS를 쓰지 않기로 결정했다.** `@wanteddev/wds`에 `time-picker` 컴포넌트가 실제로 존재하지만(`node_modules/@wanteddev/wds/dist/components/time-picker/`), 이건 `` 기반 텍스트 필드형 컴포넌트라 Figma가 그리는 iOS 스타일 휠 스크롤 피커(오전/오후·시·분 3열, 가운데 값만 진하게)와는 UI 패턴 자체가 다르다. 사용자가 명시적으로 지정한 [`@ncdai/react-wheel-picker`](https://react-wheel-picker.chanhdai.com)(unstyled core, `WheelPicker`/`WheelPickerWrapper`)로 구현했다:
+ - `optionItem`/`highlightItem`/`highlightWrapper` classNames로 텍스트 스타일만 입히고(선택 안 됨: `text-label-disable` 17px medium, 선택됨: `text-label-normal` 18px semibold), Figma의 "Selection Highlight"(3열을 가로지르는 pill 배경, `bg-background-alternative`)는 라이브러리 밖에서 별도 `absolute` div로 얹었다 — 각 컬럼마다 하이라이트 배경을 따로 안 그리기 위해서다.
+ - 이 라이브러리의 CSS(`@ncdai/react-wheel-picker/style.css`)도 `@wanteddev/wds/global.css`와 같은 이유로 **반드시 `layer(base)`로 import해야 한다**(`src/index.css`) — 안 그러면 Tailwind 유틸리티가 라이브러리의 unlayered CSS한테 밀려서 `justify-center` 같은 오버라이드가 안 먹는다.
+ - 새 토큰 `--color-label-disable`(`--semantic-label-disable` 별칭)을 이때 추가했다. `get_variable_defs`로 확인한 실제 값은 `rgba(55,56,60,0.16)`.
## 제외됨 — Stream 자체 로컬 컴포넌트 (WDS 아님)
diff --git a/package.json b/package.json
index 93b6b61..9feac36 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"check:fix": "biome check --write ."
},
"dependencies": {
+ "@ncdai/react-wheel-picker": "^1.2.3",
"@wanteddev/wds": "^3.12.0",
"@wanteddev/wds-icon": "^3.12.0",
"react": "^19.2.8",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6eb849c..0e18b92 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@ncdai/react-wheel-picker':
+ specifier: ^1.2.3
+ version: 1.2.3(react@19.2.8)
'@wanteddev/wds':
specifier: ^3.12.0
version: 3.12.0(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.8))(@emotion/serialize@1.3.3)(@emotion/utils@1.4.2)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -234,6 +237,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@ncdai/react-wheel-picker@1.2.3':
+ resolution: {integrity: sha512-TQjDfuDH9Ss7sgacSRPnakqWfkR8E1w6g7Pkt7bTYX35SfdHbGSzC8g/lXKFeMyNIikOWD9wC3uFPLy+Ui/R0g==}
+ peerDependencies:
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+
'@oxc-project/types@0.147.0':
resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==}
@@ -1485,6 +1493,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
+ '@ncdai/react-wheel-picker@1.2.3(react@19.2.8)':
+ dependencies:
+ react: 19.2.8
+
'@oxc-project/types@0.147.0': {}
'@radix-ui/number@1.1.0': {}
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 8dd6739..616d406 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -1,15 +1,18 @@
import { Route, Routes } from "react-router-dom";
+import ScreenLayout from "@/components/ui/ScreenLayout";
+import BililgeListScreen from "@/features/bililge/BililgeListScreen";
import HomeScreen from "@/features/home/HomeScreen";
-import RentalListScreen from "@/features/rental/RentalListScreen";
// 데스크톱에서 보기 좋게 아이폰 화면 크기로 가운데 정렬만 해준다.
function App() {
return (
);
diff --git a/src/assets/icons/rental-items/8pin-charger.svg b/src/assets/icons/bililge-items/8pin-charger.svg
similarity index 100%
rename from src/assets/icons/rental-items/8pin-charger.svg
rename to src/assets/icons/bililge-items/8pin-charger.svg
diff --git a/src/assets/icons/rental-items/alcohol-swab.svg b/src/assets/icons/bililge-items/alcohol-swab.svg
similarity index 100%
rename from src/assets/icons/rental-items/alcohol-swab.svg
rename to src/assets/icons/bililge-items/alcohol-swab.svg
diff --git a/src/assets/icons/rental-items/band-aid.svg b/src/assets/icons/bililge-items/band-aid.svg
similarity index 100%
rename from src/assets/icons/rental-items/band-aid.svg
rename to src/assets/icons/bililge-items/band-aid.svg
diff --git a/src/assets/icons/rental-items/curling-iron.svg b/src/assets/icons/bililge-items/curling-iron.svg
similarity index 100%
rename from src/assets/icons/rental-items/curling-iron.svg
rename to src/assets/icons/bililge-items/curling-iron.svg
diff --git a/src/assets/icons/rental-items/default.svg b/src/assets/icons/bililge-items/default.svg
similarity index 100%
rename from src/assets/icons/rental-items/default.svg
rename to src/assets/icons/bililge-items/default.svg
diff --git a/src/assets/icons/rental-items/eye-drops.svg b/src/assets/icons/bililge-items/eye-drops.svg
similarity index 100%
rename from src/assets/icons/rental-items/eye-drops.svg
rename to src/assets/icons/bililge-items/eye-drops.svg
diff --git a/src/assets/icons/rental-items/hair-dryer.svg b/src/assets/icons/bililge-items/hair-dryer.svg
similarity index 100%
rename from src/assets/icons/rental-items/hair-dryer.svg
rename to src/assets/icons/bililge-items/hair-dryer.svg
diff --git a/src/assets/icons/rental-items/laptop-charger.svg b/src/assets/icons/bililge-items/laptop-charger.svg
similarity index 100%
rename from src/assets/icons/rental-items/laptop-charger.svg
rename to src/assets/icons/bililge-items/laptop-charger.svg
diff --git a/src/assets/icons/rental-items/mask.svg b/src/assets/icons/bililge-items/mask.svg
similarity index 100%
rename from src/assets/icons/rental-items/mask.svg
rename to src/assets/icons/bililge-items/mask.svg
diff --git a/src/assets/icons/rental-items/ointment.svg b/src/assets/icons/bililge-items/ointment.svg
similarity index 100%
rename from src/assets/icons/rental-items/ointment.svg
rename to src/assets/icons/bililge-items/ointment.svg
diff --git a/src/assets/icons/rental-items/pain-relief-patch.svg b/src/assets/icons/bililge-items/pain-relief-patch.svg
similarity index 100%
rename from src/assets/icons/rental-items/pain-relief-patch.svg
rename to src/assets/icons/bililge-items/pain-relief-patch.svg
diff --git a/src/assets/icons/rental-items/pill.svg b/src/assets/icons/bililge-items/pill.svg
similarity index 100%
rename from src/assets/icons/rental-items/pill.svg
rename to src/assets/icons/bililge-items/pill.svg
diff --git a/src/assets/icons/rental-items/power-bank.svg b/src/assets/icons/bililge-items/power-bank.svg
similarity index 100%
rename from src/assets/icons/rental-items/power-bank.svg
rename to src/assets/icons/bililge-items/power-bank.svg
diff --git a/src/assets/icons/rental-items/sanitary-pad.svg b/src/assets/icons/bililge-items/sanitary-pad.svg
similarity index 100%
rename from src/assets/icons/rental-items/sanitary-pad.svg
rename to src/assets/icons/bililge-items/sanitary-pad.svg
diff --git a/src/assets/icons/rental-items/umbrella.svg b/src/assets/icons/bililge-items/umbrella.svg
similarity index 100%
rename from src/assets/icons/rental-items/umbrella.svg
rename to src/assets/icons/bililge-items/umbrella.svg
diff --git a/src/assets/icons/rental-items/usb-c-charger.svg b/src/assets/icons/bililge-items/usb-c-charger.svg
similarity index 100%
rename from src/assets/icons/rental-items/usb-c-charger.svg
rename to src/assets/icons/bililge-items/usb-c-charger.svg
diff --git a/src/assets/icons/bottom-nav/rental-selected.svg b/src/assets/icons/bottom-nav/bililge-selected.svg
similarity index 100%
rename from src/assets/icons/bottom-nav/rental-selected.svg
rename to src/assets/icons/bottom-nav/bililge-selected.svg
diff --git a/src/components/ui/BottomNav.tsx b/src/components/ui/BottomNav.tsx
index 8bfe241..4696735 100644
--- a/src/components/ui/BottomNav.tsx
+++ b/src/components/ui/BottomNav.tsx
@@ -1,3 +1,4 @@
+import { Typography } from "@wanteddev/wds";
import {
IconHome,
IconList,
@@ -5,12 +6,12 @@ import {
IconTicket,
} from "@wanteddev/wds-icon";
+import bililgeSelected from "@/assets/icons/bottom-nav/bililge-selected.svg";
import boardSelected from "@/assets/icons/bottom-nav/board-selected.svg";
import eventSelected from "@/assets/icons/bottom-nav/event-selected.svg";
import homeSelected from "@/assets/icons/bottom-nav/home-selected.svg";
-import rentalSelected from "@/assets/icons/bottom-nav/rental-selected.svg";
-export type BottomNavValue = "home" | "event" | "board" | "rental";
+export type BottomNavValue = "home" | "event" | "board" | "bililge";
interface BottomNavProps {
value: BottomNavValue;
@@ -47,8 +48,8 @@ const TABS: BottomNavTab[] = [
{
NormalIcon: IconStorage,
label: "빌릴게",
- selectedIcon: rentalSelected,
- value: "rental",
+ selectedIcon: bililgeSelected,
+ value: "bililge",
},
];
@@ -75,18 +76,22 @@ function BottomNav({ value, onValueChange }: BottomNavProps) {
) : (
)}
-
{label}
-
+
);
})}
-
-
-
+
);
}
diff --git a/src/components/ui/BottomSheet.tsx b/src/components/ui/BottomSheet.tsx
new file mode 100644
index 0000000..9b3f31c
--- /dev/null
+++ b/src/components/ui/BottomSheet.tsx
@@ -0,0 +1,56 @@
+import type { ReactNode } from "react";
+import { createPortal } from "react-dom";
+
+import { useScreenSheetPortal } from "@/components/ui/useScreenSheetPortal";
+
+interface BottomSheetProps {
+ open: boolean;
+ onClose: () => void;
+ children: ReactNode;
+}
+
+// Figma: Views / Bottom Sheets (nodeId 1422:57176) — WDS에는 대응하는 코드 컴포넌트가 없다
+// (component-convention.md 참고: Native / Bottom Sheet Indicator는 Stream/iOS 목업 전용 로컬 요소).
+// 딤+시트를 ScreenLayout의 포털 슬롯(useScreenSheetPortal)에 그려서 375×812 프레임 전체를 덮는다.
+function BottomSheet({ open, onClose, children }: BottomSheetProps) {
+ const portalEl = useScreenSheetPortal();
+
+ if (!portalEl) {
+ return null;
+ }
+
+ return createPortal(
+
+
+
+
+
+
+ {children}
+ {/* Figma의 Action Area는 버튼을 감싸는 Container(p-5=20px, 이건 WDS ActionArea 자체
+ padding으로 이미 확보됨) 다음에 iOS 홈 인디케이터용 "Gesture" 여백(pt-3.5=14px)이
+ 하나 더 붙는데, 실제 @wanteddev/wds의 ActionArea 컴포넌트에는 이 14px이 없어서
+ 여기서 더해준다. */}
+
+
+
,
+ portalEl,
+ );
+}
+
+export default BottomSheet;
diff --git a/src/components/ui/ScreenHeader.tsx b/src/components/ui/ScreenHeader.tsx
new file mode 100644
index 0000000..4c18e9e
--- /dev/null
+++ b/src/components/ui/ScreenHeader.tsx
@@ -0,0 +1,119 @@
+import { TopNavigation, Typography } from "@wanteddev/wds";
+import type { ReactNode } from "react";
+
+interface ScreenHeaderToggleTitle {
+ options: string[];
+ activeIndex: number;
+ onChange?: (index: number) => void;
+}
+
+type ScreenHeaderTitle = string | ScreenHeaderToggleTitle;
+
+function isToggleTitle(
+ title: ScreenHeaderTitle,
+): title is ScreenHeaderToggleTitle {
+ return typeof title !== "string";
+}
+
+// Figma: 게시판류 화면의 "공지 | 열린피드백" 같은 2단 탭 타이틀(nodeId 1256:81792 "Board Title").
+// 활성 옵션은 Label/Strong(검정), 비활성은 Label/Disable(흐림) — 둘 다 같은 Title 3/Bold(24px).
+function ScreenHeaderToggleTitle({
+ options,
+ activeIndex,
+ onChange,
+}: ScreenHeaderToggleTitle) {
+ return (
+
+ {/* BottomSheet 포털 대상 — 헤더/본문/Bottom Nav보다 위(z-50)에 겹쳐서, 화면 하나가
+ 열어도 375×812 프레임 전체를 딤 처리할 수 있다. 시트가 닫혀있을 때는 빈 오버레이가
+ 클릭을 가로채지 않도록 pointer-events-none — BottomSheet가 열릴 때 자기 자신에만
+ pointer-events-auto를 되돌려준다. */}
+
+
+
+
);
}
diff --git a/src/components/ui/screenHeaderContext.ts b/src/components/ui/screenHeaderContext.ts
new file mode 100644
index 0000000..737c2f6
--- /dev/null
+++ b/src/components/ui/screenHeaderContext.ts
@@ -0,0 +1,10 @@
+import {
+ createContext,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction,
+} from "react";
+
+export type SetScreenHeader = Dispatch>;
+
+export const ScreenHeaderContext = createContext(null);
diff --git a/src/components/ui/screenSheetPortalContext.ts b/src/components/ui/screenSheetPortalContext.ts
new file mode 100644
index 0000000..23232b6
--- /dev/null
+++ b/src/components/ui/screenSheetPortalContext.ts
@@ -0,0 +1,8 @@
+import { createContext } from "react";
+
+// BottomSheet가 포털로 렌더링될 위치. ScreenLayout의 375×812 프레임 최상단에 고정된 빈 div를
+// 가리켜서, 헤더/본문/Bottom Nav보다 위에 겹쳐 그려지면서도 브라우저 전체가 아니라 그 프레임
+// 안에서만 딤 처리된다.
+export const ScreenSheetPortalContext = createContext(
+ null,
+);
diff --git a/src/components/ui/useScreenHeader.ts b/src/components/ui/useScreenHeader.ts
new file mode 100644
index 0000000..16a5cb1
--- /dev/null
+++ b/src/components/ui/useScreenHeader.ts
@@ -0,0 +1,22 @@
+import type { ReactNode } from "react";
+import { useContext, useEffect } from "react";
+
+import { ScreenHeaderContext } from "@/components/ui/screenHeaderContext";
+
+// 화면(라우트)이 자신의 헤더(Top Navigation 등)를 ScreenLayout의 header 슬롯에 등록한다.
+// ScreenLayout이 라우터에서 부모 route로 화면을 감싸기 때문에, 화면은 을
+// 직접 감쌀 필요 없이 이 훅만 호출하면 된다 — 감싸는 걸 깜빡하는 불일치가 구조적으로 없어진다.
+export function useScreenHeader(header: ReactNode) {
+ const setHeader = useContext(ScreenHeaderContext);
+
+ if (!setHeader) {
+ throw new Error(
+ "useScreenHeader는 ScreenLayout의 하위 라우트에서만 쓸 수 있다",
+ );
+ }
+
+ useEffect(() => {
+ setHeader(header);
+ return () => setHeader(null);
+ }, [header, setHeader]);
+}
diff --git a/src/components/ui/useScreenSheetPortal.ts b/src/components/ui/useScreenSheetPortal.ts
new file mode 100644
index 0000000..7e6bb20
--- /dev/null
+++ b/src/components/ui/useScreenSheetPortal.ts
@@ -0,0 +1,9 @@
+import { useContext } from "react";
+
+import { ScreenSheetPortalContext } from "@/components/ui/screenSheetPortalContext";
+
+// BottomSheet가 포털로 그려질 DOM 노드를 가져온다. 마운트 첫 렌더에는 ref가 아직 안 붙어서
+// null일 수 있으므로(프로그래밍 오류가 아니라 정상적인 과도 상태), 호출부에서 null을 직접 처리한다.
+export function useScreenSheetPortal() {
+ return useContext(ScreenSheetPortalContext);
+}
diff --git a/src/features/bililge/BililgeListScreen.tsx b/src/features/bililge/BililgeListScreen.tsx
new file mode 100644
index 0000000..a6725e6
--- /dev/null
+++ b/src/features/bililge/BililgeListScreen.tsx
@@ -0,0 +1,95 @@
+import {
+ SegmentedControl,
+ SegmentedControlItem,
+ TopNavigationButton,
+} from "@wanteddev/wds";
+import { IconBell, IconSearch } from "@wanteddev/wds-icon";
+import { startTransition, useState } from "react";
+
+import ScreenHeader from "@/components/ui/ScreenHeader";
+import { useScreenHeader } from "@/components/ui/useScreenHeader";
+import BililgeCategoryFilter from "@/features/bililge/components/BililgeCategoryFilter";
+import BililgeItemCard from "@/features/bililge/components/BililgeItemCard";
+import BililgeRentalSheet from "@/features/bililge/components/BililgeRentalSheet";
+import {
+ BILILGE_ITEMS,
+ type BililgeItem,
+} from "@/features/bililge/constants/bililgeItems";
+
+// Figma: 빌릴게 (nodeId 1243:73331)
+function BililgeListScreen() {
+ const [tab, setTab] = useState("rent");
+ const [category, setCategory] = useState("전체");
+ const [rentalItem, setRentalItem] = useState(null);
+ const [rentalSheetOpen, setRentalSheetOpen] = useState(false);
+
+ useScreenHeader(
+
+
+
+
+
+
+
+ >
+ }
+ />,
+ );
+
+ return (
+
+ {/* 대여/반납 토글 + 카테고리 필터는 화면마다 값·동작이 달라 헤더가 아니라 화면이 직접 그린다.
+ 목록만 스크롤되도록 여기는 고정(shrink-0)한다. */}
+
+
+ 대여
+ 반납
+
+
+
+
+
+
+
+
+ {tab === "rent" ? (
+
+ {BILILGE_ITEMS.map((item) => (
+ {
+ // 바텀시트를 여는 것(슬라이드 애니메이션)은 즉시 반영하고, 그 안의 휠
+ // 피커(특히 분 60개) 마운트처럼 무거운 작업은 startTransition으로 낮은
+ // 우선순위로 미뤄서 첫 프레임이 버벅이지 않게 한다 — 처음 열 때만 해당하고,
+ // rentalItem은 닫아도 null로 안 돌아가서 두 번째부터는 이 마운트 비용 자체가 없다.
+ setRentalSheetOpen(true);
+ startTransition(() => {
+ setRentalItem(item);
+ });
+ }}
+ quantity={item.quantity}
+ />
+ ))}
+