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:
112
packages/client/ui-attachment/src/AttachmentRail.module.css
Normal file
112
packages/client/ui-attachment/src/AttachmentRail.module.css
Normal file
@@ -0,0 +1,112 @@
|
||||
/* Thumbnail geometry mirrors DeepSeek Chat's composer rail: 64px cards with a
|
||||
16px radius, remove control fully inside the card, arrows overlaid at the
|
||||
edges instead of a scrollbar. */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* Edge arrows page the overflow; the scrollbar stays hidden (both engines). */
|
||||
scrollbar-width: none;
|
||||
/* The rail scrolls on the composer's elevated input surface: bind the l2
|
||||
pair (ui-theme styles/scrollbar.css rebinding contract) so anything that
|
||||
does draw a thumb here matches the surface. */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.rail::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
flex: 0 0 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-button-contrast-fill);
|
||||
color: var(--dsw-alias-label-primary-inverted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.item:hover .remove,
|
||||
.remove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Touch surfaces have no hover to reveal the control. */
|
||||
@media (pointer: coarse) {
|
||||
.remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.arrow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
|
||||
.arrowLeft {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.arrowRight {
|
||||
right: 4px;
|
||||
}
|
||||
152
packages/client/ui-attachment/src/AttachmentRail.tsx
Normal file
152
packages/client/ui-attachment/src/AttachmentRail.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged
|
||||
* by edge arrows, hover-revealed per-item remove, single-click open. */
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { WheelEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './AttachmentRail.module.css'
|
||||
|
||||
/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */
|
||||
export interface AttachmentRailItem {
|
||||
/** Stable identity for the React key. */
|
||||
id: string
|
||||
/** Object or data URL rendered as the thumbnail. */
|
||||
previewUrl: string
|
||||
/** Image alt text (display name with the owner's fallback applied). */
|
||||
alt: string
|
||||
/** Accessible label of the item's remove control. */
|
||||
removeLabel: string
|
||||
}
|
||||
|
||||
/** Rail-level strings the owner resolves from its own locale namespace. */
|
||||
export interface AttachmentRailLabels {
|
||||
/** Accessible name of the rail group. */
|
||||
group: string
|
||||
/** Thumbnail tooltip inviting the original-image preview. */
|
||||
open: string
|
||||
/** Accessible label of the left paging arrow. */
|
||||
scrollLeft: string
|
||||
/** Accessible label of the right paging arrow. */
|
||||
scrollRight: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal thumbnail rail over the caller's draft attachments.
|
||||
*
|
||||
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
|
||||
* arrows recomputed from scroll geometry on scroll, item-count changes, and
|
||||
* window resizes. A vertical wheel pans horizontally, a newly added item is
|
||||
* revealed at the rail's end, and each thumbnail opens on a single click while
|
||||
* its remove control sits inside the card and reveals on hover or focus.
|
||||
* The owner decides mounting; it renders the rail only while items exist.
|
||||
*
|
||||
* @param props.items - resolved thumbnails in draft order.
|
||||
* @param props.labels - rail-level strings (group name, open tooltip, arrows).
|
||||
* @param props.onOpen - single-click open of one item's original image.
|
||||
* @param props.onRemove - remove one item from the draft.
|
||||
* @returns the rail group with its paging arrows.
|
||||
*/
|
||||
export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
|
||||
items: readonly T[]
|
||||
labels: AttachmentRailLabels
|
||||
onOpen: (item: T) => void
|
||||
onRemove: (item: T) => void
|
||||
}) {
|
||||
const railRef = useRef<HTMLDivElement | null>(null)
|
||||
const countRef = useRef(0)
|
||||
const [edges, setEdges] = useState({ left: false, right: false })
|
||||
const updateEdges = useCallback(() => {
|
||||
const el = railRef.current
|
||||
/* v8 ignore next -- defensive: every caller runs while the rail element is mounted. */
|
||||
if (el === null) return
|
||||
// 1px slack: engines report fractional scroll positions at the edges.
|
||||
const left = el.scrollLeft > 1
|
||||
const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
|
||||
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
|
||||
}, [])
|
||||
useLayoutEffect(() => {
|
||||
const grew = items.length > countRef.current
|
||||
countRef.current = items.length
|
||||
const el = railRef.current
|
||||
// A newly added attachment lands at the rail's end: reveal it.
|
||||
if (grew && el !== null) el.scrollLeft = el.scrollWidth - el.clientWidth
|
||||
updateEdges()
|
||||
}, [items.length, updateEdges])
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', updateEdges)
|
||||
return () => { window.removeEventListener('resize', updateEdges) }
|
||||
}, [updateEdges])
|
||||
const page = (direction: -1 | 1): void => {
|
||||
const el = railRef.current
|
||||
/* v8 ignore next -- defensive: the arrows render only while the rail is mounted, so a click cannot find a null ref. */
|
||||
if (el === null) return
|
||||
// One viewport minus a card keeps the last visible thumbnail as context;
|
||||
// the floor keeps narrow rails paging a useful distance.
|
||||
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: 'smooth' })
|
||||
}
|
||||
// A vertical wheel pans the rail horizontally (trackpads pan natively via
|
||||
// deltaX); per-tick travel is clamped so a fast notch wheel stays followable.
|
||||
const onWheel = (event: WheelEvent<HTMLDivElement>): void => {
|
||||
if (event.deltaX !== 0 || event.deltaY === 0) return
|
||||
event.currentTarget.scrollBy({
|
||||
left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY), 60),
|
||||
behavior: 'auto',
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{edges.left && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.arrow, css.arrowLeft)}
|
||||
aria-label={labels.scrollLeft}
|
||||
onClick={() => { page(-1) }}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={railRef}
|
||||
className={css.rail}
|
||||
role="group"
|
||||
aria-label={labels.group}
|
||||
onScroll={updateEdges}
|
||||
onWheel={onWheel}
|
||||
>
|
||||
{items.map(item => (
|
||||
<div key={item.id} className={css.item}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.thumbnail}
|
||||
title={labels.open}
|
||||
onClick={() => { onOpen(item) }}
|
||||
>
|
||||
<img src={item.previewUrl} alt={item.alt} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.remove}
|
||||
aria-label={item.removeLabel}
|
||||
onClick={() => { onRemove(item) }}
|
||||
>
|
||||
<IconCloseFill14 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{edges.right && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.arrow, css.arrowRight)}
|
||||
aria-label={labels.scrollRight}
|
||||
onClick={() => { page(1) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
packages/client/ui-attachment/src/ImageLightbox.module.css
Normal file
34
packages/client/ui-attachment/src/ImageLightbox.module.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 40px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent);
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: min(100%, 1600px);
|
||||
max-height: calc(100vh - 80px);
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.close {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
57
packages/client/ui-attachment/src/ImageLightbox.tsx
Normal file
57
packages/client/ui-attachment/src/ImageLightbox.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import css from './ImageLightbox.module.css'
|
||||
|
||||
/** Lightbox strings the owner resolves from its own locale namespace. */
|
||||
export interface ImageLightboxLabels {
|
||||
/** Accessible name of the preview dialog. */
|
||||
dialog: string
|
||||
/** Accessible label of the close control. */
|
||||
close: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Document-level original-image preview opened by clicking a thumbnail.
|
||||
* Closes on Escape, backdrop press, or the close control, and restores focus
|
||||
* to the opener on unmount.
|
||||
*
|
||||
* @param props.src - the original image URL.
|
||||
* @param props.alt - the image's alt text.
|
||||
* @param props.labels - dialog and close-control strings.
|
||||
* @param props.onClose - dismiss callback owned by the opener.
|
||||
* @returns the modal preview dialog.
|
||||
*/
|
||||
export function ImageLightbox({ src, alt, labels, onClose }: {
|
||||
src: string
|
||||
alt: string
|
||||
labels: ImageLightboxLabels
|
||||
onClose: () => void
|
||||
}) {
|
||||
const closeRef = useRef<HTMLButtonElement | null>(null)
|
||||
const restoreRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
closeRef.current?.focus()
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
restoreRef.current?.focus()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.backdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={labels.dialog}
|
||||
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
|
||||
>
|
||||
<img className={css.image} src={src} alt={alt} />
|
||||
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
packages/client/ui-attachment/src/MessageImage.module.css
Normal file
53
packages/client/ui-attachment/src/MessageImage.module.css
Normal file
@@ -0,0 +1,53 @@
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: min(240px, 100%);
|
||||
}
|
||||
|
||||
.gallery[data-align='end'] {
|
||||
justify-content: flex-end;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.gallery[data-align='start'] {
|
||||
justify-content: flex-start;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.frame {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.error {
|
||||
max-width: 240px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
96
packages/client/ui-attachment/src/MessageImage.tsx
Normal file
96
packages/client/ui-attachment/src/MessageImage.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { ImageLightbox } from './ImageLightbox.tsx'
|
||||
import type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
||||
import css from './MessageImage.module.css'
|
||||
|
||||
/** Loads a session-authorized durable image URL. */
|
||||
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
|
||||
|
||||
/** Message-image strings the owner resolves from its own locale namespace. */
|
||||
export interface MessageImageLabels {
|
||||
/** Fallback display name for an unnamed image. */
|
||||
image: string
|
||||
/** Thumbnail tooltip inviting the original-image preview. */
|
||||
open: string
|
||||
/** Accessible thumbnail label; receives the image's display name. */
|
||||
openNamed: (label: string) => string
|
||||
/** Loading placeholder shown until bytes resolve. */
|
||||
loading: string
|
||||
/** Retry-control label shown when the load fails. */
|
||||
loadFailed: string
|
||||
/** Lightbox strings forwarded to the opened preview. */
|
||||
lightbox: ImageLightboxLabels
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact history renderer with retryable loading and click-to-open original
|
||||
* preview.
|
||||
*
|
||||
* @param props.attachment - the durable image reference to load and bound.
|
||||
* @param props.load - session-authorized URL loader.
|
||||
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
|
||||
* @returns the bounded thumbnail button, or the retry control on failure.
|
||||
*/
|
||||
export function MessageImage({ attachment, load, labels }: {
|
||||
attachment: ImageAttachmentRef
|
||||
load: ImageLoader
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
const size = useMemo(() => {
|
||||
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
|
||||
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
|
||||
}, [attachment.height, attachment.width])
|
||||
|
||||
const request = useCallback(() => {
|
||||
setError(false)
|
||||
setSrc(null)
|
||||
void load(attachment).then(setSrc).catch(() => { setError(true) })
|
||||
}, [attachment, load])
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
setError(false)
|
||||
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
|
||||
return () => { live = false }
|
||||
}, [attachment, load])
|
||||
|
||||
const label = attachment.name ?? labels.image
|
||||
if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.frame}
|
||||
style={size}
|
||||
title={labels.open}
|
||||
aria-label={labels.openNamed(label)}
|
||||
onClick={() => { if (src !== null) setOpen(true) }}
|
||||
>
|
||||
{src === null ? <span className={css.loading}>{labels.loading}</span> : <img src={src} alt={label} />}
|
||||
</button>
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrapping image group shared by user and assistant history. */
|
||||
export function ImageGallery({ images, load, align, labels }: {
|
||||
images: readonly { attachment: ImageAttachmentRef }[]
|
||||
load: ImageLoader
|
||||
align: 'start' | 'end'
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
if (images.length === 0) return null
|
||||
return (
|
||||
<div className={css.gallery} data-align={align}>
|
||||
{images.map((image, index) => (
|
||||
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} labels={labels} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
packages/client/ui-attachment/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-attachment/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
14
packages/client/ui-attachment/src/index.ts
Normal file
14
packages/client/ui-attachment/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Pure React attachment atoms (zero cordis): the composer draft-image rail,
|
||||
* the chat-history image gallery, and the original-image lightbox. Owners
|
||||
* resolve every string through their own locale namespace and pass it down;
|
||||
* nothing here reads application state.
|
||||
* @module @deepseek-ai/dsh-client-ui-attachment
|
||||
*/
|
||||
|
||||
export { AttachmentRail } from './AttachmentRail.tsx'
|
||||
export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx'
|
||||
export { ImageLightbox } from './ImageLightbox.tsx'
|
||||
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
|
||||
export { ImageGallery, MessageImage } from './MessageImage.tsx'
|
||||
export type { ImageLoader, MessageImageLabels } from './MessageImage.tsx'
|
||||
31
packages/client/ui-attachment/src/invariant.ts
Normal file
31
packages/client/ui-attachment/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-attachment`.
|
||||
* @module @deepseek-ai/dsh-client-ui-attachment/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-attachment'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-attachment-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: pure props-in React atoms with no Cordis API —
|
||||
* no events, no services, no mutable cross-plugin state; rendering contracts
|
||||
* are asserted directly by this package's component specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user