@podoba/react 0.0.40 → 0.0.42

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.40",
3
+ "version": "0.0.42",
4
4
  "type": "module",
5
5
  "description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
6
6
  "repository": {
@@ -28,8 +28,8 @@
28
28
  "typecheck": "tsc --build"
29
29
  },
30
30
  "dependencies": {
31
- "@podoba/tokens": "^0.0.40",
32
- "@podoba/tailwind": "^0.0.40",
31
+ "@podoba/tokens": "^0.0.42",
32
+ "@podoba/tailwind": "^0.0.42",
33
33
  "react-aria-components": "1.18.0",
34
34
  "class-variance-authority": "0.7.1",
35
35
  "clsx": "2.1.1",
@@ -0,0 +1,98 @@
1
+ import type { CSSProperties, ReactNode } from 'react'
2
+
3
+ /**
4
+ * AnimatedSummaryText: the gs `SummaryContentTile` sentence reveal (Tasks summary,
5
+ * project and brand dashboard summary tiles).
6
+ *
7
+ * Every character is its own `inline-block` token playing `summary-token-reveal`
8
+ * (680ms `cubic-bezier(0.19, 1, 0.22, 1)`: opacity 0, 8px down and 6px blur, clear
9
+ * by 58%, settled at 100%), delayed `min(index * 22, 1800)ms`. Words are
10
+ * `inline-block whitespace-nowrap` so a word never breaks mid-reveal, and a `\n`
11
+ * in the text becomes a line break (pass an already balanced sentence).
12
+ *
13
+ * Accessibility: the per-character copy is `aria-hidden`; assistive technology
14
+ * reads one visually hidden string instead of single letters. Under
15
+ * `prefers-reduced-motion: reduce` no token animates and the sentence is simply
16
+ * shown. The reveal replays whenever `text` changes.
17
+ *
18
+ * Renders inline (`<span>`): place it inside the caller's paragraph, which owns the
19
+ * type scale, colour and `max-width`. Presentational only: no i18n, no data.
20
+ */
21
+
22
+ export interface AnimatedSummarySegment {
23
+ text: string
24
+ /** Highlighted runs take `highlightClassName` (gs `summaryHighlight`, primary ink). */
25
+ highlighted?: boolean
26
+ }
27
+
28
+ export interface AnimatedSummaryTextProps {
29
+ /** The full sentence. `\n` renders as a line break. */
30
+ text: string
31
+ /**
32
+ * Optional highlighted runs. Their texts joined must equal `text`; when omitted the
33
+ * whole sentence is one plain segment.
34
+ */
35
+ segments?: ReadonlyArray<AnimatedSummarySegment>
36
+ /** Class for highlighted words (e.g. `text-fg`). */
37
+ highlightClassName?: string
38
+ /** Per-character delay step (source 22ms). */
39
+ stepMs?: number
40
+ /** Delay ceiling (source 1800ms). */
41
+ maxDelayMs?: number
42
+ className?: string
43
+ }
44
+
45
+ const tokenClass = 'inline-block animate-summary-token will-change-[opacity,filter,transform] motion-reduce:animate-none'
46
+
47
+ export function AnimatedSummaryText({
48
+ text,
49
+ segments,
50
+ highlightClassName,
51
+ stepMs = 22,
52
+ maxDelayMs = 1800,
53
+ className,
54
+ }: AnimatedSummaryTextProps) {
55
+ const parts = segments && segments.length > 0 ? segments : [{ text, highlighted: false }]
56
+ let tokenIndex = 0
57
+ const nodes: ReactNode[] = []
58
+
59
+ parts.forEach((segment, segmentIndex) => {
60
+ segment.text.split(/(\n|\s+)/).forEach((part, partIndex) => {
61
+ if (!part) return
62
+ const key = `${segmentIndex}-${partIndex}`
63
+ if (part === '\n') {
64
+ nodes.push(<br key={`${key}-br`} />)
65
+ return
66
+ }
67
+ if (/^\s+$/.test(part)) {
68
+ nodes.push(part)
69
+ return
70
+ }
71
+ const wordClass = ['inline-block whitespace-nowrap', segment.highlighted ? highlightClassName : '']
72
+ .filter(Boolean)
73
+ .join(' ')
74
+ nodes.push(
75
+ <span key={key} className={wordClass}>
76
+ {Array.from(part).map((character, characterIndex) => {
77
+ const style: CSSProperties = { animationDelay: `${Math.min(tokenIndex * stepMs, maxDelayMs)}ms` }
78
+ tokenIndex += 1
79
+ return (
80
+ <span key={characterIndex} className={tokenClass} style={style} data-summary-token="">
81
+ {character}
82
+ </span>
83
+ )
84
+ })}
85
+ </span>,
86
+ )
87
+ })
88
+ })
89
+
90
+ return (
91
+ <span className={className} data-animated-summary="">
92
+ <span className="sr-only">{text.replace(/\s*\n\s*/g, ' ')}</span>
93
+ <span key={text} aria-hidden="true">
94
+ {nodes}
95
+ </span>
96
+ </span>
97
+ )
98
+ }
@@ -51,6 +51,13 @@ export type BrandPageHeaderProps = {
51
51
  * navigation link, not ornament (#25).
52
52
  */
53
53
  parentLink?: ReactNode
54
+ /**
55
+ * Ink of `parentLink`. `muted` (default) is `fg-muted` (5.98:1). `soft` is the
56
+ * lighter large-text grey `fg-muted-large` (3.4:1 on `surface`, above the WCAG
57
+ * 1.4.3 3:1 floor for this 30px row), the closest AA match to the source's
58
+ * decorative #b3b3b3 parent line. Hover still resolves to `fg`.
59
+ */
60
+ parentLinkTone?: 'muted' | 'soft'
54
61
  /** Optional breadcrumb trail rendered above the greeting. */
55
62
  breadcrumbs?: BrandPageHeaderCrumb[]
56
63
  /**
@@ -84,6 +91,7 @@ export function BrandPageHeader({
84
91
  variant = 'default',
85
92
  headingLevel = 1,
86
93
  parentLink,
94
+ parentLinkTone = 'muted',
87
95
  breadcrumbs,
88
96
  cta,
89
97
  ctaLabel,
@@ -299,7 +307,9 @@ export function BrandPageHeader({
299
307
  // readable `fg-muted` (5.98:1) rather than `fg-subtle`.
300
308
  <nav
301
309
  aria-label="Breadcrumb"
302
- className="h-8 text-display-large font-medium leading-8 tracking-wide text-fg-muted transition-colors [&_a:hover]:text-fg [&_a]:block [&_a]:h-8 [&_a]:text-fg-muted [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline"
310
+ className={`h-8 text-display-large font-medium leading-8 tracking-wide transition-colors [&_a:hover]:text-fg [&_a]:block [&_a]:h-8 [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline ${
311
+ parentLinkTone === 'soft' ? 'text-fg-muted-large [&_a]:text-fg-muted-large' : 'text-fg-muted [&_a]:text-fg-muted'
312
+ }`}
303
313
  >
304
314
  {parentLink}
305
315
  </nav>
@@ -17,6 +17,7 @@ import {
17
17
  } from 'react-aria-components'
18
18
  import { uic } from '../utils/uic'
19
19
  import { useInFocusOverlay } from './focus-context'
20
+ import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
20
21
 
21
22
  /**
22
23
  * ComboBox — a filterable single-select: a text input that narrows a listbox as
@@ -76,7 +77,7 @@ export const ComboBox = <T extends object>({
76
77
  const inFocus = useInFocusOverlay()
77
78
  const { contains } = useFilter({ sensitivity: 'base' })
78
79
  const desc = description ? (
79
- <Text slot="description" className="text-label text-fg-muted">
80
+ <Text slot="description" className={fieldDescriptionClass}>
80
81
  {description}
81
82
  </Text>
82
83
  ) : null
@@ -108,7 +109,7 @@ export const ComboBox = <T extends object>({
108
109
  </ListBox>
109
110
  </Autocomplete>
110
111
  {desc}
111
- {errorMessage ? <span className="text-label text-danger">{errorMessage}</span> : null}
112
+ {errorMessage ? <span className={fieldErrorClass}>{errorMessage}</span> : null}
112
113
  </div>
113
114
  )
114
115
  }
@@ -132,7 +133,7 @@ export const ComboBox = <T extends object>({
132
133
  </RACButton>
133
134
  </div>
134
135
  {desc}
135
- <FieldError className="text-label text-danger">{errorMessage}</FieldError>
136
+ <FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
136
137
  <Popover className="min-w-[var(--trigger-width)] overflow-hidden rounded-lg bg-surface-card shadow-lg">
137
138
  <ListBox
138
139
  className="flex max-h-72 flex-col gap-0.5 overflow-auto overscroll-contain p-1 outline-none"
@@ -12,6 +12,7 @@ import {
12
12
  type TimeFieldProps as RACTimeFieldProps,
13
13
  type TimeValue,
14
14
  } from 'react-aria-components'
15
+ import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
15
16
 
16
17
  /**
17
18
  * DateField / TimeField — segmented, keyboard-first date and time entry (type or
@@ -43,11 +44,11 @@ export const DateField = <T extends DateValue>({ label, description, errorMessag
43
44
  <Label className="text-heading5 font-medium text-fg">{label}</Label>
44
45
  <DateInput className={dateInputClass}>{(segment) => <DateSegment segment={segment} className={segmentClass} />}</DateInput>
45
46
  {description ? (
46
- <Text slot="description" className="text-label text-fg-muted">
47
+ <Text slot="description" className={fieldDescriptionClass}>
47
48
  {description}
48
49
  </Text>
49
50
  ) : null}
50
- <FieldError className="text-label text-danger">{errorMessage}</FieldError>
51
+ <FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
51
52
  </RACDateField>
52
53
  )
53
54
 
@@ -62,10 +63,10 @@ export const TimeField = <T extends TimeValue>({ label, description, errorMessag
62
63
  <Label className="text-heading5 font-medium text-fg">{label}</Label>
63
64
  <DateInput className={dateInputClass}>{(segment) => <DateSegment segment={segment} className={segmentClass} />}</DateInput>
64
65
  {description ? (
65
- <Text slot="description" className="text-label text-fg-muted">
66
+ <Text slot="description" className={fieldDescriptionClass}>
66
67
  {description}
67
68
  </Text>
68
69
  ) : null}
69
- <FieldError className="text-label text-danger">{errorMessage}</FieldError>
70
+ <FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
70
71
  </RACTimeField>
71
72
  )
@@ -23,6 +23,7 @@ import {
23
23
  import { clsx } from 'clsx'
24
24
  import { dateInputClass, segmentClass } from './date-field'
25
25
  import { useInFocusOverlay } from './focus-context'
26
+ import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
26
27
 
27
28
  /**
28
29
  * DatePicker — a `DateField` with a calendar popover. Built on React Aria
@@ -106,11 +107,11 @@ export const DatePicker = <T extends DateValue>({ label, description, errorMessa
106
107
  )}
107
108
  </Group>
108
109
  {description ? (
109
- <Text slot="description" className="text-label text-fg-muted">
110
+ <Text slot="description" className={fieldDescriptionClass}>
110
111
  {description}
111
112
  </Text>
112
113
  ) : null}
113
- <FieldError className="text-label text-danger">{errorMessage}</FieldError>
114
+ <FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
114
115
  {inFocus ? (
115
116
  <div className="mt-2 -ml-2">
116
117
  <CalendarBody />
@@ -15,3 +15,10 @@ export const filledFieldClasses =
15
15
  'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 ' +
16
16
  'data-[invalid]:ring-1 data-[invalid]:ring-danger data-[invalid]:outline-danger ' +
17
17
  'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed'
18
+
19
+ /** gs `.helperText` (`--font-size-label-2`): 14px/18px under a field. The source ink
20
+ * `#7d786f` fails WCAG AA, so it keeps the readable `fg-muted`. */
21
+ export const fieldDescriptionClass = 'text-small text-fg-muted'
22
+
23
+ /** gs `.errorText`: 16px, line-height normal, error red (`danger`, 4.83:1 on white). */
24
+ export const fieldErrorClass = 'text-body leading-[normal] text-danger'
@@ -8,7 +8,7 @@ import {
8
8
  type TextFieldProps,
9
9
  } from 'react-aria-components'
10
10
  import { uic } from '../utils/uic'
11
- import { filledFieldClasses, type FieldAppearance } from './field-appearance'
11
+ import { fieldDescriptionClass, fieldErrorClass, filledFieldClasses, type FieldAppearance } from './field-appearance'
12
12
 
13
13
  /**
14
14
  * Input — labelled single-line text field.
@@ -78,10 +78,10 @@ export const Input = ({ label, description, errorMessage, placeholder, size, app
78
78
  <Label className="text-panel-heading font-medium text-fg">{label}</Label>
79
79
  <StyledInput className={inputClassName} placeholder={placeholder} appearance={appearance} fieldSize={size ?? (appearance === 'filled' ? 'filled' : undefined)} />
80
80
  {description ? (
81
- <Text slot="description" className="text-label text-fg-muted">
81
+ <Text slot="description" className={fieldDescriptionClass}>
82
82
  {description}
83
83
  </Text>
84
84
  ) : null}
85
- <FieldError className="text-label text-danger">{errorMessage}</FieldError>
85
+ <FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
86
86
  </TextField>
87
87
  )
@@ -14,6 +14,7 @@ import {
14
14
  import { clsx } from 'clsx'
15
15
  import { uic } from '../utils/uic'
16
16
  import { useInFocusOverlay } from './focus-context'
17
+ import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
17
18
 
18
19
  /**
19
20
  * MultiSelect — a dropdown that selects several options at once.
@@ -142,8 +143,8 @@ export const MultiSelect = ({
142
143
  list
143
144
  )
144
145
 
145
- const desc = description ? <span className="text-label text-fg-muted">{description}</span> : null
146
- const err = isInvalid && errorMessage ? <span className="text-label text-danger">{errorMessage}</span> : null
146
+ const desc = description ? <span className={fieldDescriptionClass}>{description}</span> : null
147
+ const err = isInvalid && errorMessage ? <span className={fieldErrorClass}>{errorMessage}</span> : null
147
148
 
148
149
  return (
149
150
  <div className={clsx('flex flex-col gap-2', className)}>
@@ -9,6 +9,7 @@ import {
9
9
  type NumberFieldProps as RACNumberFieldProps,
10
10
  Text,
11
11
  } from 'react-aria-components'
12
+ import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
12
13
 
13
14
  /**
14
15
  * NumberField — numeric input with steppers, min/max and locale-aware formatting
@@ -51,10 +52,10 @@ export const NumberField = ({ label, description, errorMessage, placeholder, ...
51
52
  </RACButton>
52
53
  </Group>
53
54
  {description ? (
54
- <Text slot="description" className="text-label text-fg-muted">
55
+ <Text slot="description" className={fieldDescriptionClass}>
55
56
  {description}
56
57
  </Text>
57
58
  ) : null}
58
- <FieldError className="text-label text-danger">{errorMessage}</FieldError>
59
+ <FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
59
60
  </RACNumberField>
60
61
  )
@@ -15,7 +15,10 @@ import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from
15
15
  * All of it is ornamental: every layer is `aria-hidden`, nothing here carries content,
16
16
  * and under `prefers-reduced-motion: reduce` neither the timed states nor any motion
17
17
  * run (the skeleton shows the source static gradient, the reveal never engages).
18
- * Colours are the source literals; the skeleton is a fixed light surface by design.
18
+ * Skeleton colours: the light theme keeps the source literals (#ffffff base, #c7fee0
19
+ * glow) exactly; under `[data-theme="dark"]` the base follows `surface` and the glow
20
+ * becomes a brand-green tint of it, so a loading preview is not a white block on the
21
+ * dark shell.
19
22
  */
20
23
 
21
24
  function usePrefersReducedMotion(): boolean {
@@ -69,9 +72,14 @@ export function useMinimumVisible(active: boolean, minMs = 500): boolean {
69
72
  return reduce ? active : held
70
73
  }
71
74
 
72
- const SKELETON_ANIMATED =
73
- 'linear-gradient(80deg, #ffffff 0%, #ffffff 12.5%, #c7fee0 27.5%, #ffffff 41.5%, #ffffff 50%, #ffffff 62.5%, #c7fee0 77.5%, #ffffff 91.5%, #ffffff 100%)'
74
- const SKELETON_STATIC = 'linear-gradient(80deg, #ffffff 25%, #c7fee0 55%, #ffffff 83%)'
75
+ const SKELETON_BASE = 'var(--preview-skeleton-base)'
76
+ const SKELETON_GLOW = 'var(--preview-skeleton-glow)'
77
+ const SKELETON_ANIMATED = `linear-gradient(80deg, ${SKELETON_BASE} 0%, ${SKELETON_BASE} 12.5%, ${SKELETON_GLOW} 27.5%, ${SKELETON_BASE} 41.5%, ${SKELETON_BASE} 50%, ${SKELETON_BASE} 62.5%, ${SKELETON_GLOW} 77.5%, ${SKELETON_BASE} 91.5%, ${SKELETON_BASE} 100%)`
78
+ const SKELETON_STATIC = `linear-gradient(80deg, ${SKELETON_BASE} 25%, ${SKELETON_GLOW} 55%, ${SKELETON_BASE} 83%)`
79
+ // Light: the source literals. Dark: theme `surface` with an 18% brand-green glow.
80
+ const SKELETON_THEME =
81
+ '[--preview-skeleton-base:#ffffff] [--preview-skeleton-glow:#c7fee0] ' +
82
+ 'dark:[--preview-skeleton-base:var(--color-surface)] dark:[--preview-skeleton-glow:color-mix(in_srgb,var(--color-brand-green)_18%,var(--color-surface))]'
75
83
 
76
84
  export interface PreviewSkeletonProps {
77
85
  /** Accessible status text (translated by the caller), e.g. "Rendering preview". */
@@ -91,7 +99,7 @@ export function PreviewSkeleton({ label, className }: PreviewSkeletonProps) {
91
99
  aria-label={label}
92
100
  aria-hidden={label ? undefined : true}
93
101
  data-preview-skeleton=""
94
- className={['absolute inset-0 overflow-hidden', reduce ? '' : 'animate-preview-skeleton-shimmer', className]
102
+ className={['absolute inset-0 overflow-hidden', SKELETON_THEME, reduce ? '' : 'animate-preview-skeleton-shimmer', className]
95
103
  .filter(Boolean)
96
104
  .join(' ')}
97
105
  style={style}
@@ -8,6 +8,7 @@ import {
8
8
  type RadioProps as RACRadioProps,
9
9
  Text,
10
10
  } from 'react-aria-components'
11
+ import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
11
12
 
12
13
  /**
13
14
  * RadioGroup + Radio — labelled single-choice control.
@@ -34,11 +35,11 @@ export const RadioGroup = ({ label, description, errorMessage, children, ...prop
34
35
  {label ? <Label className="text-small font-medium text-fg">{label}</Label> : null}
35
36
  <div className="flex flex-col gap-3">{children}</div>
36
37
  {description ? (
37
- <Text slot="description" className="text-label text-fg-muted">
38
+ <Text slot="description" className={fieldDescriptionClass}>
38
39
  {description}
39
40
  </Text>
40
41
  ) : null}
41
- <FieldError className="text-label text-danger">{errorMessage}</FieldError>
42
+ <FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
42
43
  </RACRadioGroup>
43
44
  )
44
45
 
@@ -2,6 +2,7 @@ import { type ClipboardEvent as ReactClipboardEvent, type ReactNode, useEffect,
2
2
  import { clsx } from 'clsx'
3
3
  import { SAFE_LINK_HINT, safeLinkUrl } from '../utils/safe-link-url'
4
4
  import { useInFocusOverlay } from './focus-context'
5
+ import { fieldDescriptionClass, fieldErrorClass } from './field-appearance'
5
6
 
6
7
  /**
7
8
  * RichTextEditor — a dependency-free contentEditable WYSIWYG that emits an HTML
@@ -155,8 +156,8 @@ export function RichTextEditor({
155
156
  onPaste={onPaste}
156
157
  />
157
158
  </div>
158
- {description ? <span className="text-label text-fg-muted">{description}</span> : null}
159
- {isInvalid && errorMessage ? <span className="text-label text-danger">{errorMessage}</span> : null}
159
+ {description ? <span className={fieldDescriptionClass}>{description}</span> : null}
160
+ {isInvalid && errorMessage ? <span className={fieldErrorClass}>{errorMessage}</span> : null}
160
161
  </div>
161
162
  )
162
163
  }
@@ -7,6 +7,7 @@ import {
7
7
  type SearchFieldProps as RACSearchFieldProps,
8
8
  Text,
9
9
  } from 'react-aria-components'
10
+ import { fieldDescriptionClass } from './field-appearance'
10
11
 
11
12
  /**
12
13
  * SearchField — a text input for search, with a clear (✕) button that appears
@@ -58,7 +59,7 @@ export const SearchField = ({ label, description, placeholder, ...props }: Searc
58
59
  </RACButton>
59
60
  </div>
60
61
  {description ? (
61
- <Text slot="description" className="text-label text-fg-muted">
62
+ <Text slot="description" className={fieldDescriptionClass}>
62
63
  {description}
63
64
  </Text>
64
65
  ) : null}
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { Button } from './button'
3
+ import { SwitchViewIcon } from './source-icons'
3
4
 
4
5
  /**
5
6
  * SectionTabs — horizontal section/filter tab row (port of gs-platform
@@ -22,6 +23,15 @@ export type SectionTab = {
22
23
  label: ReactNode
23
24
  /** Disabled tabs render dimmed with a "Soon" badge and cannot be selected. */
24
25
  disabled?: boolean
26
+ /**
27
+ * gs `switchable` tab: pressing the already selected tab switches the view
28
+ * (e.g. Tasks list and board) instead of re-selecting it. While selected it shows
29
+ * the source "switch view" burst before the label, revealed on hover and keyboard
30
+ * focus (width 0 to 14px, opacity 0 to 1, 120ms `ease`).
31
+ */
32
+ switchable?: boolean
33
+ /** Native tooltip on a selected switchable tab (translated, e.g. "Switch view"). */
34
+ switchHintLabel?: string
25
35
  }
26
36
 
27
37
  export type SectionTabsProps = {
@@ -63,6 +73,14 @@ const tabBase =
63
73
 
64
74
  const tabActive = 'bg-surface-muted text-fg'
65
75
 
76
+ // gs `.tabButton` (switchable form): 12px gap, so the selected tab is 12px wider even
77
+ // while the 0px icon is hidden (measured 77px at rest, 91px on hover for "All (18)").
78
+ const tabSwitchable = 'group/switch'
79
+ const tabSwitchIcon =
80
+ 'h-3.5 w-0 shrink-0 overflow-hidden opacity-0 transition-[width,opacity] duration-120 ease-[ease] motion-reduce:transition-none ' +
81
+ 'group-data-[hovered]/switch:w-3.5 group-data-[hovered]/switch:opacity-100 ' +
82
+ 'group-data-[focus-visible]/switch:w-3.5 group-data-[focus-visible]/switch:opacity-100'
83
+
66
84
  export function SectionTabs({
67
85
  tabs,
68
86
  active,
@@ -105,6 +123,7 @@ export function SectionTabs({
105
123
  ) : null}
106
124
  {tabs.map((tab) => {
107
125
  const selected = active === tab.key
126
+ const switchHint = Boolean(tab.switchable && selected && !tab.disabled)
108
127
  return (
109
128
  <Button
110
129
  key={tab.key}
@@ -112,9 +131,20 @@ export function SectionTabs({
112
131
  isDisabled={tab.disabled}
113
132
  aria-pressed={selected}
114
133
  onPress={() => onChange(tab.key)}
115
- className={[tabBase, selected && !tab.disabled ? tabActive : ''].filter(Boolean).join(' ')}
134
+ data-switchable={switchHint || undefined}
135
+ className={[tabBase, selected && !tab.disabled ? tabActive : '', switchHint ? tabSwitchable : '']
136
+ .filter(Boolean)
137
+ .join(' ')}
116
138
  >
117
- <span>{tab.label}</span>
139
+ {switchHint ? (
140
+ // React Aria's Button drops `title`, so the native hint rides on the content.
141
+ <span title={tab.switchHintLabel} className="inline-flex items-center gap-3">
142
+ <SwitchViewIcon className={tabSwitchIcon} />
143
+ <span>{tab.label}</span>
144
+ </span>
145
+ ) : (
146
+ <span>{tab.label}</span>
147
+ )}
118
148
  {tab.disabled ? (
119
149
  <span className="inline-flex items-center rounded-full bg-surface-muted px-1.5 py-0.5 text-micro font-medium leading-none text-fg-muted">
120
150
  {soonLabel}
@@ -14,7 +14,7 @@ import {
14
14
  } from 'react-aria-components'
15
15
  import { uic } from '../utils/uic'
16
16
  import { useInFocusOverlay } from './focus-context'
17
- import type { FieldAppearance } from './field-appearance'
17
+ import { fieldDescriptionClass, fieldErrorClass, type FieldAppearance } from './field-appearance'
18
18
 
19
19
  const SelectAppearanceContext = createContext<FieldAppearance>('outlined')
20
20
  const filledPopoverStyle: CSSProperties & { '--select-popup-max-width': string } = {
@@ -143,11 +143,11 @@ export const Select = <T extends object>({
143
143
  </SelectListBox>
144
144
  )
145
145
  const desc = description ? (
146
- <Text slot="description" className="text-label text-fg-muted">
146
+ <Text slot="description" className={fieldDescriptionClass}>
147
147
  {description}
148
148
  </Text>
149
149
  ) : null
150
- const err = <FieldError className="text-label text-danger">{errorMessage}</FieldError>
150
+ const err = <FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
151
151
 
152
152
  return (
153
153
  <SelectAppearanceContext.Provider value={appearance}>
@@ -0,0 +1,60 @@
1
+ import type { IconProps } from './icons'
2
+
3
+ /**
4
+ * Original Manager glyphs that the curated 24px line set in `icons.tsx` has no
5
+ * equivalent for. The Radix ones are Radix Icons 1.3.2 (MIT) on their 15px grid,
6
+ * filled with `currentColor`, exactly as the Manager rendered them (the document
7
+ * library Scope / Role / Status column headers). `SwitchViewIcon` is the Manager's
8
+ * 16px "switch view" burst shown on a selected switchable section tab.
9
+ *
10
+ * All are decorative (`aria-hidden`); size via width/height or `className`.
11
+ */
12
+
13
+ /** Radix `LayersIcon` (document library "Scope" header). */
14
+ export const LayersIcon = (props: IconProps) => (
15
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true" {...props}>
16
+ <path
17
+ d="M7.75432 0.819537C7.59742 0.726821 7.4025 0.726821 7.24559 0.819537L1.74559 4.06954C1.59336 4.15949 1.49996 4.32317 1.49996 4.5C1.49996 4.67683 1.59336 4.84051 1.74559 4.93046L7.24559 8.18046C7.4025 8.27318 7.59742 8.27318 7.75432 8.18046L13.2543 4.93046C13.4066 4.84051 13.5 4.67683 13.5 4.5C13.5 4.32317 13.4066 4.15949 13.2543 4.06954L7.75432 0.819537ZM7.49996 7.16923L2.9828 4.5L7.49996 1.83077L12.0171 4.5L7.49996 7.16923ZM1.5695 7.49564C1.70998 7.2579 2.01659 7.17906 2.25432 7.31954L7.49996 10.4192L12.7456 7.31954C12.9833 7.17906 13.2899 7.2579 13.4304 7.49564C13.5709 7.73337 13.4921 8.03998 13.2543 8.18046L7.75432 11.4305C7.59742 11.5232 7.4025 11.5232 7.24559 11.4305L1.74559 8.18046C1.50786 8.03998 1.42901 7.73337 1.5695 7.49564ZM1.56949 10.4956C1.70998 10.2579 2.01658 10.1791 2.25432 10.3195L7.49996 13.4192L12.7456 10.3195C12.9833 10.1791 13.2899 10.2579 13.4304 10.4956C13.5709 10.7334 13.4921 11.04 13.2543 11.1805L7.75432 14.4305C7.59742 14.5232 7.4025 14.5232 7.24559 14.4305L1.74559 11.1805C1.50785 11.04 1.42901 10.7334 1.56949 10.4956Z"
18
+ fill="currentColor"
19
+ fillRule="evenodd"
20
+ clipRule="evenodd"
21
+ />
22
+ </svg>
23
+ )
24
+
25
+ /** Radix `BadgeIcon` (document library "Role" header). */
26
+ export const BadgeIcon = (props: IconProps) => (
27
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true" {...props}>
28
+ <path
29
+ d="M3.5 6H11.5C12.3284 6 13 6.67157 13 7.5C13 8.32843 12.3284 9 11.5 9H3.5C2.67157 9 2 8.32843 2 7.5C2 6.67157 2.67157 6 3.5 6ZM1 7.5C1 6.11929 2.11929 5 3.5 5H11.5C12.8807 5 14 6.11929 14 7.5C14 8.88071 12.8807 10 11.5 10H3.5C2.11929 10 1 8.88071 1 7.5ZM4.5 7C4.22386 7 4 7.22386 4 7.5C4 7.77614 4.22386 8 4.5 8H10.5C10.7761 8 11 7.77614 11 7.5C11 7.22386 10.7761 7 10.5 7H4.5Z"
30
+ fill="currentColor"
31
+ fillRule="evenodd"
32
+ clipRule="evenodd"
33
+ />
34
+ </svg>
35
+ )
36
+
37
+ /** Radix `CheckCircledIcon` (document library "Status" header). */
38
+ export const CheckCircledIcon = (props: IconProps) => (
39
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true" {...props}>
40
+ <path
41
+ d="M7.49991 0.877045C3.84222 0.877045 0.877075 3.84219 0.877075 7.49988C0.877075 11.1575 3.84222 14.1227 7.49991 14.1227C11.1576 14.1227 14.1227 11.1575 14.1227 7.49988C14.1227 3.84219 11.1576 0.877045 7.49991 0.877045ZM1.82708 7.49988C1.82708 4.36686 4.36689 1.82704 7.49991 1.82704C10.6329 1.82704 13.1727 4.36686 13.1727 7.49988C13.1727 10.6329 10.6329 13.1727 7.49991 13.1727C4.36689 13.1727 1.82708 10.6329 1.82708 7.49988ZM10.1589 5.53774C10.3178 5.31191 10.2636 5.00001 10.0378 4.84109C9.81194 4.68217 9.50004 4.73642 9.34112 4.96225L6.51977 8.97154L5.35681 7.78706C5.16334 7.59002 4.84677 7.58711 4.64973 7.78058C4.45268 7.97404 4.44978 8.29061 4.64325 8.48765L6.22658 10.1003C6.33054 10.2062 6.47617 10.2604 6.62407 10.2483C6.77197 10.2363 6.90686 10.1591 6.99226 10.0377L10.1589 5.53774Z"
42
+ fill="currentColor"
43
+ fillRule="evenodd"
44
+ clipRule="evenodd"
45
+ />
46
+ </svg>
47
+ )
48
+
49
+ /** Manager "switch view" burst (16px grid, 1.5px round stroke). */
50
+ export const SwitchViewIcon = (props: IconProps) => (
51
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
52
+ <path d="M8 2V6M8 10V14M2 8H6M10 8H14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
53
+ <path
54
+ d="M11.657 4.343L13.071 2.929M2.929 13.071L4.343 11.657M13.071 13.071L11.657 11.657M4.343 4.343L2.929 2.929"
55
+ stroke="currentColor"
56
+ strokeWidth="1.5"
57
+ strokeLinecap="round"
58
+ />
59
+ </svg>
60
+ )
@@ -9,6 +9,7 @@ import {
9
9
  TagList,
10
10
  Text,
11
11
  } from 'react-aria-components'
12
+ import { fieldDescriptionClass } from './field-appearance'
12
13
 
13
14
  /**
14
15
  * TagGroup + Tag — a set of chips (labels, filters, or a removable tag input).
@@ -26,7 +27,7 @@ export const TagGroup = ({ label, description, children, ...props }: TagGroupPro
26
27
  {label ? <Label className="text-heading5 font-medium text-fg">{label}</Label> : null}
27
28
  <TagList className="flex flex-wrap gap-2 outline-none">{children}</TagList>
28
29
  {description ? (
29
- <Text slot="description" className="text-label text-fg-muted">
30
+ <Text slot="description" className={fieldDescriptionClass}>
30
31
  {description}
31
32
  </Text>
32
33
  ) : null}
@@ -37,7 +37,7 @@ export const CatalogSummaryGrid = uic('div', { displayName: 'CatalogSummaryGrid'
37
37
  const SummaryHead = uic('div', { displayName: 'CatalogSummaryHead', baseClass: 'flex w-full min-w-0 items-center justify-between gap-3' })
38
38
  export const CatalogSummaryBadge = uic('span', { displayName: 'CatalogSummaryBadge', baseClass: 'ml-3 flex h-6 shrink-0 items-center rounded-2xl px-2.5 text-micro font-medium leading-5', style: { fontFeatureSettings: '"liga" off, "clig" off' }, variants: { color: { green: 'bg-brand-green text-fg-on-brand', grey: 'bg-surface-muted text-(--color-neutral-600)' } }, defaultVariants: { color: 'grey' } })
39
39
  const SummaryTitle = uic('h2', { displayName: 'CatalogSummaryTitle', baseClass: 'm-0 min-w-0 truncate text-heading5 font-medium' })
40
- const SummaryTail = uic('div', { displayName: 'CatalogSummaryTail', baseClass: 'mt-auto flex flex-col gap-1 text-small font-normal text-fg-workflow-muted' })
40
+ const SummaryTail = uic('div', { displayName: 'CatalogSummaryTail', baseClass: 'mt-auto flex flex-col gap-1 text-small font-normal text-fg-muted' })
41
41
  /** Source gs Tile image cover: caller artwork under a transparent → 30% → 50% black gradient. */
42
42
  function CatalogCover({ preview }: { preview: ReactNode }) {
43
43
  return <>
@@ -8,7 +8,7 @@ import {
8
8
  type TextFieldProps,
9
9
  } from 'react-aria-components'
10
10
  import { uic } from '../utils/uic'
11
- import { filledFieldClasses, type FieldAppearance } from './field-appearance'
11
+ import { fieldDescriptionClass, fieldErrorClass, filledFieldClasses, type FieldAppearance } from './field-appearance'
12
12
 
13
13
  /**
14
14
  * Textarea — labelled multi-line text field.
@@ -64,10 +64,10 @@ export const Textarea = ({ label, description, errorMessage, placeholder, rows,
64
64
  <Label className="text-panel-heading font-medium text-fg">{label}</Label>
65
65
  <StyledTextArea className={textAreaClassName} placeholder={placeholder} rows={rows} appearance={appearance} />
66
66
  {description ? (
67
- <Text slot="description" className="text-label text-fg-muted">
67
+ <Text slot="description" className={fieldDescriptionClass}>
68
68
  {description}
69
69
  </Text>
70
70
  ) : null}
71
- <FieldError className="text-label text-danger">{errorMessage}</FieldError>
71
+ <FieldError className={fieldErrorClass}>{errorMessage}</FieldError>
72
72
  </TextField>
73
73
  )
package/src/index.ts CHANGED
@@ -33,6 +33,8 @@ export * from "./components/document-upload-panel";
33
33
  export * from "./components/csv-binding-presentation";
34
34
  export * from "./components/dialog-action-button";
35
35
  export * from "./components/reload-icon";
36
+ export * from "./components/source-icons";
37
+ export * from "./components/animated-summary-text";
36
38
  export * from "./components/date-field";
37
39
  export * from "./components/date-picker";
38
40
  export * from "./components/dialog";
@@ -249,6 +249,26 @@ export const UserMenuSection = ({ label, children, ...props }: UserMenuSectionPr
249
249
  </MenuSection>
250
250
  )
251
251
 
252
+ /**
253
+ * Non-interactive muted line at the foot of the account menu, e.g. the running app
254
+ * version (`v2026.09.17 · a1b2c3d`). Like `UserMenuIdentity` it is a section header,
255
+ * so it is never focusable and never announced as an action. The text stays
256
+ * selectable so it can be copied into a bug report.
257
+ *
258
+ * `label` names the line for assistive tech only ("App version: v2026.09.17 · …").
259
+ * It has to live inside the header: React Aria names the section from its header,
260
+ * which would override an `aria-label` on the section. REQUIRED and a plain string,
261
+ * because podoba ships no i18n.
262
+ */
263
+ export const UserMenuMeta = ({ label, children }: { label: string; children: ReactNode }) => (
264
+ <MenuSection>
265
+ <Header className="select-text px-4 py-2 text-micro leading-5 font-normal tabular-nums text-fg-muted">
266
+ <span className="sr-only">{`${label}: `}</span>
267
+ {children}
268
+ </Header>
269
+ </MenuSection>
270
+ )
271
+
252
272
  /** 1px rule between account-menu groups (gs `.separator`). */
253
273
  export const UserMenuSeparator = DropdownMenuSeparator
254
274