205 lines
7.1 KiB
XML
205 lines
7.1 KiB
XML
// Shared visual vocabulary: inline SVG icons (stroke-only, currentColor so
|
||
// they inherit the Dawn palette), the initials avatar, and the command-card
|
||
// building blocks reused across the login and contacts surfaces.
|
||
//
|
||
// Visual canon: «Боты» mockup at
|
||
// docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx
|
||
// (BotsDesktop) — Dawn palette, fleet-violet accent, letter avatars,
|
||
// friendly cards, no terminal styling.
|
||
|
||
import type { ComponentChildren } from 'preact';
|
||
|
||
export const RefreshIcon = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||
<path d="M3.5 8.5a6.5 6.5 0 0 1 11.4-3.2" stroke-linecap="round" />
|
||
<path d="M16.5 11.5a6.5 6.5 0 0 1-11.4 3.2" stroke-linecap="round" />
|
||
<path d="M14.6 3.2v3.5h-3.5" stroke-linecap="round" stroke-linejoin="round" />
|
||
<path d="M5.4 16.8v-3.5h3.5" stroke-linecap="round" stroke-linejoin="round" />
|
||
</svg>
|
||
);
|
||
|
||
// Triangle warning glyph — leads the WhatsApp-only About card (which
|
||
// carries `command-card warn` for the amber outline) and re-appears inside
|
||
// the AboutModal's risk-disclosure callout. Stroke-only so it picks up the
|
||
// amber tint via `currentColor` in either context.
|
||
export const WarningIcon = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||
<path d="M10 3.2 L17.5 16.5 L2.5 16.5 Z" stroke-linecap="round" stroke-linejoin="round" />
|
||
<path d="M10 8.5 L10 12" stroke-linecap="round" />
|
||
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
|
||
</svg>
|
||
);
|
||
|
||
export const PhoneIcon = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||
<rect x="6" y="2.5" width="8" height="15" rx="1.6" />
|
||
<line x1="8.6" y1="14.5" x2="11.4" y2="14.5" stroke-linecap="round" />
|
||
</svg>
|
||
);
|
||
|
||
export const QrIcon = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||
<rect x="3" y="3" width="5" height="5" rx="0.6" />
|
||
<rect x="12" y="3" width="5" height="5" rx="0.6" />
|
||
<rect x="3" y="12" width="5" height="5" rx="0.6" />
|
||
<path
|
||
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
|
||
stroke-linecap="round"
|
||
/>
|
||
</svg>
|
||
);
|
||
|
||
export const SearchIcon = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||
<circle cx="9" cy="9" r="5.5" />
|
||
<line x1="13.2" y1="13.2" x2="17" y2="17" stroke-linecap="round" />
|
||
</svg>
|
||
);
|
||
|
||
export const BackIcon = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||
<path d="M12.5 4 L6.5 10 L12.5 16" stroke-linecap="round" stroke-linejoin="round" />
|
||
</svg>
|
||
);
|
||
|
||
export const UserIcon = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||
<circle cx="10" cy="6.8" r="3.3" />
|
||
<path d="M3.8 17c.9-3 3.3-4.6 6.2-4.6s5.3 1.6 6.2 4.6" stroke-linecap="round" />
|
||
</svg>
|
||
);
|
||
|
||
export const LogoutIcon = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
|
||
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
|
||
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
|
||
</svg>
|
||
);
|
||
|
||
// --- Initials avatar --------------------------------------------------------
|
||
|
||
// Dawn accent rotation (same hues the design mock assigns to bot list
|
||
// entries). Hash by codepoints so a contact keeps its color across loads.
|
||
const AVATAR_COLORS = ['#9580ff', '#7ab6d9', '#d4b88a', '#7dd3a8', '#c08e7b', '#a59cff'];
|
||
|
||
const hashString = (s: string): number => {
|
||
let h = 0;
|
||
for (let i = 0; i < s.length; i += 1) {
|
||
h = (h * 31 + s.charCodeAt(i)) | 0; // eslint-disable-line no-bitwise
|
||
}
|
||
return Math.abs(h);
|
||
};
|
||
|
||
export const avatarColor = (key: string): string =>
|
||
AVATAR_COLORS[hashString(key) % AVATAR_COLORS.length];
|
||
|
||
export const initialsOf = (name: string | undefined, fallback = '?'): string => {
|
||
const trimmed = (name ?? '').trim();
|
||
if (!trimmed) return fallback;
|
||
const parts = trimmed.split(/\s+/).filter(Boolean);
|
||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||
return trimmed.slice(0, 1).toUpperCase();
|
||
};
|
||
|
||
type AvatarProps = { name?: string; colorKey: string; size?: number; src?: string | null };
|
||
|
||
// Initials always render underneath; the photo (when the MSC4039 download
|
||
// resolved) layers on top, so a slow or failed download degrades to the
|
||
// colored-letter look instead of an empty box.
|
||
export const Avatar = ({ name, colorKey, size = 38, src }: AvatarProps) => (
|
||
<span
|
||
class="avatar"
|
||
aria-hidden="true"
|
||
style={{
|
||
width: `${size}px`,
|
||
height: `${size}px`,
|
||
fontSize: `${Math.round(size * 0.42)}px`,
|
||
background: avatarColor(colorKey),
|
||
}}
|
||
>
|
||
{initialsOf(name)}
|
||
{src ? <img class="avatar-img" src={src} alt="" loading="lazy" /> : null}
|
||
</span>
|
||
);
|
||
|
||
// --- Command card -----------------------------------------------------------
|
||
|
||
type CommandCardProps = {
|
||
icon: ComponentChildren;
|
||
name: string;
|
||
desc: string;
|
||
onClick: () => void;
|
||
danger?: boolean;
|
||
/** Amber accent — the WhatsApp About card signalling the Meta-ToS risk
|
||
* disclosure behind it. */
|
||
warn?: boolean;
|
||
disabled?: boolean;
|
||
spinning?: boolean;
|
||
};
|
||
|
||
export const CommandCard = ({
|
||
icon,
|
||
name,
|
||
desc,
|
||
onClick,
|
||
danger,
|
||
warn,
|
||
disabled,
|
||
spinning,
|
||
}: CommandCardProps) => (
|
||
<button
|
||
class={`command-card${danger ? ' danger' : ''}${warn ? ' warn' : ''}${
|
||
spinning ? ' refreshing' : ''
|
||
}`}
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
>
|
||
<span class="command-card-lead-icon" aria-hidden="true">
|
||
{icon}
|
||
</span>
|
||
<div class="command-card-body">
|
||
<div class="command-card-name">{name}</div>
|
||
<div class="command-card-desc">{desc}</div>
|
||
</div>
|
||
<span class="command-card-chevron" aria-hidden="true">
|
||
›
|
||
</span>
|
||
</button>
|
||
);
|
||
|
||
// --- Status / notice --------------------------------------------------------
|
||
|
||
type StatusPillProps = {
|
||
tone: 'connected' | 'disconnected' | 'checking';
|
||
children: ComponentChildren;
|
||
};
|
||
|
||
export const StatusPill = ({ tone, children }: StatusPillProps) => (
|
||
<span class={`section-status ${tone}`} role="status">
|
||
<span class="dot" />
|
||
{children}
|
||
</span>
|
||
);
|
||
|
||
type NoticeProps = {
|
||
tone: 'error' | 'warn' | 'info';
|
||
children: ComponentChildren;
|
||
onDismiss?: () => void;
|
||
};
|
||
|
||
// Inline notice strip — the replacement for the old transcript pane. One
|
||
// line of feedback right where the action happened, dismissable, no log.
|
||
export const Notice = ({ tone, children, onDismiss }: NoticeProps) => (
|
||
<div class={`notice ${tone}`} role={tone === 'error' ? 'alert' : 'status'}>
|
||
<span class="notice-body">{children}</span>
|
||
{onDismiss ? (
|
||
<button type="button" class="notice-dismiss" onClick={onDismiss} aria-label="×">
|
||
×
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
export const Spinner = () => <span class="spinner" aria-hidden="true" />;
|