diff --git a/package-lock.json b/package-lock.json index f513d53..5df0e1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0", "@fontsource-variable/inter": "5.2.8", + "@fontsource/caveat": "5.3.0", "@fontsource/inter": "5.2.8", "@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git", "@tanstack/react-query": "5.101.2", @@ -2368,6 +2369,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/caveat": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.3.0.tgz", + "integrity": "sha512-eHTfzQxFSW9ABERRLNruHoNJmKed2J7pqTUc5lp4a4POMgXGKpxmWj3d9Hz10kmAg6iuTTLe6UagbA4bnsmfQw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/inter": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", diff --git a/package.json b/package.json index 547bcbb..a08614f 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0", "@fontsource-variable/inter": "5.2.8", + "@fontsource/caveat": "5.3.0", "@fontsource/inter": "5.2.8", "@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git", "@tanstack/react-query": "5.101.2", diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 31d7033..68bbffd 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -26,6 +26,7 @@ import { MATRIX_SPOILER_PROPERTY_NAME, MATRIX_SPOILER_REASON_PROPERTY_NAME, } from '../../../types/matrix/common'; +import { StationeryMedia, hashStationerySeed, stationeryMediaRot } from './StationeryMedia'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { parseGeoUri, scaleYDimension } from '../../utils/common'; import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache'; @@ -207,6 +208,13 @@ const resolveMediaBoxSize = ( h?: number ): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h); +/** Extra space after media so following text stays on the 24px ruled grid — applied outside the photo chrome */ +const STATIONERY_LINE_PX = 24; +const snapStationeryGridPad = (height: number): number => { + const rem = height % STATIONERY_LINE_PX; + return rem === 0 ? 0 : STATIONERY_LINE_PX - rem; +}; + export function MImage({ content, renderImageContent, outlined }: MImageProps) { const imgInfo = content?.info; const mxcUrl = content.file?.url ?? content.url; @@ -215,11 +223,21 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) { } const { width, height } = resolveMediaBoxSize(mxcUrl, imgInfo?.w, imgInfo?.h); + const gridPad = snapStationeryGridPad(height); return ( - + {renderImageContent({ @@ -232,7 +250,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) { spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME], })} - + ); } @@ -269,9 +287,15 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: videoInfo.w ?? videoInfo.thumbnail_info?.w, videoInfo.h ?? videoInfo.thumbnail_info?.h ); + const gridPad = snapStationeryGridPad(height); return ( - + {renderVideoContent({ @@ -300,7 +327,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME], })} - + ); } @@ -439,12 +466,16 @@ export function MSticker({ content, renderImageContent }: MStickerProps) { return ; } const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152); + const tapeAlt = hashStationerySeed(mxcUrl) % 2 === 1; return ( {renderImageContent({ diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts index f020080..3a78776 100644 --- a/src/app/components/message/Reaction.css.ts +++ b/src/app/components/message/Reaction.css.ts @@ -53,6 +53,19 @@ export const Reaction = style([ }, ]); +export const ReactionStack = style([ + DefaultReset, + { + position: 'relative', + display: 'inline-grid', + placeItems: 'center', + gridTemplateColumns: '1fr', + gridTemplateRows: '1fr', + lineHeight: toRem(20), + minWidth: 0, + }, +]); + export const ReactionText = style([ DefaultReset, { @@ -60,7 +73,15 @@ export const ReactionText = style([ maxWidth: toRem(150), display: 'inline-flex', alignItems: 'center', + justifyContent: 'center', lineHeight: toRem(20), + gridArea: '1 / 1', + transformOrigin: 'center center', + // Fan each stacked copy so count>1 reads as multiple stickers + transform: `translate( + calc(var(--sticker-i, 0) * 7px - 2px), + calc(var(--sticker-i, 0) * -5px) + ) rotate(calc((var(--sticker-i, 0) - 1) * 12deg))`, }, ]); diff --git a/src/app/components/message/Reaction.tsx b/src/app/components/message/Reaction.tsx index 93180e4..c6ff295 100644 --- a/src/app/components/message/Reaction.tsx +++ b/src/app/components/message/Reaction.tsx @@ -7,6 +7,8 @@ import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji'; import { getMemberDisplayName } from '../../utils/room'; import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +const MAX_STICKER_STACK = 4; + export const Reaction = as< 'button', { @@ -15,35 +17,67 @@ export const Reaction = as< reaction: string; useAuthentication?: boolean; } ->(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => ( - - - {reaction.startsWith('mxc://') ? ( - {reaction} - ) : ( - - {reaction} +>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => { + const stackCount = Math.min(Math.max(count, 1), MAX_STICKER_STACK); + const showCount = count > 2; + const isCustomEmoji = reaction.startsWith('mxc://'); + const customSrc = isCustomEmoji + ? mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction + : undefined; + + return ( + + + {Array.from({ length: stackCount }, (_, i) => { + // Draw back-to-front so the top sticker is the last layer + const layer = stackCount - 1 - i; + return ( + + {isCustomEmoji ? ( + {i + ) : ( + + {reaction} + + )} + + ); + })} + + {showCount && ( + + {count} )} - - - {count} - - -)); + + ); +}); type ReactionTooltipMsgProps = { room: Room; diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index fd8e99f..a14da2b 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -14,29 +14,54 @@ import { useRoomEvent } from '../../hooks/useRoomEvent'; import colorMXID from '../../../util/colorMXID'; import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useOtherUserColor } from '../../hooks/useUserColor'; +import { isStationeryTheme, useTheme } from '../../hooks/useTheme'; +import { STATIONERY_NAME_INK } from '../../utils/paperSafeInk'; type ReplyLayoutProps = { userColor?: string; username?: ReactNode; }; export const ReplyLayout = as<'div', ReplyLayoutProps>( - ({ username, userColor, className, children, ...props }, ref) => ( - - - - {username} + ({ username, userColor, className, children, ...props }, ref) => { + const theme = useTheme(); + const isStationery = isStationeryTheme(theme); + + return ( + + + + {username} + + + {children} + - - {children} - - - ) + ); + } ); export const ThreadIndicator = as<'div'>(({ ...props }, ref) => ( diff --git a/src/app/components/message/StationeryMedia.tsx b/src/app/components/message/StationeryMedia.tsx new file mode 100644 index 0000000..9a80b15 --- /dev/null +++ b/src/app/components/message/StationeryMedia.tsx @@ -0,0 +1,293 @@ +import React, { + ComponentProps, + CSSProperties, + MouseEventHandler, + ReactNode, + useCallback, + useMemo, + useRef, +} from 'react'; +import { Attachment } from './attachment'; +import { isStationeryTheme, useTheme } from '../../hooks/useTheme'; + +type Corner = 'tl' | 'tr' | 'bl' | 'br'; + +type StationeryMediaProps = ComponentProps & { + children: ReactNode; + /** Stable id (e.g. mxc URL) so tilt/tape don't reshuffle on timeline re-renders */ + tiltSeed?: string; + /** Invisible bottom spacer (px) so following text stays on the ruled grid */ + gridPad?: number; +}; + +const CORNERS: { id: Corner; x: 0 | 1; y: 0 | 1 }[] = [ + { id: 'tl', x: 0, y: 0 }, + { id: 'tr', x: 1, y: 0 }, + { id: 'bl', x: 0, y: 1 }, + { id: 'br', x: 1, y: 1 }, +]; + +const MEDIA_ROTS = [-0.9, 0.7, -0.5, 1.0, -1.1, 0.8, -0.4, 0.9] as const; + +export function hashStationerySeed(seed: string): number { + let h = 2166136261; + for (let i = 0; i < seed.length; i += 1) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +export function stationeryMediaRot(seed: string): string { + return `${MEDIA_ROTS[hashStationerySeed(seed) % MEDIA_ROTS.length]}deg`; +} + +const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); + +/** + * Fold sits halfway between corner and cursor; flap hinges on that fold. + * `keep` = remaining photo; `flap` = lifted triangle. + */ +function cornerPeelPaths( + id: Corner, + w: number, + h: number, + cx: number, + cy: number, + ux: number, + uy: number, + size: number +): { + keep: string; + flap: string; + origin: string; + axisX: number; + axisY: number; + hingeSign: number; +} { + const eps = 0.0001; + // Fold line through the midpoint on corner→cursor, ⊥ to that ray + const px = cx + ux * size; + const py = cy + uy * size; + + let ax = 0; + let ay = 0; + let bx = 0; + let by = 0; + + if (id === 'tr') { + ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w); + ay = 0; + bx = w; + by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h); + } else if (id === 'tl') { + ax = clamp(Math.abs(ux) > eps ? px + (py * uy) / ux : px, 0, w); + ay = 0; + bx = 0; + by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h); + } else if (id === 'br') { + ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w); + ay = h; + bx = w; + by = clamp(Math.abs(uy) > eps ? py - ((w - px) * ux) / uy : py, 0, h); + } else { + ax = clamp(Math.abs(ux) > eps ? px + ((py - h) * uy) / ux : px, 0, w); + ay = h; + bx = 0; + by = clamp(Math.abs(uy) > eps ? py - ((0 - px) * ux) / uy : py, 0, h); + } + + let keep: string; + if (id === 'tr') { + keep = `polygon(0 0, ${ax}px 0, ${w}px ${by}px, ${w}px ${h}px, 0 ${h}px)`; + } else if (id === 'tl') { + keep = `polygon(${ax}px 0, ${w}px 0, ${w}px ${h}px, 0 ${h}px, 0 ${by}px)`; + } else if (id === 'br') { + keep = `polygon(0 0, ${w}px 0, ${w}px ${by}px, ${ax}px ${h}px, 0 ${h}px)`; + } else { + keep = `polygon(0 0, ${w}px 0, ${w}px ${h}px, ${ax}px ${h}px, 0 ${by}px)`; + } + + const flap = `polygon(${cx}px ${cy}px, ${ax}px ${ay}px, ${bx}px ${by}px)`; + + // Hinge on the fold (midpoint), axis along the fold edge + const midX = (ax + bx) / 2; + const midY = (ay + by) / 2; + const fdx = bx - ax; + const fdy = by - ay; + const flen = Math.hypot(fdx, fdy) || 1; + const axisX = fdx / flen; + const axisY = fdy / flen; + // Sign so the corner swings up over the fold (right-hand rule) + const cross = fdx * (cy - midY) - fdy * (cx - midX); + const hingeSign = cross >= 0 ? 1 : -1; + + return { + keep, + flap, + origin: `${midX}px ${midY}px`, + axisX, + axisY, + hingeSign, + }; +} + +/** + * Stationery-theme image chrome: clear corner tape + a mouse-driven peel + * that shows a duplicated slice of the photo lifting toward the cursor. + */ +export function StationeryMedia({ + children, + tiltSeed = 'stationery', + gridPad = 0, + style, + ...attachmentProps +}: StationeryMediaProps) { + const theme = useTheme(); + const isStationery = isStationeryTheme(theme); + const peelRef = useRef(null); + + const tiltStyle = useMemo((): CSSProperties => { + return { + ...(style as CSSProperties | undefined), + // Margin sits outside the paper chrome — snaps the grid without a white bar + ...(isStationery && gridPad > 0 ? { marginBottom: `${gridPad}px` } : null), + ['--media-rot' as string]: stationeryMediaRot(tiltSeed), + }; + }, [tiltSeed, style, isStationery, gridPad]); + + const tapeAlt = useMemo(() => hashStationerySeed(tiltSeed) % 2 === 1, [tiltSeed]); + // Default tape covers TL; alt covers TR — peel only the free top corner + const peelCorner = useMemo( + () => (tapeAlt ? CORNERS.find((c) => c.id === 'tl')! : CORNERS.find((c) => c.id === 'tr')!), + [tapeAlt] + ); + + const clearPeel = useCallback((host: HTMLElement) => { + const peel = peelRef.current; + const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null; + if (peel) { + // Hide flap first (no opacity tween) so restoring the photo can't double-draw + peel.style.transition = 'none'; + peel.style.opacity = '0'; + peel.style.setProperty('--peel-hinge', '0deg'); + peel.style.clipPath = ''; + } + if (target) target.style.clipPath = ''; + }, []); + + const syncPeel = useCallback( + (host: HTMLElement, clientX: number, clientY: number) => { + const peel = peelRef.current; + if (!peel) return; + + const rect = host.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + const w = rect.width; + const h = rect.height; + + const best = peelCorner; + const cx = best.x * w; + const cy = best.y * h; + const dist = Math.hypot(cx - x, cy - y); + const maxReach = Math.min(w, h) * 0.55; + + // Fold halfway between corner and cursor; fade when leaving the corner zone + let size = 0; + let amount = 0; + if (dist > 8 && dist <= maxReach) { + size = dist * 0.5; + amount = Math.min(1, size / (Math.min(w, h) * 0.22)); + } else if (dist > maxReach && dist < maxReach * 1.35) { + const fade = 1 - (dist - maxReach) / (maxReach * 0.35); + size = maxReach * 0.5 * Math.max(0, fade); + amount = Math.max(0, fade); + } + + const img = host.querySelector('img'); + const url = img?.currentSrc || img?.src || ''; + + // Unit vector from corner → cursor (triangle aims this way) + let ux = x - cx; + let uy = y - cy; + if (best.id === 'tr') { + ux = Math.min(-0.05, ux); + uy = Math.max(0.05, uy); + } else { + ux = Math.max(0.05, ux); + uy = Math.max(0.05, uy); + } + const len = Math.hypot(ux, uy) || 1; + ux /= len; + uy /= len; + + peel.dataset.corner = best.id; + if (url) { + peel.style.setProperty('--peel-img', `url(${JSON.stringify(url)})`); + } + + const target = host.querySelector('[data-paarrot-media-height]') as HTMLElement | null; + if (target && amount > 0.06 && size > 6) { + const { keep, flap, origin, axisX, axisY, hingeSign } = cornerPeelPaths( + best.id, + w, + h, + cx, + cy, + ux, + uy, + size + ); + target.style.clipPath = keep; + peel.style.clipPath = flap; + peel.style.transformOrigin = origin; + peel.style.setProperty('--peel-axis-x', String(axisX)); + peel.style.setProperty('--peel-axis-y', String(axisY)); + peel.style.setProperty('--peel-hinge', `${hingeSign * amount * 52}deg`); + peel.style.setProperty('--peel-tip-x', `${cx}px`); + peel.style.setProperty('--peel-tip-y', `${cy}px`); + peel.style.setProperty('--peel-grad-r', `${Math.max(size * 1.15, 12)}px`); + peel.style.opacity = '1'; + } else { + clearPeel(host); + } + }, + [peelCorner, clearPeel] + ); + + const handleMove: MouseEventHandler = (evt) => { + syncPeel(evt.currentTarget, evt.clientX, evt.clientY); + }; + + const handleEnter: MouseEventHandler = (evt) => { + syncPeel(evt.currentTarget, evt.clientX, evt.clientY); + }; + + const handleLeave: MouseEventHandler = (evt) => { + clearPeel(evt.currentTarget); + }; + + if (!isStationery) { + return ( + + {children} + + ); + } + + return ( + + + ); +} diff --git a/src/app/components/message/layout/Base.tsx b/src/app/components/message/layout/Base.tsx index c1bfe00..02da644 100644 --- a/src/app/components/message/layout/Base.tsx +++ b/src/app/components/message/layout/Base.tsx @@ -1,7 +1,9 @@ -import React from 'react'; +import React, { CSSProperties, useMemo } from 'react'; import { Text, as } from 'folds'; import classNames from 'classnames'; import * as css from './layout.css'; +import { isStationeryTheme, useTheme } from '../../../hooks/useTheme'; +import { STATIONERY_NAME_INK } from '../../../utils/paperSafeInk'; export const MessageBase = as<'div', css.MessageBaseVariants>( ({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => ( @@ -10,6 +12,7 @@ export const MessageBase = as<'div', css.MessageBaseVariants>( css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }), className )} + data-message-collapsed={collapse ? '' : undefined} {...props} ref={ref} /> @@ -17,12 +20,45 @@ export const MessageBase = as<'div', css.MessageBaseVariants>( ); export const AvatarBase = as<'span'>(({ className, ...props }, ref) => ( - + )); -export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => ( - -)); +export const Username = as<'span'>(({ as: AsUsername = 'span', className, style, ...props }, ref) => { + const theme = useTheme(); + const isStationery = isStationeryTheme(theme); + const userColor = + typeof (style as CSSProperties | undefined)?.color === 'string' + ? ((style as CSSProperties).color as string) + : undefined; + + const safeStyle = useMemo((): CSSProperties | undefined => { + if (!isStationery) return style as CSSProperties | undefined; + return { + ...(style as CSSProperties | undefined), + // Name in ink; keep their color as an accent underline + color: STATIONERY_NAME_INK, + ...(userColor + ? { ['--username-accent' as string]: userColor, textDecorationColor: userColor } + : null), + }; + }, [style, isStationery, userColor]); + + return ( + + ); +}); export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => ( @@ -36,6 +72,8 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice? priority={notice ? '300' : '400'} className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)} data-allow-text-selection="true" + data-message-body="" + data-jumbo-emoji={jumboEmoji ? '' : undefined} {...props} ref={ref} /> diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index fc75c96..36f4d60 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -204,6 +204,14 @@ export const Username = style({ 'button&:hover, button&:focus-visible': { textDecoration: 'underline', }, + // Stationery uses a colored border-bottom accent instead + 'html.stationery &, body.stationery &': { + overflow: 'visible', + overflowY: 'visible', + }, + 'body.stationery button&:hover, body.stationery button&:focus-visible': { + textDecoration: 'none', + }, }, }); diff --git a/src/app/components/nav/NavCategory.tsx b/src/app/components/nav/NavCategory.tsx index 4ccf115..0ca2748 100644 --- a/src/app/components/nav/NavCategory.tsx +++ b/src/app/components/nav/NavCategory.tsx @@ -7,5 +7,10 @@ type NavCategoryProps = { children: ReactNode; }; export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => ( -
+
)); diff --git a/src/app/components/nav/NavItem.tsx b/src/app/components/nav/NavItem.tsx index 1ec1257..7a12d43 100644 --- a/src/app/components/nav/NavItem.tsx +++ b/src/app/components/nav/NavItem.tsx @@ -13,6 +13,7 @@ export const NavItem = as< diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 29b0e26..2e59cbd 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -6,6 +6,7 @@ import * as css from './style.css'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel'; import { useShowCompactMasterView } from '../../hooks/useCompactNav'; +import { isStationeryTheme, useTheme } from '../../hooks/useTheme'; type PageRootProps = { nav: ReactNode; @@ -43,6 +44,7 @@ export function PageNav({ grow={isMobile ? 'Yes' : undefined} className={classNames(css.PageNav({ size }), className)} shrink={isMobile ? 'Yes' : 'No'} + data-page-nav="" > {children} @@ -58,6 +60,7 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>( className={classNames(css.PageNavHeader({ outlined }), className)} variant="Background" size="600" + data-folder-tab="nav" {...props} ref={ref} /> @@ -73,13 +76,16 @@ export function PageNavContent({ scrollRef?: MutableRefObject; scrollProps?: React.ComponentProps; }) { + const theme = useTheme(); + const hideScrollbar = isStationeryTheme(theme); + return ( (({ className, ...props }, ref) => ( grow="Yes" direction="Column" className={classNames(ContainerColor({ variant: 'Surface' }), className)} + data-page="" {...props} ref={ref} /> @@ -106,6 +113,7 @@ export const PageHeader = as<'div', css.PageHeaderVariants>( as="header" size="600" className={classNames(css.PageHeader({ balance, outlined }), className)} + data-folder-tab-bar="" {...props} ref={ref} /> diff --git a/src/app/components/sidebar/Sidebar.tsx b/src/app/components/sidebar/Sidebar.tsx index 7caf1b2..71b6a7b 100644 --- a/src/app/components/sidebar/Sidebar.tsx +++ b/src/app/components/sidebar/Sidebar.tsx @@ -4,5 +4,10 @@ import React from 'react'; import * as css from './Sidebar.css'; export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => ( - + )); diff --git a/src/app/components/sidebar/SidebarContent.tsx b/src/app/components/sidebar/SidebarContent.tsx index b09ea64..6d060d2 100644 --- a/src/app/components/sidebar/SidebarContent.tsx +++ b/src/app/components/sidebar/SidebarContent.tsx @@ -8,10 +8,10 @@ type SidebarContentProps = { export function SidebarContent({ scrollable, sticky }: SidebarContentProps) { return ( <> - + {scrollable} - + {sticky} diff --git a/src/app/components/sidebar/SidebarItem.tsx b/src/app/components/sidebar/SidebarItem.tsx index b23772b..c7f1414 100644 --- a/src/app/components/sidebar/SidebarItem.tsx +++ b/src/app/components/sidebar/SidebarItem.tsx @@ -7,6 +7,7 @@ export const SidebarItem = as<'div', css.SidebarItemVariants>( ({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => ( @@ -54,6 +55,7 @@ export const SidebarAvatar = as<'div', css.SidebarAvatarVariants & ComponentProp @@ -64,6 +66,7 @@ export const SidebarFolder = as<'div', css.SidebarFolderVariants>( ({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => ( diff --git a/src/app/components/virtualizer/VirtualTile.tsx b/src/app/components/virtualizer/VirtualTile.tsx index 0c6a5bd..f70c168 100644 --- a/src/app/components/virtualizer/VirtualTile.tsx +++ b/src/app/components/virtualizer/VirtualTile.tsx @@ -11,7 +11,7 @@ export const VirtualTile = as<'div', VirtualTileProps>( ({ className, virtualItem, style, ...props }, ref) => (
( className={classNames(css.CategoryButton, className)} variant="Background" radii="Pill" + data-nav-category-btn="" before={ diff --git a/src/app/features/room/RoomInput.css.ts b/src/app/features/room/RoomInput.css.ts new file mode 100644 index 0000000..3c4ac0f --- /dev/null +++ b/src/app/features/room/RoomInput.css.ts @@ -0,0 +1,16 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { color, config } from 'folds'; +import * as editorCss from '../../components/editor/Editor.css'; + +export const RoomInputWrap = style({ + width: '100%', +}); + +globalStyle(`${RoomInputWrap} .${editorCss.Editor}`, { + borderRadius: 0, + boxShadow: 'none', + borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + borderLeft: 'none', + borderRight: 'none', + borderBottom: 'none', +}); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index f3bd88e..68cb697 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -108,6 +108,18 @@ import { useComposingCheck } from '../../hooks/useComposingCheck'; import { useOtherUserColor } from '../../hooks/useUserColor'; import { getMemberAvatarMxc } from '../../utils/room'; import { convertEmoticons, convertEmoticonsInHtml } from '../../utils/emoticonConverter'; +import * as css from './RoomInput.css'; + +/** + * Creates a UUID used to group image events into one carousel. + */ +const createCarouselUuid = (): string => { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; interface RoomInputProps { editor: Editor; @@ -306,12 +318,34 @@ export const RoomInput = forwardRef( }; const handleSendUpload = async (uploads: UploadSuccess[]) => { + const imageUploads = uploads.filter((upload) => { + const fileItem = selectedFiles.find((f) => f.file === upload.file); + return !!fileItem && fileItem.file.type.startsWith('image'); + }); + const carouselUuid = imageUploads.length > 1 ? createCarouselUuid() : undefined; + const imageUploadIndexByFile = new Map( + imageUploads.map((upload, index) => [upload.file, index] as const) + ); + const contentsPromises = uploads.map(async (upload) => { const fileItem = selectedFiles.find((f) => f.file === upload.file); if (!fileItem) throw new Error('Broken upload'); if (fileItem.file.type.startsWith('image')) { - return getImageMsgContent(mx, fileItem, upload.mxc); + const imageIndex = imageUploadIndexByFile.get(upload.file); + + return getImageMsgContent( + mx, + fileItem, + upload.mxc, + carouselUuid !== undefined && imageIndex !== undefined + ? { + uuid: carouselUuid, + index: imageIndex, + total: imageUploads.length, + } + : undefined + ); } if (fileItem.file.type.startsWith('video')) { return getVideoMsgContent(mx, fileItem, upload.mxc); @@ -584,7 +618,7 @@ export const RoomInput = forwardRef( }; return ( -
+
{selectedFiles.length > 0 && ( ( backgroundColor: color.SurfaceVariant.Container, border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, borderBottom: 'none', - borderTopLeftRadius: config.radii.R400, - borderTopRightRadius: config.radii.R400, + borderRadius: 0, marginBottom: config.space.S100, }} direction="Column" diff --git a/src/app/features/room/RoomInputPlaceholder.css.ts b/src/app/features/room/RoomInputPlaceholder.css.ts index d0873da..7fa8caf 100644 --- a/src/app/features/room/RoomInputPlaceholder.css.ts +++ b/src/app/features/room/RoomInputPlaceholder.css.ts @@ -5,6 +5,6 @@ export const RoomInputPlaceholder = style({ minHeight: toRem(48), backgroundColor: color.SurfaceVariant.Container, color: color.SurfaceVariant.OnContainer, - boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`, - borderRadius: config.radii.R400, + borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + borderRadius: 0, }); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 76b58fc..ce4353b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -2421,6 +2421,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ref={timelineContentRef} direction="Column" justifyContent="End" + data-chatroll="" style={{ minHeight: '100%', padding: `${config.space.S600} 0`, diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 6d7a7f7..034754d 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -14,7 +14,8 @@ import { RoomTimeline } from './RoomTimeline'; import { RoomViewTyping } from './RoomViewTyping'; import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; -import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; +import { RoomViewFollowing } from './RoomViewFollowing'; +import * as roomViewCss from './RoomViewFollowing.css'; import { Page } from '../../components/page'; import { RoomViewHeader } from './RoomViewHeader'; import { useKeyDown } from '../../hooks/useKeyDown'; @@ -109,7 +110,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { ) : ( <> - + - +
+ + {!hideActivity && } +
-
- {tombstoneEvent ? ( + {tombstoneEvent ? ( +
- ) : ( - <> - {canMessage && ( - - )} - {!canMessage && ( - - You do not have permission to post in this room - - )} - - )} -
- {hideActivity ? : } +
+ ) : ( + <> + {canMessage && ( + + )} + {!canMessage && ( + + You do not have permission to post in this room + + )} + + )}
)} diff --git a/src/app/features/room/RoomViewFollowing.css.ts b/src/app/features/room/RoomViewFollowing.css.ts index 18b53ac..b880513 100644 --- a/src/app/features/room/RoomViewFollowing.css.ts +++ b/src/app/features/room/RoomViewFollowing.css.ts @@ -2,6 +2,20 @@ import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; +/** Floats typing + read receipts over the bottom of the timeline. */ +export const RoomViewBottomFloat = style({ + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + zIndex: 1, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: config.space.S200, + pointerEvents: 'none', +}); + export const RoomViewFollowingPlaceholder = style([ DefaultReset, { @@ -15,10 +29,12 @@ export const RoomViewFollowing = recipe({ { minHeight: toRem(28), padding: `0 ${config.space.S400}`, - width: '100%', - backgroundColor: color.Surface.Container, + marginLeft: 'auto', + maxWidth: '50%', + backgroundColor: 'transparent', color: color.Surface.OnContainer, outline: 'none', + pointerEvents: 'auto', }, ], variants: { diff --git a/src/app/features/room/RoomViewFollowing.tsx b/src/app/features/room/RoomViewFollowing.tsx index 38f4aaf..053fc71 100644 --- a/src/app/features/room/RoomViewFollowing.tsx +++ b/src/app/features/room/RoomViewFollowing.tsx @@ -35,6 +35,10 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>( const eventId = latestEvent?.getId(); + if (names.length === 0) { + return null; + } + return ( <> {eventId && ( @@ -56,64 +60,58 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>( )} 0 ? 'button' : 'div'} - onClick={names.length > 0 ? () => setOpen(true) : undefined} - className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)} + as="button" + onClick={() => setOpen(true)} + className={classNames(css.RoomViewFollowing({ clickable: true }), className)} alignItems="Center" justifyContent="End" gap="200" {...props} ref={ref} > - {names.length > 0 && ( - <> - - - {names.length === 1 && ( - {names[0]} - )} - {names.length === 2 && ( - <> - {names[0]} - - {' and '} - - {names[1]} - - )} - {names.length === 3 && ( - <> - {names[0]} - - {', '} - - {names[1]} - - {' and '} - - {names[2]} - - )} - {names.length > 3 && ( - <> - {names[0]} - - {', '} - - {names[1]} - - {', '} - - {names[2]} - - {' and '} - - {names.length - 3} others - - )} - - - )} + + + {names.length === 1 && {names[0]}} + {names.length === 2 && ( + <> + {names[0]} + + {' and '} + + {names[1]} + + )} + {names.length === 3 && ( + <> + {names[0]} + + {', '} + + {names[1]} + + {' and '} + + {names[2]} + + )} + {names.length > 3 && ( + <> + {names[0]} + + {', '} + + {names[1]} + + {', '} + + {names[2]} + + {' and '} + + {names.length - 3} others + + )} + ); diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index f5dc78f..d388923 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -19,7 +19,9 @@ import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils'; -import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix'; +import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix'; +import colorMXID from '../../../util/colorMXID'; +import { useOtherUserColor } from '../../hooks/useUserColor'; import { _SearchPathSearchParams } from '../../pages/paths'; import * as css from './RoomViewHeader.css'; import { useRoomUnread } from '../../state/hooks/unread'; @@ -473,13 +475,23 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { const pinnedEvents = useRoomPinnedEvents(room); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const ecryptedRoom = !!encryptionEvent; - const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); + const isDirect = mDirects.has(room.roomId); + const avatarMxc = useRoomAvatar(room, isDirect); const name = useRoomName(room); const topic = useRoomTopic(room); const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; + const dmUserId = isDirect ? guessDmRoomUserId(room, mx.getSafeUserId()) : undefined; + const dmAvatarMxc = + (dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) || + (isDirect ? avatarMxc : undefined); + const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc); + const dmFolderTabColor = isDirect + ? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))` + : undefined; + const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId)); @@ -511,7 +523,24 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
)} - + {!showInPageHeader && ( )} - + {name} @@ -578,6 +607,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { )} + diff --git a/src/app/features/room/RoomViewTyping.css.ts b/src/app/features/room/RoomViewTyping.css.ts index 01b3281..ccc9550 100644 --- a/src/app/features/room/RoomViewTyping.css.ts +++ b/src/app/features/room/RoomViewTyping.css.ts @@ -1,5 +1,5 @@ import { keyframes, style } from '@vanilla-extract/css'; -import { DefaultReset, color, config } from 'folds'; +import { DefaultReset, color, config, toRem } from 'folds'; const SlideUpAnime = keyframes({ from: { @@ -13,15 +13,17 @@ const SlideUpAnime = keyframes({ export const RoomViewTyping = style([ DefaultReset, { - padding: `0 ${config.space.S500}`, - width: '100%', - backgroundColor: color.Surface.Container, + minHeight: toRem(28), + padding: `0 ${config.space.S400}`, + minWidth: 0, + flex: 1, + backgroundColor: 'transparent', color: color.Surface.OnContainer, - position: 'absolute', - bottom: 0, + pointerEvents: 'auto', animation: `${SlideUpAnime} 100ms ease-in-out`, }, ]); + export const TypingText = style({ flexGrow: 1, overflow: 'clip', diff --git a/src/app/features/room/RoomViewTyping.tsx b/src/app/features/room/RoomViewTyping.tsx index 11fd761..49cb828 100644 --- a/src/app/features/room/RoomViewTyping.tsx +++ b/src/app/features/room/RoomViewTyping.tsx @@ -45,78 +45,76 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>( }; return ( -
- - - - {typingNames.length === 1 && ( - <> - {typingNames[0]} - - {' is typing...'} - - - )} - {typingNames.length === 2 && ( - <> - {typingNames[0]} - - {' and '} - - {typingNames[1]} - - {' are typing...'} - - - )} - {typingNames.length === 3 && ( - <> - {typingNames[0]} - - {', '} - - {typingNames[1]} - - {' and '} - - {typingNames[2]} - - {' are typing...'} - - - )} - {typingNames.length > 3 && ( - <> - {typingNames[0]} - - {', '} - - {typingNames[1]} - - {', '} - - {typingNames[2]} - - {' and '} - - {typingNames.length - 3} others - - {' are typing...'} - - - )} - - - - - -
+ + + + {typingNames.length === 1 && ( + <> + {typingNames[0]} + + {' is typing...'} + + + )} + {typingNames.length === 2 && ( + <> + {typingNames[0]} + + {' and '} + + {typingNames[1]} + + {' are typing...'} + + + )} + {typingNames.length === 3 && ( + <> + {typingNames[0]} + + {', '} + + {typingNames[1]} + + {' and '} + + {typingNames[2]} + + {' are typing...'} + + + )} + {typingNames.length > 3 && ( + <> + {typingNames[0]} + + {', '} + + {typingNames[1]} + + {', '} + + {typingNames[2]} + + {' and '} + + {typingNames.length - 3} others + + {' are typing...'} + + + )} + + + + + ); } ); diff --git a/src/app/features/room/ThreadView.tsx b/src/app/features/room/ThreadView.tsx index 7d4511f..cf6c611 100644 --- a/src/app/features/room/ThreadView.tsx +++ b/src/app/features/room/ThreadView.tsx @@ -60,7 +60,8 @@ import { activeThreadIdAtomFamily } from '../../state/activeThread'; import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; import { RoomInput } from './RoomInput'; import { RoomViewTyping } from './RoomViewTyping'; -import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; +import { RoomViewFollowing } from './RoomViewFollowing'; +import * as roomViewCss from './RoomViewFollowing.css'; import { Message, Reactions, EncryptedContent } from './message'; import { Reply } from '../../components/message'; import { RenderMessageContent } from '../../components/RenderMessageContent'; @@ -731,8 +732,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
{/* Thread messages — grows to fill remaining space */} - - + + - +
+ + {!hideActivity && } +
{/* Thread input */} -
- -
- {hideActivity ? : } +
); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index e717792..ace65e8 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -802,8 +802,9 @@ export const Message = as<'div', MessageProps>( gap="300" direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'} justifyContent="SpaceBetween" - alignItems="Baseline" + alignItems="Center" grow="Yes" + data-message-header="" > ( className={classNames(css.ReactionsContainer, className)} gap="200" wrap="Wrap" + data-reactions="" {...props} ref={ref} > diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index dd44df3..5132dfd 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -4,6 +4,9 @@ import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME, MATRIX_SPOILER_PROPERTY_NAME, + PAARROT_CAROUSEL_INDEX_PROPERTY_NAME, + PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME, + PAARROT_CAROUSEL_UUID_PROPERTY_NAME, } from '../../../types/matrix/common'; import { getImageFileUrl, @@ -18,6 +21,12 @@ import { TUploadItem } from '../../state/room/roomInputDrafts'; import { encodeBlurHash } from '../../utils/blurHash'; import { scaleYDimension } from '../../utils/common'; +type CarouselMetadata = { + uuid: string; + index: number; + total: number; +}; + const generateThumbnailContent = async ( mx: MatrixClient, img: HTMLImageElement | HTMLVideoElement, @@ -46,7 +55,8 @@ const generateThumbnailContent = async ( export const getImageMsgContent = async ( mx: MatrixClient, item: TUploadItem, - mxc: string + mxc: string, + carouselMetadata?: CarouselMetadata ): Promise => { const { file, originalFile, encInfo, metadata } = item; const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile))); @@ -78,6 +88,11 @@ export const getImageMsgContent = async ( } else { content.url = mxc; } + if (carouselMetadata) { + content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME] = carouselMetadata.uuid; + content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME] = carouselMetadata.index; + content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME] = carouselMetadata.total; + } return content; }; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 36c1cea..3f643f4 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -1,7 +1,7 @@ import { lightTheme } from 'folds'; import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { onDarkFontWeight, onLightFontWeight } from '../../config.css'; -import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, twilightTheme } from '../../colors.css'; +import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, stationeryDarkTheme, stationeryTheme, twilightTheme } from '../../colors.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; import { pluginRegistry } from '../features/settings/plugins/PluginAPI'; @@ -17,6 +17,11 @@ export type Theme = { classNames: string[]; }; +export const isStationeryTheme = (theme: Theme | string): boolean => { + const id = typeof theme === 'string' ? theme : theme.id; + return id === 'stationery-theme' || id === 'stationery-dark-theme'; +}; + export const LightTheme: Theme = { id: 'light-theme', kind: ThemeKind.Light, @@ -63,6 +68,16 @@ export const CatppuccinMochaTheme: Theme = { kind: ThemeKind.Dark, classNames: ['catppuccin-mocha-theme', catppuccinMochaTheme, onDarkFontWeight, 'prism-dark'], }; +export const StationeryTheme: Theme = { + id: 'stationery-theme', + kind: ThemeKind.Light, + classNames: ['stationery-theme', 'stationery', stationeryTheme, onLightFontWeight, 'prism-light'], +}; +export const StationeryDarkTheme: Theme = { + id: 'stationery-dark-theme', + kind: ThemeKind.Dark, + classNames: ['stationery-dark-theme', 'stationery', stationeryDarkTheme, onDarkFontWeight, 'prism-dark'], +}; export const useThemes = (): Theme[] => { const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0); @@ -76,7 +91,7 @@ export const useThemes = (): Theme[] => { }, []); const builtInThemes: Theme[] = useMemo(() => [ - LightTheme, SilverTheme, DarkTheme, ButterTheme, + LightTheme, SilverTheme, StationeryTheme, StationeryDarkTheme, DarkTheme, ButterTheme, DiscordTheme, DiscordDarkerTheme, TwilightTheme, MochaTheme, CatppuccinMochaTheme ], []); @@ -112,6 +127,8 @@ export const useThemeNames = (): Record => { const builtInNames = { [LightTheme.id]: 'Light', [SilverTheme.id]: 'Silver', + [StationeryTheme.id]: 'Stationery', + [StationeryDarkTheme.id]: 'Stationery Dark', [DarkTheme.id]: 'Dark', [ButterTheme.id]: 'Butter', [DiscordTheme.id]: 'Discord', diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index eca6cff..9273691 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -29,7 +29,20 @@ export function SidebarNav() { + {!homeHidden && } diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 78e63e9..0049fb6 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -249,7 +249,7 @@ export function Direct() { const virtualizer = useVirtualizer({ count: sortedDirects.length, getScrollElement: () => scrollRef.current, - estimateSize: () => 38, + estimateSize: () => 36, overscan: 10, }); @@ -288,7 +288,7 @@ export function Direct() { - + -
+ {sortedDirects.length > 0 && ( +
+
{virtualizer.getVirtualItems().map((vItem) => { const roomId = sortedDirects[vItem.index]; const room = mx.getRoom(roomId); @@ -332,7 +335,9 @@ export function Direct() { ); })} +
+ )} diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 06d1137..3d74c33 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -309,7 +309,7 @@ export function Home() { const virtualizer = useVirtualizer({ count: listItems.length, getScrollElement: () => scrollRef.current, - estimateSize: () => 32, + estimateSize: () => 36, overscan: 10, }); @@ -399,7 +399,7 @@ export function Home() { - + -
+ {listItems.length > 0 && ( +
+
{virtualizer.getVirtualItems().map((vItem) => { const item = listItems[vItem.index]; if (!item) return null; @@ -446,7 +449,16 @@ export function Home() { key={vItem.index} ref={virtualizer.measureElement} > -
0 ? '1.5rem' : undefined }}> +
0 ? '1.5rem' : undefined, + position: 'relative', + }} + > {treeIcon && ( ); })} +
+ )} diff --git a/src/app/pages/client/home/HomeThreadsCategory.tsx b/src/app/pages/client/home/HomeThreadsCategory.tsx index 9b144d2..9677bcc 100644 --- a/src/app/pages/client/home/HomeThreadsCategory.tsx +++ b/src/app/pages/client/home/HomeThreadsCategory.tsx @@ -154,7 +154,7 @@ export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) { if (threads.length === 0) return null; return ( - + - {!closedCategories.has(THREADS_CATEGORY_ID) && - threads.map(({ room, thread }) => ( - - ))} + {!closedCategories.has(THREADS_CATEGORY_ID) && ( +
+ {threads.map(({ room, thread }) => ( + + ))} +
+ )}
); } diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 71473bf..89c3596 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -9,7 +9,6 @@ import React, { import { useAtom, useAtomValue } from 'jotai'; import { Avatar, Box, Button, IconButton, Line, Menu, MenuItem, PopOut, RectCords, Spinner, Text, color, config, toRem } from 'folds'; import { Icon, Icons } from '../../../components/icons'; -import { useVirtualizer } from '@tanstack/react-virtual'; import { JoinRule, Room, Thread, ThreadEvent } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import FocusTrap from 'focus-trap-react'; @@ -30,7 +29,6 @@ import { useSpaceSearchSelected, } from '../../../hooks/router/useSelectedSpace'; import { useSpace } from '../../../hooks/useSpace'; -import { VirtualTile } from '../../../components/virtualizer'; import { RoomNavCategoryButton, RoomNavItem, UnjoinedSubRoomItem } from '../../../features/room-nav'; import { makeNavCategoryId } from '../../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; @@ -74,6 +72,119 @@ import { SpaceOptionsMenu } from '../../../features/space/SpaceOptionsMenu'; import { ForumFeedSidebar } from '../../../features/forum/ForumFeedSidebar'; import * as forumBoardCss from '../../../features/forum/ForumBoardView.css'; +type SpaceHierarchyItem = + | { type: 'hierarchy'; roomId: string } + | { type: 'subroom'; roomId: string; room: Room; depth: number; isLast?: boolean } + | { type: 'unjoined-subroom'; roomId: string; depth: number; isLast?: boolean }; + +type SpaceNavSectionData = { + spaceRoomId: string; + categoryId: string; + label: string; + items: SpaceHierarchyItem[]; +}; + +type SpaceNavSectionProps = { + section: SpaceNavSectionData; + closed: boolean; + onCategoryClick: MouseEventHandler; + selectedRoomId?: string; + mDirects: Set; + notificationPreferences: ReturnType; + getToLink: (roomId: string) => string; +}; + +function SpaceNavSection({ + section, + closed, + onCategoryClick, + selectedRoomId, + mDirects, + notificationPreferences, + getToLink, +}: SpaceNavSectionProps) { + const mx = useMatrixClient(); + + return ( + + + + {section.label} + + + {section.items.length > 0 && ( +
+
+ {section.items.map((item, index) => { + if (item.type === 'unjoined-subroom') { + return ( + + ); + } + + if (item.type === 'subroom') { + return ( +
+
+ 0} + showAvatar={mDirects.has(item.roomId)} + direct={mDirects.has(item.roomId)} + linkPath={getToLink(item.roomId)} + notificationMode={getRoomNotificationMode( + notificationPreferences, + item.roomId + )} + /> +
+
+ ); + } + + const room = mx.getRoom(item.roomId); + if (!room || isSpace(room)) return null; + + return ( + + ); + })} +
+
+ )} +
+ ); +} + function SpaceHeader() { const space = useSpace(); const spaceName = useRoomName(space); @@ -272,61 +383,74 @@ export function Space() { return hierarchy.filter((entry) => !globalSubRoomIds.has(entry.roomId)); }, [hierarchy, globalSubRoomIds]); - // Flatten hierarchy to include sub-rooms - type HierarchyItem = - | { type: 'hierarchy'; roomId: string } - | { type: 'subroom'; roomId: string; room: Room; depth: number; isLast?: boolean } - | { type: 'unjoined-subroom'; roomId: string; depth: number; isLast?: boolean }; - - const flattenedHierarchy = useMemo(() => { - const items: HierarchyItem[] = []; + // One paper dropdown per space: space header + its rooms/sub-rooms + const navSections = useMemo(() => { + const sections: SpaceNavSectionData[] = []; + let current: SpaceNavSectionData | null = null; const processedSubRooms = new Set(); - const addSubRooms = (parentRoomId: string, baseDepth: number) => { + const addSubRooms = (parentRoomId: string, baseDepth: number, into: SpaceHierarchyItem[]) => { const room = mx.getRoom(parentRoomId); if (!room || isSpace(room)) return; - + const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? []; - - // Filter to only joined sub-rooms that haven't been processed - const joinedSubRooms = subRooms.filter(subRoomId => - !processedSubRooms.has(subRoomId) && mx.getRoom(subRoomId) + + const joinedSubRooms = subRooms.filter( + (subRoomId) => !processedSubRooms.has(subRoomId) && mx.getRoom(subRoomId) ); - + joinedSubRooms.forEach((subRoomId, index) => { processedSubRooms.add(subRoomId); - const isLast = index === joinedSubRooms.length - 1; const subRoom = mx.getRoom(subRoomId); - if (subRoom) { - // Joined sub-room - items.push({ type: 'subroom', roomId: subRoomId, room: subRoom, depth: baseDepth + 1, isLast }); - // Recursively add sub-rooms of sub-rooms - addSubRooms(subRoomId, baseDepth + 1); - } + if (!subRoom) return; + into.push({ + type: 'subroom', + roomId: subRoomId, + room: subRoom, + depth: baseDepth + 1, + isLast, + }); + addSubRooms(subRoomId, baseDepth + 1, into); }); }; + const startSection = (spaceRoomId: string, label: string) => { + current = { + spaceRoomId, + categoryId: makeNavCategoryId(space.roomId, spaceRoomId), + label, + items: [], + }; + sections.push(current); + return current; + }; + filteredHierarchy.forEach((entry) => { - items.push({ type: 'hierarchy', roomId: entry.roomId }); - - // Add sub-rooms after each non-space room + const isSpaceEntry = 'space' in entry && entry.space === true; const room = mx.getRoom(entry.roomId); + + if (isSpaceEntry || (room && isSpace(room))) { + startSection( + entry.roomId, + entry.roomId === space.roomId ? 'Rooms' : room?.name ?? entry.roomId + ); + return; + } + + if (!current) { + startSection(space.roomId, 'Rooms'); + } + + current!.items.push({ type: 'hierarchy', roomId: entry.roomId }); if (room && !isSpace(room)) { - addSubRooms(entry.roomId, 0); + addSubRooms(entry.roomId, 0, current!.items); } }); - return items; - }, [mx, filteredHierarchy]); - - const virtualizer = useVirtualizer({ - count: flattenedHierarchy.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 32, - overscan: 10, - }); + return sections; + }, [mx, filteredHierarchy, space.roomId]); const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => closedCategories.has(categoryId) @@ -410,110 +534,20 @@ export function Space() { )} - -
- - {virtualizer.getVirtualItems().map((vItem) => { - const item = flattenedHierarchy[vItem.index]; - if (!item) return null; - - // Unjoined sub-room item - if (item.type === 'unjoined-subroom') { - return ( - - - - ); - } - - // Sub-room item (nested under parent) - if (item.type === 'subroom') { - const paddingLeft = '30px'; - - let treeIcon = ''; - if (item.depth > 0) { - treeIcon = item.isLast ? '╰' : '├'; - } - - return ( - -
- {treeIcon && ( - - {treeIcon} - - )} -
- 0} - showAvatar={mDirects.has(item.roomId)} - direct={mDirects.has(item.roomId)} - linkPath={getToLink(item.roomId)} - notificationMode={getRoomNotificationMode(notificationPreferences, item.room.roomId)} - /> -
-
-
- ); - } - - // Hierarchy item (room or space) - const { roomId } = item; - const room = mx.getRoom(roomId); - if (!room) return null; - - if (isSpace(room)) { - const categoryId = makeNavCategoryId(space.roomId, roomId); - - return ( - -
- - - {roomId === space.roomId ? 'Rooms' : room?.name} - - -
-
- ); - } - - return ( - - - - ); - })} +
+ {navSections.map((section) => ( + + ))} diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 43b63a2..449a702 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -104,6 +104,7 @@ export const renderMatrixMention = ( {...customProps} className={css.Mention({ highlight: mx.getUserId() === userId })} data-mention-id={userId} + data-mention-self={mx.getUserId() === userId ? '' : undefined} > {`@${ (currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId) @@ -213,7 +214,7 @@ export const highlightText = ( text, regex, (match, pushIndex) => ( - + {match[0]} ), diff --git a/src/app/utils/paperSafeInk.ts b/src/app/utils/paperSafeInk.ts new file mode 100644 index 0000000..751adcc --- /dev/null +++ b/src/app/utils/paperSafeInk.ts @@ -0,0 +1,2 @@ +/** Stationery paper ink for display names (follows --stationery-ink) */ +export const STATIONERY_NAME_INK = 'var(--stationery-ink)'; diff --git a/src/colors.css.ts b/src/colors.css.ts index a8abd1d..86e808b 100644 --- a/src/colors.css.ts +++ b/src/colors.css.ts @@ -722,3 +722,199 @@ export const catppuccinMochaTheme = createTheme(color, { }, }); +/** Notebook / stationery desk — paper, ink, washi accents */ +export const stationeryTheme = createTheme(color, { + Background: { + Container: '#D9D0C0', + ContainerHover: '#CFC5B3', + ContainerActive: '#C4B9A5', + ContainerLine: '#B8AC96', + OnContainer: '#2E2A24', + }, + + Surface: { + Container: '#FFFCF5', + ContainerHover: '#F5F0E6', + ContainerActive: '#EBE4D6', + ContainerLine: '#DDD4C4', + OnContainer: '#2E2A24', + }, + + SurfaceVariant: { + Container: '#F5F0E6', + ContainerHover: '#EBE4D6', + ContainerActive: '#DDD4C4', + ContainerLine: '#CFC5B3', + OnContainer: '#2E2A24', + }, + + Primary: { + Main: '#3D7A5F', + MainHover: '#356B53', + MainActive: '#2E5C47', + MainLine: '#274E3C', + OnMain: '#FFFCF5', + Container: '#C8DFD2', + ContainerHover: '#B8D4C4', + ContainerActive: '#A8C9B6', + ContainerLine: '#98BEA8', + OnContainer: '#1E3D2F', + }, + + Secondary: { + Main: '#2E2A24', + MainHover: '#3F3A32', + MainActive: '#4A443B', + MainLine: '#554E44', + OnMain: '#FFFCF5', + Container: '#DDD4C4', + ContainerHover: '#CFC5B3', + ContainerActive: '#C4B9A5', + ContainerLine: '#B8AC96', + OnContainer: '#2E2A24', + }, + + Success: { + Main: '#2F7A4A', + MainHover: '#296B41', + MainActive: '#235C38', + MainLine: '#1E4E30', + OnMain: '#FFFCF5', + Container: '#C5E0CF', + ContainerHover: '#B5D6C1', + ContainerActive: '#A5CCB3', + ContainerLine: '#95C2A5', + OnContainer: '#1A4028', + }, + + Warning: { + Main: '#B87820', + MainHover: '#A66B1C', + MainActive: '#945E19', + MainLine: '#825215', + OnMain: '#FFFCF5', + Container: '#F5E4C0', + ContainerHover: '#F0DBAD', + ContainerActive: '#EBD29A', + ContainerLine: '#E6C987', + OnContainer: '#5C3C10', + }, + + Critical: { + Main: '#B83A3A', + MainHover: '#A63434', + MainActive: '#942E2E', + MainLine: '#822828', + OnMain: '#FFFCF5', + Container: '#F0C8C8', + ContainerHover: '#EBB8B8', + ContainerActive: '#E6A8A8', + ContainerLine: '#E19898', + OnContainer: '#5C1D1D', + }, + + Other: { + FocusRing: 'rgba(61, 122, 95, 0.45)', + Shadow: 'rgba(46, 42, 36, 0.18)', + Overlay: 'rgba(46, 42, 36, 0.45)', + }, +}); + +/** Stationery dark — Catppuccin Mocha colors + stationery chrome */ +export const stationeryDarkTheme = createTheme(color, { + Background: { + Container: '#11111b', + ContainerHover: '#181825', + ContainerActive: '#1e1e2e', + ContainerLine: '#313244', + OnContainer: '#cdd6f4', + }, + + Surface: { + Container: '#1e1e2e', + ContainerHover: '#313244', + ContainerActive: '#45475a', + ContainerLine: '#585b70', + OnContainer: '#cdd6f4', + }, + + SurfaceVariant: { + Container: '#313244', + ContainerHover: '#45475a', + ContainerActive: '#585b70', + ContainerLine: '#6c7086', + OnContainer: '#cdd6f4', + }, + + Primary: { + Main: '#cba6f7', + MainHover: '#b894e0', + MainActive: '#a682c9', + MainLine: '#9470b2', + OnMain: '#11111b', + Container: '#313244', + ContainerHover: '#45475a', + ContainerActive: '#585b70', + ContainerLine: '#6c7086', + OnContainer: '#cba6f7', + }, + + Secondary: { + Main: '#bac2de', + MainHover: '#a6adc8', + MainActive: '#9399b2', + MainLine: '#7f849c', + OnMain: '#11111b', + Container: '#1e1e2e', + ContainerHover: '#313244', + ContainerActive: '#45475a', + ContainerLine: '#585b70', + OnContainer: '#cdd6f4', + }, + + Success: { + Main: '#a6e3a1', + MainHover: '#94d98e', + MainActive: '#82cf7b', + MainLine: '#70c568', + OnMain: '#11111b', + Container: '#1a2e1a', + ContainerHover: '#213821', + ContainerActive: '#284228', + ContainerLine: '#2f4c2f', + OnContainer: '#a6e3a1', + }, + + Warning: { + Main: '#fab387', + MainHover: '#f8a070', + MainActive: '#f68d59', + MainLine: '#f47a42', + OnMain: '#11111b', + Container: '#2e1f0f', + ContainerHover: '#3a2714', + ContainerActive: '#462f19', + ContainerLine: '#52371e', + OnContainer: '#fab387', + }, + + Critical: { + Main: '#f38ba8', + MainHover: '#f07494', + MainActive: '#ed5d80', + MainLine: '#ea466c', + OnMain: '#11111b', + Container: '#2e1119', + ContainerHover: '#3a1620', + ContainerActive: '#461b27', + ContainerLine: '#52202e', + OnContainer: '#f38ba8', + }, + + Other: { + FocusRing: 'rgba(203, 166, 247, 0.6)', + Shadow: 'rgba(0, 0, 0, 0.9)', + Overlay: 'rgba(0, 0, 0, 0.8)', + }, +}); + diff --git a/src/index.css b/src/index.css index d906aa8..6449a95 100644 --- a/src/index.css +++ b/src/index.css @@ -133,6 +133,12 @@ body.twilight-theme { body.mocha-theme { background: linear-gradient(135deg, #1A1614 0%, #242019 50%, #2D2721 100%); } +body.stationery-theme { + background-color: #D9D0C0; +} +body.stationery-dark-theme { + background-color: #11111b; +} #root { width: 100%; height: 100%; @@ -422,6 +428,1451 @@ body.mocha-theme { transition: outline 0.2s ease; } +/* ========== Stationery theme — manila folders, notebook chat, post-it icons ========== */ +/* Shared by .stationery-theme (light) and .stationery-dark-theme via class "stationery" */ +.stationery { + --stationery-ink: #2e2a24; + --stationery-rule: rgba(90, 130, 185, 0.4); + --stationery-margin: rgba(196, 70, 70, 0.5); + --stationery-paper: #fffcf5; + --stationery-desk: #b9a888; + --manila: #e8d4a8; + --manila-dark: #d4bc86; + --manila-deep: #c4a86e; + --manila-edge: #a89058; + --manila-border: rgba(168, 144, 88, 0.42); + --manila-border-strong: rgba(168, 144, 88, 0.5); + --nav-tab: #efe6d4; + --nav-tab-hover: #e8dcc6; + --folder-tab-room: #f3e6c8; + --paper-inset: rgba(255, 255, 255, 0.45); + --paper-shadow: rgba(46, 42, 36, 0.12); + --paper-shadow-strong: rgba(46, 42, 36, 0.18); + --message-hover: rgba(200, 223, 210, 0.28); + --message-hover-edge: rgba(61, 122, 95, 0.4); + --media-mat: #fffcf5; + --postit-1: #ffe566; + --postit-2: #ffb3c6; + --postit-3: #b8e0d2; + --postit-4: #c5d4f0; + --postit-5: #f5c6a0; + --postit-6: #e0d4f5; + --postit-ink: #2e2a24; + --postit-rot: 0deg; + /* Light paper glows / cast shadows */ + --glow-rim: #fff; + --glow-rim-soft: rgba(255, 255, 255, 0.95); + --glow-rim-faint: rgba(255, 255, 255, 0.75); + --glow-accent: rgba(255, 214, 80, 0.55); + --highlight-inset: rgba(255, 255, 255, 0.35); + --highlight-inset-strong: rgba(255, 255, 255, 0.45); + --cast-shadow: rgba(46, 42, 36, 0.12); + --cast-shadow-mid: rgba(46, 42, 36, 0.16); + --cast-shadow-strong: rgba(46, 42, 36, 0.22); + --folder-edge: rgba(196, 168, 110, 0.55); + --folder-edge-soft: rgba(168, 144, 88, 0.25); + --sleeve-paper: var(--sleeve-paper); + --sleeve-paper-soft: var(--sleeve-paper-soft); + --tape-blend: multiply; + --selected-bright: 1.04; + --peel-wash-strong: rgba(255, 255, 255, 0.4); + --peel-wash-mid: rgba(255, 252, 245, 0.18); + --peel-wash-soft: rgba(255, 252, 245, 0.04); + --hl-selection: rgba(255, 220, 40, 0.5); + --code-mark: rgba(255, 229, 102, 0.4); + --hl-streak: rgba(255, 200, 0, 0.07); + --hl-wash-a: rgba(255, 220, 40, 0.22); + --hl-wash-b: rgba(255, 224, 50, 0.48); + --hl-wash-c: rgba(255, 214, 30, 0.42); + --hl-wash-d: rgba(255, 220, 40, 0.2); + --hl-pink-streak: rgba(255, 80, 140, 0.08); + --hl-pink-a: rgba(255, 140, 180, 0.22); + --hl-pink-b: rgba(255, 130, 170, 0.48); + --hl-pink-c: rgba(255, 110, 155, 0.42); + --hl-pink-d: rgba(255, 140, 180, 0.2); + --tape-fill-a: rgba(255, 230, 150, 0.25); + --tape-fill-b: rgba(255, 210, 110, 0.4); + --tape-fill-c: rgba(255, 220, 130, 0.32); + --tape-base: rgba(235, 195, 100, 0.32); + --tape-border: rgba(160, 120, 40, 0.22); + --font-secondary: 'Space Grotesk', 'Inter Variable', var(--font-emoji), sans-serif; +} + +.stationery #root { + background: var(--stationery-desk); +} + +/* Chat shell stays plain paper; ruled lines live on the scrolling chatroll */ +.stationery [data-page] { + background-color: var(--stationery-paper) !important; + background-image: none !important; + box-shadow: inset 4px 0 12px var(--cast-shadow); +} + +/* Ruled notebook — red margin is global; horizontal rules reset per message */ +.stationery { + --stationery-line: 24px; +} + +.stationery [data-chatroll] { + background-color: transparent; + /* Continuous red margin only — horizontal rules live on each message */ + background-image: linear-gradient( + 90deg, + transparent 57px, + var(--stationery-margin) 57px, + var(--stationery-margin) 59px, + transparent 59px + ); + background-attachment: local; + padding-top: calc(var(--stationery-line) * 2) !important; + padding-bottom: calc(var(--stationery-line) * 2) !important; +} + +.stationery [data-room-timeline-scroll] { + background-color: var(--stationery-paper) !important; +} + +/* Each message restarts the ruled grid so scroll never drifts text off the lines */ +.stationery [data-message-item] { + background-color: transparent; + background-image: repeating-linear-gradient( + transparent, + transparent calc(var(--stationery-line) - 1px), + var(--stationery-rule) calc(var(--stationery-line) - 1px), + var(--stationery-rule) var(--stationery-line) + ); + background-position: left top; + background-repeat: repeat-y; + background-size: 100% var(--stationery-line); + padding-top: 0 !important; + padding-bottom: 0 !important; + margin-top: 0 !important; +} + +/* Separator only when the next item starts a new message group (not a rapid continuation / last msg) */ +.stationery [data-message-item]:has(+ [data-message-item]:not([data-message-collapsed])) { + padding-bottom: var(--stationery-line) !important; +} + +.stationery [data-message-item][data-message-collapsed] { + margin-top: 0 !important; +} + +.stationery [data-message-body], +.stationery [data-message-item] p { + line-height: var(--stationery-line) !important; + margin: 0; +} + +.stationery [data-message-item] [data-message-body] { + min-height: var(--stationery-line); +} + +/* Username = ink signature on one ruled line */ +.stationery { + --stationery-signature: 'Caveat', 'Segoe Print', 'Bradley Hand', cursive; + /* Soft fountain-pen user colors on paper */ + --mx-uc-1: #2a6a9e; + --mx-uc-2: #9a3d7a; + --mx-uc-3: #2a8a6a; + --mx-uc-4: #c43a5c; + --mx-uc-5: #c45a1a; + --mx-uc-6: #1a8a8a; + --mx-uc-7: #4a4ab0; + --mx-uc-8: #5a8a20; +} + +.stationery [data-message-header] { + height: var(--stationery-line) !important; + min-height: var(--stationery-line) !important; + max-height: none !important; + align-items: center !important; + overflow: visible !important; + margin: 0 !important; + padding: 0 !important; +} + +.stationery [data-message-header] > * { + min-height: 0; + align-items: center !important; +} + +.stationery [data-message-username] { + display: inline-flex !important; + align-items: center !important; + height: var(--stationery-line) !important; + max-height: none !important; + line-height: 1 !important; + overflow: visible !important; + font-family: var(--stationery-signature) !important; + font-size: 1.25rem !important; + font-weight: 600 !important; + letter-spacing: 0.01em; + /* Caveat swashes hang past the em-box — don't clip or shrink them */ + flex-shrink: 0 !important; + min-width: max-content !important; + max-width: none !important; + padding: 0 0.28em 2px 0.04em !important; + margin: 0 !important; + text-shadow: 0.4px 0.6px 0 var(--cast-shadow); + color: var(--stationery-ink, #2e2a24) !important; + text-decoration: none !important; + text-overflow: clip !important; + white-space: nowrap !important; +} + +.stationery [data-message-username] span, +.stationery [data-message-username] b { + font-family: inherit !important; + font-size: inherit !important; + font-weight: inherit !important; + line-height: 1 !important; + color: inherit !important; + overflow: visible !important; + text-overflow: clip !important; + max-width: none !important; + min-width: 0 !important; +} + +/* Their color as a pen underline under the ink name */ +.stationery [data-message-username][data-username-accent], +.stationery [data-username-accent] { + text-decoration-line: underline !important; + text-decoration-style: solid !important; + text-decoration-color: var(--username-accent, #3d7a5f) !important; + text-decoration-thickness: 2px !important; + text-underline-offset: 1px !important; + text-decoration-skip-ink: none !important; + border-bottom: none !important; +} + +.stationery [data-message-username][data-username-accent]:hover, +.stationery [data-message-username][data-username-accent]:focus-visible, +.stationery [data-message-username]:hover, +.stationery [data-message-username]:focus-visible { + text-decoration-line: underline !important; + text-decoration-color: var(--username-accent, var(--stationery-ink)) !important; + text-decoration-thickness: 2.5px !important; + text-underline-offset: 1px !important; + border-bottom: none !important; +} + +.stationery [data-message-header] time { + font-family: var(--stationery-signature) !important; + font-size: 0.95rem !important; + line-height: 1 !important; + opacity: 0.55; + align-self: center; +} + +/* Reactions float in the inter-message gap — zero layout height keeps the 24px grid */ +.stationery [data-reactions] { + height: 0 !important; + margin: 0 !important; + padding: 0 !important; + gap: 10px !important; + overflow: visible !important; + position: relative; + z-index: 2; + align-self: flex-start; + /* Sit just below the message, into the ruled separator — not on the text */ + transform: translateY(-1px); + pointer-events: none; +} + +.stationery [data-reaction] { + background: transparent !important; + border: none !important; + border-radius: 0 !important; + padding: 0 2px !important; + gap: 6px !important; + align-items: center !important; + box-shadow: none !important; + transition: transform 0.18s ease !important; + pointer-events: auto; +} + +.stationery [data-reaction]:hover, +.stationery [data-reaction]:focus-visible, +.stationery button[data-reaction]:hover { + background: transparent !important; + box-shadow: none !important; + transform: translateY(-2px) scale(1.05); +} + +.stationery [data-reaction][aria-pressed='true'] { + background: transparent !important; + box-shadow: none !important; +} + +.stationery [data-reaction][aria-pressed='true'] [data-reaction-sticker] { + filter: + drop-shadow(0 0 0.6px var(--glow-rim)) + drop-shadow(0 0 1.2px var(--glow-rim)) + drop-shadow(0 0 2px var(--glow-rim)) + drop-shadow(0.5px 0.5px 0 var(--glow-rim-soft)) + drop-shadow(-1px -1px 0 var(--glow-rim-faint)) + drop-shadow(1px 2px 0 var(--cast-shadow)) + drop-shadow(2px 3px 3px var(--cast-shadow-strong)) + drop-shadow(0 0 3px var(--glow-accent)); +} + +.stationery [data-reaction-stack-layer] { + font-size: 1.25rem !important; + line-height: 1 !important; + /* Room for a wider fan */ + padding: 6px 14px 4px 6px; +} + +.stationery [data-reaction-sticker] { + font-size: 1.25rem !important; + line-height: 1 !important; + max-width: none !important; + /* Same white sticker outline + bevel as jumbo emoji */ + filter: + drop-shadow(0 0 0.6px var(--glow-rim)) + drop-shadow(0 0 1.2px var(--glow-rim)) + drop-shadow(0 0 2px var(--glow-rim)) + drop-shadow(0.5px 0.5px 0 var(--glow-rim-soft)) + drop-shadow(-1px -1px 0 var(--glow-rim-faint)) + drop-shadow(1px 2px 0 var(--cast-shadow)) + drop-shadow(2px 3px 3px var(--cast-shadow-strong)); + /* Clearer multi-sticker fan */ + transform: translate( + calc(var(--sticker-i, 0) * 8px - 2px), + calc(var(--sticker-i, 0) * -6px) + ) + rotate(calc((var(--sticker-i, 0) - 1) * 14deg)) !important; +} + +.stationery [data-reaction-sticker] img { + height: 1.25em !important; + width: auto !important; + max-width: 2.2em !important; + object-fit: contain; + border-radius: 4px; + background: color-mix(in srgb, var(--media-mat) 35%, transparent); + box-shadow: inset 0 0 0 2px var(--glow-rim-soft); +} + +.stationery [data-reaction-count] { + font-family: var(--stationery-signature) !important; + font-size: 1.05rem !important; + font-weight: 600 !important; + line-height: 1 !important; + color: var(--stationery-ink) !important; + opacity: 0.7; + min-width: 0.6em; + text-align: center; + align-self: flex-end; + padding-bottom: 2px; + transform: translate(4px, -2px); +} + +/* Avatar column = two ruled lines so the row height stays on-grid */ +.stationery [data-message-avatar] { + display: flex; + align-items: flex-start; + justify-content: center; + width: var(--stationery-line); + height: calc(var(--stationery-line) * 2); + min-height: calc(var(--stationery-line) * 2); + padding-top: 0 !important; +} + +.stationery [data-message-avatar] > * { + width: var(--stationery-line) !important; + height: var(--stationery-line) !important; + min-width: var(--stationery-line) !important; + min-height: var(--stationery-line) !important; +} + +/* Jumbo emoji → sticker on the notebook grid (3 ruled lines tall) */ +.stationery [data-jumbo-emoji] { + font-size: calc(var(--stationery-line) * 3) !important; + line-height: calc(var(--stationery-line) * 3) !important; + min-height: calc(var(--stationery-line) * 3); + overflow: visible !important; +} + +.stationery [data-jumbo-emoji] img, +.stationery [data-jumbo-emoji] span span { + border-radius: 8px; + /* White sticker outline + soft bevel / lift */ + filter: + drop-shadow(0 0 0.6px var(--glow-rim)) + drop-shadow(0 0 1.2px var(--glow-rim)) + drop-shadow(0 0 2px var(--glow-rim)) + drop-shadow(0.5px 0.5px 0 var(--glow-rim-soft)) + drop-shadow(-1px -1px 0 var(--glow-rim-faint)) + drop-shadow(1px 2px 0 var(--cast-shadow)) + drop-shadow(2px 3px 3px var(--cast-shadow-strong)); + transform: rotate(-5deg); + transform-origin: center center; +} + +.stationery [data-jumbo-emoji] img { + /* Custom emoji stickers get a paper edge */ + background: color-mix(in srgb, var(--media-mat) 35%, transparent); + box-shadow: inset 0 0 0 2px var(--glow-rim-soft); +} + +.stationery [data-message-item]:nth-child(2n) [data-jumbo-emoji] img, +.stationery [data-message-item]:nth-child(2n) [data-jumbo-emoji] span span { + transform: rotate(5deg); +} + +/* Left bars = manila folders */ +.stationery [data-sidebar], +.stationery [data-page-nav] { + position: relative; + background: + linear-gradient(90deg, rgba(168, 144, 88, 0.18) 0 1px, transparent 1px), + linear-gradient(180deg, var(--manila) 0%, var(--manila-dark) 100%) !important; + background-color: var(--manila) !important; + border-right: 1px solid var(--manila-edge) !important; + box-shadow: + 3px 0 0 var(--folder-edge), + 6px 0 0 var(--folder-edge-soft), + 4px 0 14px var(--cast-shadow); +} + +/* Let space post-its peek past the left bar edge */ +.stationery [data-sidebar] { + overflow: visible !important; + z-index: 20; +} + +.stationery [data-page-nav] { + z-index: 1; +} + +/* + * overflow-y: scroll forces overflow-x to clip. Widen the scrollport with + * negative margin + matching padding so post-its can hang into that gutter + * (over the room list) without being cropped. + */ +.stationery [data-sidebar-scroll-region], +.stationery [data-sidebar-sticky] { + overflow: visible !important; +} + +.stationery [data-sidebar-scroll] { + overflow-x: hidden !important; + overflow-y: auto !important; + margin-right: -25px !important; + padding-right: 25px !important; + width: calc(100% + 25px) !important; + max-width: none !important; + box-sizing: border-box !important; +} + +.stationery [data-sidebar] > *, +.stationery [data-sidebar] [class*='SidebarStack'], +.stationery [data-sidebar-item], +.stationery [data-sidebar-folder] { + overflow: visible !important; +} + +/* Bunch space icons closer together */ +.stationery [data-sidebar] [class*='SidebarStack'] { + gap: 8px !important; + padding-top: 8px !important; + padding-bottom: 8px !important; +} + +.stationery [data-sidebar-item], +.stationery [data-sidebar-folder] { + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +/* Folder tab on top of sidebar only (nav/room headers use Chrome trapezoid tabs) */ +.stationery [data-sidebar]::before { + content: ''; + position: absolute; + top: 8px; + left: 10px; + width: 36px; + height: 14px; + background: var(--manila-deep); + border-radius: 3px 3px 0 0; + box-shadow: inset 0 1px 0 var(--highlight-inset); + pointer-events: none; + z-index: 2; + opacity: 0.85; +} + +.stationery [data-page-nav]::before { + content: none !important; + display: none !important; +} + +/* —— Chrome-style folder tabs: straight steep sides, rounded top only —— */ +.stationery [data-folder-tab] { + --tab-slope: 12px; + --tab-fill: var(--manila); + position: relative; + z-index: 2; + flex-shrink: 0; + width: fit-content !important; + max-width: calc(100% - 16px) !important; + min-height: 0 !important; + height: auto !important; + margin: 8px 8px 0 !important; + padding: 5px calc(var(--tab-slope) + 10px) 6px !important; + border: none !important; + border-radius: 0 !important; + background: var(--tab-fill) !important; + background-color: var(--tab-fill) !important; + color: var(--stationery-ink) !important; + filter: none; + box-shadow: none !important; + /* Steeper straight sides; only the top corners round */ + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 40' preserveAspectRatio='none'%3E%3Cpath fill='black' d='M0 40 L8 7 L8 5 C8 2 10 0 14 0 L186 0 C190 0 192 2 192 5 L192 7 L200 40 Z'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 40' preserveAspectRatio='none'%3E%3Cpath fill='black' d='M0 40 L8 7 L8 5 C8 2 10 0 14 0 L186 0 C190 0 192 2 192 5 L192 7 L200 40 Z'/%3E%3C/svg%3E"); + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-mode: alpha; + mask-mode: alpha; +} + +.stationery [data-folder-tab]::after { + content: none !important; + display: none !important; +} + +.stationery [data-folder-tab='nav'] { + --tab-slope: 12px; + --tab-fill: var(--manila); + margin: 8px 8px 0 10px !important; + display: flex !important; + align-items: center !important; +} + +.stationery [data-folder-tab='nav'] > * { + flex-grow: 0 !important; + width: auto !important; + max-width: 100% !important; +} + +.stationery [data-folder-tab='room'] { + --tab-slope: 12px; + --tab-fill: var(--folder-tab-room); + margin: 0 !important; + display: flex !important; + align-items: center !important; + max-width: min(60%, 420px) !important; + background: var(--folder-tab-fill, var(--tab-fill)) !important; + background-color: var(--folder-tab-fill, var(--tab-fill)) !important; +} + +/* Manila strip behind / beside the channel title tab */ +.stationery [data-folder-tab-bar] { + background: var(--manila) !important; + background-color: var(--manila) !important; + border: none !important; + border-bottom: 1px solid var(--manila-border) !important; + box-shadow: none !important; + padding-top: 6px !important; + padding-bottom: 0 !important; + align-items: flex-end !important; + gap: 8px; + overflow: visible !important; +} + +.stationery [data-folder-tab] [class*='Text'], +.stationery [data-folder-tab] span { + position: relative; + z-index: 1; + font-size: 13px !important; + font-weight: 600 !important; + line-height: 1.25 !important; +} + +.stationery [data-folder-tab='room'] [class*='Avatar'], +.stationery [data-folder-tab='room'] [data-avatar] { + width: 22px !important; + height: 22px !important; + min-width: 22px !important; + min-height: 22px !important; +} + +/* Generic headers that aren't folder tabs stay quiet */ +.stationery header:not([data-folder-tab]):not([data-folder-tab-bar]) { + background: color-mix(in srgb, var(--manila) 92%, transparent) !important; + border-bottom: 1px solid var(--manila-border); +} + +/* —— Space icons as oversized post-its that peek past the left bar —— */ +.stationery [data-sidebar-item], +.stationery [data-sidebar-folder] { + z-index: 20; +} + +.stationery [data-sidebar-avatar] { + --postit-rot: -2deg; + position: relative; + width: 62px !important; + height: 62px !important; + min-width: 62px !important; + min-height: 62px !important; + margin-right: 0 !important; + border-radius: 2px !important; + overflow: hidden !important; + padding: 4px; + box-sizing: border-box; + background: var(--postit-1) !important; + box-shadow: + 0 1px 0 var(--highlight-inset) inset, + 0 4px 2px var(--cast-shadow), + 0 8px 12px var(--cast-shadow-mid), + 0 14px 20px var(--cast-shadow), + inset 0 -6px 10px rgba(0, 0, 0, 0.05) !important; + border: none !important; + /* Sit past the manila bar edge into the room-list gutter */ + transform: rotate(var(--postit-rot)) translateX(16px); + transition: transform 0.18s ease, box-shadow 0.18s ease; + z-index: 21; +} + +.stationery [data-sidebar-avatar] > * { + border-radius: 1px !important; + overflow: hidden; + width: 100%; + height: 100%; +} + +/* Initials / icons scale with the oversized post-it (+40%); always dark on paper */ +.stationery [data-sidebar-avatar], +.stationery [data-sidebar-avatar] span, +.stationery [data-sidebar-avatar] p, +.stationery [data-sidebar-avatar] [class*='Text'] { + color: var(--postit-ink) !important; +} + +.stationery [data-sidebar-avatar] span, +.stationery [data-sidebar-avatar] p, +.stationery [data-sidebar-avatar] [class*='Text'] { + font-size: 1.4em !important; + line-height: 1.1 !important; +} + +.stationery [data-sidebar-avatar] svg { + width: 1.4em !important; + height: 1.4em !important; + min-width: 22px !important; + min-height: 22px !important; + color: var(--postit-ink) !important; + fill: none !important; +} + +.stationery [data-sidebar-avatar]:hover { + transform: rotate(var(--postit-rot)) translateX(16px) translateY(-2px) scale(1.06); + box-shadow: + 0 1px 0 var(--highlight-inset-strong) inset, + 0 5px 3px var(--cast-shadow), + 0 10px 16px var(--cast-shadow-mid), + 0 18px 28px var(--cast-shadow), + inset 0 -6px 10px rgba(0, 0, 0, 0.05) !important; + z-index: 22; +} + +.stationery [data-sidebar-item]:nth-child(6n + 1) [data-sidebar-avatar], +.stationery [data-sidebar-folder] [data-sidebar-avatar]:nth-child(6n + 1) { + --postit-rot: -4deg; + background: var(--postit-1) !important; +} +.stationery [data-sidebar-item]:nth-child(6n + 2) [data-sidebar-avatar], +.stationery [data-sidebar-folder] [data-sidebar-avatar]:nth-child(6n + 2) { + --postit-rot: 3deg; + background: var(--postit-2) !important; +} +.stationery [data-sidebar-item]:nth-child(6n + 3) [data-sidebar-avatar], +.stationery [data-sidebar-folder] [data-sidebar-avatar]:nth-child(6n + 3) { + --postit-rot: -2deg; + background: var(--postit-3) !important; +} +.stationery [data-sidebar-item]:nth-child(6n + 4) [data-sidebar-avatar], +.stationery [data-sidebar-folder] [data-sidebar-avatar]:nth-child(6n + 4) { + --postit-rot: 5deg; + background: var(--postit-4) !important; +} +.stationery [data-sidebar-item]:nth-child(6n + 5) [data-sidebar-avatar], +.stationery [data-sidebar-folder] [data-sidebar-avatar]:nth-child(6n + 5) { + --postit-rot: -6deg; + background: var(--postit-5) !important; +} +.stationery [data-sidebar-item]:nth-child(6n + 6) [data-sidebar-avatar], +.stationery [data-sidebar-folder] [data-sidebar-avatar]:nth-child(6n + 6) { + --postit-rot: 2deg; + background: var(--postit-6) !important; +} + +/* Opened space folder = nested papers in a manila sleeve */ +.stationery [data-sidebar-folder] { + position: relative; + border-radius: 2px !important; + background: linear-gradient(180deg, var(--manila-deep) 0%, var(--manila-dark) 100%) !important; + outline-color: var(--cast-shadow) !important; + box-shadow: + inset 0 0 0 1px var(--highlight-inset), + 1px 2px 4px var(--cast-shadow); + transform: rotate(-0.5deg); + overflow: visible !important; + gap: 6px !important; +} + +/* Vertical paper strip down expanded folder contents */ +.stationery [data-sidebar-folder]::after { + content: ''; + position: absolute; + left: 50%; + top: 40px; + bottom: 6px; + width: 22px; + margin-left: -11px; + background: + linear-gradient(90deg, transparent 0 2px, var(--sleeve-paper) 2px 20px, transparent 20px), + repeating-linear-gradient( + transparent, + transparent 11px, + rgba(90, 130, 185, 0.2) 11px, + rgba(90, 130, 185, 0.2) 12px + ); + background-color: var(--sleeve-paper-soft); + border-radius: 1px; + box-shadow: 1px 0 2px var(--cast-shadow); + pointer-events: none; + z-index: 0; + opacity: 0; +} + +.stationery [data-sidebar-folder]:has([data-sidebar-item] + [data-sidebar-item])::after, +.stationery [data-sidebar-folder]:has([data-sidebar-avatar] + [data-sidebar-item])::after { + opacity: 1; +} + +.stationery [data-sidebar-folder] [data-sidebar-item], +.stationery [data-sidebar-folder] [data-sidebar-avatar] { + position: relative; + z-index: 1; +} + +/* Active indicator = folder tab notch */ +.stationery [data-sidebar-item]::before { + background: var(--manila-deep) !important; + border-radius: 0 2px 2px 0 !important; + width: 4px !important; + box-shadow: 1px 0 0 var(--highlight-inset); +} + +/* —— Room list: each dropdown is its own paper sheet of post-its —— */ +.stationery [data-nav-category] { + position: relative; + display: flex; + flex-direction: column; + gap: 0; +} + +.stationery [data-nav-category][data-nav-dropdown] { + margin-bottom: 10px; +} + +.stationery [data-nav-category]::before, +.stationery [data-nav-depth]::before { + content: none !important; + display: none !important; +} + +/* Tab sits behind the paper; no gap between them */ +.stationery [data-nav-dropdown] > *:has([data-nav-category-btn]) { + position: relative; + z-index: 1; + margin: 0 !important; + padding: 0 !important; + min-height: 0 !important; + height: auto !important; + gap: 0 !important; + background: transparent !important; + border: none !important; + box-shadow: none !important; +} + +.stationery [data-nav-category-btn] { + background: var(--nav-tab) !important; + color: var(--stationery-ink) !important; + border-radius: 6px 6px 0 0 !important; + border: 1px solid var(--manila-border-strong) !important; + border-bottom: none !important; + box-shadow: inset 0 1px 0 var(--highlight-inset-strong) !important; + padding: 4px 12px !important; + min-height: 0 !important; + height: auto !important; + margin: 0 6px !important; + position: relative; + z-index: 1; +} + +.stationery [data-nav-category-btn]:hover, +.stationery [data-nav-category-btn]:focus-visible { + background: var(--nav-tab-hover) !important; +} + +/* Paper sheet in front of the tab, flush against it */ +.stationery [data-nav-rooms] { + --postit-rot: -0.6deg; + --postit-bg: var(--postit-1); + --postit-shift: 0px; + position: relative; + z-index: 2; + margin: -1px 2px 0 !important; + padding: 10px 8px 14px !important; + background-color: var(--stationery-paper) !important; + background-image: + linear-gradient( + 90deg, + transparent 12px, + rgba(196, 70, 70, 0.35) 12px, + rgba(196, 70, 70, 0.35) 13px, + transparent 13px + ), + repeating-linear-gradient( + transparent, + transparent 14px, + rgba(90, 130, 185, 0.18) 14px, + rgba(90, 130, 185, 0.18) 15px + ); + border: 1px solid var(--manila-border); + border-radius: 0 2px 2px 2px; + box-shadow: + 1px 2px 4px var(--paper-shadow), + 2px 5px 12px var(--paper-shadow), + inset 0 0 0 1px var(--paper-inset); + overflow: visible !important; +} + +/* No scrollbar on the channel list / page nav (still scrollable) */ +.stationery [data-page-nav] ::-webkit-scrollbar { + width: 0 !important; + height: 0 !important; + display: none !important; +} + +.stationery [data-page-nav] { + scrollbar-width: none !important; + -ms-overflow-style: none !important; +} + +.stationery [data-page-nav] [class*='ScrollTrack'], +.stationery [data-page-nav] [class*='ScrollThumb'] { + display: none !important; + opacity: 0 !important; + pointer-events: none !important; +} + +.stationery [data-nav-rooms], +.stationery [data-nav-room-list] { + overflow: visible !important; + scrollbar-width: none !important; +} + +/* Inner virtualizer list — do not fight absolute tile positioning */ +.stationery [data-nav-rooms] > [data-nav-room-list], +.stationery [data-nav-rooms][data-nav-room-list] { + position: relative; + display: block; + overflow: visible; +} + +.stationery [data-nav-rooms] [data-nav-room-list] > * { + overflow: visible !important; +} + +/* Each channel = post-it; next note sits on top of the previous bottom edge */ +.stationery [data-nav-rooms] [data-nav-item] { + --postit-rot: -0.4deg; + --postit-bg: var(--postit-1); + --postit-shift: 0px; + position: relative !important; + z-index: auto !important; + display: block !important; + width: calc(100% - 6px - var(--postit-shift)) !important; + min-height: 40px !important; + height: 40px !important; + max-height: 40px !important; + margin: 0 0 -4px !important; + margin-left: calc(2px + var(--postit-shift)) !important; + padding: 0 !important; + border-radius: 1px 1px 2px 1px !important; + background: var(--postit-bg) !important; + color: var(--postit-ink) !important; + box-shadow: + 0 1px 0 var(--highlight-inset-strong) inset, + 0 1px 1px var(--cast-shadow), + 1px 2px 3px var(--cast-shadow) !important; + outline: none !important; + transform: rotate(var(--postit-rot)); + transform-origin: 50% center; + transition: transform 0.15s ease, box-shadow 0.15s ease !important; + overflow: hidden !important; +} + +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 1)[data-nav-item], +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 1) [data-nav-item], +.stationery [data-nav-rooms][data-nav-room-list] > *:nth-child(6n + 1)[data-nav-item] { + --postit-rot: -0.6deg; + --postit-bg: var(--postit-1); + --postit-shift: 0px; +} +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 2)[data-nav-item], +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 2) [data-nav-item], +.stationery [data-nav-rooms][data-nav-room-list] > *:nth-child(6n + 2)[data-nav-item] { + --postit-rot: 0.5deg; + --postit-bg: var(--postit-2); + --postit-shift: 2px; +} +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 3)[data-nav-item], +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 3) [data-nav-item], +.stationery [data-nav-rooms][data-nav-room-list] > *:nth-child(6n + 3)[data-nav-item] { + --postit-rot: -0.3deg; + --postit-bg: var(--postit-3); + --postit-shift: 1px; +} +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 4)[data-nav-item], +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 4) [data-nav-item], +.stationery [data-nav-rooms][data-nav-room-list] > *:nth-child(6n + 4)[data-nav-item] { + --postit-rot: 0.7deg; + --postit-bg: var(--postit-4); + --postit-shift: 2px; +} +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 5)[data-nav-item], +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 5) [data-nav-item], +.stationery [data-nav-rooms][data-nav-room-list] > *:nth-child(6n + 5)[data-nav-item] { + --postit-rot: -0.5deg; + --postit-bg: var(--postit-5); + --postit-shift: 0px; +} +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 6)[data-nav-item], +.stationery [data-nav-rooms] > [data-nav-room-list] > *:nth-child(6n + 6) [data-nav-item], +.stationery [data-nav-rooms][data-nav-room-list] > *:nth-child(6n + 6)[data-nav-item] { + --postit-rot: 0.4deg; + --postit-bg: var(--postit-6); + --postit-shift: 1px; +} + +.stationery [data-nav-rooms] [data-nav-item]:hover, +.stationery [data-nav-rooms] [data-nav-item][data-hover='true'], +.stationery [data-nav-rooms] [data-nav-item][aria-selected='true'] { + z-index: auto !important; +} + +.stationery [data-nav-rooms] [data-nav-item]:hover, +.stationery [data-nav-rooms] [data-nav-item][data-hover='true'] { + box-shadow: + 0 1px 0 var(--highlight-inset-strong) inset, + 0 2px 2px var(--cast-shadow), + 2px 4px 8px var(--cast-shadow-mid) !important; +} + +.stationery [data-nav-rooms] [data-nav-item][aria-selected='true'] { + box-shadow: + 0 0 0 1.5px var(--message-hover-edge), + 0 1px 0 var(--highlight-inset-strong) inset, + 0 2px 3px var(--cast-shadow), + 2px 4px 8px var(--cast-shadow) !important; + filter: brightness(var(--selected-bright)); +} + +/* Row guts */ +.stationery [data-nav-rooms] [data-nav-item] > * { + padding-top: 0 !important; + padding-bottom: 0 !important; + padding-inline: 8px 10px !important; + min-height: 40px !important; + height: 40px !important; + align-items: center !important; + color: var(--postit-ink) !important; +} + +.stationery [data-nav-rooms] [data-nav-item] span, +.stationery [data-nav-rooms] [data-nav-item] p, +.stationery [data-nav-rooms] [data-nav-item] [class*='Text'] { + color: var(--postit-ink) !important; +} + +.stationery [data-nav-rooms] [data-nav-item] svg { + color: var(--postit-ink) !important; + fill: none !important; +} + +.stationery [data-nav-rooms] [data-nav-item] [data-avatar], +.stationery [data-nav-rooms] [data-nav-item] [class*='Avatar'] { + width: 18px !important; + height: 18px !important; + min-width: 18px !important; + min-height: 18px !important; +} + +.stationery [data-nav-rooms] [data-nav-item] svg { + width: 14px !important; + height: 14px !important; +} + +/* Nested depth: still post-its, stepped in on the paper */ +.stationery [data-nav-rooms] [data-nav-depth] { + min-height: 0 !important; + margin: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + padding-left: 0 !important; + box-shadow: none !important; +} + +.stationery [data-nav-rooms] [data-nav-depth='1'] { + padding-left: 22px !important; +} +.stationery [data-nav-rooms] [data-nav-depth='2'] { + padding-left: 40px !important; +} +.stationery [data-nav-rooms] [data-nav-depth='3'] { + padding-left: 58px !important; +} +.stationery [data-nav-rooms] [data-nav-depth='4'], +.stationery [data-nav-rooms] [data-nav-depth='5'], +.stationery [data-nav-rooms] [data-nav-depth='6'] { + padding-left: 76px !important; +} + +.stationery [data-nav-rooms] [data-nav-depth]:not([data-nav-depth='0']) [data-nav-item] { + width: calc(100% - 8px - var(--postit-shift)) !important; + margin-left: calc(2px + var(--postit-shift)) !important; +} + +/* Hide tree glyphs — the inset post-it shift carries nesting */ +.stationery [data-nav-rooms] [data-nav-depth] > span { + display: none !important; +} + +/* Lobby / search above the paper stay quiet ink chips, not post-its */ +.stationery [data-page-nav] [data-nav-category]:not([data-nav-rooms]) > [data-nav-item] { + min-height: 28px !important; + border-radius: 2px !important; + background: color-mix(in srgb, var(--media-mat) 35%, transparent) !important; + color: var(--stationery-ink) !important; + box-shadow: none !important; + transform: none !important; + margin: 0 2px !important; +} + +.stationery [data-page-nav] [data-nav-category]:not([data-nav-rooms]) > [data-nav-item][aria-selected='true'] { + background: var(--sleeve-paper-soft) !important; + box-shadow: inset 3px 0 0 var(--message-hover-edge) !important; +} + +.stationery button:hover:not([data-sidebar-avatar]):not([data-reaction]), +.stationery [role='button']:hover:not([data-sidebar-avatar]):not([data-reaction]) { + box-shadow: 0 2px 0 var(--cast-shadow); +} + +.stationery [data-message-item] { + transition: background-color 0.2s ease, box-shadow 0.2s ease; + border-radius: 2px; + /* Keep overflowing tape/peel inside this message's paint layer */ + isolation: isolate; +} + +.stationery [data-message-item]:hover { + background-color: var(--message-hover); + box-shadow: inset 3px 0 0 var(--message-hover-edge); +} + +/* Chat images / media = photo scraps with clear corner tape + slight tilt */ +.stationery [data-stationery-media] { + --media-rot: -0.6deg; + position: relative; + transform: rotate(var(--media-rot)); + transform-origin: center center; + border-radius: 0 !important; + overflow: visible; + background: var(--media-mat) !important; + box-shadow: + 0 1px 2px var(--cast-shadow), + 2px 4px 8px var(--cast-shadow-mid), + 4px 10px 20px var(--cast-shadow), + 0 0 0 3px var(--media-mat) !important; + transition: transform 0.25s ease, box-shadow 0.25s ease; + isolation: isolate; + overflow-anchor: none; + perspective: 520px; + transform-style: preserve-3d; +} + +.stationery [data-stationery-media], +.stationery [data-stationery-media] *, +.stationery [data-stationery-media] img, +.stationery [data-stationery-media] video { + border-radius: 0 !important; +} + +.stationery [data-stationery-media] > *:not([data-stationery-peel]) { + overflow: hidden; +} + +.stationery [data-stationery-media]::before, +.stationery [data-stationery-media]::after { + content: ''; + position: absolute; + width: 48px; + height: 16px; + /* Mid-weight film — readable ink, still reads as tape */ + background: + linear-gradient( + 105deg, + var(--tape-fill-a) 0%, + var(--tape-fill-b) 45%, + var(--tape-fill-c) 100% + ), + var(--tape-base); + border: 1px solid var(--tape-border); + box-shadow: + 0 1px 2px var(--cast-shadow), + 1px 2px 3px var(--cast-shadow), + inset 0 1px 0 var(--highlight-inset-strong); + pointer-events: none; + z-index: 2; + mix-blend-mode: var(--tape-blend); + opacity: 0.88; +} + +/* Default pair: top-left + bottom-right tape */ +.stationery [data-stationery-media]::before { + top: -2px; + left: -6px; + transform: rotate(-42deg); +} + +.stationery [data-stationery-media]::after { + bottom: -2px; + right: -6px; + left: auto; + top: auto; + transform: rotate(-42deg); +} + +/* Alternate pair: top-right + bottom-left tape (stable per media seed) */ +.stationery [data-stationery-media][data-tape-alt]::before { + top: -2px; + left: auto; + right: -6px; + transform: rotate(42deg); +} + +.stationery [data-stationery-media][data-tape-alt]::after { + bottom: -2px; + right: auto; + left: -6px; + transform: rotate(42deg); +} + +/* Mouse-driven corner peel — hinges on the fold (halfway to cursor) */ +.stationery [data-stationery-peel] { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 4; + opacity: 0; + background-image: var(--peel-img); + background-repeat: no-repeat; + background-size: 100% 100%; + box-shadow: none; + transition: none; + transform-style: preserve-3d; + /* Pivot on fold midpoint; axis along the fold edge */ + transform: rotate3d( + var(--peel-axis-x, 1), + var(--peel-axis-y, 0), + 0, + var(--peel-hinge, 0deg) + ); + filter: drop-shadow(1px 2px 4px var(--cast-shadow-strong)); + backface-visibility: hidden; +} + +/* Light wash: builds from the fold, brightest at the tip */ +.stationery [data-stationery-peel]::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient( + circle var(--peel-grad-r, 40px) at var(--peel-tip-x, 0) var(--peel-tip-y, 0), + var(--peel-wash-strong) 0%, + var(--peel-wash-mid) 50%, + var(--peel-wash-soft) 78%, + transparent 100% + ); +} + +.stationery [data-stationery-media]:hover { + box-shadow: + 0 2px 4px var(--cast-shadow), + 3px 8px 14px var(--cast-shadow-mid), + 6px 16px 28px var(--cast-shadow), + 0 0 0 3px var(--media-mat) !important; + /* Don't raise above neighboring messages (tape was covering them) */ +} + + +.stationery [data-message-item] [data-mx-spoiler], +.stationery code { + background: var(--code-mark); + border-radius: 2px; + box-shadow: 0 1px 0 var(--cast-shadow); +} + +/* Highlighter-marker strokes for selection, search hits, and @mentions */ +.stationery { + --hl-yellow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='28' viewBox='0 0 160 28' preserveAspectRatio='none'%3E%3Cdefs%3E%3Cfilter id='r' x='-8%25' y='-30%25' width='116%25' height='160%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.55 0.28' numOctaves='3' seed='7' result='n'/%3E%3CfeDisplacementMap in='SourceGraphic' in2='n' scale='3.2' xChannelSelector='R' yChannelSelector='G'/%3E%3C/filter%3E%3C/defs%3E%3Cg filter='url(%23r)'%3E%3Cpath d='M3 9.5 C28 6.5 52 11 78 8.5 C104 6 128 10.5 157 8.2 L156.5 20.8 C130 22.5 105 18.5 79 21 C53 23.5 28 19.5 3.5 21.2 Z' fill='%23ffe033' fill-opacity='0.62'/%3E%3Cpath d='M5 11.2 C36 9 62 13.5 90 10.8 C118 8.2 140 12.5 155 11 L154.2 18.6 C138 19.8 116 16.2 88 18.5 C60 20.8 34 16.8 5.5 18.2 Z' fill='%23ffd000' fill-opacity='0.38'/%3E%3Cpath d='M8 12.8 C40 11.5 70 14.8 100 12.2 C125 10.2 145 13.5 152 12.8 L151.5 16.8 C142 17.2 124 14.5 99 16.5 C70 18.8 42 15.5 8.5 16.5 Z' fill='%23fff06a' fill-opacity='0.28'/%3E%3C/g%3E%3C/svg%3E"); + --hl-pink: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='28' viewBox='0 0 160 28' preserveAspectRatio='none'%3E%3Cdefs%3E%3Cfilter id='r' x='-8%25' y='-30%25' width='116%25' height='160%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.55 0.28' numOctaves='3' seed='11' result='n'/%3E%3CfeDisplacementMap in='SourceGraphic' in2='n' scale='3.2' xChannelSelector='R' yChannelSelector='G'/%3E%3C/filter%3E%3C/defs%3E%3Cg filter='url(%23r)'%3E%3Cpath d='M3 9.5 C28 6.5 52 11 78 8.5 C104 6 128 10.5 157 8.2 L156.5 20.8 C130 22.5 105 18.5 79 21 C53 23.5 28 19.5 3.5 21.2 Z' fill='%23ff8cb4' fill-opacity='0.62'/%3E%3Cpath d='M5 11.2 C36 9 62 13.5 90 10.8 C118 8.2 140 12.5 155 11 L154.2 18.6 C138 19.8 116 16.2 88 18.5 C60 20.8 34 16.8 5.5 18.2 Z' fill='%23ff6a9a' fill-opacity='0.4'/%3E%3Cpath d='M8 12.8 C40 11.5 70 14.8 100 12.2 C125 10.2 145 13.5 152 12.8 L151.5 16.8 C142 17.2 124 14.5 99 16.5 C70 18.8 42 15.5 8.5 16.5 Z' fill='%23ffb0d0' fill-opacity='0.3'/%3E%3C/g%3E%3C/svg%3E"); +} + +.stationery ::selection { + background: var(--hl-selection); + color: inherit; +} + +.stationery ::-moz-selection { + background: var(--hl-selection); + color: inherit; +} + +.stationery [data-text-highlight], +.stationery [data-mention-id] { + background-color: transparent !important; + /* Three overlapping marker passes + fiber streaks */ + background-image: + repeating-linear-gradient( + 92deg, + transparent 0, + transparent 2px, + var(--hl-streak) 2px, + var(--hl-streak) 3px + ), + var(--hl-yellow), + linear-gradient( + 101deg, + transparent 0%, + var(--hl-wash-a) 3%, + var(--hl-wash-b) 12%, + var(--hl-wash-c) 88%, + var(--hl-wash-d) 97%, + transparent 100% + ) !important; + /* Oversized L/R so the marker bleeds past the glyphs */ + background-size: + calc(100% + 0.45em) 0.72em, + calc(100% + 0.55em) 0.95em, + calc(100% + 0.4em) 0.78em !important; + background-position: + -0.2em 0.58em, + -0.28em 0.42em, + -0.18em 0.52em !important; + background-repeat: no-repeat !important; + box-shadow: none !important; + border-radius: 0 !important; + padding: 0.05em 0.28em 0.08em !important; + margin: 0 -0.16em !important; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + color: inherit !important; + font-weight: 550 !important; + text-shadow: none; + filter: none; + text-decoration: none !important; +} + +/* Mentions that are you — pink highlighter strokes */ +.stationery [data-mention-self] { + background-image: + repeating-linear-gradient( + 92deg, + transparent 0, + transparent 2px, + var(--hl-pink-streak) 2px, + var(--hl-pink-streak) 3px + ), + var(--hl-pink), + linear-gradient( + 101deg, + transparent 0%, + var(--hl-pink-a) 3%, + var(--hl-pink-b) 12%, + var(--hl-pink-c) 88%, + var(--hl-pink-d) 97%, + transparent 100% + ) !important; +} + +.stationery a[data-mention-id] { + border: none !important; + outline: none; +} + +.stationery a[data-mention-id]:hover { + background-size: + calc(100% + 0.5em) 0.82em, + calc(100% + 0.6em) 1.05em, + calc(100% + 0.45em) 0.88em !important; + background-position: + -0.22em 0.52em, + -0.3em 0.36em, + -0.2em 0.46em !important; +} + +.stationery a[data-mention-self]:hover { + background-image: + repeating-linear-gradient( + 92deg, + transparent 0, + transparent 2px, + var(--hl-pink-streak) 2px, + var(--hl-pink-streak) 3px + ), + var(--hl-pink), + linear-gradient( + 101deg, + transparent 0%, + var(--hl-pink-a) 3%, + var(--hl-pink-b) 12%, + var(--hl-pink-c) 88%, + var(--hl-pink-d) 97%, + transparent 100% + ) !important; +} + +.stationery ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.stationery ::-webkit-scrollbar-track { + background: color-mix(in srgb, var(--manila) 45%, transparent); +} + +.stationery ::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, var(--manila-deep) 0%, var(--manila-edge) 100%); + border-radius: 2px; + border: 2px solid color-mix(in srgb, var(--manila) 45%, transparent); +} + +.stationery ::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, var(--manila-edge) 0%, var(--manila-dark) 100%); +} + +.stationery input:focus, +.stationery textarea:focus, +.stationery button:focus-visible { + outline: 2px solid var(--message-hover-edge); + outline-offset: 2px; +} + +.stationery textarea { + background-image: repeating-linear-gradient( + transparent, + transparent 1.4em, + rgba(120, 150, 190, 0.12) 1.4em, + rgba(120, 150, 190, 0.12) calc(1.4em + 1px) + ); + background-attachment: local; +} + + +/* Dark stationery — Catppuccin Mocha */ +.stationery-dark-theme { + --stationery-ink: #cdd6f4; + --stationery-rule: rgba(137, 180, 250, 0.28); + --stationery-margin: rgba(243, 139, 168, 0.5); + --stationery-paper: #1e1e2e; + --stationery-desk: #11111b; + --manila: #313244; + --manila-dark: #181825; + --manila-deep: #45475a; + --manila-edge: #585b70; + --manila-border: rgba(108, 112, 134, 0.45); + --manila-border-strong: rgba(108, 112, 134, 0.6); + --nav-tab: #45475a; + --nav-tab-hover: #585b70; + --folder-tab-room: #45475a; + --paper-inset: rgba(205, 214, 244, 0.06); + --paper-shadow: rgba(0, 0, 0, 0.4); + --paper-shadow-strong: rgba(0, 0, 0, 0.55); + --message-hover: rgba(203, 166, 247, 0.12); + --message-hover-edge: rgba(203, 166, 247, 0.55); + --media-mat: #313244; + /* + * Catppuccin Mocha brand stickies — Yellow / Pink / Teal / Blue / Peach / Mauve. + * Pastel over surface0 so the hue stays obvious without neon glow. + */ + --postit-1: color-mix(in srgb, #f9e2af 62%, #313244); /* Yellow */ + --postit-2: color-mix(in srgb, #f5c2e7 58%, #313244); /* Pink */ + --postit-3: color-mix(in srgb, #94e2d5 58%, #313244); /* Teal */ + --postit-4: color-mix(in srgb, #89b4fa 58%, #313244); /* Blue */ + --postit-5: color-mix(in srgb, #fab387 60%, #313244); /* Peach */ + --postit-6: color-mix(in srgb, #cba6f7 58%, #313244); /* Mauve */ + --postit-ink: #11111b; + /* No white halos — dark crust rims + soft mauve accent */ + --glow-rim: #11111b; + --glow-rim-soft: rgba(17, 17, 27, 0.95); + --glow-rim-faint: rgba(30, 30, 46, 0.85); + --glow-accent: rgba(203, 166, 247, 0.35); + --highlight-inset: rgba(205, 214, 244, 0.07); + --highlight-inset-strong: rgba(205, 214, 244, 0.11); + --cast-shadow: rgba(0, 0, 0, 0.35); + --cast-shadow-mid: rgba(0, 0, 0, 0.45); + --cast-shadow-strong: rgba(0, 0, 0, 0.6); + --folder-edge: rgba(69, 71, 90, 0.7); + --folder-edge-soft: rgba(49, 50, 68, 0.45); + --sleeve-paper: rgba(49, 50, 68, 0.92); + --sleeve-paper-soft: rgba(49, 50, 68, 0.85); + --tape-blend: soft-light; + --selected-bright: 1; + --peel-wash-strong: rgba(205, 214, 244, 0.22); + --peel-wash-mid: rgba(203, 166, 247, 0.12); + --peel-wash-soft: rgba(203, 166, 247, 0.04); + --hl-selection: rgba(249, 226, 175, 0.35); + --code-mark: rgba(249, 226, 175, 0.22); + --hl-streak: rgba(249, 226, 175, 0.08); + --hl-wash-a: rgba(249, 226, 175, 0.18); + --hl-wash-b: rgba(249, 226, 175, 0.32); + --hl-wash-c: rgba(249, 226, 175, 0.28); + --hl-wash-d: rgba(249, 226, 175, 0.16); + --hl-pink-streak: rgba(245, 194, 231, 0.1); + --hl-pink-a: rgba(245, 194, 231, 0.18); + --hl-pink-b: rgba(245, 194, 231, 0.32); + --hl-pink-c: rgba(243, 139, 168, 0.28); + --hl-pink-d: rgba(245, 194, 231, 0.16); + --tape-fill-a: rgba(249, 226, 175, 0.18); + --tape-fill-b: rgba(249, 226, 175, 0.28); + --tape-fill-c: rgba(203, 166, 247, 0.2); + --tape-base: rgba(203, 166, 247, 0.18); + --tape-border: rgba(186, 194, 222, 0.25); + --mx-uc-1: #89b4fa; + --mx-uc-2: #f5c2e7; + --mx-uc-3: #a6e3a1; + --mx-uc-4: #f38ba8; + --mx-uc-5: #fab387; + --mx-uc-6: #94e2d5; + --mx-uc-7: #cba6f7; + --mx-uc-8: #a6e3a1; +} + + *, *::before, *::after { diff --git a/src/index.tsx b/src/index.tsx index 8d3e1d5..dbf0e60 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,9 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource-variable/inter'; +import '@fontsource/caveat/500.css'; +import '@fontsource/caveat/600.css'; +import '@fontsource/caveat/700.css'; import 'folds/dist/style.css'; import { configClass, varsClass } from 'folds';