@podoba/react 0.0.22 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "type": "module",
5
5
  "description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "typecheck": "tsc --build"
26
26
  },
27
27
  "dependencies": {
28
- "@podoba/tokens": "^0.0.22",
29
- "@podoba/tailwind": "^0.0.22",
28
+ "@podoba/tokens": "^0.0.24",
29
+ "@podoba/tailwind": "^0.0.24",
30
30
  "react-aria-components": "1.18.0",
31
31
  "class-variance-authority": "0.7.1",
32
32
  "clsx": "2.1.1",
@@ -1,140 +1,42 @@
1
- import { Button as RACButton } from 'react-aria-components'
2
- import { type ReactElement, type ReactNode, cloneElement, isValidElement } from 'react'
3
- import { uic } from '../utils/uic'
1
+ import { type ReactElement, type ReactNode, isValidElement } from 'react'
2
+ import { Tile } from './tile'
4
3
 
5
4
  /**
6
- * StatsCard — KPI card (port of gs-platform `GSStatsCard` / `StatsCard`).
5
+ * StatsCard — DEPRECATED. Kept as a thin wrapper over the unified {@link Tile} so
6
+ * existing call sites keep working while every tile shares ONE inner. Prefer `<Tile>`
7
+ * directly for new code:
8
+ * title → eyebrow · value → title · description → text · dark → theme="dark" ·
9
+ * align → titleAlign · subtleValue → subtleTitle · asChild+<Link> → link.
7
10
  *
8
- * gs-platform renders title (heading-5) · large value (label-1) · footer
9
- * (small body) inside a spacious cream `Tile` with panel radius. The `dark`
10
- * variant mirrors gs's dark "Cloud" tile (inverted surface, light text).
11
- *
12
- * Presentational only (hard rule #1): all copy via props, no API/i18n.
13
- *
14
- * When `onPress` is set the whole card becomes a React Aria `Button` — it gets
15
- * keyboard activation, `data-[focus-visible]` ring and `data-[pressed]` for
16
- * free. Otherwise it renders as a plain non-interactive `div`.
11
+ * @deprecated use `<Tile>`.
17
12
  */
18
13
  export type StatsCardProps = {
19
- /** Small uppercase-ish label above the value. */
14
+ /** Small label above the value → Tile `eyebrow`. */
20
15
  title: ReactNode
21
- /** The large headline number / value. */
16
+ /** The large headline number / value → Tile `title`. */
22
17
  value: ReactNode
23
- /** Optional supporting footer line (e.g. "+3 this week"). */
18
+ /** Optional supporting footer line Tile `footer`. */
24
19
  footer?: ReactNode
25
- /** Optional status pill shown at the end of the title row (e.g. a `<Badge>`). */
20
+ /** Optional status pill Tile `badge`. */
26
21
  badge?: ReactNode
27
- /** Optional muted line under the title (e.g. "Plan sections, tasks and outputs"). */
22
+ /** Optional muted line under the title Tile `text`. */
28
23
  description?: ReactNode
29
- /** When provided the card becomes a clickable React Aria Button. */
24
+ /** Clickable via a press handler Tile `onPress`. */
30
25
  onPress?: () => void
31
- /**
32
- * Render the card AS the provided child element (e.g. a router `<Link>`) instead
33
- * of a button — so the whole tile is a real anchor (cmd/middle-click, open in
34
- * new tab, link semantics). The card styling + hover/focus are merged onto the
35
- * child and the title/value/footer are injected as its children. Takes
36
- * precedence over `onPress`.
37
- */
26
+ /** Render AS the provided child element (a router `<Link>`) → Tile `link`. */
38
27
  asChild?: boolean
39
- /** The element to render as the card when `asChild` is set (a single element). */
28
+ /** The element to render as the card when `asChild` is set. */
40
29
  children?: ReactNode
41
- /** Dark/inverted surface (gs's dark "Cloud" tile). */
30
+ /** Dark/inverted surface Tile `theme="dark"`. */
42
31
  dark?: boolean
43
- /**
44
- * Vertical placement of the value band. `center` (default) is gs's
45
- * `contentAlign="center"` hero-count tile (value fills + centres, footer pinned
46
- * bottom). `top` sits the value directly under the eyebrow (gs's dark "Summary"
47
- * data tile) with the footer pushed to the bottom.
48
- */
32
+ /** Value placement → Tile `titleAlign` (default `center`). */
49
33
  align?: 'center' | 'top'
50
- /** Mute the value colour (gs's Summary tile — a descriptive title, not a hero count). */
34
+ /** Mute the value colour Tile `subtleTitle`. */
51
35
  subtleValue?: boolean
52
- /** Accessible label for the clickable card (defaults to nothing — title is read). */
53
36
  'aria-label'?: string
54
37
  className?: string
55
38
  }
56
39
 
57
- // gs Tile: `--radius-lg` (8px) corners, 16px padding, min-height 208px (size
58
- // "compact"), and it FILLS its grid cell (h-full) so a tall dashboard tile is a
59
- // tall card. The three bands (title top / value centered / footer bottom) are laid
60
- // out by StatsCardBody. The "skin" (surface + border) is split out so the dark
61
- // variant can override it cleanly.
62
- const baseSurface = 'flex h-full min-h-[208px] flex-col gap-2 rounded-lg p-4 text-left'
63
- const lightSkin = 'border border-border bg-surface-card'
64
- const darkSkin = 'border border-transparent bg-surface-inverted'
65
-
66
- function StatsCardBody({
67
- title,
68
- value,
69
- footer,
70
- badge,
71
- description,
72
- dark,
73
- align = 'center',
74
- subtleValue = false,
75
- }: Pick<StatsCardProps, 'title' | 'value' | 'footer' | 'badge' | 'description' | 'dark' | 'align' | 'subtleValue'>) {
76
- const isTop = align === 'top'
77
- const valueTone = dark ? (subtleValue ? 'text-white/60' : 'text-white') : subtleValue ? 'text-fg-muted' : 'text-fg'
78
- return (
79
- <>
80
- <div className="flex items-center justify-between gap-2">
81
- <span className={`text-body font-medium leading-5 ${dark ? 'text-white' : 'text-fg'}`}>
82
- {title}
83
- </span>
84
- {badge ?? null}
85
- </div>
86
- {description ? (
87
- <span className={`text-compact leading-4 ${dark ? 'text-white/60' : 'text-fg-muted'}`}>
88
- {description}
89
- </span>
90
- ) : null}
91
- {/*
92
- * `center` (gs `contentAlign="center"`): the value sits in the CENTRE band —
93
- * `flex-1` grows to fill the tile and vertically centres it, pushing any footer
94
- * to the bottom. `top`: the value sits directly under the eyebrow (no grow) and
95
- * the footer takes `mt-auto` to stay pinned at the bottom. Value ramp = gs
96
- * label-1 (1.875rem / 500 / 2rem line). Letter-spacing tracks gs 1:1: light
97
- * tiles use -0.56px via `tracking-wide`; the dark count tile uses gs's em-based
98
- * `-0.02em` (`AssetsTile.module.scss .count`).
99
- */}
100
- <span
101
- className={`flex ${isTop ? 'items-start pt-5' : 'flex-1 items-center'} text-display font-medium leading-[2rem] ${dark ? 'tracking-normal' : 'tracking-wide'} ${valueTone}`}
102
- >
103
- {/*
104
- * Inner wrapper is deliberate: the band is a flex container, and flex
105
- * TRIMS the leading whitespace of an anonymous text run — so a value of
106
- * `<CountUp/> {' '} unit` collapses to "1version". Nesting the value in one
107
- * inline span makes it a single flex item, preserving the internal space.
108
- */}
109
- <span>{value}</span>
110
- </span>
111
- {footer ? (
112
- <span className={`${isTop ? 'mt-auto ' : ''}pt-4 text-small leading-5 ${dark ? 'text-white/50' : 'text-fg-muted'}`}>
113
- {footer}
114
- </span>
115
- ) : null}
116
- </>
117
- )
118
- }
119
-
120
- const PressableCard = uic(RACButton, {
121
- displayName: 'StatsCardButton',
122
- baseClass:
123
- `${baseSurface} ${lightSkin} ` +
124
- 'w-full outline-none transition-transform duration-150 ' +
125
- 'data-[hovered]:scale-[1.02] ' +
126
- 'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
127
- 'data-[pressed]:scale-100',
128
- })
129
-
130
- // Anchor interaction skin: same subtle scale-up as the pressable card (gs Tile
131
- // `whileHover` scale 1.02, no shadow), but driven by CSS `:hover`/`:focus-visible`
132
- // (real <a>) rather than RAC `data-` attributes.
133
- const anchorInteractive =
134
- 'no-underline outline-none transition-transform duration-150 ' +
135
- 'hover:scale-[1.02] ' +
136
- 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
137
-
138
40
  export function StatsCard({
139
41
  title,
140
42
  value,
@@ -145,37 +47,26 @@ export function StatsCard({
145
47
  asChild,
146
48
  children,
147
49
  dark,
148
- align,
50
+ align = 'center',
149
51
  subtleValue,
150
52
  className,
151
53
  ...rest
152
54
  }: StatsCardProps) {
153
- // asChild wins: render the supplied element (typically a router <Link>) as the
154
- // card. The body is injected as its children so callers pass a self-closing link.
155
- if (asChild && isValidElement(children)) {
156
- const child = children as ReactElement<{ className?: string }>
157
- const cardClass = [baseSurface, dark ? darkSkin : lightSkin, anchorInteractive, child.props.className, className]
158
- .filter(Boolean)
159
- .join(' ')
160
- return cloneElement(child, { className: cardClass } as { className: string }, <StatsCardBody title={title} value={value} footer={footer} badge={badge} description={description} dark={dark} align={align} subtleValue={subtleValue} />)
161
- }
162
-
163
- if (onPress) {
164
- // uic merges via tailwind-merge, so `darkSkin` cleanly overrides the
165
- // light surface/border in the base class.
166
- return (
167
- <PressableCard
168
- onPress={onPress}
169
- className={[dark ? darkSkin : '', className].filter(Boolean).join(' ')}
170
- aria-label={rest['aria-label']}
171
- >
172
- <StatsCardBody title={title} value={value} footer={footer} badge={badge} description={description} dark={dark} align={align} subtleValue={subtleValue} />
173
- </PressableCard>
174
- )
175
- }
55
+ const link = asChild && isValidElement(children) ? (children as ReactElement) : undefined
176
56
  return (
177
- <div className={[baseSurface, dark ? darkSkin : lightSkin, className].filter(Boolean).join(' ')}>
178
- <StatsCardBody title={title} value={value} footer={footer} badge={badge} description={description} dark={dark} align={align} subtleValue={subtleValue} />
179
- </div>
57
+ <Tile
58
+ theme={dark ? 'dark' : 'light'}
59
+ eyebrow={title}
60
+ badge={badge}
61
+ title={value}
62
+ titleAlign={align}
63
+ subtleTitle={subtleValue}
64
+ text={description}
65
+ footer={footer}
66
+ link={link}
67
+ onPress={onPress}
68
+ aria-label={rest['aria-label']}
69
+ className={className}
70
+ />
180
71
  )
181
72
  }
@@ -1,102 +1,340 @@
1
- import { type ReactElement, type ReactNode, cloneElement, isValidElement } from 'react'
1
+ import { useState, type ReactElement, type ReactNode, cloneElement, isValidElement } from 'react'
2
+ import { CloseIcon } from './icons'
2
3
 
3
4
  /**
4
- * Tile — the canonical dashboard CARD (port of gs-platform's `Tile`).
5
+ * Tile — the ONE dashboard card (the single source of tile "inner"). Every tile in
6
+ * the product is a `<Tile>`: they all share the same inner spec — 16px padding, 24px
7
+ * section gap, 8px radius, `overflow-hidden` — and a common slot set. A given tile
8
+ * just uses the SUBSET of slots it needs (port of gs-platform `Tile` +
9
+ * `DashboardContentCard`, matched 1:1).
5
10
  *
6
- * Layout: `head` (label row, optional trailing badge) · `children` (the big
7
- * content band, fills + top-aligned) · `tail` (footer links, avatars). 8px
8
- * panel radius, generous padding, row-gap. Four themes mirror gs's Figma palette
9
- * via the design tokens: light (surface-card), dark (inverted, light text), teal
10
- * (brand green), yellow (accent).
11
+ * Slots (all optional):
12
+ * - `eyebrow` (+ `badge`) the small label row at the top (gs heading-5).
13
+ * - `title` the big headline / value (gs heading-1). `titleAlign` places it
14
+ * top (default) or centred; `subtleTitle` mutes its colour.
15
+ * - `text` supporting copy under the title (gs 14px).
16
+ * - `image` — a side preview in the RIGHT half of the content band.
17
+ * - `footer` — the bottom band.
18
+ * - `backgroundImage` / `video` — turn the tile into a full-bleed MEDIA HERO
19
+ * (image, or click-to-play inline video); `eyebrow`/`title`/`footer`/`badge`
20
+ * become overlays. No modal.
21
+ * - `children` — an escape hatch for the content band (lists, infographics). When
22
+ * present it REPLACES title/text/image.
11
23
  *
12
- * Router-agnostic link (hard rule #1 podoba never imports a router): pass a
13
- * link ELEMENT as `link` (e.g. a `<Link/>` from your router, self-closing). The
14
- * tile clones it, merges the card styling + a hover-lift onto it, and injects the
15
- * tile body as its children — so the whole tile is a real anchor (cmd/middle-click,
16
- * open in new tab) without podoba knowing about any router. `children` stays the
17
- * content band (unlike StatsCard's `asChild`, because a Tile's children is content).
18
- *
19
- * Presentational only: all copy via props, no API/i18n.
24
+ * Clickable: pass a router `<Link/>` as `link` (whole tile becomes a real anchor
25
+ * cmd/middle-click, new tab) or an `onPress` handler (button). Router-agnostic (hard
26
+ * rule #1 podoba never imports a router). Presentational only: copy via props.
20
27
  */
21
28
  export type TileTheme = 'light' | 'dark' | 'teal' | 'yellow'
29
+ export type TileAlign = 'top' | 'center'
22
30
 
23
- // Light tiles carry a visible hairline (border-border), matching StatsCard's
24
- // lightSkin; dark/colored tiles use a transparent border so every tile keeps the
25
- // same box size (no 1px layout shift between themes).
26
31
  const THEME: Record<TileTheme, string> = {
32
+ // Light tiles carry a visible hairline; dark/colored use a transparent border so
33
+ // every tile keeps the same box size (no 1px shift between themes).
27
34
  light: 'border border-border bg-surface-card text-fg',
28
35
  dark: 'border border-transparent bg-surface-inverted text-white',
29
36
  teal: 'border border-transparent bg-brand-secondary text-fg',
30
37
  yellow: 'border border-transparent bg-accent-yellow text-fg',
31
38
  }
32
39
 
33
- /** Hover-lift + focus ring applied only when the tile is a link. */
34
- const linkInteractive =
35
- 'block no-underline outline-none transition-[transform,box-shadow] duration-150 ' +
40
+ // The shared inner for EVERY tile (gs `DashboardContentCard`): 16px padding
41
+ // (`--spacing-4`), 24px section gap, 8px radius, clip overflow. `h-full` fills the
42
+ // grid cell; `min-h` floors it.
43
+ const baseSurface = 'flex h-full min-h-[200px] flex-col gap-6 overflow-hidden rounded-lg p-4'
44
+
45
+ /** Hover-lift + focus ring applied only when the tile is interactive. */
46
+ const interactive =
47
+ 'no-underline outline-none transition-[transform,box-shadow] duration-150 ' +
36
48
  'hover:-translate-y-1 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-ring'
37
49
 
38
50
  export type TileProps = {
39
51
  /** Surface theme (gs's Figma palette). */
40
52
  theme?: TileTheme
41
- /** Label row at the top (optionally a trailing `<Badge>`). */
53
+ /** Small label row at the top (gs eyebrow / heading-5). */
54
+ eyebrow?: ReactNode
55
+ /** @deprecated alias for `eyebrow`. */
42
56
  head?: ReactNode
43
- /** The big content band — grows to fill, top-aligned. */
44
- children?: ReactNode
45
- /** Footer band (links, avatars, meta). */
57
+ /** Status pill shown next to the eyebrow. */
58
+ badge?: ReactNode
59
+ /** Big headline / value (gs heading-1). */
60
+ title?: ReactNode
61
+ /** Vertical placement of `title` in the content band (default `top`). */
62
+ titleAlign?: TileAlign
63
+ /** Mute the `title` colour (a descriptive title, not a hero count). */
64
+ subtleTitle?: boolean
65
+ /** Supporting text under the title (gs 14px). */
66
+ text?: ReactNode
67
+ /** Footer band (links, meta, actions). */
68
+ footer?: ReactNode
69
+ /** @deprecated alias for `footer`. */
46
70
  tail?: ReactNode
71
+ /** A side preview element in the right half of the content band. */
72
+ image?: ReactNode
47
73
  /**
48
- * A link ELEMENT to render the whole tile as (e.g. a router `<Link/>`). The tile
49
- * styling + hover-lift merge onto it and the tile body becomes its children.
74
+ * Custom content band escape hatch for lists / infographics. When present it
75
+ * REPLACES `title`/`text`/`image`.
50
76
  */
77
+ children?: ReactNode
78
+ /** Full-bleed background image → the tile becomes a media hero. */
79
+ backgroundImage?: string
80
+ imageAlt?: string
81
+ /** When set (media hero), pressing the tile plays this video INLINE (no modal). */
82
+ video?: string
83
+ /** Accessible label for the "stop / back to poster" control while playing. */
84
+ closeLabel?: string
85
+ /** A link ELEMENT to render the whole tile as (e.g. a router `<Link/>`). */
51
86
  link?: ReactElement
87
+ /** A whole-tile press handler (renders a button). `pressLabel` names it for a11y. */
88
+ onPress?: () => void
89
+ pressLabel?: string
52
90
  className?: string
91
+ 'aria-label'?: string
53
92
  }
54
93
 
55
- /** The head/content/tail bands, shared by the plain + linked renders. */
56
- function TileBody({ head, children, tail }: Pick<TileProps, 'head' | 'children' | 'tail'>) {
94
+ /** The big "stat" content typography (gs heading-1: 28px / 500 / tight). */
95
+ export const tileStatClass = 'text-display font-medium leading-[30px] tracking-wide'
96
+
97
+ function titleTone(theme: TileTheme, subtle: boolean): string {
98
+ if (theme === 'dark') return subtle ? 'text-white/60' : 'text-white'
99
+ return subtle ? 'text-fg-muted' : 'text-fg'
100
+ }
101
+
102
+ /** head / content / tail bands — the normal (non-media) layout. */
103
+ function TileBands({
104
+ theme,
105
+ eyebrow,
106
+ badge,
107
+ title,
108
+ titleAlign = 'top',
109
+ subtleTitle = false,
110
+ text,
111
+ footer,
112
+ image,
113
+ children,
114
+ }: Pick<
115
+ TileProps,
116
+ 'theme' | 'eyebrow' | 'badge' | 'title' | 'titleAlign' | 'subtleTitle' | 'text' | 'footer' | 'image' | 'children'
117
+ > & { theme: TileTheme }) {
118
+ const titleNode =
119
+ title != null ? (
120
+ <p className={`${tileStatClass} ${theme === 'dark' ? 'tracking-normal' : 'tracking-wide'} ${titleTone(theme, subtleTitle)}`}>
121
+ {title}
122
+ </p>
123
+ ) : null
124
+ const textNode =
125
+ text != null ? (
126
+ <p className={`text-small leading-4 ${theme === 'dark' ? 'text-white/60' : 'text-fg-muted'} ${title != null ? 'mt-3' : ''}`}>
127
+ {text}
128
+ </p>
129
+ ) : null
130
+
131
+ // Content band: custom children win; else a right-side image → two columns; else
132
+ // the title/text stack (top or vertically centred).
133
+ let content: ReactNode
134
+ if (children != null) {
135
+ content = <div className="flex min-h-0 flex-1 flex-col items-start">{children}</div>
136
+ } else if (image != null) {
137
+ content = (
138
+ <div className="grid min-h-0 flex-1 grid-cols-2 grid-rows-[minmax(0,1fr)] gap-4">
139
+ <div className="flex min-h-0 min-w-0 flex-col">
140
+ {titleNode}
141
+ {textNode}
142
+ </div>
143
+ <div className="flex min-h-0 items-end justify-end">{image}</div>
144
+ </div>
145
+ )
146
+ } else {
147
+ content = (
148
+ <div className={`flex min-h-0 flex-1 flex-col ${titleAlign === 'center' ? 'justify-center' : ''}`}>
149
+ {titleNode}
150
+ {textNode}
151
+ </div>
152
+ )
153
+ }
154
+
57
155
  return (
58
156
  <>
59
- {head ? (
60
- <div className="flex items-center justify-between gap-2 text-body font-medium leading-5">
61
- {head}
157
+ {eyebrow != null || badge != null ? (
158
+ <div className="flex items-center gap-3 text-body font-medium leading-5">
159
+ {eyebrow}
160
+ {badge}
62
161
  </div>
63
162
  ) : null}
64
- <div className="flex min-h-0 flex-1 flex-col items-start">{children}</div>
65
- {tail ? <div className="min-w-0">{tail}</div> : null}
163
+ {content}
164
+ {footer != null ? <div className="min-w-0">{footer}</div> : null}
66
165
  </>
67
166
  )
68
167
  }
69
168
 
70
- const baseSurface = 'flex h-full min-h-[200px] flex-col gap-6 rounded-lg p-6'
169
+ /** Full-bleed media hero (background image, or click-to-play inline video). */
170
+ function MediaHero({
171
+ backgroundImage,
172
+ imageAlt,
173
+ video,
174
+ eyebrow,
175
+ title,
176
+ footer,
177
+ badge,
178
+ onPress,
179
+ pressLabel,
180
+ closeLabel = 'Close video',
181
+ className,
182
+ }: Pick<
183
+ TileProps,
184
+ 'backgroundImage' | 'imageAlt' | 'video' | 'eyebrow' | 'title' | 'footer' | 'badge' | 'onPress' | 'pressLabel' | 'closeLabel' | 'className'
185
+ >) {
186
+ const [playing, setPlaying] = useState(false)
187
+ const hasVideo = Boolean(video)
188
+ const pressable = hasVideo || Boolean(onPress)
189
+ const handlePress = () => {
190
+ if (hasVideo) setPlaying(true)
191
+ else onPress?.()
192
+ }
193
+
194
+ return (
195
+ <div
196
+ className={['relative isolate h-full min-h-[200px] w-full overflow-hidden rounded-lg bg-surface-inverted', className]
197
+ .filter(Boolean)
198
+ .join(' ')}
199
+ >
200
+ {playing && video ? (
201
+ <>
202
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption -- caption track is
203
+ supplied by the consumer via a fuller player when needed. */}
204
+ <video
205
+ src={video}
206
+ poster={backgroundImage}
207
+ className="absolute inset-0 z-0 h-full w-full bg-black object-cover"
208
+ controls
209
+ autoPlay
210
+ playsInline
211
+ />
212
+ <button
213
+ type="button"
214
+ aria-label={closeLabel}
215
+ onClick={() => setPlaying(false)}
216
+ className="absolute right-4 top-4 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white outline-none transition-colors hover:bg-black/80 focus-visible:ring-2 focus-visible:ring-white/70"
217
+ >
218
+ <CloseIcon className="h-4 w-4" />
219
+ </button>
220
+ </>
221
+ ) : (
222
+ <>
223
+ {backgroundImage ? (
224
+ <img src={backgroundImage} alt={imageAlt ?? ''} className="absolute inset-0 h-full w-full object-cover" />
225
+ ) : null}
226
+ {/* Legibility scrim — darkens top + bottom where text sits. */}
227
+ <div aria-hidden="true" className="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-black/60" />
228
+
229
+ {pressable ? (
230
+ <button
231
+ type="button"
232
+ onClick={handlePress}
233
+ aria-label={pressLabel}
234
+ className="absolute inset-0 z-0 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70"
235
+ />
236
+ ) : null}
237
+
238
+ {eyebrow ? (
239
+ <span className="pointer-events-none absolute left-6 top-6 z-10 text-compact font-medium text-white">{eyebrow}</span>
240
+ ) : null}
241
+
242
+ {/* Content is click-through so pressing the title/badge falls to the
243
+ press overlay; only `footer` re-enables its own pointer events. */}
244
+ <div className="pointer-events-none absolute inset-x-6 bottom-6 z-10 flex flex-col gap-4">
245
+ {title ? <h2 className="max-w-md text-display font-medium leading-tight text-white">{title}</h2> : null}
246
+ {footer != null || badge != null ? (
247
+ <div className="flex items-center justify-between gap-4">
248
+ <span className="pointer-events-auto">{footer}</span>
249
+ <span>{badge}</span>
250
+ </div>
251
+ ) : null}
252
+ </div>
253
+ </>
254
+ )}
255
+ </div>
256
+ )
257
+ }
258
+
259
+ export function Tile({
260
+ theme = 'light',
261
+ eyebrow,
262
+ head,
263
+ badge,
264
+ title,
265
+ titleAlign,
266
+ subtleTitle,
267
+ text,
268
+ footer,
269
+ tail,
270
+ image,
271
+ children,
272
+ backgroundImage,
273
+ imageAlt,
274
+ video,
275
+ closeLabel,
276
+ link,
277
+ onPress,
278
+ pressLabel,
279
+ className,
280
+ ...rest
281
+ }: TileProps) {
282
+ const eb = eyebrow ?? head
283
+ const ft = footer ?? tail
71
284
 
72
- export function Tile({ theme = 'light', head, children, tail, link, className }: TileProps) {
285
+ // Media hero: a background image / inline video with overlaid text.
286
+ if (backgroundImage || video) {
287
+ return (
288
+ <MediaHero
289
+ backgroundImage={backgroundImage}
290
+ imageAlt={imageAlt}
291
+ video={video}
292
+ eyebrow={eb}
293
+ title={title}
294
+ footer={ft}
295
+ badge={badge}
296
+ onPress={onPress}
297
+ pressLabel={pressLabel}
298
+ closeLabel={closeLabel}
299
+ className={className}
300
+ />
301
+ )
302
+ }
303
+
304
+ const body = (
305
+ <TileBands
306
+ theme={theme}
307
+ eyebrow={eb}
308
+ badge={badge}
309
+ title={title}
310
+ titleAlign={titleAlign}
311
+ subtleTitle={subtleTitle}
312
+ text={text}
313
+ footer={ft}
314
+ image={image}
315
+ >
316
+ {children}
317
+ </TileBands>
318
+ )
73
319
  const surface = [baseSurface, THEME[theme], className].filter(Boolean).join(' ')
74
320
 
75
- // Link element supplied → render the tile AS that element (real anchor semantics).
321
+ // A link element → render the tile AS that element (real anchor semantics).
76
322
  if (link && isValidElement(link)) {
77
323
  const el = link as ReactElement<{ className?: string }>
78
- const cardClass = [surface, linkInteractive, el.props.className].filter(Boolean).join(' ')
79
- return cloneElement(el, { className: cardClass } as { className: string }, (
80
- <TileBody head={head} tail={tail}>
81
- {children}
82
- </TileBody>
83
- ))
324
+ const cardClass = [surface, 'block', interactive, el.props.className].filter(Boolean).join(' ')
325
+ return cloneElement(el, { className: cardClass } as { className: string }, body)
84
326
  }
85
327
 
86
- return (
87
- <div className={surface}>
88
- <TileBody head={head} tail={tail}>
89
- {children}
90
- </TileBody>
91
- </div>
92
- )
93
- }
328
+ if (onPress) {
329
+ return (
330
+ <button type="button" onClick={onPress} aria-label={rest['aria-label']} className={`${surface} w-full text-left ${interactive}`}>
331
+ {body}
332
+ </button>
333
+ )
334
+ }
94
335
 
95
- /**
96
- * The big "stat" content typography used inside a tile's content band (gs `.content`:
97
- * 28px / 500 / tight tracking). Apply to the element wrapping a tile's headline value.
98
- */
99
- export const tileStatClass = 'text-display font-medium leading-[30px] tracking-wide'
336
+ return <div className={surface}>{body}</div>
337
+ }
100
338
 
101
339
  /**
102
340
  * Badge — a small presentational status pill (gs `GSTag`). NOTE: this is a plain
@@ -117,7 +355,7 @@ const BADGE: Record<BadgeColor, string> = {
117
355
  export function Badge({ label, color = 'grey' }: { label: ReactNode; color?: BadgeColor }) {
118
356
  return (
119
357
  <span
120
- className={`inline-flex items-center rounded-full px-2 py-0.5 text-caption font-medium leading-none ${BADGE[color]}`}
358
+ className={`inline-flex items-center rounded-full px-3 py-1 text-caption font-medium leading-none ${BADGE[color]}`}
121
359
  >
122
360
  {label}
123
361
  </span>
package/src/index.ts CHANGED
@@ -54,7 +54,6 @@ export * from "./layout/persistent-page-shell";
54
54
  export * from "./components/brand-page-header";
55
55
  export * from "./components/count-up";
56
56
  export * from "./components/cta-pill";
57
- export * from "./components/feature-tile";
58
57
  export * from "./components/stat-stack";
59
58
  export * from "./components/icons";
60
59
  export * from "./components/subtle";
@@ -1,140 +0,0 @@
1
- import { useState, type ReactNode } from 'react'
2
- import { CloseIcon } from './icons'
3
-
4
- /**
5
- * FeatureTile — a full-width media hero tile: a background image with an overlaid
6
- * eyebrow (top-left), title (bottom-left), an optional action control and a badge.
7
- * Generic — the common use is a "start here" tutorial banner, but it fits any
8
- * promo/feature spotlight. Presentational only (hard rule #1): text arrives via props.
9
- *
10
- * Pressing the tile either PLAYS an inline video (when `video` is set — the poster
11
- * image + overlays are replaced by a `<video controls autoPlay>` filling the tile, no
12
- * modal) or fires `onPress` (e.g. navigate). `action` / `badge` sit ABOVE the press
13
- * target (z-10) so their own handlers still work. Falls back to a dark surface when no
14
- * `image` is given.
15
- */
16
- export interface FeatureTileProps {
17
- /** Background/poster image URL. Falls back to a dark surface when omitted. */
18
- image?: string
19
- imageAlt?: string
20
- /** When set, pressing the tile plays this video INLINE (no modal). */
21
- video?: string
22
- /** Small category label, top-left (e.g. "Tutorial"). */
23
- eyebrow?: ReactNode
24
- /** Large title, bottom-left. */
25
- title: ReactNode
26
- /** Bottom-left action control (e.g. a round icon button). */
27
- action?: ReactNode
28
- /** Bottom-right badge (e.g. a duration pill). */
29
- badge?: ReactNode
30
- /** Press behaviour when there is NO `video`. `pressLabel` names it for a11y. */
31
- onPress?: () => void
32
- pressLabel?: string
33
- /** Accessible label for the "stop / back to poster" control while playing. */
34
- closeLabel?: string
35
- /** Override the default `aspect-[2/1]` ratio / sizing. */
36
- className?: string
37
- }
38
-
39
- export function FeatureTile({
40
- image,
41
- imageAlt,
42
- video,
43
- eyebrow,
44
- title,
45
- action,
46
- badge,
47
- onPress,
48
- pressLabel,
49
- closeLabel = 'Close video',
50
- className,
51
- }: FeatureTileProps) {
52
- const [playing, setPlaying] = useState(false)
53
- const hasVideo = Boolean(video)
54
- const pressable = hasVideo || Boolean(onPress)
55
-
56
- const handlePress = () => {
57
- if (hasVideo) setPlaying(true)
58
- else onPress?.()
59
- }
60
-
61
- return (
62
- <div
63
- className={[
64
- // A large fixed-aspect banner (default ~2:1); media covers it. Override the
65
- // ratio/size via `className` (e.g. `aspect-[1440/700]`).
66
- 'relative isolate aspect-[2/1] w-full overflow-hidden rounded-lg bg-surface-inverted',
67
- className,
68
- ]
69
- .filter(Boolean)
70
- .join(' ')}
71
- >
72
- {playing && video ? (
73
- <>
74
- {/* eslint-disable-next-line jsx-a11y/media-has-caption -- caption track is
75
- supplied by the consumer via a fuller player when needed. */}
76
- <video
77
- src={video}
78
- poster={image}
79
- className="absolute inset-0 z-0 h-full w-full bg-black object-cover"
80
- controls
81
- autoPlay
82
- playsInline
83
- />
84
- <button
85
- type="button"
86
- aria-label={closeLabel}
87
- onClick={() => setPlaying(false)}
88
- className="absolute right-4 top-4 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white outline-none transition-colors hover:bg-black/80 focus-visible:ring-2 focus-visible:ring-white/70"
89
- >
90
- <CloseIcon className="h-4 w-4" />
91
- </button>
92
- </>
93
- ) : (
94
- <>
95
- {image ? (
96
- <img
97
- src={image}
98
- alt={imageAlt ?? ''}
99
- className="absolute inset-0 h-full w-full object-cover"
100
- />
101
- ) : null}
102
- {/* Legibility scrim — darkens the top + bottom where the text sits. */}
103
- <div
104
- aria-hidden="true"
105
- className="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-black/60"
106
- />
107
-
108
- {/* Full-bleed press target (behind the content controls). */}
109
- {pressable ? (
110
- <button
111
- type="button"
112
- onClick={handlePress}
113
- aria-label={pressLabel}
114
- className="absolute inset-0 z-0 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70"
115
- />
116
- ) : null}
117
-
118
- {eyebrow ? (
119
- <span className="pointer-events-none absolute left-6 top-6 z-10 text-compact font-medium text-white">
120
- {eyebrow}
121
- </span>
122
- ) : null}
123
-
124
- {/* Content is click-through (pointer-events-none) so pressing the title
125
- or badge falls through to the play/press overlay; only `action`
126
- re-enables its own pointer events. */}
127
- <div className="pointer-events-none absolute inset-x-6 bottom-6 z-10 flex flex-col gap-4">
128
- <h2 className="max-w-md text-display font-medium leading-tight text-white">
129
- {title}
130
- </h2>
131
- <div className="flex items-center justify-between gap-4">
132
- <span className="pointer-events-auto">{action}</span>
133
- <span>{badge}</span>
134
- </div>
135
- </div>
136
- </>
137
- )}
138
- </div>
139
- )
140
- }