[feat/MAT-272] content-renderer 패키지 개발#268
Conversation
Create Vite + vanilla-ts package with singlefile build for WebView content rendering. Includes serializer migration (split into 5 files), shared types, bridge protocol, core modules (CSS, math-renderer, text-length), and base styles migrated from ProblemViewer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Document mode: serializer-based rendering with ResizeObserver height reporting - Chat mode: sequential pointing flow with typing indicator, yes/no gates, expand content - Overview mode: card/plain/chat/divider sections with IntersectionObserver and bookmark support - Main entry point with mode dispatch and bridge message routing - Dev panel with mock data for browser-based testing of all 3 modes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ContentWebView: mode-aware WebView with scroll/height behavior per mode - useContentBridge: message handling hook for RN ↔ WebView communication - Add @repo/pointer-content-renderer workspace dependency to native app - Create assets/webview directory for build output Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ibleSection bridge messages - Sticky tab bar with horizontal scroll, built from sections with label or divider type - Tab click scrolls to section with sticky offset compensation - IntersectionObserver syncs active tab on scroll - Remove scrollToSection (RN→WebView) and visibleSection (WebView→RN) from bridge protocol - Update native ContentWebView and useContentBridge to match new protocol Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…y when at bottom - Overview: remove position:fixed/inset:0 from .overview-mode so normal document scroll works with window.scrollTo and IntersectionObserver - Chat: scrollToBottom() now checks if user is within 100px of bottom before scrolling, preserving scroll position when reading earlier messages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace point-in-time isNearBottom() check with persistent sticky state: - Track user scroll intent via scroll event listener - Programmatic scrolls are flagged to avoid falsely disabling sticky mode - Re-scroll after renderMath() to catch KaTeX-induced height changes - Fixes issue where math content bubbles would break auto-scroll chain Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lit mock data per pointing - Chat controller: always show divider including first pointing - Chat renderer renderAllBubbles: skip first divider (overview provides its own) - Overview controller: replace IntersectionObserver with scroll-event-based active tab detection - Mock data: split single multi-pointing chat section into per-pointing divider+chat pairs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…iner - Chat controller: showWithTypingIndicator and showFixedMessage return bubble element - waitForYesNo appends buttons inside the target bubble - renderAllBubbles uses renderStaticYesNo for overview (disabled + selected highlight) - Question yes/no goes inside last question bubble, confirm yes/no inside fixed message bubble Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Added new variants for overview mode: 'summary' and 'pointing' - Updated tab bar to support sticky behavior with edge-to-edge styling - Introduced collapsible card variant for overview sections - Adjusted chat bubble styles for better alignment and readability - Refactored font and color tokens for consistent theming - Removed unused mock-data functions and improved mock section structure
init to resolve deadlock on RN
Introduce `AbortSignal` support for async operations to enable better cancellation handling. Refactor rendering logic to include cleanup functions for improved memory management and DOM consistency.
- Added `sync-webview-html` script to generate WebView HTML asset - Updated `.gitignore` to exclude generated WebView HTML file - Modified `metro.config.js` to include `.html` in asset extensions - Updated `ContentWebView` to accept dynamic HTML sources - Adjusted `pointer-content-renderer` build script to remove direct asset copy
- Added global flags to detect KaTeX load success or failure. - Implemented timeout fallback for slow/offline CDN. - Updated `renderMath` to fallback to raw LaTeX if KaTeX is unavailable.
logic Replace inline bookmark button state management with a shared utility function `setBookmarkButtonState`.
prevent race condition Add `bookmarked` field to `bookmarkResult` message for better state tracking. Update `handleBookmarkResult` to conditionally rollback optimistic updates only if the current state matches the failed attempt.
scenarios Add validation to skip pointings with no questionNodes, preventing crashes.
renderer Ensure answers are keyed by pointingId to handle reordering, filtering, or missing entries without causing misalignment in chat scenarios.
Ensure initMessage is re-injected when it changes, provided the bridge is ready. Added useEffect to handle updates and improved bridgeReady handling with refs.
Introduce `safePositiveInt` utility to validate and constrain numeric attributes like `start`, `colspan`, and `rowspan`. Update serializers to use this function for safer handling of input values.
Introduce `requestId` to bookmark messages for deduplication and state management. Updated native and web implementations to handle this new parameter, ensuring proper rollback and re-enabling of buttons after failed attempts.
Implements per-section bookmark state handling to manage in-flight requests, prevent stale results, and ensure UI consistency.
handling - Introduced support for chat resume scenarios with partial user answers. - Added `onAnswer` callback to handle per-step answer events. - Updated `RNToWebViewMessage` and `WebViewToRNMessage` types for new payloads. - Enhanced `runChatScenario` to support static rendering and answer tracking.
Workspace package @repo/pointer-content-renderer was loading a different physical react path than the app, causing "Invalid hook call" errors when hooks inside the package were invoked. - metro.config.js: force resolveRequest to redirect react / react-native / react-native-webview (and their sub-paths) through the app's package.json so every consumer resolves to the same realpath. - pointer-content-renderer/package.json: declare react / react-native / react-native-webview as peerDependencies to document the requirement. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
학생 앱에서 TipTap 기반 수학 콘텐츠를 렌더링하기 위한 신규 공용 WebView 패키지 @repo/pointer-content-renderer를 추가하고, 앱(apps/native)에서 HTML 번들 동기화 및 Metro 설정을 통해 단일 WebView 기반 렌더링을 가능하게 하는 PR입니다.
Changes:
- 신규 패키지
packages/pointer-content-renderer추가(Vite singlefile 빌드 + document/chat/overview 모드 + RN↔WebView 브리지) apps/native에 WebView HTML 번들 sync 스크립트/훅 및 Metro resolver 설정(React/RN dedupe, html assetExts) 추가- TipTap JSON → HTML serializer 및 KaTeX 렌더링/스타일(CSS) 이관
Reviewed changes
Copilot reviewed 36 out of 38 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | 신규 패키지/의존성 추가에 따른 lockfile 갱신 |
| packages/pointer-content-renderer/vite.config.ts | Vite + singlefile 기반 index.html 단일 번들 빌드 설정 |
| packages/pointer-content-renderer/tsconfig.native.json | RN(native) 측 타입체크 설정 추가 |
| packages/pointer-content-renderer/tsconfig.json | Web/dev 포함 TS 타입체크 설정 추가 |
| packages/pointer-content-renderer/src/web/modes/overview/overview.css | overview 모드(탭/카드/북마크/디바이더) 스타일 정의 |
| packages/pointer-content-renderer/src/web/modes/overview/overview-renderer.ts | overview 섹션 렌더러(카드/플레인/채팅/디바이더) 구현 |
| packages/pointer-content-renderer/src/web/modes/overview/overview-controller.ts | overview 탭 네비게이션/스크롤 동기화 + bookmarkResult 처리 |
| packages/pointer-content-renderer/src/web/modes/overview/bookmark-state.ts | 북마크 optimistic + pending lock + requestId 기반 상태관리 |
| packages/pointer-content-renderer/src/web/modes/overview/bookmark-icons.ts | 북마크 버튼 아이콘/active 상태 토글 유틸 |
| packages/pointer-content-renderer/src/web/modes/document/document.css | document 모드 스크롤/overflow 제어 스타일 |
| packages/pointer-content-renderer/src/web/modes/document/document-renderer.ts | document 모드 렌더 + ResizeObserver 기반 높이 리포트 |
| packages/pointer-content-renderer/src/web/modes/chat/typing-indicator.ts | typing indicator 및 abortable delay 유틸 |
| packages/pointer-content-renderer/src/web/modes/chat/scroll.ts | sticky-to-bottom intent 기반 스크롤 제어 유틸 |
| packages/pointer-content-renderer/src/web/modes/chat/chat.css | chat UI(버블/선택지/typing) 스타일 정의 |
| packages/pointer-content-renderer/src/web/modes/chat/chat-renderer.ts | chat 버블/디바이더/정적 yes-no 렌더링 유틸 |
| packages/pointer-content-renderer/src/web/modes/chat/chat-controller.ts | chat 시나리오 실행(typing, resume, answer 이벤트 emit) |
| packages/pointer-content-renderer/src/web/main.ts | init 메시지 기반 모드 분기 렌더 + stale render 가드 + bridgeReady |
| packages/pointer-content-renderer/src/web/index.html | KaTeX/폰트 CDN 포함 WebView 엔트리 HTML |
| packages/pointer-content-renderer/src/web/core/text-length.ts | typing 타이밍 산정을 위한 텍스트 길이/이미지 포함 여부 계산 |
| packages/pointer-content-renderer/src/web/core/styles/base.css | 공용 타이포/테이블 넘버링/blockquote 등 기본 스타일 이관 |
| packages/pointer-content-renderer/src/web/core/serializer/utils.ts | serializer 유틸(escape/safePositiveInt/marks compare) |
| packages/pointer-content-renderer/src/web/core/serializer/nodes.ts | block 노드(table/list/blockquote/hr 등) 직렬화 |
| packages/pointer-content-renderer/src/web/core/serializer/marks.ts | mark(bold/italic/underline/highlight 등) 직렬화 |
| packages/pointer-content-renderer/src/web/core/serializer/inline.ts | inline(text/math/br/image 등) 직렬화 및 mark grouping |
| packages/pointer-content-renderer/src/web/core/serializer/index.ts | JSON(doc/node) → HTML 직렬화 진입점 |
| packages/pointer-content-renderer/src/web/core/math-renderer.ts | KaTeX 로드 대기/실패 fallback 포함 수식 렌더러 |
| packages/pointer-content-renderer/src/web/bridge.ts | WebView 메시지 수신(onMessage) 및 RN 송신(sendToRN) |
| packages/pointer-content-renderer/src/types.ts | 모드 payload/브리지 메시지/시나리오/섹션 타입 정의 |
| packages/pointer-content-renderer/src/native/useContentBridge.ts | RN 훅: bridgeReady 기반 init 재주입 + 이벤트 라우팅 |
| packages/pointer-content-renderer/src/native/index.ts | RN entrypoint exports 정의 |
| packages/pointer-content-renderer/src/native/ContentWebView.tsx | RN 컴포넌트: htmlSource 주입 + mode별 scroll/height 처리 |
| packages/pointer-content-renderer/package.json | 패키지 메타/스크립트/peerDeps 정의 |
| packages/pointer-content-renderer/dev/mock-data.ts | 브라우저 dev 패널용 mock 데이터(문서/채팅/오버뷰) |
| packages/pointer-content-renderer/dev/dev-panel.ts | dev 서버에서 모드 전환/리렌더 트리거 UI |
| packages/pointer-content-renderer/.gitignore | 패키지 로컬 산출물/IDE 파일 ignore |
| apps/native/package.json | HTML 번들 sync 스크립트 및 pre* / EAS 훅 추가 + 패키지 의존성 추가 |
| apps/native/metro.config.js | html assetExts + React/RN/WebView dedupe resolver 설정 추가 |
| apps/native/.gitignore | 생성되는 assets/webview/content.html ignore 추가 |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .document-mode html, | ||
| .document-mode body { |
There was a problem hiding this comment.
.document-mode html / .document-mode body selectors won’t match because the document-mode class is applied to <body> (see main.ts), not to a parent of html/body. As written, overflow is only guaranteed on the body via .document-mode { ... }, and the intended html/body rules are effectively dead. Consider targeting html, body.document-mode (or body.document-mode, body.document-mode * as needed) so the overflow lock reliably applies.
| .document-mode html, | |
| .document-mode body { | |
| html, | |
| body.document-mode { |
| markProgrammatic(smooth); | ||
| window.scrollTo({ | ||
| top: document.body.scrollHeight, | ||
| behavior: smooth ? 'smooth' : 'instant', |
There was a problem hiding this comment.
window.scrollTo({ behavior: 'instant' }) uses a non-standard value; the ScrollOptions spec only allows 'auto' or 'smooth'. In WebViews/browsers that don’t support 'instant', this may be ignored or behave inconsistently. Use behavior: 'auto' for the non-smooth branch.
| behavior: smooth ? 'smooth' : 'instant', | |
| behavior: smooth ? 'smooth' : 'auto', |
| const timer = setTimeout(resolve, ms); | ||
| signal?.addEventListener('abort', () => { | ||
| clearTimeout(timer); | ||
| reject(signal.reason); | ||
| }, { once: true }); |
There was a problem hiding this comment.
delay() adds an abort event listener on the signal but never removes it when the timeout resolves normally. With many delays, this accumulates listeners until the controller is aborted (or forever if it isn’t), causing a memory leak. Please remove the listener on resolve (or use { once: true } plus explicit cleanup in both the resolve and abort paths).
| const timer = setTimeout(resolve, ms); | |
| signal?.addEventListener('abort', () => { | |
| clearTimeout(timer); | |
| reject(signal.reason); | |
| }, { once: true }); | |
| const cleanup = () => { | |
| signal?.removeEventListener('abort', onAbort); | |
| }; | |
| const timer = setTimeout(() => { | |
| cleanup(); | |
| resolve(); | |
| }, ms); | |
| const onAbort = () => { | |
| clearTimeout(timer); | |
| cleanup(); | |
| reject(signal.reason); | |
| }; | |
| signal?.addEventListener('abort', onAbort, { once: true }); |
|
|
||
| export function onMessage(handler: MessageHandler): void { | ||
| _handler = handler; | ||
|
|
||
| window.addEventListener('message', (event: MessageEvent) => { | ||
| try { | ||
| const data = | ||
| typeof event.data === 'string' ? JSON.parse(event.data) : event.data; | ||
| handler(data as RNToWebViewMessage); | ||
| } catch { | ||
| // ignore non-JSON messages | ||
| } | ||
| }); |
There was a problem hiding this comment.
onMessage() registers a new window.addEventListener('message', ...) every time it’s called and never unregisters the old listener. In Vite dev/HMR (or if onMessage is invoked more than once), this can lead to duplicate handlers and multiple renders per message. Consider installing the listener once (module-level) and routing to the latest _handler, or returning a cleanup function to remove the listener.
| export function onMessage(handler: MessageHandler): void { | |
| _handler = handler; | |
| window.addEventListener('message', (event: MessageEvent) => { | |
| try { | |
| const data = | |
| typeof event.data === 'string' ? JSON.parse(event.data) : event.data; | |
| handler(data as RNToWebViewMessage); | |
| } catch { | |
| // ignore non-JSON messages | |
| } | |
| }); | |
| let _listenerInstalled = false; | |
| function handleWindowMessage(event: MessageEvent): void { | |
| try { | |
| const data = | |
| typeof event.data === 'string' ? JSON.parse(event.data) : event.data; | |
| _handler?.(data as RNToWebViewMessage); | |
| } catch { | |
| // ignore non-JSON messages | |
| } | |
| } | |
| export function onMessage(handler: MessageHandler): void { | |
| _handler = handler; | |
| if (!_listenerInstalled) { | |
| window.addEventListener('message', handleWindowMessage); | |
| _listenerInstalled = true; | |
| } |
- Adjusted indentation and line breaks for better readability - Unified import statement formatting across files - Corrected minor style issues in JSX and TypeScript code
- Added pointer-content-renderer to lint filter in CI - Removed specific filters for typecheck and build in CI scripts - Added lint script to pointer-content-renderer package
Refined `.document-mode` to use `:has()` for better specificity and clarity.
Refactored `delay` function to remove abort event listener after timeout resolves, ensuring proper cleanup and avoiding potential memory leaks.
Ensure the message listener is installed only once, even during Vite HMR.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 37 out of 39 changed files in this pull request and generated 3 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const [height, setHeight] = useState(0); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
|
|
||
| const { webViewRef, handleMessage } = useContentBridge({ | ||
| initMessage, | ||
| onReady: (m) => { | ||
| setIsLoading(false); | ||
| onReady?.(m); | ||
| }, | ||
| onHeight: isDocument ? setHeight : undefined, | ||
| onComplete, | ||
| onAnswer, | ||
| onBookmark, | ||
| }); | ||
|
|
||
| const backgroundColor = | ||
| isDocument && initMessage.mode === 'document' | ||
| ? (initMessage.backgroundColor ?? '#ffffff') | ||
| : '#f5f5f5'; | ||
|
|
||
| return ( | ||
| <View style={[isDocument ? { height, width: '100%' } : { flex: 1 }, style]}> | ||
| <WebView | ||
| ref={webViewRef} | ||
| source={htmlSource as unknown as WebViewSource} | ||
| onMessage={handleMessage} | ||
| scrollEnabled={!isDocument} | ||
| style={[ | ||
| isDocument ? { height, width: '100%' } : { flex: 1 }, | ||
| { backgroundColor }, | ||
| isLoading ? { opacity: 0 } : { opacity: 1 }, | ||
| ]} |
There was a problem hiding this comment.
In document mode the wrapper and WebView height are driven by height state, which is initialized to 0. This can collapse the layout initially (and the loading overlay won't be visible because the parent height is 0) until the first height message arrives. Consider using a non-zero initial height and/or adding a minHeight prop/default so the loader and first paint are visible.
| "ci:typecheck": "turbo run typecheck", | ||
| "ci:build": "turbo build" |
There was a problem hiding this comment.
ci:typecheck and ci:build were changed from filtered runs to turbo run typecheck / turbo build for the whole monorepo. This can significantly increase CI time and may run tasks for packages that weren’t previously validated in CI. If the intent is only to include @repo/pointer-content-renderer, consider keeping filters consistent (or document why full-monorepo CI is required).
| "ci:typecheck": "turbo run typecheck", | |
| "ci:build": "turbo build" | |
| "ci:typecheck": "turbo run typecheck --filter=admin --filter=@repo/pointer-editor-v2 --filter=@repo/pointer-content-renderer", | |
| "ci:build": "turbo build --filter=admin --filter=@repo/pointer-editor-v2 --filter=@repo/pointer-content-renderer" |
| "build": "tsc --noEmit && vite build --config vite.config.ts", | ||
| "typecheck": "tsc --noEmit", |
There was a problem hiding this comment.
tsconfig.native.json is added but the package scripts (typecheck/build) run tsc without a project, which will use tsconfig.json and currently excludes src/native. This means the exported native code can ship without being typechecked. Consider updating the scripts to run tsc -p tsconfig.native.json as well (or merging native + web into a single tsconfig include).
| "build": "tsc --noEmit && vite build --config vite.config.ts", | |
| "typecheck": "tsc --noEmit", | |
| "build": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.native.json && vite build --config vite.config.ts", | |
| "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.native.json", |
Updated the `typecheck` script to include both `tsconfig.json` and `tsconfig.native.json` for comprehensive type checking. Adjusted the `build` script to use the updated `typecheck` command.
Summary
학생 앱에서 TipTap 기반 수학 콘텐츠를 렌더링하는 공용 WebView 패키지
@repo/pointer-content-renderer를 신규 추가합니다. 한 화면에 20+개까지 존재하던 WebView를 단일 WebView로 통합하며, 하나의 번들에서 document / chat / overview 3가지 렌더링 모드를 지원합니다.기존
ProblemViewer의 수식 조판 CSS(테이블 ①②③ 보기 넘버링, blockquote 조건박스, highlight 변수 등)를 그대로 이관하면서, WebView 내부에 채팅 순차 진행·오버뷰 탭 네비게이션·북마크 상호작용까지 포함했습니다.Linear
Changes
New package:
@repo/pointer-content-renderervite-plugin-singlefile로 단일index.html을 빌드 (JS/CSS 인라인, KaTeX/Pretendard/KoPub CDN만 외부 참조)serializeJSONToHTML,serializeNodeToHTML)를 5개 파일로 분리 이관. 로직 변경 없음. 숫자 attr(start,colspan,rowspan)과type화이트리스트로 attribute injection 차단.hardBreak노드<br>대응3가지 렌더링 모드
ProblemViewer대체.ResizeObserver+ debounce 기반height리포트,fontStyle/backgroundColor/paddingprop 지원. KoPub Batang을 기본 serif로 사용.userAnswers를 init에 전달하여 부분 완료 상태부터 이어서 진행. 완료된 phase는 static 재생(애니메이션/typing 없음), 미완료 phase부터 interactive.answer이벤트(fire-and-forget) emit, 전체 완료 시completeemit.summary(학습 마무리) /pointing(포인팅 전체보기) 분기.label있는 섹션 +divider섹션을 탭으로 자동 생성. sticky 탭 바, 탭 클릭 → 섹션 스크롤, 스크롤 → 활성 탭 동기화 (scroll 이벤트 + rAF).scrollHeight기반 정확한 max-height transition, transition 중 클릭 차단.Bridge 프로토콜
init(mode별 payload + chat의userAnswers, document의padding),bookmarkResultbridgeReady(리스너 준비 완료 신호),ready,height,complete,answer,bookmarkbridgeReady도입으로 기존 RN/Web 상호 대기 deadlock 해소. 매bridgeReady마다 init 재주입 가능.initMessageprop 변경 시 자동 재주입 (useEffect추적)Render lifecycle
disposeCurrentRender+currentRenderId기반 stale render 가드. Document는 detached fragment에 렌더 후isCurrent확인하여 live DOM commit. Overview는 루프 내isCurrent체크. Chat은AbortSignal로 pendingdelay/yes-no wait 중단.
<script onload/onerror>로 즉시 상태 캡처, 수식 없으면 wait skip, 실패 시 raw LaTeX fallback.Native 컴포넌트 & 통합
ContentWebView: mode별scrollEnabled/ height / background 분기.htmlSourceprop으로 asset 주입(consumer가 asset 소유).useContentBridgehook: ref + 메시지 송수신 관리.apps/native/assets/webview/content.html은 build-time generated asset으로 취급 (.gitignore추가).apps/native의sync-webview-html스크립트가 패키지 빌드 + 복사 담당prestart/preios/preandroid/preweb훅으로 로컬 실행 시 자동 동기화eas-build-post-install훅으로 자동 동기화metro.config.js:resolver.assetExts에html추가resolveRequest로react/react-native/react-native-webview경로를 앱 node_modules로 강제 고정 → monorepo에서 React 이중 인스턴스로 인한 "Invalid hook call" 차단Dev 환경
Testing
pnpm build --filter=@repo/pointer-content-renderer빌드 통과,dist/index.html~36KB 단일 파일pnpm typecheck통과pnpm dev)에서 3가지 모드 + chat resume 4종 + overview 2 variant 수동 검증test/content-renderer-smoke브랜치에서 실제 RN 앱(ContentWebView) 통합 smoke test 수행:Risk / Impact
ProblemViewer와 그 사용처는 변경하지 않아 현재 기능은 영향 없음.apps/native는resolver.assetExts와resolveRequest확장, 신규sync-webview-html스크립트가 추가됨..pnpm에react@16.14.0이 남아있는 것으로 확인됨 —resolveRequest강제 경로 고정으로 해결했으나, 장기적으로pnpm why react@16.14.0으로 원인 제거 권장.eas-build-post-install훅이 실제 호출되는지는 실배포 시 확인 필요.apps/nativemetro.config 변경이 포함되므로 앱 Metro 캐시 초기화(pnpm start -c) 1회 필요할 수 있음.pnpm install+pnpm sync-webview-html(또는pnpm start의prestart훅) 실행 필요.Screenshots / Video
Screen.Recording.2026-04-13.at.21.18.29.mp4