diff --git a/web/lib/opal/src/components/cards/card/README.md b/web/lib/opal/src/components/cards/card/README.md
index f1552b5dca..728ec18c96 100644
--- a/web/lib/opal/src/components/cards/card/README.md
+++ b/web/lib/opal/src/components/cards/card/README.md
@@ -150,7 +150,7 @@ Everything from plain mode, **plus**:
- **Rounding adapts automatically.** When `expanded && expandedContent !== undefined`, the header's bottom corners flatten and the content's top corners flatten so they meet seamlessly. When collapsed (or when `expandedContent` is undefined), the header is fully rounded.
- **Content background is always transparent.** The `color` prop applies to the header only; the content slot never fills its own background so the page shows through and keeps the two regions visually distinct.
- **Content has no intrinsic padding.** The `padding` prop applies to the header only. Callers own any padding inside whatever they pass to `expandedContent` — wrap it in a `
` (or whatever) if you want spacing.
-- **Animation.** Content uses a pure CSS grid `0fr ↔ 1fr` animation with an opacity fade (~200ms ease-out). No `@radix-ui/react-collapsible` dependency. The fold lives in `cards/fold/` as `CardFold` and is shared with [`SelectCard`](../select-card/README.md), which has the same `expandable` API on an interactive header.
+- **Animation.** Content uses a pure CSS grid `0fr ↔ 1fr` animation and a fade. Opening grows the body and squares the header's bottom corners together (150ms), then fades the content in (100ms). Closing fades the content out (100ms), then shrinks the body and rounds the corners together (150ms). No `@radix-ui/react-collapsible` dependency. The fold lives in `cards/fold/` as `CardFold` and is shared with [`SelectCard`](../select-card/README.md), which has the same `expandable` API on an interactive header.
### Accessibility
diff --git a/web/lib/opal/src/components/cards/card/components.tsx b/web/lib/opal/src/components/cards/card/components.tsx
index 541b6bf898..5ad56b5329 100644
--- a/web/lib/opal/src/components/cards/card/components.tsx
+++ b/web/lib/opal/src/components/cards/card/components.tsx
@@ -243,13 +243,6 @@ function Card(props: CardProps) {
const paddingStyle = { padding: spacingToRem(paddingProp) };
const radius = roundingToRem(roundingProp);
- // Expanded, the header rounds only at the top and the body only at the
- // bottom, so the two halves read as one card rather than two.
- const topRadius = {
- borderTopLeftRadius: radius,
- borderTopRightRadius: radius,
- };
-
// Plain mode — unchanged behavior
if (!props.expandable) {
return (
@@ -276,7 +269,13 @@ function Card(props: CardProps) {
expandableContentHeight = 80,
} = props;
const showContent = expanded && expandedContent !== undefined;
- const headerRadius = showContent ? topRadius : { borderRadius: radius };
+ // The stylesheet rounds the header, and squares its bottom corners while
+ // the fold has any height, so the two halves stay joined as it closes.
+ const headerStyle: React.CSSProperties &
+ Record<"--opal-card-radius", string> = {
+ ...paddingStyle,
+ "--opal-card-radius": radius,
+ };
return (
=
+ { "--opal-card-radius": radius };
+
return (
-
-
- {children}
-
+ // The bordered body is the fold's frame: it grows with the height and
+ // stays visible, and only the content inside it fades.
+ (
+
+ )}
+ >
+ {children}
);
}
diff --git a/web/lib/opal/src/components/cards/fold/styles.css b/web/lib/opal/src/components/cards/fold/styles.css
index b2efbc9891..9cfc076f8e 100644
--- a/web/lib/opal/src/components/cards/fold/styles.css
+++ b/web/lib/opal/src/components/cards/fold/styles.css
@@ -5,8 +5,9 @@
Structure:
.opal-fold (the animation; see components/fold/)
- .opal-fold-inner
- .opal-card-fold-body (border minus its top edge, bottom rounding)
+ .opal-card-fold-body (the frame: the edge, bottom rounding)
+ .opal-card-fold-scroll (the height cap)
+ .opal-fold-grid
Shared by Card and SelectCard.
============================================================================ */
@@ -17,21 +18,52 @@
@apply w-full bg-transparent;
}
-.opal-card-fold-body[data-content-height="80"] {
+/* Scrolls inside the body, so the scroll box does not clip the edge. */
+.opal-card-fold-scroll {
+ border-bottom-left-radius: var(--opal-card-radius);
+ border-bottom-right-radius: var(--opal-card-radius);
+}
+
+.opal-card-fold-scroll[data-content-height="80"] {
@apply max-h-80 overflow-y-auto;
}
/* "full" = no constraint; natural height, no scroll. */
-/* The top edge is the header's bottom border, so the body never draws one. */
-.opal-card-fold-body[data-border="none"] {
- border: none;
+/* The edge is drawn by `::before`, not the body's own border. It reaches up
+ by one corner radius over the header, so its bottom corners keep their
+ round shape at any height. A short box would flatten them: at zero height
+ it lines up with the header's own corners instead of drawing a flat line
+ under them. The body pads in by the border width, so the content keeps its
+ place, and moves up by it, so a closed fold adds no height. `relative`
+ paints the edge above the header. */
+.opal-card-fold-body {
+ @apply relative;
+ border-bottom-left-radius: var(--opal-card-radius);
+ border-bottom-right-radius: var(--opal-card-radius);
}
-.opal-card-fold-body[data-border="dashed"] {
- @apply border border-t-0 border-dashed;
+.opal-card-fold-body:not([data-border="none"]) {
+ margin-top: -1px;
+ padding: 0 1px 1px;
}
-.opal-card-fold-body[data-border="solid"] {
- @apply border border-t-0;
+.opal-card-fold-body:not([data-border="none"])::before {
+ @apply pointer-events-none absolute inset-x-0 bottom-0 border border-t-0;
+ content: "";
+ top: calc(1px - var(--opal-card-radius));
+ border-color: inherit;
+ border-bottom-left-radius: inherit;
+ border-bottom-right-radius: inherit;
+}
+
+.opal-card-fold-body[data-border="dashed"]::before {
+ @apply border-dashed;
+}
+
+/* The top edge is the header's bottom border, so the edge never draws one.
+ A fully closed fold shows no edge. `!important` wins over the status and
+ selected border colours, which the edge inherits. */
+.opal-fold[data-mounted="false"] > .opal-card-fold-body {
+ border-color: transparent !important;
}
diff --git a/web/lib/opal/src/components/cards/message-card/components.tsx b/web/lib/opal/src/components/cards/message-card/components.tsx
index 5beff376d0..1ece9fecab 100644
--- a/web/lib/opal/src/components/cards/message-card/components.tsx
+++ b/web/lib/opal/src/components/cards/message-card/components.tsx
@@ -13,6 +13,7 @@ import type {
import { spacingToRem } from "@opal/shared";
import { ContentAction } from "@opal/layouts";
import { Card } from "@opal/components/cards/card/components";
+import { Fold } from "@opal/components/fold/components";
import { Button, Divider } from "@opal/components";
import {
SvgAlertCircle,
@@ -24,7 +25,6 @@ import {
} from "@opal/icons";
import { useState } from "react";
import { useOpalStrings } from "@opal/strings";
-import usePresence from "@opal/hooks/usePresence";
// ---------------------------------------------------------------------------
// Types
@@ -207,15 +207,6 @@ function MessageCard({
const strings = useOpalStrings();
// Falsey content (`condition && `) counts as absent, as it always has.
const expanded = Boolean(bottomChildren);
- const presence = usePresence(expanded, 200);
- // Animate only once the section has come or gone, so a card that mounts
- // with it does not play the opening on page load.
- const [toggled, setToggled] = useState(false);
- const [prevExpanded, setPrevExpanded] = useState(expanded);
- if (expanded !== prevExpanded) {
- setPrevExpanded(expanded);
- setToggled(true);
- }
// The last section shown, kept so it can animate out after the caller
// drops it.
const [shownBottom, setShownBottom] = useState(bottomChildren);
@@ -266,21 +257,14 @@ function MessageCard({
/>
- {presence.mounted && (
-
-
-
-
- {expanded ? bottomChildren : shownBottom}
-
-
+ {/* Fold animates only a change, so a card that mounts with the
+ section does not play the opening on page load. */}
+
+
+
+ {expanded ? bottomChildren : shownBottom}
- )}
+
diff --git a/web/lib/opal/src/components/cards/message-card/styles.css b/web/lib/opal/src/components/cards/message-card/styles.css
index 00bcb0b52a..4d6ce651e7 100644
--- a/web/lib/opal/src/components/cards/message-card/styles.css
+++ b/web/lib/opal/src/components/cards/message-card/styles.css
@@ -12,56 +12,8 @@
@apply flex flex-col w-full;
}
-/* The bottom section animates its height open and closed. The 0.25rem above
- it lives inside, so it collapses with the section instead of jumping. */
-.opal-message-card-bottom {
- display: grid;
- grid-template-rows: 1fr;
-}
-
-.opal-message-card-bottom[data-state="closed"] {
- grid-template-rows: 0fr;
-}
-
-/* No padding on the clipped box: a grid row at 0fr shrinks an item only down
- to its padding, so the space sits one level in. */
-.opal-message-card-bottom-inner {
- min-height: 0;
- overflow: hidden;
-}
-
+/* The 0.25rem above the bottom section lives inside its fold, so it closes
+ with the section. */
.opal-message-card-bottom-content {
@apply pt-1;
}
-
-.opal-message-card-bottom[data-animate][data-state="open"] {
- animation: opal-message-card-bottom-open 200ms ease-out;
-}
-
-.opal-message-card-bottom[data-animate][data-state="closed"] {
- animation: opal-message-card-bottom-close 200ms ease-in;
-}
-
-@keyframes opal-message-card-bottom-open {
- from {
- grid-template-rows: 0fr;
- }
- to {
- grid-template-rows: 1fr;
- }
-}
-
-@keyframes opal-message-card-bottom-close {
- from {
- grid-template-rows: 1fr;
- }
- to {
- grid-template-rows: 0fr;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .opal-message-card-bottom[data-animate] {
- animation: none;
- }
-}
diff --git a/web/lib/opal/src/components/cards/select-card/README.md b/web/lib/opal/src/components/cards/select-card/README.md
index 1ad2678d3f..a1980a1266 100644
--- a/web/lib/opal/src/components/cards/select-card/README.md
+++ b/web/lib/opal/src/components/cards/select-card/README.md
@@ -70,7 +70,7 @@ Everything above, **plus**:
- **Only the header is interactive.** `Interactive.Stateful` wraps the header alone, so `onClick`, hover and the state colours stay there. A form inside `expandedContent` keeps its own clicks instead of toggling the card.
- **Always controlled.** `expanded` is a one-way visual prop. There is no `defaultExpanded` and no `onExpandChange` — the caller owns the state and the trigger, exactly as with [`Card`](../card/README.md).
-- **Rounding adapts.** Expanded, the header rounds only at the top and the fold only at the bottom, so they read as one card. The corners animate over 200ms.
+- **Rounding adapts.** Expanded, the header rounds only at the top and the fold only at the bottom, so they read as one card. The corners square as the fold expands and round as it collapses, over the same 150ms.
- **`state` is the caller's, open or closed.** The card paints whatever state it is given and never rewrites it. A card that should stop looking selected once it opens does that at the call site, with `state={open ? "filled" : "selected"}` — which is also what decides whether a `Content` inside it, set to `color="interactive"`, picks up the selection colour.
- **The separator keeps its resting colour.** The header's bottom border stays `border-01` while expanded, even on a selected card, so it reads as a divider rather than an edge. The fold's own border does follow the selection.
- **`padding` applies to the header only.** The fold has no intrinsic padding; pad whatever you pass to `expandedContent`.
diff --git a/web/lib/opal/src/components/cards/select-card/components.tsx b/web/lib/opal/src/components/cards/select-card/components.tsx
index 8e95a79102..478c485dcc 100644
--- a/web/lib/opal/src/components/cards/select-card/components.tsx
+++ b/web/lib/opal/src/components/cards/select-card/components.tsx
@@ -192,7 +192,7 @@ type SelectCardShellProps = SelectCardBaseProps & {
expandable?: boolean;
/** The fold's content; `undefined` renders no fold at all. */
fold?: React.ReactNode;
- /** Whether the fold is open, which flattens the header's bottom corners. */
+ /** Whether the fold is open. */
foldOpen?: boolean;
foldHeight?: CardFoldHeight;
};
@@ -214,11 +214,13 @@ function SelectCardShell({
}: SelectCardShellProps) {
const paddingStyle = { padding: spacingToRem(paddingProp) };
const radius = roundingToRem(roundingProp);
- // Expanded, the header rounds only at the top and the fold only at the
- // bottom, so the two halves read as one card rather than two.
- const headerRadius = foldOpen
- ? { borderTopLeftRadius: radius, borderTopRightRadius: radius }
- : { borderRadius: radius };
+ // Expandable, the stylesheet rounds the header, and squares its bottom
+ // corners while the fold has any height, so the two halves stay joined as
+ // it closes.
+ const headerStyle: React.CSSProperties &
+ Partial
> = expandable
+ ? { ...paddingStyle, "--opal-card-radius": radius }
+ : { ...paddingStyle, borderRadius: radius };
// A caller with its own tab index (e.g. a roving radio group) keeps it.
const isControl = !!onClick && !disabled;
@@ -248,7 +250,7 @@ function SelectCardShell({
diff --git a/web/lib/opal/src/components/cards/select-card/styles.css b/web/lib/opal/src/components/cards/select-card/styles.css
index 527b2b736b..3260b09222 100644
--- a/web/lib/opal/src/components/cards/select-card/styles.css
+++ b/web/lib/opal/src/components/cards/select-card/styles.css
@@ -46,7 +46,7 @@
@apply w-full flex flex-col;
}
-/* The corners animate as the fold opens. The list restates the one in
+/* The corners round as the fold collapses. The list restates the one in
`core/interactive/shared.css`, because `transition` is a shorthand and a
second declaration here would otherwise drop the colour transitions. */
.opal-select-card-expandable > .opal-select-card {
@@ -56,12 +56,30 @@
var(--interactive-easing),
--interactive-foreground-icon var(--interactive-duration)
var(--interactive-easing),
- border-radius 200ms ease-out;
+ border-radius 150ms ease-out 100ms;
+ border-radius: var(--opal-card-radius);
+}
+
+/* While the fold is open, the header's bottom corners are square so it
+ joins the fold. The corners move with the fold's height (see
+ `components/fold/styles.css`): they square as it expands, and round as it
+ collapses, after the content fades out. */
+.opal-select-card-expandable
+ > .opal-select-card:has(+ .opal-fold[data-open="true"]) {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ transition:
+ background-color var(--interactive-duration) var(--interactive-easing),
+ --interactive-foreground var(--interactive-duration)
+ var(--interactive-easing),
+ --interactive-foreground-icon var(--interactive-duration)
+ var(--interactive-easing),
+ border-radius 150ms ease-out;
}
/* Expanded, the header's bottom border is the separator between the two
halves, so it keeps the resting colour whatever state the card is in. */
-.opal-select-card:has(+ .opal-fold[data-open="true"]) {
+.opal-select-card:has(+ .opal-fold[data-mounted="true"]) {
@apply border-b-border-01;
}
diff --git a/web/lib/opal/src/components/collapsible/components.tsx b/web/lib/opal/src/components/collapsible/components.tsx
index a2f5b8b247..b60fac4500 100644
--- a/web/lib/opal/src/components/collapsible/components.tsx
+++ b/web/lib/opal/src/components/collapsible/components.tsx
@@ -5,6 +5,7 @@ import { useId, useState } from "react";
import type { RichStr } from "@opal/types";
import { ContentAction } from "@opal/layouts";
import { Button, type TagProps } from "@opal/components";
+import { Fold } from "@opal/components/fold/components";
import { SvgExpand, SvgFold } from "@opal/icons";
import { useOpalStrings } from "@opal/strings";
import { toPlainString } from "@opal/components/text/InlineMarkdown";
@@ -101,13 +102,11 @@ function Collapsible({
}
/>
-
-
-
- {hasOpened && children}
-
+
+
+ {hasOpened && children}
-
+
);
}
diff --git a/web/lib/opal/src/components/collapsible/styles.css b/web/lib/opal/src/components/collapsible/styles.css
index dde3c179c8..42dba96055 100644
--- a/web/lib/opal/src/components/collapsible/styles.css
+++ b/web/lib/opal/src/components/collapsible/styles.css
@@ -21,30 +21,8 @@
outline: none;
}
-/* The body's grid row animates between 0fr and 1fr. The clipped box has no
- padding of its own, so it can close all the way. */
-.opal-collapsible-body {
- display: grid;
- grid-template-rows: 0fr;
- transition: grid-template-rows 200ms ease-out;
-}
-
-.opal-collapsible[data-state="open"] .opal-collapsible-body {
- grid-template-rows: 1fr;
-}
-
-.opal-collapsible-body-inner {
- min-height: 0;
- overflow: hidden;
-}
-
-/* The gap under the header lives one level in, so it closes with the body. */
+/* The gap under the header lives inside the fold, so it closes with the
+ body. */
.opal-collapsible-body-content {
@apply pt-3;
}
-
-@media (prefers-reduced-motion: reduce) {
- .opal-collapsible-body {
- transition: none;
- }
-}
diff --git a/web/lib/opal/src/components/divider/components.tsx b/web/lib/opal/src/components/divider/components.tsx
index e235e8fc1b..ceb8f69558 100644
--- a/web/lib/opal/src/components/divider/components.tsx
+++ b/web/lib/opal/src/components/divider/components.tsx
@@ -2,6 +2,7 @@
import "@opal/components/divider/styles.css";
import { useState, useCallback } from "react";
+import { Fold } from "@opal/components/fold/components";
import type { OrientationVariants, RichStr } from "@opal/types";
import { Button, Text } from "@opal/components";
import { SvgChevronRight } from "@opal/icons";
@@ -247,16 +248,11 @@ function FoldableDivider({
- {/* The content stays mounted so the fold can close as smoothly as it
+ {/* The content stays mounted, so the fold closes as smoothly as it
opens; closed, it is inert and hidden from assistive tech. */}
-