Files
multica/packages/views/autopilots/components/webhook-payload-preview.tsx
Jiayuan Zhang 9c90327ce4 fix(ui): replace the text-transparency ladder with solid tones (MUL-5452) (#6152)
* fix(ui): replace the text-transparency ladder with solid tones (MUL-5452)

Hierarchy was being expressed with transparency: 152 call sites of
text-muted-foreground/30..80, 26 of text-foreground/60..90, plus a handful
on destructive and current, and a few written as a standalone opacity-*
utility instead of a slash alpha.

On light surfaces every muted variant failed WCAG AA - /80 reached only
3.78:1 and /40 sat at 1.80:1, below even the 3:1 floor for non-text -
because the palette had no step below --muted-foreground, so transparency
was the only tool for 'quieter than muted'.

The palette now has that step, and it is deliberately non-text:
--faint-foreground clears 3:1 (WCAG 1.4.11) on every surface for icons,
chevrons, separator glyphs and empty-cell em dashes. There is no room for
a third readable text tone - AA caps a lighter text tone 0.018 L away from
muted - so text keeps exactly one floor, --muted-foreground.

Also fixes text-destructive/70 on a cron error message, which was 3.61:1.

This branch changes zero font sizes. The sub-12px half of the issue is
MUL-5451's (#6136); keeping the two apart is what makes this one
reviewable on its own after #6108 was reverted.

apps/web/app/text-contrast.test.ts replaces muted-foreground-contrast.test.ts
rather than sitting beside it. It recomputes the floors from tokens.css
instead of hard-coding ratios, and fails the build on all four ways to spell
the defect: /70, /[0.5], /[50%], and a detached opacity-* in the same class
string. Transparency behind hover/focus/disabled stays allowed - the resting
state carries the contrast obligation and it is solid.

Co-authored-by: multica-agent <github@multica.ai>

* fix(ui): correlate transparency across a whole class expression

Review found two ways past the guard, both real.

A per-literal check cannot see cn("… text-muted-foreground", suppressed &&
"opacity-60") - one element wearing a colour in one argument and a dim in
the next. That split shape is the common one, and it was hiding live
violations: the comment trigger chips dimmed aria-pressed label text to
2.55:1 while the sweep reported clean.

The second was my own exemption. Accepting any state word within 80
characters let "text-muted-foreground hover:text-foreground opacity-50"
through, because the hover: belongs to the colour, not to the opacity.

The detector now correlates across a whole cn() call or template literal,
splits it into segments that each carry their own condition, and exempts
only on the variant prefix the opacity utility itself carries or on the
condition governing its segment. Segment splitting is what keeps
${disabled ? "opacity-60" : ""} exempt while flagging its neighbours.

Fixed what that surfaced: three trigger-chip controls (the suppressed state
is already carried by the avatar's own grayscale, the sentence wording and a
solid muted step), a disabled-skill icon chip, and the diff gutter marker.
tab-bar's isDragging is exempt - a drag ghost is an in-flight gesture, the
same category as :active.

The detector now has its own table of thirteen cases. Every hole so far has
been silent, so the shapes it must and must not catch are pinned next to the
reason each one exists.

Co-authored-by: multica-agent <github@multica.ai>

* test(ui): cover the faint tone in the cn() merge regression test

text-faint-foreground is a new text-<x> class, which is the exact shape
that silently broke the sidebar labels in #6108: tailwind-merge cannot
tell a size from a colour and drops one of them. The token does resolve
correctly today - verified both orders against a size step and against
another colour - but the test that exists to catch this was not covering
it, so the guarantee rested on nothing.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 18:27:38 +08:00

142 lines
5.5 KiB
TypeScript

"use client";
import { useState, useMemo } from "react";
import { Webhook, ChevronDown, ChevronRight, Copy, Check } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { useT } from "../../i18n";
interface WebhookPayloadPreviewProps {
payload: unknown;
/** Default open vs collapsed. The dialog has limited vertical space, so
* we collapse by default and let the user expand. */
defaultOpen?: boolean;
}
/**
* Renders a webhook trigger payload (the WebhookEnvelope shape produced
* server-side by normalizeWebhookPayload) inline with the autopilot run
* detail. Falls back gracefully when the payload isn't an envelope —
* showing whatever JSON is there with a generic header.
*
* This is intentionally read-only and decoupled from any specific dialog
* — it gets dropped into AgentTranscriptDialog's headerSlot.
*/
export function WebhookPayloadPreview({
payload,
defaultOpen = false,
}: WebhookPayloadPreviewProps) {
const { t } = useT("autopilots");
const [open, setOpen] = useState(defaultOpen);
const [copied, setCopied] = useState(false);
const { event, receivedAt, contentType, fullJSON, displayJSON, isTruncated } = useMemo(() => {
let event: string | null = null;
let eventPayload: unknown = null;
let receivedAt: string | null = null;
let contentType: string | null = null;
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
const obj = payload as Record<string, unknown>;
if (typeof obj.event === "string") event = obj.event;
if ("eventPayload" in obj) eventPayload = obj.eventPayload;
const req = obj.request;
if (req && typeof req === "object") {
const r = req as Record<string, unknown>;
if (typeof r.receivedAt === "string") receivedAt = r.receivedAt;
if (typeof r.contentType === "string") contentType = r.contentType;
}
}
// If the payload didn't match the envelope shape (caller wrote
// directly to trigger_payload, malformed history row, etc.), show
// the whole thing as the eventPayload so nothing is hidden.
if (eventPayload === null && payload !== null && payload !== undefined) {
eventPayload = payload;
}
const fullJSON = JSON.stringify(eventPayload, null, 2);
// Truncate the in-DOM string so the dialog stays responsive even when a
// provider sent a 256 KiB envelope. The Copy button still yields the
// full string, so the user never loses the data. 4 KiB is large enough
// to show the envelope header + first object-level fields of a typical
// webhook payload.
const TRUNCATE_AT = 4096;
const isTruncated = fullJSON.length > TRUNCATE_AT;
const displayJSON = isTruncated ? fullJSON.slice(0, TRUNCATE_AT) : fullJSON;
return { event, receivedAt, contentType, fullJSON, displayJSON, isTruncated };
}, [payload]);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
if (await copyText(fullJSON)) {
setCopied(true);
toast.success(t(($) => $.webhook_payload.copied));
setTimeout(() => setCopied(false), 1500);
} else {
toast.error(t(($) => $.webhook_payload.copy_failed));
}
};
return (
<div className="rounded-md border bg-background">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-caption hover:bg-accent/30 transition-colors"
>
<Webhook className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="font-medium">
{t(($) => $.webhook_payload.label)}
</span>
<code className="truncate font-mono text-muted-foreground">
{event ?? t(($) => $.webhook_payload.unknown_event)}
</code>
{receivedAt && (
<span className="ml-auto shrink-0 text-muted-foreground">
{receivedAt}
</span>
)}
{open ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
</button>
{open && (
<div className="border-t">
<div className="flex items-center justify-between px-3 py-1.5 text-micro text-muted-foreground">
<span>
{contentType
? t(($) => $.webhook_payload.content_type, { type: contentType })
: t(($) => $.webhook_payload.payload)}
</span>
<button
type="button"
onClick={handleCopy}
className={cn(
"flex items-center gap-1 rounded px-2 py-0.5 hover:bg-accent transition-colors",
)}
>
{copied ? (
<Check className="h-3 w-3 text-emerald-500" />
) : (
<Copy className="h-3 w-3" />
)}
{copied
? t(($) => $.webhook_payload.copied_short)
: t(($) => $.webhook_payload.copy)}
</button>
</div>
<pre className="max-h-64 overflow-auto bg-muted/40 px-3 py-2 text-caption font-mono leading-relaxed">
{displayJSON}
{isTruncated && (
<span className="block pt-2 text-muted-foreground">
{t(($) => $.webhook_payload.truncated_marker)}
</span>
)}
</pre>
</div>
)}
</div>
);
}