diff --git a/scripts/design-system/check-contrast.mjs b/scripts/design-system/check-contrast.mjs index 18f014673..c23cb41d0 100644 --- a/scripts/design-system/check-contrast.mjs +++ b/scripts/design-system/check-contrast.mjs @@ -109,6 +109,8 @@ const TEXT_ROLES = [ * every fill it can actually sit on, and hover is one of them. */ const PAIRS = [ + ["--text-standard", "--affordance-floating-hover"], + ["--text-danger", "--affordance-floating-hover"], ["--text-inverse", "--surface-inverse"], ["--text-link", "--affordance-link-hover"], ...["subtle", "subtle-hover", "subtle-pressed"].map((state) => [ diff --git a/scripts/design-system/check-type.mjs b/scripts/design-system/check-type.mjs index 1f48c963c..0f04f5560 100644 --- a/scripts/design-system/check-type.mjs +++ b/scripts/design-system/check-type.mjs @@ -79,6 +79,10 @@ const RETIRED_ROLES = new Map([ * wrong. Fix the system at that point rather than adding a tenth override. */ const OVERRIDES = new Map([ + [ + "styles/search-field.css:font-size:", + "The capsule recipe forwards the text-body-sm role into Emoji Mart's shadow root, where Tailwind utilities cannot reach.", + ], [ "styles/flex-workspace.css:font-size:", "FlexLayout's public theme variable forwards the existing text-body role; no new text size is authored.", diff --git a/src/bundled/agents/AgentCard.tsx b/src/bundled/agents/AgentCard.tsx index abae67d64..7ebb5292e 100644 --- a/src/bundled/agents/AgentCard.tsx +++ b/src/bundled/agents/AgentCard.tsx @@ -1,5 +1,12 @@ import { useRef, type ReactNode } from "react"; -import { Menu } from "@base-ui/react/menu"; +import { + MenuRoot, + MenuTrigger, + MenuPopup, + MenuItem, + MenuNote, +} from "../../shared/design-system/ui/Menu"; +import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow"; import { DotsThreeIcon, UsersIcon, @@ -45,8 +52,8 @@ export function AgentCard({ > {onEdit && (
- - + } /> - - - - {editable.length ? ( - editable.map((agent) => ( - { - // The menu item unmounts; return from the dialog to the card. - trigger.current?.focus(); - onEdit(agent, picture); - }} - > - {editable.length === 1 ? ( - "Edit" - ) : ( + + {editable.length ? ( + editable.map((agent) => ( + { + // The menu item unmounts; return from the dialog to the card. + trigger.current?.focus(); + onEdit(agent, picture); + }} + > + {editable.length === 1 ? ( + "Edit" + ) : ( + - Edit {identityLabel(agent)} {agent.relayUrl} @@ -85,28 +88,23 @@ export function AgentCard({ {agent.pubkey} - )} - - )) - ) : ( - <> - - Edit - -

- {identities.length - ? "Import this identity to edit in Foundation." - : "No linked identity to edit."} -

- - )} -
-
-
-
+ } + /> + )} + + )) + ) : ( + <> + Edit + + {identities.length + ? "Import this identity to edit in Foundation." + : "No linked identity to edit."} + + + )} + +
)}
{ + const user = userEvent.setup(); + const item: ThreadActivityItem = { + channelId: "studio", + rootId: "thread", + latestMessageId: "message", + authorId: "alex", + createdAt: 1, + preview: "Please review the designs", + unreadCount: 2, + }; + const snapshot: ThreadActivitySnapshot = { + channelId: "studio", + items: [item], + coverage: "observed", + freshness: "stale", + }; + const profiles = new Map([["alex", { name: "Alex" }]]); + const ensure = vi.fn(async () => {}); + const open = vi.fn(); + const session = { + unread: { activity: () => snapshot, subscribeActivity: () => () => {} }, + profiles: { snapshot: () => profiles, subscribe: () => () => {}, ensure }, + } as unknown as RelaySession; + render( + Activity} + onOpenThread={open} + />, + ); + expect(ensure).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Activity" })); + expect( + await screen.findByRole("dialog", { name: "Activity in Studio" }), + ).toBeVisible(); + expect(ensure).toHaveBeenCalledExactlyOnceWith(["alex"], "background"); + expect(screen.getByText("May be out of date")).toBeVisible(); + await user.click( + screen.getByRole("button", { + name: "Open unread thread from Alex: Please review the designs", + }), + ); + expect(open).toHaveBeenCalledExactlyOnceWith(item); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); +}); diff --git a/src/bundled/channels/ChannelActivityPopover.tsx b/src/bundled/channels/ChannelActivityPopover.tsx index b49fd14ad..9a2d74c85 100644 --- a/src/bundled/channels/ChannelActivityPopover.tsx +++ b/src/bundled/channels/ChannelActivityPopover.tsx @@ -1,6 +1,10 @@ import { useChannelIdentityNames } from "../../features/identity-names/react"; import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; -import { Popover } from "@base-ui/react/popover"; +import { + PopoverRoot, + PopoverTrigger, + PopoverPopup, +} from "../../shared/design-system/ui/Popover"; import { useCallback, useMemo, @@ -104,7 +108,7 @@ export function ChannelActivityPopover({ if (!items.length) return trigger; const stale = snapshot.freshness === "stale"; return ( - { @@ -118,44 +122,37 @@ export function ChannelActivityPopover({ .catch(() => {}); }} > - - - - - {stale && ( -

May be out of date

- )} - {open && ( -
- {items.map((item) => ( - { - setOpen(false); - onOpenThread(selected); - }} - /> - ))} -
- )} -
-
-
-
+ + {stale &&

May be out of date

} + {open && ( +
+ {items.map((item) => ( + { + setOpen(false); + onOpenThread(selected); + }} + /> + ))} +
+ )} +
+ ); } diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index a14a07b92..284d007bd 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -456,21 +456,6 @@ html[data-keyboard-navigation] .threadActivityDot { cursor: help; } -.activityPopover { - @apply popover-surface; - width: min(360px, calc(100vw - 24px)); - max-height: min(420px, calc(100vh - 24px)); - overflow: hidden; - transform-origin: var(--transform-origin); - transition: - opacity 120ms ease-out, - transform 120ms ease-out; -} -.activityPopover[data-starting-style], -.activityPopover[data-ending-style] { - opacity: 0; - transform: scale(0.98); -} .activityStale { margin: 0; padding: var(--space-1h) var(--space-3); diff --git a/src/bundled/composer/lab/fixture.tsx b/src/bundled/composer/lab/fixture.tsx index 8287a577f..da06ca7aa 100644 --- a/src/bundled/composer/lab/fixture.tsx +++ b/src/bundled/composer/lab/fixture.tsx @@ -46,6 +46,11 @@ const rawSession = { reply: () => "preview-reply", }, typing: { snapshot: () => emptyList, subscribe: empty }, + names: { + snapshot: () => 0, + subscribe: empty, + resolve: (_pubkey: string, fallback: string) => fallback, + }, profiles: { snapshot: () => profiles, subscribe: empty, diff --git a/src/bundled/emoji/Emoji.module.css b/src/bundled/emoji/Emoji.module.css index 5351adaa8..9498f93d6 100644 --- a/src/bundled/emoji/Emoji.module.css +++ b/src/bundled/emoji/Emoji.module.css @@ -13,51 +13,45 @@ margin: 0; min-width: 0; } -.emojiPopover { - @apply popover-surface; - --picker-container-radius: var(--radius-card); - border-radius: var(--picker-container-radius); - --picker-inner-radius: calc(var(--picker-container-radius) - var(--space-2)); - --picker-scrollbar-space: var(--space-4); - --picker-tab-foreground: var(--text-standard); - --picker-tab-muted: var(--text-subtle); - --picker-tab-selected: var(--affordance-selected); - --picker-scrollbar-thumb: var(--border-prominent); - position: absolute; - bottom: 42px; - left: 0; - max-width: 100%; - overflow: hidden; +.emojiContent { + display: flex; + flex-direction: column; + max-height: calc(var(--available-height) - 2px); } -.sharedSearchIcon { - position: absolute; - top: calc(var(--space-2) + (var(--size-control) - var(--space-4)) / 2); - left: calc(var(--space-2) + var(--space-control-inset)); - z-index: 4; - color: var(--text-subtle); - pointer-events: none; +/* The panel gap comes from Tabs, so search adds no second top gap. */ +.emojiContent[data-tabs] { + --picker-search-top: 0px; } -.reactionPositioner { - z-index: var(--layer-popover); +.emojiContent [role="tablist"] { + flex-shrink: 0; + margin: var(--space-3) var(--space-3) 0; } -.reactionPopover { - position: relative; - bottom: auto; - left: auto; - max-width: calc(100vw - 32px); +.emojiContent > :first-child, +.emojiContent [role="tabpanel"] { + display: flex; + flex-direction: column; + min-height: 0; +} +.emojiContent [role="tabpanel"][hidden] { + display: none; +} +.emojiContent:not([data-tabs]) > :first-child { + gap: 0; } -@media (max-width: 900px) { - .emojiPopover { - --picker-container-radius: var(--radius-card-compact); - border-radius: var(--radius-card-compact); - } +.emojiContent:not([data-tabs]) [role="tablist"] { + display: none; } .emojiMart { position: relative; + height: min(348px, 40vh); + min-height: 0; +} +.emojiMart > div { + height: 100%; } .emojiMart em-emoji-picker { width: 100%; - height: min(348px, 40vh); + height: 100%; --border-radius: 0; --shadow: none; } @@ -71,41 +65,20 @@ letter-spacing: var(--text-caption--letter-spacing); } .gifPicker { + min-height: 0; position: relative; display: flex; height: min(348px, 40vh); - min-height: 230px; flex-direction: column; } .gifSearch { - margin: var(--space-2); + margin: var(--picker-search-top, var(--space-3)) var(--space-3) var(--space-3); } .gifResults { min-height: 0; flex: 1; overflow-y: auto; padding: 0 var(--space-3) var(--space-2); - scrollbar-width: none; -} -.gifResults::-webkit-scrollbar { - display: none; -} -.gifScrollbarTrack { - position: absolute; - right: 4px; - z-index: 4; - width: 8px; - opacity: 0.6; - pointer-events: none; -} -.gifScrollbarThumb { - position: absolute; - top: 0; - left: 0; - width: 8px; - min-height: 32px; - border-radius: var(--radius-pill); - background: var(--picker-scrollbar-thumb); } .gifGrid { columns: 2; @@ -157,7 +130,11 @@ text-align: center; } .gifAttribution { - padding: var(--space-1h) var(--space-2); + display: grid; + height: var(--size-control); + flex-shrink: 0; + place-items: center; + padding: 0 var(--space-2); border-top: 1px solid var(--border-standard); color: var(--text-subtle); font-size: var(--text-caption); diff --git a/src/bundled/emoji/EmojiPicker.tsx b/src/bundled/emoji/EmojiPicker.tsx index 5eb18f485..8ed628c78 100644 --- a/src/bundled/emoji/EmojiPicker.tsx +++ b/src/bundled/emoji/EmojiPicker.tsx @@ -4,18 +4,20 @@ import { IconButton } from "../../shared/design-system/ui/IconButton"; import { type RefObject, useEffect, - useId, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react"; import { - MagnifyingGlassIcon, SmileyIcon, SmileyStickerIcon, } from "../../shared/design-system/icons/index"; -import { Popover } from "@base-ui/react/popover"; +import { + PopoverRoot, + PopoverTrigger, + PopoverPopup, +} from "../../shared/design-system/ui/Popover"; import type { RelaySession } from "../../features/relay/session"; import { communityFromScope, @@ -59,7 +61,10 @@ export function EmojiPicker({ supported: boolean | undefined; }>(); const [gifDiscoveryRequested, setGifDiscoveryRequested] = useState(false); - const [perLine, setPerLine] = useState(0); + const [{ perLine, availableWidth }, setLayout] = useState({ + perLine: 0, + availableWidth: 0, + }); const [error, setError] = useState(); const [attempt, retry] = useState(0); const trigger = useRef(null); @@ -68,8 +73,7 @@ export function EmojiPicker({ const search = useRef(""); const onInsert = useRef(insert); onInsert.current = insert; - const ownId = useId(); - const id = externalTrigger?.id ?? ownId; + const accepted = useRef(false); const community = reaction ? undefined : communityFromScope(scope); const gifs = community ? gifAvailability?.community === community @@ -82,7 +86,7 @@ export function EmojiPicker({ session.emoji.snapshot, session.emoji.snapshot, ); - useEffect(() => { + useLayoutEffect(() => { // The popover is positioned against the action row; intermediate tool groups // may be narrower and are not its available width. const container = reaction @@ -90,8 +94,9 @@ export function EmojiPicker({ : controls.current?.offsetParent; if (!open || disabled || !(container instanceof HTMLElement)) return; const resize = () => - setPerLine( - Math.max( + setLayout({ + availableWidth: container.clientWidth, + perLine: Math.max( 1, Math.min( 6, @@ -100,7 +105,7 @@ export function EmojiPicker({ ), ), ), - ); + }); resize(); const observer = new ResizeObserver(resize); observer.observe(container); @@ -135,18 +140,6 @@ export function EmojiPicker({ ); return () => controller.abort(); }, [community, gifDiscoveryRequested, gifAvailability]); - useEffect(() => { - if (!open || disabled || reaction) return; - function outside(event: PointerEvent) { - if ( - event.target instanceof Node && - !controls.current?.contains(event.target) - ) - setOpen(false); - } - document.addEventListener("pointerdown", outside); - return () => document.removeEventListener("pointerdown", outside); - }, [open, disabled, reaction]); // biome-ignore lint/correctness/useExhaustiveDependencies: attempt explicitly retries a failed lazy import. useLayoutEffect(() => { if (!open || disabled || tab !== "emoji" || !host || !perLine) return; @@ -172,6 +165,7 @@ export function EmojiPicker({ media: session.media, select: (value) => { onInsert.current(value); + accepted.current = true; setOpen(false); }, close: () => setOpen(false), @@ -193,11 +187,6 @@ export function EmojiPicker({ }, [open, disabled, session, scope, catalog, attempt, perLine, tab, host]); const emojiContent = (
-
); const button = ( setGifDiscoveryRequested(true)} onFocus={() => setGifDiscoveryRequested(true)} - onClick={() => { - if (open) { - setOpen(false); - return; - } - - void session.emoji.ensure(); - if (gifs !== true && gifAvailability?.community === community) - setGifAvailability(undefined); - setGifDiscoveryRequested(true); - setOpen(true); - }} icon={ reaction ? (