feat(web): align attachment display with DeepSeek Chat via ui-attachment atoms

Single-click original preview in the composer rail and chat history; remove
control inside the thumbnail, revealed on hover/focus (always on touch);
hidden-scrollbar rail overflow paged by edge arrows with wheel panning and
end-reveal on add; image-intake rejections and prompt failures announce as a
transient top-center toast instead of inline strips.

The attachment atoms move to a new zero-cordis package
@deepseek-ai/dsh-client-ui-attachment (rail, message gallery, lightbox),
seeded as a platform module; the toast is a ui-primitives atom. Strings
arrive as label props bridged from the conversation dictionary.
This commit is contained in:
creatixchu
2026-08-11 17:01:29 +08:00
parent 5d591e55c1
commit e611e825b1
56 changed files with 1366 additions and 251 deletions
@@ -0,0 +1,58 @@
/* Transient top-center banner (DeepSeek Chat toast look): contrast fill,
inverted label, slide-in, then hold-and-fade. The fade delay/duration MUST
agree with HOLD_MS/FADE_MS in Toast.tsx: the component unmounts at their
sum, so a mismatched sheet either cuts the fade or leaves an invisible
banner blocking nothing. */
.toast {
position: fixed;
top: 80px;
left: 50%;
/* Above the 1000 the image lightbox backdrop uses: a failure reported while
a preview is open must stay readable. */
z-index: 1100;
display: flex;
align-items: center;
gap: 10px;
max-width: min(560px, calc(100vw - 48px));
padding: 12px 16px;
border-radius: 14px;
background: var(--dsw-alias-button-contrast-fill);
color: var(--dsw-alias-label-primary-inverted);
font-size: 14px;
line-height: 22px;
box-shadow: var(--dsw-shadow-lv3);
transform: translateX(-50%);
animation:
dsh-toast-in 160ms ease-out,
dsh-toast-fade 1000ms ease 3000ms forwards;
}
.icon {
display: grid;
place-items: center;
flex: none;
color: var(--dsw-alias-state-warn-label);
}
.text {
min-width: 0;
}
@keyframes dsh-toast-in {
from {
opacity: 0;
transform: translate(-50%, -6px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@keyframes dsh-toast-fade {
to {
opacity: 0;
}
}
@@ -0,0 +1,37 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import css from './Toast.module.css'
/** Full-opacity hold before the fade starts. Must agree with the stylesheet's
* toast-fade delay (Toast.module.css) or the banner unmounts mid-fade. */
const HOLD_MS = 3000
/** Fade duration. Must agree with the stylesheet's toast-fade duration. */
const FADE_MS = 1000
/**
* Transient top-center banner: slides in, holds at full opacity, fades out,
* then reports done so the owner can unmount it. Re-showing the same text
* restarts the cycle when the owner remounts the component (key it by a
* per-show sequence).
*
* @param props.text - resolved banner copy; the owner passes localized text.
* @param props.icon - optional leading glyph (e.g. a warning icon).
* @param props.onDone - called once the fade completes; unmount the toast here.
* @returns the floating banner.
*/
export function Toast({ text, icon, onDone }: {
text: string
icon?: ReactNode
onDone: () => void
}) {
useEffect(() => {
const timer = setTimeout(onDone, HOLD_MS + FADE_MS)
return () => { clearTimeout(timer) }
}, [onDone])
return (
<div className={css.toast} role="alert">
{icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
<span className={css.text}>{text}</span>
</div>
)
}
@@ -23,6 +23,7 @@ export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { Toast } from './Toast.tsx'
export { writeClipboard } from './clipboard.ts'
export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'