143 lines
4.4 KiB
TypeScript
143 lines
4.4 KiB
TypeScript
import { style } from '@vanilla-extract/css';
|
|
import { color, config, toRem } from 'folds';
|
|
|
|
// Root layout — header strip → image stage → action row. `position:
|
|
// relative` so the prev/next chevrons can absolute-position over the
|
|
// stage on desktop.
|
|
export const root = style({
|
|
flex: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
minHeight: 0,
|
|
minWidth: 0,
|
|
backgroundColor: color.Background.Container,
|
|
color: color.Background.OnContainer,
|
|
});
|
|
|
|
// `PageHeader` (folds `Header size="600"`) provides the strip
|
|
// height; we only override the left gutter so the title sits at
|
|
// the same x-offset as the profile side pane's title — keeps both
|
|
// right-pane variants visually consistent.
|
|
export const header = style({
|
|
paddingLeft: config.space.S300,
|
|
});
|
|
|
|
export const title = style({
|
|
minWidth: 0,
|
|
flex: '0 1 auto',
|
|
});
|
|
|
|
// Vertical hairline between the action cluster and the close
|
|
// button — visually separates "do something with this image" from
|
|
// "close the viewer", which are semantically different actions.
|
|
// Rendered for image kind only (no zoom controls for video → only
|
|
// download + close, which doesn't need a separator).
|
|
export const headerSeparator = style({
|
|
flexShrink: 0,
|
|
width: '1px',
|
|
height: toRem(20),
|
|
margin: `0 ${config.space.S100}`,
|
|
backgroundColor: color.SurfaceVariant.Container,
|
|
});
|
|
|
|
// Image stage — fills remaining vertical space, centres the image
|
|
// with object-fit-style logic on the wrapper. `overflow: hidden` so
|
|
// a zoomed-in pan can't bleed past the stage. `touch-action: none`
|
|
// blocks browser-native gestures (pinch-zoom, scroll, refresh-pull)
|
|
// — we own the touch surface here for swipe + pan.
|
|
export const stage = style({
|
|
flex: 1,
|
|
position: 'relative',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
touchAction: 'none',
|
|
userSelect: 'none',
|
|
});
|
|
|
|
// The actual <img>. `max-*: 100%` keeps it within the stage at
|
|
// zoom=1; transforms (scale, translate) layer above without
|
|
// re-flowing. `will-change` hints the compositor for smooth pan /
|
|
// swipe / zoom animations.
|
|
export const image = style({
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
objectFit: 'contain',
|
|
willChange: 'transform',
|
|
// Sharp pixel-ratio handling on dense screens — keeps phone
|
|
// photos crisp at zoom=1, smooths only when zoomed beyond
|
|
// intrinsic resolution.
|
|
imageRendering: 'auto',
|
|
});
|
|
|
|
// Side chevrons — visibility logic:
|
|
// • Coarse pointer (touch devices, mobile): always visible at
|
|
// 0.7 opacity so users have a tappable nav affordance without
|
|
// needing the hover state that touch lacks. Swipe still works
|
|
// but the buttons are the discoverability path.
|
|
// • Fine pointer (desktop): hidden until the stage is hovered,
|
|
// fading in to full opacity. Keeps the image surface clean
|
|
// when the cursor isn't on it.
|
|
// • Boundary (first/last item): the chevron is dimmed (0.25
|
|
// opacity, pointer-events disabled) rather than hidden, so the
|
|
// user gets a visual cue that prev/next isn't available
|
|
// instead of a missing button.
|
|
export const chevron = style({
|
|
position: 'absolute',
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
width: toRem(40),
|
|
height: toRem(40),
|
|
borderRadius: '50%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
|
color: color.Background.OnContainer,
|
|
cursor: 'pointer',
|
|
border: 'none',
|
|
outline: 'none',
|
|
transition: 'opacity 150ms ease, background-color 150ms ease',
|
|
opacity: 0,
|
|
'@media': {
|
|
'(pointer: coarse)': {
|
|
opacity: 0.7,
|
|
},
|
|
},
|
|
selectors: {
|
|
[`${stage}:hover &`]: { opacity: 1 },
|
|
'&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.75)' },
|
|
'&[aria-disabled="true"]': { opacity: 0.25, pointerEvents: 'none' },
|
|
},
|
|
});
|
|
|
|
export const chevronLeft = style({
|
|
left: config.space.S300,
|
|
});
|
|
|
|
export const chevronRight = style({
|
|
right: config.space.S300,
|
|
});
|
|
|
|
// Loading / error state overlay — centred inside the stage.
|
|
export const stateOverlay = style({
|
|
position: 'absolute',
|
|
inset: 0,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
pointerEvents: 'none',
|
|
});
|
|
|
|
// Index pill — "3 / 12" in the header. Subtle, matches the title
|
|
// row tone.
|
|
export const indexPill = style({
|
|
flexShrink: 0,
|
|
padding: `${toRem(2)} ${config.space.S200}`,
|
|
borderRadius: toRem(999),
|
|
backgroundColor: color.SurfaceVariant.Container,
|
|
color: color.SurfaceVariant.OnContainer,
|
|
fontSize: toRem(12),
|
|
fontWeight: 500,
|
|
});
|