Add Stationery and Stationery Dark themes with notebook chrome.

Manila folders, ruled chat, post-it nav, and folder tabs; dark variant uses Catppuccin Mocha colors with muted stickies and soft glows.
This commit is contained in:
2026-07-23 00:24:10 +10:00
parent e7777f42d8
commit 61d41900cc
43 changed files with 2765 additions and 421 deletions

10
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<Attachment outlined={outlined} transparent>
<StationeryMedia
outlined={outlined}
transparent
tiltSeed={mxcUrl}
gridPad={gridPad}
>
<AttachmentBox
style={{ width: toRem(width), height: toRem(height) }}
style={{
width: toRem(width),
height: toRem(height),
['--media-h' as string]: `${height}px`,
}}
data-paarrot-media-height={height}
>
{renderImageContent({
@@ -232,7 +250,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</AttachmentBox>
</Attachment>
</StationeryMedia>
);
}
@@ -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 (
<Attachment outlined={outlined} transparent>
<StationeryMedia
outlined={outlined}
transparent
tiltSeed={mxcUrl}
gridPad={gridPad}
>
<AttachmentHeader>
<FileHeader
body={filename}
@@ -287,7 +311,10 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
/>
</AttachmentHeader>
<AttachmentBox
style={{ width: toRem(width), height: toRem(height) }}
style={{
width: toRem(width),
height: toRem(height),
}}
data-paarrot-media-height={height}
>
{renderVideoContent({
@@ -300,7 +327,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</AttachmentBox>
</Attachment>
</StationeryMedia>
);
}
@@ -439,12 +466,16 @@ export function MSticker({ content, renderImageContent }: MStickerProps) {
return <MessageBrokenContent />;
}
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
const tapeAlt = hashStationerySeed(mxcUrl) % 2 === 1;
return (
<AttachmentBox
data-stationery-media=""
data-tape-alt={tapeAlt ? '' : undefined}
style={{
height: toRem(height < 48 ? 48 : height),
width: toRem(152),
['--media-rot' as string]: stationeryMediaRot(mxcUrl),
}}
>
{renderImageContent({

View File

@@ -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))`,
},
]);

View File

@@ -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) => (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
{...props}
ref={ref}
>
<Text className={css.ReactionText} as="span" size="T400">
{reaction.startsWith('mxc://') ? (
<img
className={css.ReactionImg}
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
}
alt={reaction}
/>
) : (
<Text as="span" size="Inherit" truncate>
{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 (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
data-reaction=""
data-reaction-stack={stackCount}
aria-label={`${reaction}, ${count}`}
{...props}
ref={ref}
>
<span className={css.ReactionStack} data-reaction-stack-layer="">
{Array.from({ length: stackCount }, (_, i) => {
// Draw back-to-front so the top sticker is the last layer
const layer = stackCount - 1 - i;
return (
<Text
key={layer}
className={css.ReactionText}
as="span"
size="T400"
data-reaction-sticker=""
style={{
['--sticker-i' as string]: layer,
zIndex: i + 1,
}}
aria-hidden={i < stackCount - 1 ? true : undefined}
>
{isCustomEmoji ? (
<img
className={css.ReactionImg}
src={customSrc}
alt={i === stackCount - 1 ? reaction : ''}
/>
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
</Text>
)}
</Text>
);
})}
</span>
{showCount && (
<Text as="span" size="T300" data-reaction-count="">
{count}
</Text>
)}
</Text>
<Text as="span" size="T300">
{count}
</Text>
</Box>
));
</Box>
);
});
type ReactionTooltipMsgProps = {
room: Room;

View File

@@ -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) => (
<Box
className={classNames(css.Reply, className)}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
<Icon size="100" src={Icons.ReplyArrow} />
{username}
({ username, userColor, className, children, ...props }, ref) => {
const theme = useTheme();
const isStationery = isStationeryTheme(theme);
return (
<Box
className={classNames(css.Reply, className)}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Box
style={
isStationery
? {
color: STATIONERY_NAME_INK,
maxWidth: toRem(200),
...(userColor
? {
['--username-accent' as string]: userColor,
textDecorationColor: userColor,
}
: null),
}
: { color: userColor, maxWidth: toRem(200) }
}
data-username-accent={isStationery && userColor ? '' : undefined}
alignItems="Center"
shrink="No"
>
<Icon size="100" src={Icons.ReplyArrow} />
{username}
</Box>
<Box grow="Yes" className={css.ReplyContent}>
{children}
</Box>
</Box>
<Box grow="Yes" className={css.ReplyContent}>
{children}
</Box>
</Box>
)
);
}
);
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (

View File

@@ -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<typeof Attachment> & {
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<HTMLSpanElement>(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<HTMLElement> = (evt) => {
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
};
const handleEnter: MouseEventHandler<HTMLElement> = (evt) => {
syncPeel(evt.currentTarget, evt.clientX, evt.clientY);
};
const handleLeave: MouseEventHandler<HTMLElement> = (evt) => {
clearPeel(evt.currentTarget);
};
if (!isStationery) {
return (
<Attachment style={style} {...attachmentProps}>
{children}
</Attachment>
);
}
return (
<Attachment
{...attachmentProps}
style={tiltStyle}
data-stationery-media=""
data-tape-alt={tapeAlt ? '' : undefined}
onMouseEnter={handleEnter}
onMouseMove={handleMove}
onMouseLeave={handleLeave}
>
<span ref={peelRef} data-stationery-peel="" aria-hidden="true" />
{children}
</Attachment>
);
}

View File

@@ -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) => (
<span className={classNames(css.AvatarBase, className)} {...props} ref={ref} />
<span
className={classNames(css.AvatarBase, className)}
data-message-avatar=""
{...props}
ref={ref}
/>
));
export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
<AsUsername className={classNames(css.Username, className)} {...props} ref={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 (
<AsUsername
className={classNames(css.Username, className)}
data-message-username=""
data-username-accent={isStationery && userColor ? '' : undefined}
style={safeStyle}
{...props}
ref={ref}
/>
);
});
export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...props }, ref) => (
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={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}
/>

View File

@@ -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',
},
},
});

View File

@@ -7,5 +7,10 @@ type NavCategoryProps = {
children: ReactNode;
};
export const NavCategory = as<'div', NavCategoryProps>(({ className, ...props }, ref) => (
<div className={classNames(css.NavCategory, className)} {...props} ref={ref} />
<div
className={classNames(css.NavCategory, className)}
data-nav-category=""
{...props}
ref={ref}
/>
));

View File

@@ -13,6 +13,7 @@ export const NavItem = as<
<AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight}
data-nav-item=""
{...props}
ref={ref}
>

View File

@@ -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=""
>
<Box grow="Yes" direction="Column">
{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<HTMLDivElement | null>;
scrollProps?: React.ComponentProps<typeof Scroll>;
}) {
const theme = useTheme();
const hideScrollbar = isStationeryTheme(theme);
return (
<Box grow="Yes" direction="Column">
<Scroll
ref={scrollRef}
variant="Background"
direction="Vertical"
size="300"
size={hideScrollbar ? '0' : '300'}
hideTrack
visibility="Hover"
{...scrollProps}
@@ -95,6 +101,7 @@ export const Page = as<'div'>(({ 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}
/>

View File

@@ -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) => (
<AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} />
<AsSidebar
className={classNames(css.Sidebar, className)}
data-sidebar=""
{...props}
ref={ref}
/>
));

View File

@@ -8,10 +8,10 @@ type SidebarContentProps = {
export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
return (
<>
<Box direction="Column" grow="Yes">
<Box direction="Column" grow="Yes" data-sidebar-scroll-region="" style={{ overflow: 'visible', minWidth: 0 }}>
{scrollable}
</Box>
<Box direction="Column" shrink="No">
<Box direction="Column" shrink="No" data-sidebar-sticky="" style={{ overflow: 'visible' }}>
{sticky}
</Box>
</>

View File

@@ -7,6 +7,7 @@ export const SidebarItem = as<'div', css.SidebarItemVariants>(
({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => (
<AsSidebarAvatarBox
className={classNames(css.SidebarItem({ active }), className)}
data-sidebar-item=""
{...props}
ref={ref}
/>
@@ -54,6 +55,7 @@ export const SidebarAvatar = as<'div', css.SidebarAvatarVariants & ComponentProp
<Avatar
className={classNames(css.SidebarAvatar({ size, outlined }), className)}
radii={radii}
data-sidebar-avatar=""
{...props}
ref={ref}
/>
@@ -64,6 +66,7 @@ export const SidebarFolder = as<'div', css.SidebarFolderVariants>(
({ as: AsSidebarFolder = 'div', className, state, ...props }, ref) => (
<AsSidebarFolder
className={classNames(css.SidebarFolder({ state }), className)}
data-sidebar-folder=""
{...props}
ref={ref}
/>

View File

@@ -11,7 +11,7 @@ export const VirtualTile = as<'div', VirtualTileProps>(
({ className, virtualItem, style, ...props }, ref) => (
<div
className={classNames(css.VirtualTile, className)}
style={{ top: virtualItem.start, ...style }}
style={{ top: virtualItem.start, zIndex: virtualItem.index, ...style }}
data-index={virtualItem.index}
{...props}
ref={ref}

View File

@@ -10,6 +10,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>(
className={classNames(css.CategoryButton, className)}
variant="Background"
radii="Pill"
data-nav-category-btn=""
before={
<Icon
className={css.CategoryButtonIcon}

View File

@@ -41,7 +41,13 @@ export function UnjoinedSubRoomItem({ roomId, depth, isLast = false }: UnjoinedS
return (
<Box
style={{ paddingLeft, padding: `${config.space.S100} ${config.space.S200}`, minHeight: '1.5rem' }}
data-nav-depth={depth}
style={{
paddingLeft,
padding: `${config.space.S100} ${config.space.S200}`,
minHeight: '1.5rem',
position: 'relative',
}}
alignItems="Center"
gap="200"
>

View File

@@ -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',
});

View File

@@ -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<HTMLDivElement, RoomInputProps>(
};
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<HTMLDivElement, RoomInputProps>(
};
return (
<div ref={ref}>
<div ref={ref} className={css.RoomInputWrap}>
{selectedFiles.length > 0 && (
<UploadBoard
header={
@@ -690,8 +724,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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"

View File

@@ -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,
});

View File

@@ -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`,

View File

@@ -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 }) {
<ThreadView room={room} threadRootId={activeThreadId} />
) : (
<>
<Box grow="Yes" direction="Column">
<Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
<RoomTimeline
key={roomId}
room={room}
@@ -117,40 +118,42 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
roomInputRef={roomInputRef}
editor={editor}
/>
<RoomViewTyping room={room} />
<div className={roomViewCss.RoomViewBottomFloat}>
<RoomViewTyping room={room} />
{!hideActivity && <RoomViewFollowing room={room} />}
</div>
</Box>
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? (
{tombstoneEvent ? (
<div style={{ padding: `0 ${config.space.S400}` }}>
<RoomTombstone
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</div>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</Box>
</>
)}

View File

@@ -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: {

View File

@@ -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>(
</Overlay>
)}
<Box
as={names.length > 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 && (
<>
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
<Text size="T300" truncate>
{names.length === 1 && (
<b>{names[0]}</b>
)}
{names.length === 2 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names[1]}</b>
</>
)}
{names.length === 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names[2]}</b>
</>
)}
{names.length > 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names.length - 3} others</b>
</>
)}
</Text>
</>
)}
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
<Text size="T300" truncate>
{names.length === 1 && <b>{names[0]}</b>}
{names.length === 2 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names[1]}</b>
</>
)}
{names.length === 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names[2]}</b>
</>
)}
{names.length > 3 && (
<>
<b>{names[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{names[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{names.length - 3} others</b>
</>
)}
</Text>
</Box>
</>
);

View File

@@ -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) {
</IconButton>
</Box>
)}
<Box grow="Yes" alignItems="Center" gap="300">
<Box
data-folder-tab="room"
data-folder-tab-dm={isDirect ? '' : undefined}
shrink="No"
alignItems="Center"
gap="300"
style={{
minWidth: 0,
maxWidth: '100%',
...(dmFolderTabColor
? {
['--folder-tab-fill' as string]: dmFolderTabColor,
background: dmFolderTabColor,
backgroundColor: dmFolderTabColor,
}
: undefined),
}}
>
{!showInPageHeader && (
<Avatar size="300">
<RoomAvatar
@@ -528,7 +557,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
/>
</Avatar>
)}
<Box direction="Column">
<Box direction="Column" style={{ minWidth: 0 }}>
<Box alignItems="Center" gap="100">
<Text size={topic ? 'H5' : 'H3'} truncate>
{name}
@@ -578,6 +607,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
)}
</Box>
</Box>
<Box grow="Yes" />
<Box shrink="No" alignItems="Center" gap="100">
<CallIndicator roomId={room.roomId} />
<RoomCallButtons roomId={room.roomId} />

View File

@@ -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',

View File

@@ -45,78 +45,76 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
};
return (
<div style={{ position: 'relative' }}>
<Box
className={classNames(css.RoomViewTyping, className)}
alignItems="Center"
gap="400"
{...props}
ref={ref}
>
<TypingIndicator />
<Text className={css.TypingText} size="T300" truncate>
{typingNames.length === 1 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is typing...'}
</Text>
</>
)}
{typingNames.length === 2 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length === 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length > 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
</Text>
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
</div>
<Box
className={classNames(css.RoomViewTyping, className)}
alignItems="Center"
gap="400"
{...props}
ref={ref}
>
<TypingIndicator />
<Text className={css.TypingText} size="T300" truncate>
{typingNames.length === 1 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is typing...'}
</Text>
</>
)}
{typingNames.length === 2 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length === 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
{typingNames.length > 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{typingNames.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are typing...'}
</Text>
</>
)}
</Text>
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
);
}
);

View File

@@ -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) {
</Box>
{/* Thread messages — grows to fill remaining space */}
<Box grow="Yes" direction="Column">
<Box grow="Yes" style={{ position: 'relative' }} ref={containerRef}>
<Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
<Box grow="Yes" style={{ position: 'relative', minHeight: 0 }} ref={containerRef}>
<Scroll ref={scrollRef} visibility="Hover">
<Box
direction="Column"
@@ -759,22 +760,22 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
</Box>
</Scroll>
</Box>
<RoomViewTyping room={room} />
<div className={roomViewCss.RoomViewBottomFloat}>
<RoomViewTyping room={room} />
{!hideActivity && <RoomViewFollowing room={room} />}
</div>
</Box>
{/* Thread input */}
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
<RoomInput
room={room}
editor={editor}
roomId={room.roomId}
fileDropContainerRef={containerRef}
threadRootId={threadRootId}
ref={inputRef}
/>
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
<RoomInput
room={room}
editor={editor}
roomId={room.roomId}
fileDropContainerRef={containerRef}
threadRootId={threadRootId}
ref={inputRef}
/>
</Box>
</>
);

View File

@@ -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=""
>
<Box alignItems="Center" gap="200">
<Username

View File

@@ -55,6 +55,7 @@ export const Reactions = as<'div', ReactionsProps>(
className={classNames(css.ReactionsContainer, className)}
gap="200"
wrap="Wrap"
data-reactions=""
{...props}
ref={ref}
>

View File

@@ -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<IContent> => {
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;
};

View File

@@ -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<string, string> => {
const builtInNames = {
[LightTheme.id]: 'Light',
[SilverTheme.id]: 'Silver',
[StationeryTheme.id]: 'Stationery',
[StationeryDarkTheme.id]: 'Stationery Dark',
[DarkTheme.id]: 'Dark',
[ButterTheme.id]: 'Butter',
[DiscordTheme.id]: 'Discord',

View File

@@ -29,7 +29,20 @@ export function SidebarNav() {
<Sidebar>
<SidebarContent
scrollable={
<Scroll ref={scrollRef} variant="Background" size="0">
<Scroll
ref={scrollRef}
variant="Background"
size="0"
data-sidebar-scroll=""
style={{
marginRight: -25,
paddingRight: 25,
width: 'calc(100% + 25px)',
boxSizing: 'border-box',
overflowX: 'hidden',
overflowY: 'auto',
}}
>
<SidebarStack>
{!homeHidden && <HomeTab />}
<DirectTab />

View File

@@ -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() {
<NotificationsNavItem />
<InvitesNavItem />
</NavCategory>
<NavCategory>
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
@@ -298,13 +298,16 @@ export function Direct() {
Chats
</RoomNavCategoryButton>
</NavCategoryHeader>
<div
ref={animationRef}
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{sortedDirects.length > 0 && (
<div data-nav-rooms="">
<div
ref={animationRef}
data-nav-room-list=""
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedDirects[vItem.index];
const room = mx.getRoom(roomId);
@@ -332,7 +335,9 @@ export function Direct() {
</VirtualTile>
);
})}
</div>
</div>
)}
</NavCategory>
</Box>
</PageNavContent>

View File

@@ -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() {
</NavItem>
<PluginNavSlot location="channel-list" />
</NavCategory>
<NavCategory>
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
@@ -410,12 +410,15 @@ export function Home() {
</RoomNavCategoryButton>
</NavCategoryHeader>
<PluginNavSlot location="home-section" />
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{listItems.length > 0 && (
<div data-nav-rooms="">
<div
data-nav-room-list=""
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{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}
>
<div style={{ paddingLeft, display: 'flex', alignItems: 'center', minHeight: item.depth > 0 ? '1.5rem' : undefined }}>
<div
data-nav-depth={item.depth}
style={{
paddingLeft,
display: 'flex',
alignItems: 'center',
minHeight: item.depth > 0 ? '1.5rem' : undefined,
position: 'relative',
}}
>
{treeIcon && (
<span style={{
paddingRight: '2px',
@@ -475,7 +487,9 @@ export function Home() {
</VirtualTile>
);
})}
</div>
</div>
)}
</NavCategory>
</Box>
</PageNavContent>

View File

@@ -154,7 +154,7 @@ export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) {
if (threads.length === 0) return null;
return (
<NavCategory>
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(THREADS_CATEGORY_ID)}
@@ -164,15 +164,18 @@ export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) {
My Threads
</RoomNavCategoryButton>
</NavCategoryHeader>
{!closedCategories.has(THREADS_CATEGORY_ID) &&
threads.map(({ room, thread }) => (
<ThreadNavItem
key={`${room.roomId}:${thread.id}`}
room={room}
thread={thread}
selected={false}
/>
))}
{!closedCategories.has(THREADS_CATEGORY_ID) && (
<div data-nav-rooms="">
{threads.map(({ room, thread }) => (
<ThreadNavItem
key={`${room.roomId}:${thread.id}`}
room={room}
thread={thread}
selected={false}
/>
))}
</div>
)}
</NavCategory>
);
}

View File

@@ -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<HTMLButtonElement>;
selectedRoomId?: string;
mDirects: Set<string>;
notificationPreferences: ReturnType<typeof useRoomsNotificationPreferencesContext>;
getToLink: (roomId: string) => string;
};
function SpaceNavSection({
section,
closed,
onCategoryClick,
selectedRoomId,
mDirects,
notificationPreferences,
getToLink,
}: SpaceNavSectionProps) {
const mx = useMatrixClient();
return (
<NavCategory data-nav-dropdown="">
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={section.categoryId}
onClick={onCategoryClick}
closed={closed}
>
{section.label}
</RoomNavCategoryButton>
</NavCategoryHeader>
{section.items.length > 0 && (
<div data-nav-rooms="">
<div data-nav-room-list="">
{section.items.map((item, index) => {
if (item.type === 'unjoined-subroom') {
return (
<UnjoinedSubRoomItem
key={`${item.roomId}:${index}`}
roomId={item.roomId}
depth={item.depth}
isLast={item.isLast}
/>
);
}
if (item.type === 'subroom') {
return (
<div
key={`${item.roomId}:${index}`}
data-nav-depth={item.depth}
style={{
display: 'flex',
alignItems: 'center',
position: 'relative',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<RoomNavItem
room={item.room}
selected={selectedRoomId === item.roomId}
hideIcon={item.depth > 0}
showAvatar={mDirects.has(item.roomId)}
direct={mDirects.has(item.roomId)}
linkPath={getToLink(item.roomId)}
notificationMode={getRoomNotificationMode(
notificationPreferences,
item.roomId
)}
/>
</div>
</div>
);
}
const room = mx.getRoom(item.roomId);
if (!room || isSpace(room)) return null;
return (
<RoomNavItem
key={`${item.roomId}:${index}`}
room={room}
selected={selectedRoomId === item.roomId}
showAvatar={mDirects.has(item.roomId)}
direct={mDirects.has(item.roomId)}
linkPath={getToLink(item.roomId)}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
);
})}
</div>
</div>
)}
</NavCategory>
);
}
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<string>();
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() {
</NavItem>
</>
)}
<PluginNavSlot location="channel-list" />
</NavCategory>
<NavCategory
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const item = flattenedHierarchy[vItem.index];
if (!item) return null;
// Unjoined sub-room item
if (item.type === 'unjoined-subroom') {
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<UnjoinedSubRoomItem roomId={item.roomId} depth={item.depth} isLast={item.isLast} />
</VirtualTile>
);
}
// Sub-room item (nested under parent)
if (item.type === 'subroom') {
const paddingLeft = '30px';
let treeIcon = '';
if (item.depth > 0) {
treeIcon = item.isLast ? '╰' : '├';
}
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<div style={{ paddingLeft, display: 'flex', alignItems: 'center', minHeight: '1.5rem' }}>
{treeIcon && (
<span style={{
paddingRight: '2px',
opacity: 0.5,
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: '1',
userSelect: 'none'
}}>
{treeIcon}
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<RoomNavItem
room={item.room}
selected={selectedRoomId === item.roomId}
hideIcon={item.depth > 0}
showAvatar={mDirects.has(item.roomId)}
direct={mDirects.has(item.roomId)}
linkPath={getToLink(item.roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, item.room.roomId)}
/>
</div>
</div>
</VirtualTile>
);
}
// 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 (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={categoryId}
onClick={handleCategoryClick}
closed={closedCategories.has(categoryId)}
>
{roomId === space.roomId ? 'Rooms' : room?.name}
</RoomNavCategoryButton>
</NavCategoryHeader>
</div>
</VirtualTile>
);
}
return (
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
<RoomNavItem
room={room}
selected={selectedRoomId === roomId}
showAvatar={mDirects.has(roomId)}
direct={mDirects.has(roomId)}
linkPath={getToLink(roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
</VirtualTile>
);
})}
<PluginNavSlot location="channel-list" />
</NavCategory>
{navSections.map((section) => (
<SpaceNavSection
key={section.categoryId}
section={section}
closed={closedCategories.has(section.categoryId)}
onCategoryClick={handleCategoryClick}
selectedRoomId={selectedRoomId}
mDirects={mDirects}
notificationPreferences={notificationPreferences}
getToLink={getToLink}
/>
))}
</Box>
</PageNavContent>
</PageNav>

View File

@@ -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) => (
<span key={`highlight-${pushIndex}`} className={css.highlightText}>
<span key={`highlight-${pushIndex}`} className={css.highlightText} data-text-highlight="">
{match[0]}
</span>
),

View File

@@ -0,0 +1,2 @@
/** Stationery paper ink for display names (follows --stationery-ink) */
export const STATIONERY_NAME_INK = 'var(--stationery-ink)';

View File

@@ -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)',
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -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';