@podoba/react 0.0.30 → 0.0.34

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.30",
3
+ "version": "0.0.34",
4
4
  "type": "module",
5
5
  "description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
6
6
  "repository": {
@@ -13,7 +13,8 @@
13
13
  "main": "./src/index.ts",
14
14
  "types": "./src/index.ts",
15
15
  "exports": {
16
- ".": "./src/index.ts"
16
+ ".": "./src/index.ts",
17
+ "./editor": "./src/editor/index.ts"
17
18
  },
18
19
  "files": [
19
20
  "src",
@@ -27,17 +28,48 @@
27
28
  "typecheck": "tsc --build"
28
29
  },
29
30
  "dependencies": {
30
- "@podoba/tokens": "^0.0.30",
31
- "@podoba/tailwind": "^0.0.30",
31
+ "@podoba/tokens": "^0.0.34",
32
+ "@podoba/tailwind": "^0.0.34",
32
33
  "react-aria-components": "1.18.0",
33
34
  "class-variance-authority": "0.7.1",
34
35
  "clsx": "2.1.1",
35
36
  "tailwind-merge": "3.6.0",
36
37
  "@radix-ui/react-slot": "1.2.4"
37
38
  },
39
+ "//tiptap": "Only `@podoba/react/editor` (BlockEditor) needs Tiptap. Optional peers, not dependencies: the base package must not make every consumer install ProseMirror, and a peer keeps ONE @tiptap/pm in the tree — two copies of prosemirror-model break instanceof across the editor.",
38
40
  "peerDependencies": {
39
41
  "react": ">=19",
40
- "react-dom": ">=19"
42
+ "react-dom": ">=19",
43
+ "@tiptap/react": "^3.27.4",
44
+ "@tiptap/pm": "^3.27.4",
45
+ "@tiptap/starter-kit": "^3.27.4",
46
+ "@tiptap/extension-placeholder": "^3.27.4",
47
+ "@tiptap/extension-highlight": "^3.27.4",
48
+ "@tiptap/extension-task-list": "^3.27.4",
49
+ "@tiptap/extension-task-item": "^3.27.4"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@tiptap/react": {
53
+ "optional": true
54
+ },
55
+ "@tiptap/pm": {
56
+ "optional": true
57
+ },
58
+ "@tiptap/starter-kit": {
59
+ "optional": true
60
+ },
61
+ "@tiptap/extension-placeholder": {
62
+ "optional": true
63
+ },
64
+ "@tiptap/extension-highlight": {
65
+ "optional": true
66
+ },
67
+ "@tiptap/extension-task-list": {
68
+ "optional": true
69
+ },
70
+ "@tiptap/extension-task-item": {
71
+ "optional": true
72
+ }
41
73
  },
42
74
  "devDependencies": {
43
75
  "react": "^19.2.1",
@@ -45,6 +77,13 @@
45
77
  "@types/react": "^19.2.7",
46
78
  "@types/react-dom": "^19.2.3",
47
79
  "typescript": "^5.9.3",
48
- "@types/bun": "^1.3.3"
80
+ "@types/bun": "^1.3.3",
81
+ "@tiptap/react": "^3.27.4",
82
+ "@tiptap/pm": "^3.27.4",
83
+ "@tiptap/starter-kit": "^3.27.4",
84
+ "@tiptap/extension-placeholder": "^3.27.4",
85
+ "@tiptap/extension-highlight": "^3.27.4",
86
+ "@tiptap/extension-task-list": "^3.27.4",
87
+ "@tiptap/extension-task-item": "^3.27.4"
49
88
  }
50
89
  }
@@ -1,4 +1,4 @@
1
- import { type ReactNode, useState } from 'react'
1
+ import { type ReactNode, useEffect, useId, useRef, useState } from 'react'
2
2
  import { Button } from './button'
3
3
  import { DisplayHeading } from './text'
4
4
 
@@ -8,10 +8,9 @@ import { DisplayHeading } from './text'
8
8
  *
9
9
  * gs-platform's header is a two-column grid: a left "welcome" section
10
10
  * (optional breadcrumbs + a large greeting line) and a right section holding an
11
- * `ExpandableCTA` — a collapsed teal pill that expands into an inline
12
- * create-hub panel. We port that interaction to React Aria + Tailwind + the
13
- * teal accent token (`brand-secondary`), dropping gs-platform's mobile
14
- * fixed-sheet behaviour for a simpler inline disclosure.
11
+ * `ExpandableCTA` — a collapsed teal pill that expands into a create-hub panel.
12
+ * The supplied hero `CtaPill` owns the source mobile fixed-bar treatment; this
13
+ * header switches to the desktop 2/3 + 1/3 grid at the matching 768px breakpoint.
15
14
  *
16
15
  * The expandable CTA is a controlled disclosure: the collapsed teal pill is a
17
16
  * React Aria `Button` (keyboard + focus ring + press handling) wired to a
@@ -28,6 +27,12 @@ export type BrandPageHeaderCrumb = {
28
27
  onPress?: () => void
29
28
  }
30
29
 
30
+ export type BrandPageHeaderCtaRenderProps = {
31
+ expanded: boolean
32
+ controls: string
33
+ toggle: () => void
34
+ }
35
+
31
36
  export type BrandPageHeaderProps = {
32
37
  /** The large greeting / page-title slot (e.g. "Good morning Jonas 👋"). */
33
38
  greeting: ReactNode
@@ -37,17 +42,19 @@ export type BrandPageHeaderProps = {
37
42
  * gs-style "title to go back": a muted, clickable PARENT link rendered as the
38
43
  * line ABOVE the title (so the title reads "Parent ⏎ Current"). Pass a real
39
44
  * router `<Link>` so it's an anchor (cmd/middle-click, deep-link-safe), exactly
40
- * like gs's `parentLink`. The header styles it muted with a hover→fg affordance.
45
+ * like gs's `parentLink`. The header uses the source's decorative `fg-subtle`
46
+ * (#b3b3b3) with a hover→fg affordance.
41
47
  */
42
48
  parentLink?: ReactNode
43
49
  /** Optional breadcrumb trail rendered above the greeting. */
44
50
  breadcrumbs?: BrandPageHeaderCrumb[]
45
51
  /**
46
- * Arbitrary right-column CTA node (e.g. the gs hero `CtaPill` banner). When
47
- * set it REPLACES the collapsed-pill ExpandableCTA use this for the
48
- * gs-faithful "Let's create something" hero. `ctaLabel`/`createHub` are ignored.
52
+ * Arbitrary right-column CTA node (e.g. the gs hero `CtaPill` banner), or a
53
+ * render function receiving the disclosure state and generated controls id.
54
+ * Prefer the render function when the CTA opens `createHub`, so its trigger can
55
+ * expose `aria-expanded` and `aria-controls`.
49
56
  */
50
- cta?: ReactNode
57
+ cta?: ReactNode | ((state: BrandPageHeaderCtaRenderProps) => ReactNode)
51
58
  /** Label on the collapsed teal "Create" pill. Required to render the CTA. */
52
59
  ctaLabel?: ReactNode
53
60
  /** Inline content revealed when the CTA expands (the create hub). */
@@ -59,11 +66,14 @@ export type BrandPageHeaderProps = {
59
66
  closeLabel?: string
60
67
  /** Sticky header on scroll. */
61
68
  sticky?: boolean
69
+ /**
70
+ * Dock the supplied CTA to the safe bottom edge below 768px, matching the
71
+ * source Manager's collapsed ExpandableCTA. Enabled by default.
72
+ */
73
+ mobileCtaDocked?: boolean
62
74
  className?: string
63
75
  }
64
76
 
65
- const PANEL_ID = 'brand-page-header-create-hub'
66
-
67
77
  export function BrandPageHeader({
68
78
  greeting,
69
79
  headingLevel = 1,
@@ -76,18 +86,152 @@ export function BrandPageHeader({
76
86
  onExpandedChange,
77
87
  closeLabel = 'Close',
78
88
  sticky = false,
89
+ mobileCtaDocked = true,
79
90
  className,
80
91
  }: BrandPageHeaderProps) {
81
92
  const [internalExpanded, setInternalExpanded] = useState(false)
93
+ const [mobileSheet, setMobileSheet] = useState(false)
94
+ const [mobileFull, setMobileFull] = useState(false)
82
95
  const isControlled = expandedProp !== undefined
83
96
  const expanded = isControlled ? expandedProp : internalExpanded
84
97
  const hasExpandable = Boolean(createHub)
85
98
  const HeadingTag = `h${headingLevel}` as const
99
+ const panelId = useId()
100
+ const panelRef = useRef<HTMLDivElement>(null)
101
+ const contentRef = useRef<HTMLDivElement>(null)
102
+ const returnFocusRef = useRef<HTMLElement | null>(null)
103
+ const touchStartYRef = useRef(0)
104
+ const touchStartScrollTopRef = useRef(0)
86
105
 
87
106
  const setExpanded = (next: boolean) => {
88
107
  if (!isControlled) setInternalExpanded(next)
89
108
  onExpandedChange?.(next)
90
109
  }
110
+ const renderedCta =
111
+ typeof cta === 'function'
112
+ ? cta({
113
+ expanded,
114
+ controls: panelId,
115
+ toggle: () => setExpanded(!expanded),
116
+ })
117
+ : cta
118
+
119
+ useEffect(() => {
120
+ if (!expanded || !hasExpandable || typeof window === 'undefined') return
121
+
122
+ returnFocusRef.current =
123
+ document.activeElement instanceof HTMLElement ? document.activeElement : null
124
+
125
+ let lockedScrollY = 0
126
+ let restoreBody: (() => void) | undefined
127
+
128
+ const updateComposition = () => {
129
+ const isMobile = window.innerWidth < 768
130
+ setMobileSheet(isMobile)
131
+
132
+ const content = contentRef.current
133
+ setMobileFull(Boolean(isMobile && content && content.scrollHeight >= window.innerHeight * 0.4))
134
+
135
+ if (isMobile && !restoreBody) {
136
+ lockedScrollY = window.scrollY
137
+ const previous = {
138
+ overflow: document.body.style.overflow,
139
+ position: document.body.style.position,
140
+ top: document.body.style.top,
141
+ width: document.body.style.width,
142
+ }
143
+ document.body.style.overflow = 'hidden'
144
+ document.body.style.position = 'fixed'
145
+ document.body.style.top = `-${lockedScrollY}px`
146
+ document.body.style.width = '100%'
147
+ restoreBody = () => {
148
+ document.body.style.overflow = previous.overflow
149
+ document.body.style.position = previous.position
150
+ document.body.style.top = previous.top
151
+ document.body.style.width = previous.width
152
+ window.scrollTo(0, lockedScrollY)
153
+ }
154
+ } else if (!isMobile && restoreBody) {
155
+ restoreBody()
156
+ restoreBody = undefined
157
+ }
158
+ }
159
+
160
+ const keepFocusInsideMobileSheet = (event: KeyboardEvent) => {
161
+ if (event.key === 'Escape') {
162
+ event.preventDefault()
163
+ setExpanded(false)
164
+ return
165
+ }
166
+ if (event.key !== 'Tab' || window.innerWidth >= 768 || !panelRef.current) return
167
+
168
+ const focusable = Array.from(
169
+ panelRef.current.querySelectorAll<HTMLElement>(
170
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
171
+ ),
172
+ ).filter((node) => {
173
+ const style = window.getComputedStyle(node)
174
+ return (
175
+ !node.hidden &&
176
+ !node.closest('[hidden]') &&
177
+ node.getAttribute('aria-hidden') !== 'true' &&
178
+ style.display !== 'none' &&
179
+ style.visibility !== 'hidden'
180
+ )
181
+ })
182
+ if (focusable.length === 0) {
183
+ event.preventDefault()
184
+ panelRef.current.focus()
185
+ return
186
+ }
187
+ const first = focusable[0]
188
+ const last = focusable[focusable.length - 1]
189
+ if (!panelRef.current.contains(document.activeElement)) {
190
+ event.preventDefault()
191
+ const boundary = event.shiftKey ? last : first
192
+ boundary?.focus()
193
+ return
194
+ }
195
+ if (event.shiftKey && document.activeElement === first) {
196
+ event.preventDefault()
197
+ last?.focus()
198
+ } else if (!event.shiftKey && document.activeElement === last) {
199
+ event.preventDefault()
200
+ first?.focus()
201
+ }
202
+ }
203
+
204
+ updateComposition()
205
+ const observer =
206
+ typeof ResizeObserver === 'undefined' || !contentRef.current
207
+ ? undefined
208
+ : new ResizeObserver(updateComposition)
209
+ if (contentRef.current) observer?.observe(contentRef.current)
210
+ window.addEventListener('resize', updateComposition)
211
+ document.addEventListener('keydown', keepFocusInsideMobileSheet)
212
+
213
+ return () => {
214
+ observer?.disconnect()
215
+ window.removeEventListener('resize', updateComposition)
216
+ document.removeEventListener('keydown', keepFocusInsideMobileSheet)
217
+ restoreBody?.()
218
+ returnFocusRef.current?.focus()
219
+ returnFocusRef.current = null
220
+ setMobileSheet(false)
221
+ setMobileFull(false)
222
+ }
223
+ }, [expanded, hasExpandable])
224
+
225
+ useEffect(() => {
226
+ if (!expanded || !hasExpandable || typeof window === 'undefined') return
227
+ const frame = window.requestAnimationFrame(() => {
228
+ const selector = mobileSheet
229
+ ? '[data-create-hub-focus="mobile"]'
230
+ : '[data-create-hub-focus="desktop"]'
231
+ panelRef.current?.querySelector<HTMLElement>(selector)?.focus()
232
+ })
233
+ return () => window.cancelAnimationFrame(frame)
234
+ }, [expanded, hasExpandable, mobileSheet])
91
235
 
92
236
  return (
93
237
  <div
@@ -99,8 +243,8 @@ export function BrandPageHeader({
99
243
  .filter(Boolean)
100
244
  .join(' ')}
101
245
  >
102
- <div className="flex flex-col gap-2 sm:grid sm:grid-cols-3 sm:items-stretch sm:gap-4">
103
- <div className="flex min-w-0 flex-1 flex-col gap-1 sm:col-span-2">
246
+ <div className="flex flex-col gap-2 md:grid md:grid-cols-3 md:items-stretch md:gap-4">
247
+ <div className="flex min-w-0 flex-1 flex-col gap-1 md:col-span-2">
104
248
  {breadcrumbs && breadcrumbs.length > 0 ? (
105
249
  <nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-compact text-fg-muted">
106
250
  {breadcrumbs.map((crumb, i) => (
@@ -128,7 +272,7 @@ export function BrandPageHeader({
128
272
  // `[&_a]` styles the nested router <Link> (anchor) without @app/ui
129
273
  // importing the router.
130
274
  <>
131
- <span className="text-fg-muted transition-colors [&_a:hover]:text-fg [&_a]:text-fg-muted [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline">
275
+ <span className="text-fg-subtle transition-colors [&_a:hover]:text-fg [&_a]:text-fg-subtle [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline">
132
276
  {parentLink}
133
277
  </span>
134
278
  <br />
@@ -141,14 +285,25 @@ export function BrandPageHeader({
141
285
 
142
286
  {cta ? (
143
287
  // Hero CTA spans 4 of 12 columns (one third) — the greeting takes the rest.
144
- <div className="h-full min-w-0">{cta}</div>
288
+ <div
289
+ className={
290
+ [
291
+ mobileCtaDocked
292
+ ? 'fixed inset-x-0 bottom-0 z-40 min-w-0 px-3 pb-mobile-cta-bottom md:static md:inset-auto md:z-auto md:h-full md:p-0'
293
+ : 'h-full min-w-0',
294
+ expanded ? 'hidden' : '',
295
+ ].join(' ')
296
+ }
297
+ >
298
+ {renderedCta}
299
+ </div>
145
300
  ) : ctaLabel ? (
146
301
  <div className="shrink-0">
147
302
  {hasExpandable ? (
148
303
  <Button
149
304
  onPress={() => setExpanded(!expanded)}
150
305
  aria-expanded={expanded}
151
- aria-controls={PANEL_ID}
306
+ aria-controls={panelId}
152
307
  className="h-10 rounded-full bg-brand-secondary px-5 text-small font-medium text-fg data-[hovered]:opacity-90 data-[pressed]:opacity-80"
153
308
  >
154
309
  {ctaLabel}
@@ -162,35 +317,80 @@ export function BrandPageHeader({
162
317
  ) : null}
163
318
  </div>
164
319
 
165
- {hasExpandable ? (
166
- <div
167
- id={PANEL_ID}
168
- role="region"
169
- aria-label={typeof ctaLabel === 'string' ? ctaLabel : undefined}
170
- hidden={!expanded}
171
- className="mt-4"
172
- >
173
- {expanded ? (
174
- <div className="relative rounded-lg border border-border bg-surface-card p-4 animate-expand-cta motion-reduce:animate-none">
175
- <Button
176
- variant="ghost"
177
- aria-label={closeLabel}
178
- onPress={() => setExpanded(false)}
179
- className="absolute right-2 top-2 h-7 w-7 rounded-full p-0 text-fg-muted data-[hovered]:bg-surface-muted data-[hovered]:text-fg"
180
- >
181
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
182
- <path
183
- d="M6 6l12 12M18 6L6 18"
184
- stroke="currentColor"
185
- strokeWidth="2"
186
- strokeLinecap="round"
187
- />
188
- </svg>
189
- </Button>
320
+ {hasExpandable && expanded ? (
321
+ <>
322
+ <div
323
+ aria-hidden="true"
324
+ onClick={() => setExpanded(false)}
325
+ className="fixed inset-0 z-40 bg-black/50 animate-create-hub-backdrop md:hidden motion-reduce:animate-none"
326
+ />
327
+ <div
328
+ ref={panelRef}
329
+ id={panelId}
330
+ role={mobileSheet ? 'dialog' : 'region'}
331
+ aria-modal={mobileSheet || undefined}
332
+ aria-label={typeof ctaLabel === 'string' ? ctaLabel : undefined}
333
+ tabIndex={-1}
334
+ onTouchStart={(event) => {
335
+ const touch = event.touches[0]
336
+ if (!touch || !contentRef.current) return
337
+ touchStartYRef.current = touch.clientY
338
+ touchStartScrollTopRef.current = contentRef.current.scrollTop
339
+ }}
340
+ onTouchMove={(event) => {
341
+ const touch = event.touches[0]
342
+ if (
343
+ !touch ||
344
+ touchStartScrollTopRef.current > 10 ||
345
+ touch.clientY - touchStartYRef.current < 80
346
+ ) {
347
+ return
348
+ }
349
+ event.preventDefault()
350
+ setExpanded(false)
351
+ }}
352
+ className={[
353
+ 'fixed inset-x-0 bottom-0 z-50 flex flex-col overflow-hidden bg-brand-green',
354
+ 'outline-none motion-reduce:animate-none',
355
+ 'md:relative md:inset-auto md:z-auto md:mt-4 md:max-h-none md:origin-top-right md:rounded-lg md:animate-create-hub-desktop',
356
+ mobileFull
357
+ ? 'top-0 max-h-create-hub-full animate-create-hub-full'
358
+ : 'max-h-create-hub-partial animate-create-hub-sheet',
359
+ ].join(' ')}
360
+ >
361
+ <Button
362
+ variant="ghost"
363
+ aria-label={closeLabel}
364
+ data-create-hub-focus="mobile"
365
+ onPress={() => setExpanded(false)}
366
+ className="h-10 w-full shrink-0 rounded-none p-0 md:hidden data-[hovered]:bg-transparent"
367
+ >
368
+ <span className="h-1 w-10 rounded-full bg-fg/25" aria-hidden="true" />
369
+ </Button>
370
+ <Button
371
+ variant="ghost"
372
+ aria-label={closeLabel}
373
+ data-create-hub-focus="desktop"
374
+ onPress={() => setExpanded(false)}
375
+ className="absolute right-4 top-14 z-10 hidden h-8 w-8 rounded-md p-0 text-fg md:flex data-[hovered]:bg-black/5"
376
+ >
377
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
378
+ <path
379
+ d="M6 6l12 12M18 6L6 18"
380
+ stroke="currentColor"
381
+ strokeWidth="2"
382
+ strokeLinecap="round"
383
+ />
384
+ </svg>
385
+ </Button>
386
+ <div
387
+ ref={contentRef}
388
+ className="min-h-0 flex-1 overflow-y-auto overscroll-contain md:overflow-visible"
389
+ >
190
390
  {createHub}
191
391
  </div>
192
- ) : null}
193
- </div>
392
+ </div>
393
+ </>
194
394
  ) : null}
195
395
  </div>
196
396
  )
@@ -19,9 +19,9 @@ export const Button = uic(RACButton, {
19
19
  'data-[pending]:opacity-70 data-[pending]:cursor-progress',
20
20
  variants: {
21
21
  variant: {
22
- // primary (gs `_primary`): base neutral-400 (#242423 = surface-inverted)
23
- // hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow → active back
24
- // to neutral-400 (surface-inverted, no shadow).
22
+ // primary (gs `_primary`): base brand-primary (#0d0d0d light / brand-green on
23
+ // dark) → hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow →
24
+ // active back to brand-primary (no shadow).
25
25
  primary:
26
26
  'bg-brand-primary text-fg-inverted hover:bg-neutral-600 hover:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:bg-brand-primary data-[pressed]:shadow-none',
27
27
  // secondary: #f7f6f2 (surface-card) + 1px #eceae1 (border); hover bg #eceae1
@@ -5,8 +5,11 @@ import type { ReactNode } from 'react'
5
5
  * something" (the middle word emphasised) with an action control on the right.
6
6
  *
7
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.
8
+ * translate each per-locale while keeping the middle-word highlight. With
9
+ * `mobileHeader`, it adopts the source ExpandableCTA's 99px mobile touch surface;
10
+ * `BrandPageHeader` owns the safe-bottom positioning and returns it to the one-third
11
+ * desktop grid at the `md` breakpoint. Presentational only (hard rule #1): every
12
+ * string arrives via props — no i18n, no domain data.
10
13
  *
11
14
  * a11y NOTE: the background is the FIXED light brand-secondary (#6eddb1) — it does
12
15
  * not flip with the theme, so every sentence fragment sits on `fg-on-brand` (stable
@@ -23,11 +26,23 @@ export interface CtaPillProps {
23
26
  tail: ReactNode
24
27
  /** Right-side action control (e.g. a Create button). */
25
28
  children: ReactNode
29
+ /**
30
+ * Use the source mobile header density (99px minimum height and doubled
31
+ * horizontal content padding). Positioning stays with `BrandPageHeader`.
32
+ */
33
+ mobileHeader?: boolean
26
34
  }
27
35
 
28
- export function CtaPill({ lead, emphasis, tail, children }: CtaPillProps) {
36
+ export function CtaPill({ lead, emphasis, tail, children, mobileHeader = false }: CtaPillProps) {
29
37
  return (
30
- <div className="flex h-full min-h-16 w-full items-center justify-between gap-nav-x rounded-lg bg-brand-green py-2.5 pr-3 pl-4.5">
38
+ <div
39
+ className={[
40
+ 'flex h-full w-full items-center justify-between gap-nav-x rounded-lg bg-brand-green',
41
+ mobileHeader
42
+ ? 'min-h-mobile-cta px-12 py-5 shadow-mobile-cta md:min-h-16 md:py-2.5 md:pr-3 md:pl-4.5 md:shadow-none'
43
+ : 'min-h-16 py-2.5 pr-3 pl-4.5',
44
+ ].join(' ')}
45
+ >
31
46
  <p className="min-w-0 text-heading4 font-medium leading-5 tracking-tight text-fg-on-brand">
32
47
  {lead} <span className="font-semibold">{emphasis}</span> {tail}
33
48
  </p>
@@ -133,6 +133,10 @@ export const Dialog = ({
133
133
  // Either way the RACDialog render-prop `close` resolves against the active
134
134
  // overlay state, so `close()` works in both modes.
135
135
  <ModalOverlay
136
+ // Full-screen is an immersive takeover: swap the light scrim for a heavier
137
+ // backdrop blur so the page behind reads as clearly blurred around the
138
+ // near-fullscreen canvas. (tailwind-merge dedupes the base blur/scrim.)
139
+ className={isFlex ? 'bg-black/25 backdrop-blur-lg' : undefined}
136
140
  isOpen={isOpen}
137
141
  defaultOpen={defaultOpen}
138
142
  onOpenChange={onOpenChange}
@@ -20,15 +20,21 @@ import { uic } from '../utils/uic'
20
20
  */
21
21
  const StyledInput = uic(RACInput, {
22
22
  displayName: 'InputControl',
23
- // gs source: borderless cream fill, 8px radius, 14/18 text and 16px inline
24
- // padding. Focus keeps an explicit ring for keyboard accessibility.
23
+ // White fill + border so the field reads as editable. The gs original used a
24
+ // borderless cream (#f7f6f2 surface-card) fill, but our Card surface is ALSO
25
+ // surface-card — and in dark theme surface-card and surface are the SAME colour
26
+ // (#242423) — so a borderless cream field vanishes on both. Inverted: active =
27
+ // surface, disabled = the muted cream. border #eceae1 → border · hover #aba89c →
28
+ // fg-subtle · focus #75e7b8 → brand-green · error → danger. This is the shared
29
+ // filled-field skin (textarea / combobox / date-field / number-field /
30
+ // search-field); `fieldSize` adds the single-line height on top.
25
31
  baseClass:
26
- 'w-full rounded-lg border-0 bg-surface-card px-4 text-small text-fg ' +
32
+ 'w-full rounded-lg border border-border bg-surface px-4 text-small text-fg ' +
27
33
  'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
28
- 'data-[hovered]:bg-surface-muted ' +
29
- 'data-[focused]:ring-2 data-[focused]:ring-ring ' +
30
- 'data-[invalid]:ring-2 data-[invalid]:ring-danger ' +
31
- 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
34
+ 'data-[hovered]:border-fg-subtle ' +
35
+ 'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
36
+ 'data-[invalid]:border-danger data-[invalid]:ring-2 data-[invalid]:ring-danger ' +
37
+ 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
32
38
  variants: {
33
39
  // `fieldSize` (not `size`) to avoid colliding with the native <input size>
34
40
  // attribute, which RAC's Input inherits (a numeric prop).
@@ -1,12 +1,19 @@
1
1
  import { type ClipboardEvent as ReactClipboardEvent, type ReactNode, useEffect, useRef } from 'react'
2
2
  import { clsx } from 'clsx'
3
+ import { SAFE_LINK_HINT, safeLinkUrl } from '../utils/safe-link-url'
3
4
  import { useInFocusOverlay } from './focus-context'
4
5
 
5
6
  /**
6
7
  * RichTextEditor — a dependency-free contentEditable WYSIWYG that emits an HTML
7
8
  * string (round-trips with any `set:html` / `dangerouslySetInnerHTML` renderer,
8
9
  * so no value migration and no bundled ProseMirror). Ported from pramen's
9
- * cms-editor `RichText`; TipTap is the upgrade path if tables/embeds are needed.
10
+ * cms-editor `RichText`.
11
+ *
12
+ * Still the right pick for a short caption/bio field. For document-shaped content
13
+ * — blocks, a `/` palette, tables/embeds — reach for `BlockEditor` from
14
+ * `@podoba/react/editor` instead; it is the Tiptap-backed upgrade this component's
15
+ * `execCommand` core cannot grow into. Both emit the same HTML-string value, so
16
+ * swapping one for the other needs no data migration.
10
17
  *
11
18
  * The `prose prose-sm` body relies on the @tailwindcss/typography plugin, which
12
19
  * @podoba/tailwind's preset already registers.
@@ -32,7 +39,7 @@ const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?
32
39
  if (!raw) return
33
40
  const url = safeLinkUrl(raw)
34
41
  if (!url) {
35
- window.alert('Only http(s), mailto, tel, or relative (/, #) links are allowed.')
42
+ window.alert(SAFE_LINK_HINT)
36
43
  return
37
44
  }
38
45
  x('createLink', url)
@@ -42,13 +49,6 @@ const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?
42
49
  { label: 'Clear', title: 'Clear formatting', run: (x) => x('removeFormat') },
43
50
  ]
44
51
 
45
- /** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
46
- * The prefix allow-list inherently rejects `javascript:`/`data:`/`vbscript:`. */
47
- function safeLinkUrl(raw: string): string | null {
48
- const url = raw.trim()
49
- return /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(url) ? url : null
50
- }
51
-
52
52
  /** Cosmetic scrub (NOT a security boundary — sanitise on the server). */
53
53
  function scrubHtml(html: string): string {
54
54
  return html
@@ -21,24 +21,26 @@ import { useInFocusOverlay } from './focus-context'
21
21
  * RAC handles the listbox ARIA pattern, keyboard navigation, typeahead and
22
22
  * focus management. Styling via `uic`. Pass options as `SelectItem` children.
23
23
  *
24
- * Re-skinned 1:1 to gs-platform's designer spec (Figma GraphicStandard 1.5,
25
- * node 2115-4271 — `Select.module.scss`): a tall (min-h 58px) borderless filled
26
- * trigger on the cream `surface-card`, matching our `Input` / `Textarea` fill so
27
- * the form controls stay consistent. gs token map: bg #f7f6f2surface-card ·
28
- * hover #eceae1 → surface-muted · text #0d0d0d → fg · placeholder → fg-muted ·
24
+ * Re-skinned to gs-platform's designer spec (Figma GraphicStandard 1.5, node
25
+ * 2115-4271 — `Select.module.scss`): a tall (58px) filled trigger matching our
26
+ * `Input` / `Textarea` so the form controls stay consistent. gs token map:
27
+ * hover border #aba89c fg-subtle · text #0d0d0d fg · placeholder fg-muted ·
29
28
  * 8px radius → rounded-lg · 6px item radius → rounded-md · error → danger.
30
29
  */
31
30
  const SelectTrigger = uic(RACButton, {
32
31
  displayName: 'SelectTrigger',
33
- // Exact gs source control: 58px tall, 20px inline padding, borderless cream
34
- // fill and 8px radius.
32
+ // gs source control geometry (58px tall, 20px inline padding, 8px radius) with
33
+ // the shared bordered fill: gs draws it borderless on cream, but our Card is
34
+ // also surface-card and dark theme collapses surface-card onto surface, so a
35
+ // borderless cream trigger disappears. White fill + border keeps it visible and
36
+ // consistent with the other form controls; hover darkens the border.
35
37
  baseClass:
36
- 'flex h-control-tall w-full items-center justify-between gap-2.5 rounded-lg border-0 bg-surface-card px-5 ' +
38
+ 'flex h-control-tall w-full items-center justify-between gap-2.5 rounded-lg border border-border bg-surface px-5 ' +
37
39
  'text-small text-fg outline-none transition-colors ' +
38
- 'data-[hovered]:bg-surface-muted ' +
40
+ 'data-[hovered]:border-fg-subtle ' +
39
41
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
40
- 'group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
41
- 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
42
+ 'group-data-[invalid]:border-danger group-data-[invalid]:ring-2 group-data-[invalid]:ring-danger ' +
43
+ 'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
42
44
  })
43
45
 
44
46
  export const SelectItem = uic(ListBoxItem, {