(feat): add avatars to call modals
Some checks failed
/ deploy (push) Has been cancelled

(feat): add fullscreen & popout buttons
(feat): remove clock icon from awaiting messages
(qol): update todos
This commit is contained in:
Alois 2026-05-09 14:36:19 +02:00
commit 30cf5ae3b9
14 changed files with 248 additions and 29 deletions

View file

@ -255,7 +255,7 @@
}, },
}, },
"overrides": { "overrides": {
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.15.tar.gz", "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.17.tar.gz",
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.30.tar.gz", "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.30.tar.gz",
}, },
"packages": { "packages": {
@ -695,7 +695,7 @@
"@tensamin/ttp": ["@tensamin/ttp@workspace:packages/ttp"], "@tensamin/ttp": ["@tensamin/ttp@workspace:packages/ttp"],
"@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.15.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.59.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "zod": "^4.4.1" } }, "sha512-jeaju9OB/RX2LxdDISWOVM5HMSNHOFF3dIxhgF+niwiOm9qLPMdEIqdUDIPat1dwlKugsNHfZfrbW5cW+xXJag=="], "@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.17.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.59.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "zod": "^4.4.1" } }, "sha512-smyx+04hSnWM1oyWJfJrRWmxu7OInwyHeIlaD1h3tUrkrSxiKWHl1qCmxVO1bC+cDwyUXhJ5HUnNCbx1aWx7Dg=="],
"@tensamin/ui": ["@tensamin/ui@https://git.methanium.net/tensamin/ui/archive/0.0.30.tar.gz", { "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/inter": "^5.2.6", "@tauri-apps/api": "^2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "react-day-picker": "^9.14.0", "react-resizable-panels": "^4.10.0", "recharts": "3.8.0", "shadcn": "^3.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.3.0", "vaul": "^1.1.2" }, "peerDependencies": { "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-dlJBw7M/GFGHveJrL6uZA2d9yjAJ9F8QNJ2NfqCHTwzMoF6Y2ESHdeKZE5wBr2BEh9ublNqAGpN0TGD3iDV8rQ=="], "@tensamin/ui": ["@tensamin/ui@https://git.methanium.net/tensamin/ui/archive/0.0.30.tar.gz", { "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/inter": "^5.2.6", "@tauri-apps/api": "^2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "react-day-picker": "^9.14.0", "react-resizable-panels": "^4.10.0", "recharts": "3.8.0", "shadcn": "^3.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.3.0", "vaul": "^1.1.2" }, "peerDependencies": { "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-dlJBw7M/GFGHveJrL6uZA2d9yjAJ9F8QNJ2NfqCHTwzMoF6Y2ESHdeKZE5wBr2BEh9ublNqAGpN0TGD3iDV8rQ=="],

View file

@ -40,7 +40,7 @@
}, },
"overrides": { "overrides": {
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.30.tar.gz", "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.30.tar.gz",
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.15.tar.gz" "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.17.tar.gz"
}, },
"dependencies": { "dependencies": {
"@tensamin/ttp-core": "*", "@tensamin/ttp-core": "*",

View file

@ -1,10 +1,28 @@
import { Card, CardContent, Button } from "@tensamin/ui"; import {
Card,
CardContent,
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@tensamin/ui";
import MuteButton from "./buttons/mute"; import MuteButton from "./buttons/mute";
import DeafButton from "./buttons/deaf"; import DeafButton from "./buttons/deaf";
import ScreenshareButton from "./buttons/screenshare"; import ScreenshareButton from "./buttons/screenshare";
import LeaveButton from "./buttons/leave"; import LeaveButton from "./buttons/leave";
import { stopWatchingFocusedStream, useCall } from "../store"; import {
setCallIsPopout,
stopWatchingFocusedStream,
triggerCallLayoutCalculation,
useCall,
} from "../store";
import InviteButton from "./buttons/invite"; import InviteButton from "./buttons/invite";
import {
Maximize,
Minimize,
SquareArrowOutDownLeft,
SquareArrowOutUpRight,
} from "lucide-react";
export default function Actions() { export default function Actions() {
const sharedClasses = "w-14 h-10"; const sharedClasses = "w-14 h-10";
@ -19,8 +37,23 @@ export default function Actions() {
focusedParticipantId != null && focusedParticipantId != null &&
watchedStreamParticipantIds.includes(focusedParticipantId); watchedStreamParticipantIds.includes(focusedParticipantId);
const callIsPopout = useCall((state) => state.callIsPopout);
const callIsFullscreen = useCall((state) => state.callIsFullscreen);
const screenRef = useCall((state) => state.screenRef);
const toggleFullscreen = async () => {
if (callIsFullscreen) {
await document.exitFullscreen().catch(() => undefined);
} else {
await screenRef?.current?.requestFullscreen().catch(() => undefined);
}
triggerCallLayoutCalculation();
};
return ( return (
<div className="w-full flex justify-center"> <div className="w-full flex justify-between items-center">
<div className="w-30" />
<Card className="p-1.75"> <Card className="p-1.75">
<CardContent className="p-0! flex gap-1.75"> <CardContent className="p-0! flex gap-1.75">
<MuteButton className={sharedClasses} iconSize={sharedIconSize} /> <MuteButton className={sharedClasses} iconSize={sharedIconSize} />
@ -43,6 +76,46 @@ export default function Actions() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
<div className="w-30 flex justify-end">
<Tooltip>
<TooltipTrigger
render={
<Button
onClick={() => setCallIsPopout(!callIsPopout)}
variant="link"
className="w-11 h-11 p-0! text-foreground"
>
{callIsPopout ? (
<SquareArrowOutDownLeft className="size-6" />
) : (
<SquareArrowOutUpRight className="size-6" />
)}
</Button>
}
/>
<TooltipContent>Popout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
onClick={() => {
void toggleFullscreen();
}}
variant="link"
className="w-11 h-11 p-0! mr-3 text-foreground"
>
{callIsFullscreen ? (
<Minimize className="size-6" />
) : (
<Maximize className="size-6" />
)}
</Button>
}
/>
<TooltipContent>Fullscreen</TooltipContent>
</Tooltip>
</div>
</div> </div>
); );
} }

View file

@ -1,4 +1,7 @@
import { import {
Avatar,
AvatarFallback,
AvatarImage,
Button, Button,
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@ -13,7 +16,7 @@ import {
useCall, useCall,
} from "../../store"; } from "../../store";
import { Track, type Participant } from "livekit-client"; import { Track, type Participant } from "livekit-client";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useUser, type User } from "@tensamin/user/context"; import { useUser, type User } from "@tensamin/user/context";
import VideoViewer from "../videoViewer"; import VideoViewer from "../videoViewer";
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react"; import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
@ -130,6 +133,9 @@ export default function Base({
}; };
}, [participant, get]); }, [participant, get]);
// Avatar calc
const currentCard = useRef<HTMLDivElement>(null);
if (!participant || !user) { if (!participant || !user) {
return ( return (
<div <div
@ -202,9 +208,11 @@ export default function Base({
</> </>
</div> </div>
<div <div
ref={currentCard}
className={`z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${ className={`z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${
flush ? "rounded-none" : "rounded-sm" flush ? "rounded-none" : "rounded-sm"
}`} }`}
style={{ containerType: "size" }}
> >
{/* Detect video / user and place here */} {/* Detect video / user and place here */}
@ -230,7 +238,23 @@ export default function Base({
</div> </div>
) : null)} ) : null)}
{type === "user" && <p>{user.display}</p>} {type === "user" && (
<Avatar
style={{
width: "32cqh",
height: "32cqh",
}}
>
<AvatarImage src={user.avatar} />
<AvatarFallback
style={{
fontSize: "11cqh",
}}
>
{user.display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
)}
</div> </div>
</div> </div>
} }

View file

@ -90,6 +90,10 @@ type CallStore = {
pendingWatchedParticipantIds: number[]; pendingWatchedParticipantIds: number[];
activeScreenShareParticipantIds: number[]; activeScreenShareParticipantIds: number[];
isEncrypted: boolean; isEncrypted: boolean;
callIsFullscreen: boolean;
callIsPopout: boolean;
layoutVersion: number;
screenRef: React.RefObject<HTMLDivElement | null> | null;
room: Room; room: Room;
keyProvider: ExternalE2EEKeyProvider; keyProvider: ExternalE2EEKeyProvider;
e2eeWorker: Worker; e2eeWorker: Worker;
@ -504,6 +508,24 @@ export function setCallView(view: CallView) {
useCall.setState({ view }); useCall.setState({ view });
} }
export function setCallIsFullscreen(callIsFullscreen: boolean) {
useCall.setState({ callIsFullscreen });
}
export function setCallIsPopout(callIsPopout: boolean) {
useCall.setState({ callIsPopout });
}
export function triggerCallLayoutCalculation() {
useCall.setState((state) => ({
layoutVersion: state.layoutVersion + 1,
}));
}
export function setScreenRef(screenRef: React.RefObject<HTMLDivElement | null> | null) {
useCall.setState({ screenRef });
}
// Keep the current call id in sync with navigation and connection flow. // Keep the current call id in sync with navigation and connection flow.
export function setCallId(callId: string | null) { export function setCallId(callId: string | null) {
useCall.setState({ callId }); useCall.setState({ callId });
@ -950,6 +972,10 @@ export const useCall = create<CallStore>(() => ({
activeScreenShareParticipantIds: [], activeScreenShareParticipantIds: [],
isEncrypted: isEncrypted:
room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted, room.localParticipant.isE2EEEnabled && room.localParticipant.isEncrypted,
callIsFullscreen: false,
callIsPopout: false,
layoutVersion: 0,
screenRef: null,
room, room,
keyProvider, keyProvider,
e2eeWorker, e2eeWorker,
@ -1051,6 +1077,41 @@ export function useInitializeCall() {
} }
}, [location.pathname, location.searchStr]); }, [location.pathname, location.searchStr]);
// fullscreen sync
useEffect(() => {
const handleFullscreenChange = () => {
const screenRef = useCall.getState().screenRef;
if (!screenRef?.current) return;
if (screenRef.current === document.fullscreenElement) {
setCallIsFullscreen(true);
} else {
setCallIsFullscreen(false);
}
triggerCallLayoutCalculation();
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
// esc key listener
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (
e.key === "Escape" &&
useCall.getState().callIsFullscreen &&
location.pathname.startsWith("/call")
) {
useCall.setState({ callIsFullscreen: false });
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [location.pathname]);
// room setup // room setup
useEffect(() => { useEffect(() => {
if (listenersRegistered.current) { if (listenersRegistered.current) {

View file

@ -9,6 +9,7 @@ const STACK_GAP_PX = 12;
export default function View() { export default function View() {
const room = useCall((state) => state.room); const room = useCall((state) => state.room);
const layoutVersion = useCall((state) => state.layoutVersion);
const focusedParticipantId = useCall((state) => state.focusedParticipantId); const focusedParticipantId = useCall((state) => state.focusedParticipantId);
const activeScreenShareParticipantIds = useCall( const activeScreenShareParticipantIds = useCall(
@ -136,7 +137,7 @@ export default function View() {
observer.disconnect(); observer.disconnect();
window.removeEventListener("resize", syncTileLayout); window.removeEventListener("resize", syncTileLayout);
}; };
}, [tiles.length, focusedParticipantId]); }, [layoutVersion, tiles.length, focusedParticipantId]);
if (focusedParticipantId == null) { if (focusedParticipantId == null) {
return null; return null;

View file

@ -83,6 +83,7 @@ function calculateOptimalGridLayout(
export default function View() { export default function View() {
const room = useCall((state) => state.room); const room = useCall((state) => state.room);
const layoutVersion = useCall((state) => state.layoutVersion);
const activeScreenShareParticipantIds = useCall( const activeScreenShareParticipantIds = useCall(
(state) => state.activeScreenShareParticipantIds, (state) => state.activeScreenShareParticipantIds,
); );
@ -98,15 +99,21 @@ export default function View() {
return; return;
} }
const syncContainerSize = () => {
const { width, height } = element.getBoundingClientRect();
setContainerSize({ width, height });
};
const observer = new ResizeObserver(([entry]) => { const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect; const { width, height } = entry.contentRect;
setContainerSize({ width, height }); setContainerSize({ width, height });
}); });
syncContainerSize();
observer.observe(element); observer.observe(element);
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, [layoutVersion]);
useEffect(() => { useEffect(() => {
const syncParticipants = () => { const syncParticipants = () => {

View file

@ -1,9 +1,17 @@
import { useEffect, useRef } from "react";
import Actions from "../../components/actions"; import Actions from "../../components/actions";
import TopBar from "../../components/top"; import TopBar from "../../components/top";
import { setScreenRef } from "../../store";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const screenRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setScreenRef(screenRef);
}, []);
return ( return (
<div className="w-full h-full flex flex-col"> <div ref={screenRef} className="w-full h-full flex flex-col">
<div className="shrink-0"> <div className="shrink-0">
<TopBar /> <TopBar />
</div> </div>

View file

@ -2,7 +2,6 @@
- Overlay for stream modals - Overlay for stream modals
- User modals - User modals
- Bg based on avatar - Bg based on avatar
- Avatar in the center
- Mobile - Mobile
- Call invite popup - Call invite popup
- Save call invite in `calls` array - Save call invite in `calls` array
@ -11,3 +10,5 @@
- Admin call actions - Admin call actions
- Timeout - Timeout
- Disconnect - Disconnect
- Desktop-App screenshares
- Toggle members button in focused view

View file

@ -1,7 +1,7 @@
import * as React from "react"; import * as React from "react";
import type { RawMessage } from "../values"; import type { RawMessage } from "../values";
import Text from "@tensamin/markdown/text"; import Text from "@tensamin/markdown/text";
import { AlertTriangle, Clock } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { cn, Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui"; import { cn, Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
import { useIsMobile } from "@tensamin/ui"; import { useIsMobile } from "@tensamin/ui";
@ -50,9 +50,6 @@ function MessageComponent({
<TooltipTrigger render={<AlertTriangle size={17} />} /> <TooltipTrigger render={<AlertTriangle size={17} />} />
</Tooltip> </Tooltip>
)} )}
{!message.failed && message.message_state === "awaiting" && (
<Clock size={17} />
)}
<Text value={message.content} /> <Text value={message.content} />
</div> </div>
} }

View file

@ -41,6 +41,7 @@ export default function Screen() {
scrollTop: number; scrollTop: number;
} | null>(null); } | null>(null);
const [scrollWidth, setScrollWidth] = React.useState(0); const [scrollWidth, setScrollWidth] = React.useState(0);
const [scrollHeight, setScrollHeight] = React.useState(0);
const [inputHeight, setInputHeight] = React.useState(0); const [inputHeight, setInputHeight] = React.useState(0);
const [measuredHeights, setMeasuredHeights] = React.useState< const [measuredHeights, setMeasuredHeights] = React.useState<
Record<number, number> Record<number, number>
@ -93,6 +94,10 @@ export default function Screen() {
return [...historicalMessages, ...liveWithoutDuplicates]; return [...historicalMessages, ...liveWithoutDuplicates];
}, [historicalMessages, liveMessagesSnapshot]); }, [historicalMessages, liveMessagesSnapshot]);
const shouldShowConversationStart =
!!messagesQuery.data && !messagesQuery.hasNextPage;
const virtualRowCount =
messages.length + (shouldShowConversationStart ? 1 : 0);
const messagesRef = React.useRef(messages); const messagesRef = React.useRef(messages);
@ -101,8 +106,13 @@ export default function Screen() {
}, [messages]); }, [messages]);
const getItemKey = React.useCallback((index: number) => { const getItemKey = React.useCallback((index: number) => {
return messagesRef.current[index]?.send_time ?? index; if (shouldShowConversationStart && index === 0) {
}, []); return "conversation-start";
}
const messageIndex = shouldShowConversationStart ? index - 1 : index;
return messagesRef.current[messageIndex]?.send_time ?? index;
}, [shouldShowConversationStart]);
const setMeasurementRef = React.useCallback( const setMeasurementRef = React.useCallback(
(sendTime: number, element: HTMLDivElement | null) => { (sendTime: number, element: HTMLDivElement | null) => {
@ -118,7 +128,12 @@ export default function Screen() {
const estimateMessageSize = React.useCallback( const estimateMessageSize = React.useCallback(
(index: number) => { (index: number) => {
const sendTime = messages[index]?.send_time; if (shouldShowConversationStart && index === 0) {
return FALLBACK_MESSAGE_HEIGHT;
}
const messageIndex = shouldShowConversationStart ? index - 1 : index;
const sendTime = messages[messageIndex]?.send_time;
if (sendTime === undefined) { if (sendTime === undefined) {
return FALLBACK_MESSAGE_HEIGHT; return FALLBACK_MESSAGE_HEIGHT;
@ -126,12 +141,12 @@ export default function Screen() {
return measuredHeights[sendTime] ?? FALLBACK_MESSAGE_HEIGHT; return measuredHeights[sendTime] ?? FALLBACK_MESSAGE_HEIGHT;
}, },
[measuredHeights, messages], [measuredHeights, messages, shouldShowConversationStart],
); );
// eslint-disable-next-line react-hooks/incompatible-library // eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: messages.length, count: virtualRowCount,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
getItemKey, getItemKey,
estimateSize: estimateMessageSize, estimateSize: estimateMessageSize,
@ -144,13 +159,14 @@ export default function Screen() {
return; return;
} }
const updateWidth = () => { const updateDimensions = () => {
setScrollWidth(element.clientWidth); setScrollWidth(element.clientWidth);
setScrollHeight(element.clientHeight);
}; };
updateWidth(); updateDimensions();
const observer = new ResizeObserver(updateWidth); const observer = new ResizeObserver(updateDimensions);
observer.observe(element); observer.observe(element);
return () => { return () => {
@ -300,14 +316,14 @@ export default function Screen() {
if ( if (
!scrollRef.current || !scrollRef.current ||
hasScrolledToBottomInitially || hasScrolledToBottomInitially ||
messages.length === 0 virtualRowCount === 0
) { ) {
return; return;
} }
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setHasScrolledToBottomInitially(true); setHasScrolledToBottomInitially(true);
}, [hasScrolledToBottomInitially, messages]); }, [hasScrolledToBottomInitially, virtualRowCount]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const count = liveMessagesSnapshot.length; const count = liveMessagesSnapshot.length;
@ -355,6 +371,9 @@ export default function Screen() {
}, [onScroll]); }, [onScroll]);
const [value, setValue] = React.useState(""); const [value, setValue] = React.useState("");
const totalSize = virtualizer.getTotalSize();
const verticalOffset = Math.max(0, scrollHeight - totalSize);
const contentHeight = Math.max(totalSize, scrollHeight);
// Render // Render
if (!hasValidChatUser) { if (!hasValidChatUser) {
@ -381,11 +400,38 @@ export default function Screen() {
<div <div
className="relative w-full" className="relative w-full"
style={{ style={{
height: `${virtualizer.getTotalSize()}px`, height: `${contentHeight}px`,
}} }}
> >
{virtualizer.getVirtualItems().map((virtualRow) => { {virtualizer.getVirtualItems().map((virtualRow) => {
const message = messages[virtualRow.index]; if (shouldShowConversationStart && virtualRow.index === 0) {
return (
<div
key="conversation-start"
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start + verticalOffset}px)`,
padding: "4px 0",
}}
>
<div className="w-full flex justify-start">
<div className="max-w-[80%] rounded-lg bg-muted px-2 py-px text-sm text-foreground/55 whitespace-pre-wrap break-all">
This is the start of your conversation with{" "}
</div>
</div>
</div>
);
}
const messageIndex = shouldShowConversationStart
? virtualRow.index - 1
: virtualRow.index;
const message = messages[messageIndex];
if (!message) { if (!message) {
return null; return null;
} }
@ -400,7 +446,7 @@ export default function Screen() {
top: 0, top: 0,
left: 0, left: 0,
width: "100%", width: "100%",
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start + verticalOffset}px)`,
padding: "4px 0", padding: "4px 0",
}} }}
> >

1
todo.md Normal file
View file

@ -0,0 +1 @@
- Move legal to extra onboarding package