feat: video letterboxing, card badge styling, component updates

- Add object-fit: contain + black background for video in DTMediaFrame
- Style .dt-card-badge with mode-colored background, padding, and
  bevel-aware positioning (negative right offset for clean diagonal clip)
- Restore DTStaggerContainer in react-native (was incorrectly deleted)
- Update DTGallery, DTModal, DTMobileFilterOverlay, DTMediaFrame components
- Refresh showcase pages for desktop and mobile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-24 11:18:49 -07:00
parent d1e2bf1964
commit 195c47602d
24 changed files with 351 additions and 185 deletions

View File

@@ -160,7 +160,6 @@ export function DTDrawer({
fontWeight: 900,
fontSize: '2em',
letterSpacing: '0.05em',
textTransform: 'uppercase',
borderBottom: '1px solid var(--color-bg)',
}}>
<span>{heading}</span>

View File

@@ -1,56 +1,168 @@
/**
* DTGallery — Image gallery with beveled media frame and thumbnails.
* DTGallery — Media gallery with beveled frame and thumbnails.
* Supports images, 3D models (via <model-viewer>), videos, and external videos.
*
* CSS reference: bevels.css .dt-bevel-media
*/
import { useState, type CSSProperties } from 'react';
import { useState, useEffect, type CSSProperties } from 'react';
import type { DTVariant } from '@dangerousthings/tokens';
import { cx } from '../utils/cx';
import { getVariantClass } from '../utils/variantClasses';
export interface DTGalleryItem {
key: string;
src: string;
alt?: string;
thumbnail?: string;
/* ── TypeScript declaration for <model-viewer> web component ── */
declare global {
namespace JSX {
interface IntrinsicElements {
'model-viewer': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
> & {
src?: string;
alt?: string;
poster?: string;
'camera-controls'?: boolean | string;
'auto-rotate'?: boolean | string;
'interaction-prompt'?: string;
ar?: boolean | string;
loading?: 'auto' | 'lazy' | 'eager';
};
}
}
}
/* ── Gallery item types ── */
export type DTGalleryItem =
| { type?: 'image'; key: string; src: string; alt?: string; thumbnail?: string }
| { type: 'model3d'; key: string; src: string; alt?: string; thumbnail?: string; poster?: string }
| { type: 'video'; key: string; src: string; alt?: string; thumbnail?: string; poster?: string }
| { type: 'external-video'; key: string; src: string; alt?: string; thumbnail?: string };
interface DTGalleryProps {
items: DTGalleryItem[];
/** Color variant @default 'normal' */
variant?: DTVariant;
/** Aspect ratio (width / height) for the display area @default 1 */
aspectRatio?: number;
className?: string;
style?: CSSProperties;
}
/* ── Model-viewer script loader ── */
const MODEL_VIEWER_SRC =
'https://unpkg.com/@google/model-viewer@v1.12.1/dist/model-viewer.min.js';
let modelViewerLoaded = false;
function useModelViewerScript(needed: boolean) {
useEffect(() => {
if (!needed || modelViewerLoaded || typeof document === 'undefined') return;
if (customElements.get('model-viewer')) {
modelViewerLoaded = true;
return;
}
const script = document.createElement('script');
script.type = 'module';
script.src = MODEL_VIEWER_SRC;
document.head.appendChild(script);
modelViewerLoaded = true;
}, [needed]);
}
/* ── Shared styles ── */
const fillStyle: CSSProperties = {
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
display: 'block',
};
/* ── Component ── */
export function DTGallery({
items,
variant = 'normal',
aspectRatio = 1,
className,
style,
}: DTGalleryProps) {
const [activeIndex, setActiveIndex] = useState(0);
const hasModel = items.some((i) => i.type === 'model3d');
useModelViewerScript(hasModel);
if (items.length === 0) return null;
const current = items[activeIndex];
const currentType = current.type || 'image';
return (
<div className={cx(getVariantClass(variant), className)} style={style}>
{/* Main image */}
<div className="dt-bevel-media" style={{ overflow: 'hidden' }}>
<img
src={current.src}
alt={current.alt || ''}
style={{
width: '100%',
height: 'auto',
display: 'block',
transition: 'opacity 300ms ease-in-out',
}}
/>
{/* Main display */}
<div
className="dt-bevel-media"
style={{ overflow: 'hidden', aspectRatio, position: 'relative' }}
>
{currentType === 'image' && (
<img
src={current.src}
alt={current.alt || ''}
style={{
...fillStyle,
objectFit: 'contain',
transition: 'opacity 300ms ease-in-out',
}}
/>
)}
{currentType === 'model3d' && (
<model-viewer
src={current.src}
alt={current.alt || ''}
poster={'poster' in current ? current.poster : undefined}
camera-controls=""
auto-rotate=""
interaction-prompt="auto"
loading="lazy"
style={{
...fillStyle,
backgroundColor: 'var(--color-bg, #000)',
}}
/>
)}
{currentType === 'video' && (
<video
src={current.src}
poster={'poster' in current ? current.poster : undefined}
controls
playsInline
preload="metadata"
style={{
...fillStyle,
objectFit: 'contain',
}}
/>
)}
{currentType === 'external-video' && (
<iframe
src={current.src}
title={current.alt || 'Video'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{
...fillStyle,
border: 'none',
}}
/>
)}
</div>
{/* Thumbnails */}
{items.length > 1 && (
<div
@@ -59,31 +171,59 @@ export function DTGallery({
gap: '8px',
marginTop: '8px',
overflowX: 'auto',
}}>
{items.map((item, i) => (
<button
key={item.key}
onClick={() => setActiveIndex(i)}
type="button"
style={{
flexShrink: 0,
width: '64px',
height: '64px',
padding: 0,
border: i === activeIndex
? '2px solid var(--dt-card-color, var(--color-primary))'
: '2px solid transparent',
background: 'none',
cursor: 'pointer',
overflow: 'hidden',
}}>
<img
src={item.thumbnail || item.src}
alt={item.alt || ''}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</button>
))}
}}
>
{items.map((item, i) => {
const itemType = item.type || 'image';
const thumbSrc = item.thumbnail || ('poster' in item ? item.poster : undefined) || item.src;
const isMedia = itemType === 'video' || itemType === 'external-video';
return (
<button
key={item.key}
onClick={() => setActiveIndex(i)}
type="button"
style={{
flexShrink: 0,
width: '64px',
height: '64px',
padding: 0,
border:
i === activeIndex
? '2px solid var(--dt-card-color, var(--color-primary))'
: '2px solid transparent',
background: 'none',
cursor: 'pointer',
overflow: 'hidden',
position: 'relative',
}}
>
<img
src={thumbSrc}
alt={item.alt || ''}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{/* Play icon overlay for video thumbnails */}
{isMedia && (
<svg
viewBox="0 0 24 24"
fill="white"
style={{
position: 'absolute',
inset: 0,
margin: 'auto',
width: '24px',
height: '24px',
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.6))',
pointerEvents: 'none',
}}
>
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
);
})}
</div>
)}
</div>

View File

@@ -2,25 +2,43 @@
* DTMediaFrame — Diagonal beveled frame (top-left + bottom-right).
*
* CSS reference: bevels.css .dt-bevel-media
*
* The outer div provides the colored bevel border via clip-path.
* A ::before pseudo-element fills the inner surface.
* Direct img/video children are auto-clipped to match.
* A ::after pseudo-element shows "MISSING MEDIA" when empty.
*/
import type { ReactNode, CSSProperties } from 'react';
import type { DTVariant } from '@dangerousthings/tokens';
import { cx } from '../utils/cx';
import { getVariantClass } from '../utils/variantClasses';
interface DTMediaFrameProps {
children: ReactNode;
/** Color variant for the bevel border @default 'normal' */
variant?: DTVariant;
/** Custom placeholder shown when children is nullish (overrides CSS ::after) */
placeholder?: ReactNode;
className?: string;
style?: CSSProperties;
}
export function DTMediaFrame({
children,
variant = 'normal',
placeholder,
className,
style,
}: DTMediaFrameProps) {
const isEmpty = children == null || children === false;
return (
<div className={cx('dt-bevel-media', className)} style={style}>
{children}
<div
className={cx('dt-bevel-media', getVariantClass(variant), className)}
style={style}
>
{isEmpty && placeholder ? placeholder : children}
</div>
);
}

View File

@@ -5,7 +5,7 @@
* .dt-filter-overlay-content
*/
import { useEffect, useCallback, type ReactNode, type CSSProperties } from 'react';
import { useEffect, useCallback, useRef, type ReactNode, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import type { DTVariant } from '@dangerousthings/tokens';
import { cx } from '../utils/cx';
@@ -38,6 +38,9 @@ export function DTMobileFilterOverlay({
className,
style,
}: DTMobileFilterOverlayProps) {
const panelRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
@@ -45,6 +48,7 @@ export function DTMobileFilterOverlay({
[onDismiss],
);
// Escape key
useEffect(() => {
if (visible) {
document.addEventListener('keydown', handleKeyDown);
@@ -52,12 +56,57 @@ export function DTMobileFilterOverlay({
}
}, [visible, handleKeyDown]);
// Body scroll lock
useEffect(() => {
if (visible) {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}
}, [visible]);
// Focus trap
useEffect(() => {
if (!visible) return;
triggerRef.current = document.activeElement as HTMLElement;
const timer = setTimeout(() => {
const first = panelRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
first?.focus();
}, 100);
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab' || !panelRef.current) return;
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleTab);
return () => {
clearTimeout(timer);
document.removeEventListener('keydown', handleTab);
triggerRef.current?.focus();
};
}, [visible]);
if (!visible) return null;
return createPortal(
<div className={cx('dt-filter-overlay', getVariantClass(variant), className)} style={style}>
<div className="dt-filter-overlay-backdrop" onClick={onDismiss} />
<div className="dt-filter-overlay-content">
<div ref={panelRef} className="dt-filter-overlay-content">
{/* Header */}
<div
style={{
@@ -67,7 +116,7 @@ export function DTMobileFilterOverlay({
padding: '16px',
borderBottom: '1px solid rgba(var(--color-primary-rgb), 0.2)',
}}>
<span style={{ fontWeight: 700, fontSize: '1.125rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<span style={{ fontWeight: 700, fontSize: '1.125rem', letterSpacing: '0.05em' }}>
{heading}
{activeFilterCount !== undefined && activeFilterCount > 0 && (
<span
@@ -88,7 +137,6 @@ export function DTMobileFilterOverlay({
color: 'var(--color-primary)',
fontSize: '0.875rem',
cursor: 'pointer',
textTransform: 'uppercase',
}}>
Clear All
</button>

View File

@@ -82,13 +82,35 @@ export function DTModal({
position: 'relative',
maxWidth: '90vw',
maxHeight: '85vh',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
...style,
}}
role="dialog"
aria-modal="true">
{title && <div className="card-title">{title}</div>}
<div style={{ padding: 'var(--space-6, 24px)' }}>{children}</div>
{title && (
<div className="card-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
<span>{title}</span>
{dismissable && (
<button
type="button"
onClick={onDismiss}
aria-label="Close"
style={{
background: 'none',
border: 'none',
color: 'inherit',
cursor: 'pointer',
padding: 0,
fontSize: '1.25rem',
lineHeight: 1,
}}>
&#x2715;
</button>
)}
</div>
)}
<div style={{ padding: 'var(--space-6, 24px)', overflow: 'auto', flex: '1 1 auto' }}>{children}</div>
</div>
</div>,
document.body,

View File

@@ -36,7 +36,6 @@ export function DTTextInput({
marginBottom: '4px',
fontSize: '0.875rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
{label}