@podoba/react 0.0.13 → 0.0.15

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.13",
3
+ "version": "0.0.15",
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.13",
29
- "@podoba/tailwind": "^0.0.13",
28
+ "@podoba/tokens": "^0.0.15",
29
+ "@podoba/tailwind": "^0.0.15",
30
30
  "react-aria-components": "1.18.0",
31
31
  "class-variance-authority": "0.7.1",
32
32
  "clsx": "2.1.1",
@@ -116,7 +116,7 @@ export function BrandPageHeader({
116
116
  ))}
117
117
  </nav>
118
118
  ) : null}
119
- <h1 className="text-display font-medium leading-[1.12] tracking-[-0.56px] text-fg">
119
+ <h1 className="text-display font-medium leading-[1.12] tracking-wide text-fg">
120
120
  {parentLink ? (
121
121
  // gs "title to go back": muted clickable parent line above the title.
122
122
  // `[&_a]` styles the nested router <Link> (anchor) without @app/ui
@@ -126,7 +126,7 @@ const MenuItem = uic(RACMenuItem, {
126
126
  // gs `.panel`: 258px, radius 12px, 8px/6px padding, 180deg #242424→#1f1f1f
127
127
  // gradient, 0 12px 28px rgba(0,0,0,.22) shadow. No token covers the gradient/shadow.
128
128
  const panelClass =
129
- 'w-[258px] max-w-[calc(100vw-16px)] rounded-xl bg-gradient-to-b from-[#242424] to-[#1f1f1f] ' +
129
+ 'w-[258px] max-w-[calc(100vw-16px)] rounded-lg bg-gradient-to-b from-[#242424] to-[#1f1f1f] ' +
130
130
  'px-1.5 py-2 shadow-[0_12px_28px_rgba(0,0,0,0.22)] outline-none'
131
131
  const menuClass = 'grid max-h-[calc(100vh-16px)] gap-0.5 overflow-y-auto outline-none'
132
132
 
@@ -178,7 +178,7 @@ function ContextMenuBody({
178
178
  <span className="min-w-0 flex-1 truncate">{item.label}</span>
179
179
  {badge ? (
180
180
  // gs `.badge`: 24px pill, #333437 bg / #c8c8ca text, 10px (text-micro).
181
- <span className="ml-auto inline-flex h-6 items-center justify-center rounded-full bg-[#333437] px-3 py-0.5 text-micro font-medium leading-5 tracking-[-0.2px] text-[#c8c8ca]">
181
+ <span className="ml-auto inline-flex h-6 items-center justify-center rounded-full bg-[#333437] px-3 py-0.5 text-micro font-medium leading-5 tracking-tight text-[#c8c8ca]">
182
182
  {badge}
183
183
  </span>
184
184
  ) : null}
@@ -0,0 +1,36 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /**
4
+ * CtaPill — the gs Brand Core hero CTA: a GS-green bar reading "Let's create
5
+ * something" (the middle word emphasised) with an action control on the right.
6
+ *
7
+ * The copy is three fragments (`lead` · `emphasis` · `tail`) so a consumer can
8
+ * translate each per-locale while keeping the middle-word highlight. Presentational
9
+ * only (hard rule #1): every string arrives via props — no i18n, no domain data.
10
+ *
11
+ * a11y NOTE: the background is the light brand-green (#6eddb1); the emphasised word is
12
+ * rendered WHITE per the gs design — white-on-green is ~1.66:1, below WCAG AA, so it
13
+ * reads as a decorative highlight (it also carries a bold weight), not load-bearing
14
+ * copy. The surrounding text stays on the dark `fg` (~10:1).
15
+ */
16
+ export interface CtaPillProps {
17
+ /** Regular lead-in before the emphasised word (e.g. "Let's"). */
18
+ lead: ReactNode
19
+ /** The emphasised bold, white middle word (e.g. "create"). */
20
+ emphasis: ReactNode
21
+ /** Regular trailing text after the emphasis (e.g. "something"). */
22
+ tail: ReactNode
23
+ /** Right-side action control (e.g. a Create button). */
24
+ children: ReactNode
25
+ }
26
+
27
+ export function CtaPill({ lead, emphasis, tail, children }: CtaPillProps) {
28
+ return (
29
+ <div className="flex min-w-[560px] items-center justify-between gap-4 rounded-lg bg-brand-secondary px-6 py-4">
30
+ <p className="text-subtitle font-medium leading-[22px] tracking-normal text-fg">
31
+ {lead} <span className="font-semibold text-white">{emphasis}</span> {tail}
32
+ </p>
33
+ <div className="shrink-0">{children}</div>
34
+ </div>
35
+ )
36
+ }
@@ -22,6 +22,10 @@ export type StatsCardProps = {
22
22
  value: ReactNode
23
23
  /** Optional supporting footer line (e.g. "+3 this week"). */
24
24
  footer?: ReactNode
25
+ /** Optional status pill shown at the end of the title row (e.g. a `<Badge>`). */
26
+ badge?: ReactNode
27
+ /** Optional muted line under the title (e.g. "Plan sections, tasks and outputs"). */
28
+ description?: ReactNode
25
29
  /** When provided the card becomes a clickable React Aria Button. */
26
30
  onPress?: () => void
27
31
  /**
@@ -54,15 +58,23 @@ function StatsCardBody({
54
58
  title,
55
59
  value,
56
60
  footer,
61
+ badge,
62
+ description,
57
63
  dark,
58
- }: Pick<StatsCardProps, 'title' | 'value' | 'footer' | 'dark'>) {
64
+ }: Pick<StatsCardProps, 'title' | 'value' | 'footer' | 'badge' | 'description' | 'dark'>) {
59
65
  return (
60
66
  <>
61
- <span
62
- className={`text-body font-medium leading-5 ${dark ? 'text-white' : 'text-fg'}`}
63
- >
64
- {title}
65
- </span>
67
+ <div className="flex items-center justify-between gap-2">
68
+ <span className={`text-body font-medium leading-5 ${dark ? 'text-white' : 'text-fg'}`}>
69
+ {title}
70
+ </span>
71
+ {badge ?? null}
72
+ </div>
73
+ {description ? (
74
+ <span className={`text-compact leading-4 ${dark ? 'text-white/60' : 'text-fg-muted'}`}>
75
+ {description}
76
+ </span>
77
+ ) : null}
66
78
  {/*
67
79
  * gs Tile `contentAlign="center"`: the value sits in the CENTRE band of the
68
80
  * three-band tile (title top / value centred / footer bottom). The `flex-1`
@@ -73,7 +85,7 @@ function StatsCardBody({
73
85
  * `-0.02em` (`AssetsTile.module.scss .count`).
74
86
  */}
75
87
  <span
76
- className={`flex flex-1 items-center text-[1.875rem] font-medium leading-[2rem] ${dark ? 'tracking-[-0.02em] text-white' : 'tracking-wide text-fg'}`}
88
+ className={`flex flex-1 items-center text-[1.875rem] font-medium leading-[2rem] ${dark ? 'tracking-normal text-white' : 'tracking-wide text-fg'}`}
77
89
  >
78
90
  {value}
79
91
  </span>
@@ -108,6 +120,8 @@ export function StatsCard({
108
120
  title,
109
121
  value,
110
122
  footer,
123
+ badge,
124
+ description,
111
125
  onPress,
112
126
  asChild,
113
127
  children,
@@ -122,7 +136,7 @@ export function StatsCard({
122
136
  const cardClass = [baseSurface, dark ? darkSkin : lightSkin, anchorInteractive, child.props.className, className]
123
137
  .filter(Boolean)
124
138
  .join(' ')
125
- return cloneElement(child, { className: cardClass } as { className: string }, <StatsCardBody title={title} value={value} footer={footer} dark={dark} />)
139
+ return cloneElement(child, { className: cardClass } as { className: string }, <StatsCardBody title={title} value={value} footer={footer} badge={badge} description={description} dark={dark} />)
126
140
  }
127
141
 
128
142
  if (onPress) {
@@ -134,13 +148,13 @@ export function StatsCard({
134
148
  className={[dark ? darkSkin : '', className].filter(Boolean).join(' ')}
135
149
  aria-label={rest['aria-label']}
136
150
  >
137
- <StatsCardBody title={title} value={value} footer={footer} dark={dark} />
151
+ <StatsCardBody title={title} value={value} footer={footer} badge={badge} description={description} dark={dark} />
138
152
  </PressableCard>
139
153
  )
140
154
  }
141
155
  return (
142
156
  <div className={[baseSurface, dark ? darkSkin : lightSkin, className].filter(Boolean).join(' ')}>
143
- <StatsCardBody title={title} value={value} footer={footer} dark={dark} />
157
+ <StatsCardBody title={title} value={value} footer={footer} badge={badge} description={description} dark={dark} />
144
158
  </div>
145
159
  )
146
160
  }
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /**
4
+ * Subtle — inline de-emphasised text (the light `fg-subtle` grey, #b3b3b3). Wraps its
5
+ * children in a `text-fg-subtle` span; anything OUTSIDE it stays at the surrounding
6
+ * full-contrast `fg`. Compose it freely to two-tone arbitrary runs of a line — unlike
7
+ * a fixed-slot header it handles mid-line emphasis in ANY order and ANY language:
8
+ *
9
+ * <h1>
10
+ * <Subtle>Good afternoon</Subtle> {name} 👋
11
+ * <br />
12
+ * <Subtle>You have</Subtle> {count} planned tasks <Subtle>today</Subtle>
13
+ * </h1>
14
+ *
15
+ * With i18n, put the `<subtle>…</subtle>` runs INSIDE the translation string and render
16
+ * it via `<Trans components={{ subtle: <Subtle /> }} />`, so each locale places the
17
+ * emphasis where its own word order needs it. Presentational only (hard rule #1).
18
+ */
19
+ export function Subtle({ children }: { children: ReactNode }) {
20
+ return <span className="text-fg-subtle">{children}</span>
21
+ }
@@ -0,0 +1,120 @@
1
+ import { type ReactElement, type ReactNode, cloneElement, isValidElement } from 'react'
2
+
3
+ /**
4
+ * Tile — the canonical dashboard CARD (port of gs-platform's `Tile`).
5
+ *
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
+ *
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.
20
+ */
21
+ export type TileTheme = 'light' | 'dark' | 'teal' | 'yellow'
22
+
23
+ const THEME: Record<TileTheme, string> = {
24
+ light: 'bg-surface-card text-fg',
25
+ dark: 'bg-surface-inverted text-white',
26
+ teal: 'bg-brand-secondary text-fg',
27
+ yellow: 'bg-accent-yellow text-fg',
28
+ }
29
+
30
+ /** Hover-lift + focus ring applied only when the tile is a link. */
31
+ const linkInteractive =
32
+ 'block no-underline outline-none transition-[transform,box-shadow] duration-150 ' +
33
+ 'hover:-translate-y-1 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-ring'
34
+
35
+ export type TileProps = {
36
+ /** Surface theme (gs's Figma palette). */
37
+ theme?: TileTheme
38
+ /** Label row at the top (optionally a trailing `<Badge>`). */
39
+ head?: ReactNode
40
+ /** The big content band — grows to fill, top-aligned. */
41
+ children?: ReactNode
42
+ /** Footer band (links, avatars, meta). */
43
+ tail?: ReactNode
44
+ /**
45
+ * A link ELEMENT to render the whole tile as (e.g. a router `<Link/>`). The tile
46
+ * styling + hover-lift merge onto it and the tile body becomes its children.
47
+ */
48
+ link?: ReactElement
49
+ className?: string
50
+ }
51
+
52
+ /** The head/content/tail bands, shared by the plain + linked renders. */
53
+ function TileBody({ head, children, tail }: Pick<TileProps, 'head' | 'children' | 'tail'>) {
54
+ return (
55
+ <>
56
+ {head ? (
57
+ <div className="flex items-center justify-between gap-2 text-body font-medium leading-5">
58
+ {head}
59
+ </div>
60
+ ) : null}
61
+ <div className="flex min-h-0 flex-1 flex-col items-start">{children}</div>
62
+ {tail ? <div className="min-w-0">{tail}</div> : null}
63
+ </>
64
+ )
65
+ }
66
+
67
+ const baseSurface = 'flex h-full min-h-[200px] flex-col gap-6 rounded-lg p-6'
68
+
69
+ export function Tile({ theme = 'light', head, children, tail, link, className }: TileProps) {
70
+ const surface = [baseSurface, THEME[theme], className].filter(Boolean).join(' ')
71
+
72
+ // Link element supplied → render the tile AS that element (real anchor semantics).
73
+ if (link && isValidElement(link)) {
74
+ const el = link as ReactElement<{ className?: string }>
75
+ const cardClass = [surface, linkInteractive, el.props.className].filter(Boolean).join(' ')
76
+ return cloneElement(el, { className: cardClass } as { className: string }, (
77
+ <TileBody head={head} tail={tail}>
78
+ {children}
79
+ </TileBody>
80
+ ))
81
+ }
82
+
83
+ return (
84
+ <div className={surface}>
85
+ <TileBody head={head} tail={tail}>
86
+ {children}
87
+ </TileBody>
88
+ </div>
89
+ )
90
+ }
91
+
92
+ /**
93
+ * The big "stat" content typography used inside a tile's content band (gs `.content`:
94
+ * 28px / 500 / tight tracking). Apply to the element wrapping a tile's headline value.
95
+ */
96
+ export const tileStatClass = 'text-display font-medium leading-[30px] tracking-wide'
97
+
98
+ /**
99
+ * Badge — a small presentational status pill (gs `GSTag`). NOTE: this is a plain
100
+ * label pill, distinct from podoba's interactive React-Aria `Tag` (tag-group.tsx);
101
+ * named `Badge` to avoid the collision.
102
+ */
103
+ export type BadgeColor = 'green' | 'yellow' | 'grey' | 'dark'
104
+
105
+ const BADGE: Record<BadgeColor, string> = {
106
+ green: 'bg-accent-mint text-fg',
107
+ yellow: 'bg-accent-yellow text-fg',
108
+ grey: 'bg-surface-muted text-fg-muted',
109
+ dark: 'bg-surface-inverted text-white',
110
+ }
111
+
112
+ export function Badge({ label, color = 'grey' }: { label: ReactNode; color?: BadgeColor }) {
113
+ return (
114
+ <span
115
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-caption font-medium leading-none ${BADGE[color]}`}
116
+ >
117
+ {label}
118
+ </span>
119
+ )
120
+ }
package/src/index.ts CHANGED
@@ -52,7 +52,10 @@ export * from "./layout/persistent-page-shell";
52
52
 
53
53
  // --- product patterns (label-driven; no domain coupling) ---
54
54
  export * from "./components/brand-page-header";
55
+ export * from "./components/cta-pill";
56
+ export * from "./components/subtle";
55
57
  export * from "./components/stats-card";
58
+ export * from "./components/tile";
56
59
  export * from "./components/dashboard-grid";
57
60
  export * from "./components/task-approval-modal";
58
61
  export * from "./components/request-changes-modal";
@@ -4,9 +4,8 @@ import { uic } from '../utils/uic'
4
4
  * Card — surface panel (port of gs-manager's `Card`).
5
5
  *
6
6
  * gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule #3):
7
- * - `--radius-lg` (8px) → `rounded-lg` (our `--radius-lg` is 0.5rem = 8px). NB the
8
- * `rounded-panel` token is 1.1rem (~17.6px), NOT the 8px card radius gs's Card
9
- * used `--radius-lg`, so this maps to `rounded-lg`.
7
+ * - `rounded-lg` (`--radius-lg` = 0.5rem = 8px) the single unified card/panel
8
+ * radius across the design system (the old 1.1rem soft-panel radius was retired).
10
9
  * - `--color-background-secondary` (the light card fill) → `bg-surface-card`
11
10
  * (our `--color-surface-card` = #f7f6f2).
12
11
  * - `outlined` → 1px `--color-border` (#eceae1) → `border border-border`.