@podoba/react 0.0.36 → 0.0.37

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.36",
3
+ "version": "0.0.37",
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.36",
32
- "@podoba/tailwind": "^0.0.36",
31
+ "@podoba/tokens": "^0.0.37",
32
+ "@podoba/tailwind": "^0.0.37",
33
33
  "react-aria-components": "1.18.0",
34
34
  "class-variance-authority": "0.7.1",
35
35
  "clsx": "2.1.1",
@@ -10,10 +10,11 @@ import { uic } from '../utils/uic'
10
10
  export const Button = uic(RACButton, {
11
11
  displayName: 'Button',
12
12
  // gs `Button.module.scss`: pill radius, FIXED 13px / weight-400 text (`text-compact`)
13
- // on every size — size variants change PADDING ONLY, never the text scale. 200ms
14
- // ease-in-out transition.
13
+ // on every size — size variants change PADDING ONLY, never the text scale. The
14
+ // source eases background-color, color, box-shadow and border-color over 120ms `ease`.
15
15
  baseClass:
16
- 'inline-flex items-center justify-center gap-2 rounded-full text-compact leading-4 transition-all duration-200 ease-in-out ' +
16
+ 'inline-flex items-center justify-center gap-2 rounded-full text-compact leading-4 ' +
17
+ 'transition-[background-color,color,box-shadow,border-color] duration-120 ease-[ease] motion-reduce:transition-none ' +
17
18
  'outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
18
19
  // Inactive states (disabled / pending) soften the LABEL ONLY and leave every
19
20
  // variant on its own surface. Two reasons:
@@ -118,16 +118,17 @@ const MenuItem = uic(RACMenuItem, {
118
118
  // upstream API — a hand-written custom property has nowhere to be defined and
119
119
  // silently resolves to nothing.
120
120
  baseClass:
121
- 'flex min-h-8.5 cursor-pointer select-none items-center gap-2.5 rounded-[10px] px-3 py-1.5 ' +
121
+ 'flex min-h-8.5 min-w-0 cursor-pointer select-none items-center gap-2.5 rounded-[10px] px-3 py-1.5 ' +
122
122
  'text-compact leading-4 tracking-normal font-normal text-white outline-none ' +
123
123
  'data-[focused]:bg-[#2f2f2f] data-[focused]:font-medium data-[hovered]:bg-[#2f2f2f] data-[hovered]:font-medium ' +
124
124
  'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45',
125
125
  variants: {
126
- // gs `--color-error-light` (#ffb5b5), a light red tuned for this dark panel.
127
- // `--color-danger-light` DOES exist, but it flips to a ~15% translucent red
128
- // under `[data-theme="dark"]` and would all but vanish here; `text-danger`
126
+ // gs `var(--color-error-light, #ffb5b5)`: the Manager defines `--color-error-light`
127
+ // as #fee2e2, so the rendered source ink is #fee2e2 (the #ffb5b5 fallback never
128
+ // applies). `--color-danger-light` DOES exist, but it flips to a ~15% translucent
129
+ // red under `[data-theme="dark"]` and would all but vanish here; `text-danger`
129
130
  // (#dc2626) is a light-surface red that fails contrast on #242424.
130
- destructive: { true: 'text-[#ffb5b5] data-[focused]:text-[#ffb5b5]' },
131
+ destructive: { true: 'text-[#fee2e2] data-[focused]:text-[#fee2e2]' },
131
132
  },
132
133
  }) as (
133
134
  props: React.ComponentProps<typeof RACMenuItem> & { destructive?: boolean },
@@ -142,7 +143,9 @@ const panelClass =
142
143
  'w-[258px] max-w-[calc(100vw-16px)] max-h-[calc(100vh-16px)] box-border overflow-y-auto rounded-xl ' +
143
144
  'bg-gradient-to-b from-[#242424] to-[#1f1f1f] ' +
144
145
  'px-1.5 py-2 shadow-[0_12px_28px_rgba(0,0,0,0.22)] outline-none'
145
- const menuClass = 'grid gap-0.5 outline-none'
146
+ // `minmax(0, 1fr)` tracks: an `auto` grid column grows to the items' nowrap
147
+ // min-content, so a long label plus a trailing pill pushed rows past the 258px panel.
148
+ const menuClass = 'grid grid-cols-[minmax(0,1fr)] gap-0.5 outline-none'
146
149
 
147
150
  const CursorPopoverSurface = uic(RACPopover, {
148
151
  displayName: 'ContextMenuPopoverSurface',
@@ -264,7 +267,7 @@ function ContextMenuBody({
264
267
  {groups.map((group, gi) => (
265
268
  // gs separates groups with an 18px top margin (`.group + .group`), not a
266
269
  // rule line — so the first group sits flush, the rest gain the gap.
267
- <RACMenuSection key={groupId(group, gi)} className={gi > 0 ? 'mt-4.5 grid gap-0.5' : 'grid gap-0.5'}>
270
+ <RACMenuSection key={groupId(group, gi)} className={gi > 0 ? 'mt-4.5 grid min-w-0 grid-cols-[minmax(0,1fr)] gap-0.5' : 'grid min-w-0 grid-cols-[minmax(0,1fr)] gap-0.5'}>
268
271
  {group.label ? (
269
272
  // The grid contributes 2px: 2px margin + 2px gap = source 4px below label.
270
273
  <Header className="mx-2 mt-1.5 mb-0.5 text-compact leading-4 tracking-normal font-medium text-white">{group.label}</Header>
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState, type ElementType } from 'react'
1
+ import { useEffect, useLayoutEffect, useRef, useState, type ElementType } from 'react'
2
2
 
3
3
  /**
4
4
  * CountUp — animates a number from 0 up to `value` on mount (and whenever `value`
@@ -20,6 +20,10 @@ export interface CountUpProps {
20
20
 
21
21
  const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3)
22
22
 
23
+ // Layout effect in the browser so the reset to 0 commits before the first paint
24
+ // (a passive effect painted the final value for one frame, then jumped to 0).
25
+ const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
26
+
23
27
  export function CountUp({
24
28
  value,
25
29
  durationMs = 900,
@@ -31,13 +35,14 @@ export function CountUp({
31
35
  const rafRef = useRef<number | null>(null)
32
36
  const startRef = useRef<number | null>(null)
33
37
 
34
- useEffect(() => {
38
+ useBrowserLayoutEffect(() => {
35
39
  if (typeof window === 'undefined') return
36
40
  const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
37
41
  if (reduce || durationMs <= 0) {
38
42
  setDisplay(value)
39
43
  return
40
44
  }
45
+ setDisplay(0)
41
46
  startRef.current = null
42
47
  const tick = (now: number) => {
43
48
  if (startRef.current === null) startRef.current = now
@@ -40,14 +40,17 @@ import { uic } from '../utils/uic'
40
40
  * ```
41
41
  *
42
42
  * gs token map (this is the LIGHT menu, so the gs literals map cleanly to our
43
- * tokens): content bg white → `surface` · 1px `#eceae1` border → `border` · 8px
44
- * radius → `rounded-lg` · `shadow-lg` · item 12/16px padding → `py-3 px-4` · 6px
45
- * item radius → `rounded-md` · item hover `#f7f6f2` → `surface-card` · item text
46
- * `#0d0d0d` → `fg` · section label `#aba89c` 13px → `text-fg-muted text-compact`
43
+ * tokens): content bg `--color-background-secondary` (#f7f6f2) → `surface-card` ·
44
+ * 1px `#eceae1` border → `border` · 8px radius → `rounded-lg` · `shadow-lg` · item
45
+ * 12/16px padding → `py-3 px-4` · 16px/22px base text → `text-body leading-5.5` ·
46
+ * 6px item radius → `rounded-md` · item highlight `--color-background-hover`
47
+ * (#eceae1) → `surface-muted` · item text `#0d0d0d` → `fg` · section label 13px →
48
+ * `text-fg-muted text-compact`. Motion: gs `slideDownAndFade` in / `slideUpAndFade`
49
+ * out, 200ms `cubic-bezier(0.16, 1, 0.3, 1)` (`animate-dropdown-in` / `-out`).
47
50
  * (#25: the source grey is 2.10:1 on `surface`; a section label is read, not ornament)
48
51
  * · separator `#eceae1` → `border`. The destructive item uses `text-danger`
49
52
  * (`#dc2626`) — a light-surface red that reads correctly here (unlike the dark
50
- * ContextMenu, which needs the lighter `#ffb5b5`).
53
+ * ContextMenu, which needs the lighter `#fee2e2`).
51
54
  *
52
55
  * Presentational only (hard rule #1) — no app imports.
53
56
  */
@@ -55,18 +58,25 @@ import { uic } from '../utils/uic'
55
58
  /** Re-export of RAC `MenuTrigger`; owns the open/close state for the pair. */
56
59
  export const DropdownMenuTrigger = RACMenuTrigger
57
60
 
58
- // gs `.item`: 12/16px padding, 6px radius, base text on white; hover/focus wash
59
- // `#f7f6f2` (→ surface-card). We light up both `data-[focused]` (keyboard) and
60
- // `data-[hovered]` (pointer) so keyboard focus stays clearly visible. Item text
61
- // size follows the house light Select (`text-small`) rather than gs's 16px base so
62
- // the two light dropdowns stay consistent.
61
+ /** Shared gs dropdown panel skin (also used by the Topbar `UserMenu`). */
62
+ export const dropdownMenuPanelClass =
63
+ 'min-w-[220px] rounded-lg border border-border bg-surface-card p-2 shadow-lg outline-none ' +
64
+ 'data-[entering]:animate-dropdown-in data-[exiting]:animate-dropdown-out motion-reduce:animate-none'
65
+
66
+ // gs `.item`: 12/16px padding, 6px radius, 16px/22px base text; the Radix
67
+ // `[data-highlighted]` wash is `#eceae1` (→ surface-muted). Radix highlights on
68
+ // pointer hover and keyboard focus, never on a pointer open, so we key on
69
+ // `data-[hovered]` + `data-[focus-visible]` rather than RAC's plain `data-[focused]`
70
+ // (which also marks the first item when a mouse press opens the menu).
71
+ export const dropdownMenuItemClass =
72
+ 'flex cursor-pointer select-none items-center gap-2.5 rounded-md px-4 py-3 ' +
73
+ 'text-body leading-5.5 font-normal text-fg outline-none ' +
74
+ 'data-[focus-visible]:bg-surface-muted data-[hovered]:bg-surface-muted ' +
75
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50'
76
+
63
77
  export const DropdownMenuItem = uic(RACMenuItem, {
64
78
  displayName: 'DropdownMenuItem',
65
- baseClass:
66
- 'flex cursor-pointer select-none items-center gap-2.5 rounded-md px-4 py-3 ' +
67
- 'text-small text-fg outline-none ' +
68
- 'data-[focused]:bg-surface-card data-[hovered]:bg-surface-card ' +
69
- 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
79
+ baseClass: dropdownMenuItemClass,
70
80
  variants: {
71
81
  // gs has no destructive item style on the light menu; we add the house danger
72
82
  // token (red text), which reads correctly on the white / cream surface.
@@ -128,11 +138,11 @@ export function DropdownMenu<T extends object>({
128
138
  }: DropdownMenuProps<T>) {
129
139
  return (
130
140
  // gs `.content`: min-width 220px, 8px padding (`p-2`), 8px radius, 1px
131
- // `#eceae1` border, white fill, `shadow-lg`.
141
+ // `#eceae1` border, `#f7f6f2` fill, `shadow-lg`, 200ms slide/fade both ways.
132
142
  <RACPopover
133
143
  placement={placement}
134
144
  className={
135
- 'min-w-[220px] rounded-lg border border-border bg-surface p-2 shadow-lg outline-none' +
145
+ dropdownMenuPanelClass +
136
146
  (popoverClassName ? ` ${popoverClassName}` : '')
137
147
  }
138
148
  >
@@ -9,6 +9,9 @@ export const filledFieldClasses =
9
9
  'border-0 bg-surface-card text-small font-normal leading-4.5 text-fg ' +
10
10
  'outline-none transition-colors duration-200 motion-reduce:transition-none placeholder:text-fg-muted placeholder:font-normal ' +
11
11
  'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-card ' +
12
- 'focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 ' +
12
+ // `outline-none` above zeroes Tailwind v4's `--tw-outline-style`, so the focus
13
+ // width alone would still resolve to `outline-style: none`. Restate the solid
14
+ // style or the gs 2px focus outline never paints (WCAG 2.4.7).
15
+ 'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 ' +
13
16
  'data-[invalid]:ring-1 data-[invalid]:ring-danger data-[invalid]:outline-danger ' +
14
17
  'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed'
@@ -52,9 +52,12 @@ const GridGlyph = () => (
52
52
  </svg>
53
53
  )
54
54
 
55
+ // gs `.tabButton`: inactive ink `#242423` (the light-theme neutral that
56
+ // `surface-inverted` carries; it flips to a light ink on the dark shell), selected
57
+ // and hovered `fg` on `surface-muted`, background + colour eased over 120ms `ease`.
55
58
  const tabBase =
56
- 'inline-flex h-7 shrink-0 items-center gap-2 rounded-sm px-nav-x py-1.5 text-compact font-normal text-fg-muted outline-none ' +
57
- 'transition-colors duration-150 ease-in-out data-[hovered]:bg-surface-muted data-[hovered]:text-fg ' +
59
+ 'inline-flex h-7 shrink-0 items-center gap-2 rounded-sm px-nav-x py-1.5 text-compact font-normal text-surface-inverted outline-none ' +
60
+ 'transition-colors duration-120 ease-[ease] motion-reduce:transition-none data-[hovered]:bg-surface-muted data-[hovered]:text-fg ' +
58
61
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
59
62
  'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:bg-transparent data-[disabled]:text-fg-muted'
60
63
 
@@ -90,7 +93,7 @@ export function SectionTabs({
90
93
  aria-pressed={isResetSelected}
91
94
  onPress={onReset}
92
95
  className={[
93
- resetContent ? tabBase : 'h-7 w-7 rounded-sm p-0 text-fg-muted data-[hovered]:text-fg',
96
+ resetContent ? tabBase : 'h-7 w-7 rounded-sm p-0 text-surface-inverted data-[hovered]:text-fg',
94
97
  isResetSelected ? tabActive : '',
95
98
  ]
96
99
  .filter(Boolean)
@@ -43,7 +43,7 @@ const SelectTrigger = uic(RACButton, {
43
43
  appearance: {
44
44
  filled: 'min-h-control-tall border-0 bg-surface-card py-5 font-normal leading-4.5 duration-200 motion-reduce:transition-none ' +
45
45
  'data-[hovered]:bg-surface-muted group-data-[open]:bg-surface-card ' +
46
- 'data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2 ' +
46
+ 'data-[focus-visible]:outline-solid data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2 ' +
47
47
  'group-data-[invalid]:ring-1 group-data-[invalid]:ring-danger ' +
48
48
  'data-[disabled]:bg-surface-card data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
49
49
  outlined: 'h-control-tall border border-border bg-surface ' +
@@ -66,7 +66,7 @@ const StyledSelectItem = uic(ListBoxItem, {
66
66
  appearance: {
67
67
  filled: 'min-w-0 overflow-hidden px-5 py-0 font-normal leading-4.5 max-md:min-h-11 ' +
68
68
  'data-[hovered]:font-medium data-[focused]:font-medium data-[selected]:font-medium ' +
69
- 'data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:-outline-offset-2',
69
+ 'data-[focus-visible]:outline-solid data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:-outline-offset-2',
70
70
  outlined: 'px-3 py-2 ' +
71
71
  'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
72
72
  'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
@@ -98,7 +98,8 @@ export function SettingsDialogSurface({ variant, isOpen, onOpenChange, isPending
98
98
  <Body ref={bodyRef} className={bodyClass} data-testid={bodyTestId}>{children}</Body>
99
99
  </Container>
100
100
  </ScrollBody>
101
- <footer className={catalog ? 'shrink-0 border-t border-border bg-surface pt-3' : create ? 'shrink-0 border-t border-border bg-surface pt-4' : 'shrink-0 border-t border-border bg-surface pt-5'}>
101
+ {/* gs dialog footers sit on the white surface with no rule above them. */}
102
+ <footer className={catalog ? 'shrink-0 bg-surface pt-3' : create ? 'shrink-0 bg-surface pt-4' : 'shrink-0 bg-surface pt-5'}>
102
103
  <Container className={containerClass}>
103
104
  <div className={`flex justify-end ${csv || basic ? 'items-center gap-1' : variant === 'edit' ? 'gap-3' : 'gap-2'}`}>{footer}</div>
104
105
  </Container>
@@ -130,7 +131,7 @@ export function WizardFormDialog({ isOpen, onOpenChange, isPending = false, isEx
130
131
  </Button>
131
132
  <header className="shrink-0"><Container className="max-w-384 px-5 min-[901px]:px-6"><DisplayHeading id={titleId} className="m-0 whitespace-pre-line pb-0.5 text-fg-muted" style={{ letterSpacing: 0 }}>{title}</DisplayHeading></Container></header>
132
133
  <ScrollBody style={{ scrollbarGutter: 'stable' }}><Container className="max-w-384 px-5 min-[901px]:px-6"><Body className="gap-8">{children}</Body></Container></ScrollBody>
133
- <footer className="shrink-0 border-t border-border bg-surface pt-5"><Container className="max-w-384 px-5 min-[901px]:px-6"><div className="flex flex-wrap justify-end gap-2">{footer}</div></Container></footer>
134
+ <footer className="shrink-0 bg-surface pt-5"><Container className="max-w-384 px-5 min-[901px]:px-6"><div className="flex flex-wrap justify-end gap-2">{footer}</div></Container></footer>
134
135
  </Layout>
135
136
  </Panel>
136
137
  </ModalOverlay>
@@ -220,7 +221,7 @@ export function ProductionSettingsDialog({ isOpen, onOpenChange, prefix, title,
220
221
  }
221
222
 
222
223
  const DisclosureRoot = uic(AriaDisclosure, { displayName: 'SettingsDisclosureRoot', baseClass: 'group/settings-disclosure flex min-w-0 flex-col gap-3' })
223
- const DisclosureTrigger = uic(AriaButton, { displayName: 'SettingsDisclosureTrigger', baseClass: 'flex w-full cursor-pointer items-start justify-between gap-3 bg-transparent p-0 text-left outline-none data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2' })
224
+ const DisclosureTrigger = uic(AriaButton, { displayName: 'SettingsDisclosureTrigger', baseClass: 'flex w-full cursor-pointer items-start justify-between gap-3 bg-transparent p-0 text-left outline-none data-[focus-visible]:outline-solid data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2' })
224
225
  const DisclosureCopy = uic('span', { displayName: 'SettingsDisclosureCopy', baseClass: 'flex min-w-0 flex-col gap-1' })
225
226
  const DisclosureTitle = uic('strong', { displayName: 'SettingsDisclosureTitle', baseClass: 'text-body font-medium leading-normal text-fg' })
226
227
  const DisclosureHint = uic('span', { displayName: 'SettingsDisclosureHint', baseClass: 'text-body font-normal leading-normal text-fg-workflow-muted' })
@@ -116,7 +116,7 @@ export function Table<Row>({
116
116
 
117
117
  return (
118
118
  <div className={['w-full overflow-x-auto', className].filter(Boolean).join(' ')}>
119
- <table style={team ? { '--table-columns': gridTemplate } as CSSProperties : undefined} className={`w-full border-collapse text-small ${worksheet ? 'border-b border-border font-normal leading-4.5' : ''} ${team ? 'block border-b border-border font-normal leading-4.5' : ''}`} {...aria}>
119
+ <table style={team ? { '--table-columns': gridTemplate } as CSSProperties : undefined} className={`w-full border-collapse text-small ${worksheet ? 'border-b border-border font-normal leading-4.5' : ''} ${team ? 'block font-normal leading-4.5' : ''}`} {...aria}>
120
120
  <thead className={team ? 'block' : undefined}>
121
121
  <tr className={`${worksheet ? '' : 'border-b border-border'} ${team ? 'grid grid-cols-[var(--table-columns)] gap-3 bg-surface-muted px-4 max-[1023px]:hidden' : ''}`}>
122
122
  {columns.map((column) => {
@@ -131,7 +131,7 @@ export function Table<Row>({
131
131
  aria-sort={
132
132
  canSort ? (active ? (sort?.dir === 'asc' ? 'ascending' : 'descending') : 'none') : undefined
133
133
  }
134
- className={`${ALIGN_CLASS[align]} ${worksheet ? 'border-0 px-3 py-4 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case bg-surface whitespace-nowrap' : team ? 'border-0 px-4 py-3 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case whitespace-nowrap' : 'px-4 py-3 text-label font-medium tracking-wide text-fg-muted uppercase'}`}
134
+ className={`${ALIGN_CLASS[align]} ${worksheet ? 'border-0 px-3 py-4 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case bg-surface whitespace-nowrap' : team ? 'border-0 px-0 py-3 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case whitespace-nowrap' : 'px-4 py-3 text-label font-medium tracking-wide text-fg-muted uppercase'}`}
135
135
  >
136
136
  {canSort ? (
137
137
  <button
@@ -25,9 +25,12 @@ export const TabList = uic(RACTabList, {
25
25
 
26
26
  export const Tab = uic(RACTab, {
27
27
  displayName: 'Tab',
28
+ // gs `Tabs.module.scss` `.trigger`: 12/16px padding, heading-5 (16px/20px) medium,
29
+ // `--color-text-secondary` ink (#242423, the light neutral `surface-inverted`
30
+ // carries), 2px underline, `all 200ms ease-in-out`.
28
31
  baseClass:
29
- 'cursor-pointer select-none border-b-2 border-transparent px-4 py-3 text-small font-medium text-fg-muted ' +
30
- 'outline-none transition-colors ' +
32
+ 'cursor-pointer select-none border-b-2 border-transparent px-4 py-3 text-heading5 font-medium text-surface-inverted ' +
33
+ 'outline-none transition-all duration-200 ease-[ease-in-out] motion-reduce:transition-none ' +
31
34
  'data-[hovered]:text-fg ' +
32
35
  'data-[selected]:border-brand-primary data-[selected]:text-fg ' +
33
36
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
@@ -58,8 +58,10 @@ export type TextareaProps = TextFieldProps & {
58
58
  }
59
59
 
60
60
  export const Textarea = ({ label, description, errorMessage, placeholder, rows, textAreaClassName, appearance = 'outlined', ...props }: TextareaProps) => (
61
- <TextField {...props} className={`flex w-full flex-col ${appearance === 'filled' ? 'gap-3' : 'gap-2'}`}>
62
- <Label className={`${appearance === 'filled' ? 'text-panel-heading' : 'text-small'} font-medium text-fg`}>{label}</Label>
61
+ // Label and gap match `Input` in both appearances (gs 17px/20px field label,
62
+ // 12px to the control), so a Title + Description pair reads as one form.
63
+ <TextField {...props} className="flex w-full flex-col gap-3">
64
+ <Label className="text-panel-heading font-medium text-fg">{label}</Label>
63
65
  <StyledTextArea className={textAreaClassName} placeholder={placeholder} rows={rows} appearance={appearance} />
64
66
  {description ? (
65
67
  <Text slot="description" className="text-label text-fg-muted">
@@ -395,10 +395,44 @@ const BADGE: Record<BadgeColor, string> = {
395
395
 
396
396
  export function Badge({ label, color = 'grey' }: { label: ReactNode; color?: BadgeColor }) {
397
397
  return (
398
+ // gs `Tag.module.scss`: 24px tall, 0 10px padding, 16px (`radius-2xl`) corners,
399
+ // 10px/20px medium label (Label 4). The source's `margin-left: 12px` is layout
400
+ // the caller owns, so it is not baked in here.
398
401
  <span
399
- className={`inline-flex items-center rounded-full px-3 py-1 text-caption font-medium leading-none ${BADGE[color]}`}
402
+ className={`inline-flex h-6 shrink-0 items-center whitespace-nowrap rounded-2xl px-2.5 text-micro font-medium leading-5 ${BADGE[color]}`}
400
403
  >
401
404
  {label}
402
405
  </span>
403
406
  )
404
407
  }
408
+
409
+ /**
410
+ * Chip: the gs `GSChip` dot pill used for task status and priority cells.
411
+ *
412
+ * Distinct from {@link Badge} (gs `Tag`): a Chip is the table-cell pill with a
413
+ * leading 8px dot, 13px/16px regular label, 24px height and 0 12px padding. gs has
414
+ * two tones only: `green` (translucent mint surface, brand-green dot) for work that
415
+ * is moving or finished, and `grey` (neutral surface, neutral dot) for everything
416
+ * else. The dot is decorative; the label carries the meaning.
417
+ */
418
+ export type ChipColor = 'green' | 'grey'
419
+
420
+ const CHIP: Record<ChipColor, { surface: string; dot: string }> = {
421
+ // gs `rgba(150, 246, 194, 0.8)` → accent-green-lighter at 80%; dot brand green.
422
+ green: { surface: 'bg-accent-green-lighter/80 text-fg-on-brand', dot: 'bg-brand-green' },
423
+ // gs neutral-100 surface; the source neutral-200 dot (#aba89c) maps to the
424
+ // nearest shared neutral. Ornamental: the adjacent label is the content.
425
+ grey: { surface: 'bg-surface-muted text-fg', dot: 'bg-neutral-300' },
426
+ }
427
+
428
+ export function Chip({ label, color = 'grey' }: { label: ReactNode; color?: ChipColor }) {
429
+ const tone = CHIP[color]
430
+ return (
431
+ <span
432
+ className={`inline-flex h-6 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-2xl px-3 text-compact font-normal leading-4 ${tone.surface}`}
433
+ >
434
+ <span aria-hidden="true" className={`size-2 shrink-0 rounded-full ${tone.dot}`} />
435
+ {label}
436
+ </span>
437
+ )
438
+ }
@@ -8,13 +8,16 @@ import {
8
8
  ListBox,
9
9
  ListBoxItem,
10
10
  type ListBoxItemProps,
11
+ Header,
11
12
  Menu as RACMenu,
12
13
  MenuItem as RACMenuItem,
13
14
  type MenuItemProps,
15
+ MenuSection,
14
16
  MenuTrigger,
15
17
  Popover,
16
18
  } from 'react-aria-components'
17
19
  import { uic } from '../utils/uic'
20
+ import { dropdownMenuItemClass, dropdownMenuPanelClass, DropdownMenuSeparator } from '../components/dropdown-menu'
18
21
 
19
22
  /**
20
23
  * Topbar — sticky application header (ported pattern from gs-manager's
@@ -87,8 +90,12 @@ const TopbarActions = uic('div', {
87
90
  const TopbarNavLink = uic('a', {
88
91
  displayName: 'Topbar.NavLink',
89
92
  baseClass:
93
+ // gs inactive ink is `#242423` (the neutral that `surface-inverted` carries in
94
+ // light theme and flips to a light ink on the dark shell); hover/active darken to
95
+ // `fg`. gs `Button.module.scss` eases bg + colour over 120ms `ease`.
90
96
  'inline-flex items-center rounded-sm px-[13px] py-[6px] text-compact leading-4 tracking-[0] whitespace-nowrap ' +
91
- 'text-fg no-underline transition-colors hover:bg-surface-muted hover:text-fg outline-none ' +
97
+ 'text-surface-inverted no-underline transition-colors duration-120 ease-[ease] motion-reduce:transition-none ' +
98
+ 'hover:bg-surface-muted hover:text-fg outline-none ' +
92
99
  'focus-visible:ring-2 focus-visible:ring-ring ' +
93
100
  'data-[active]:bg-surface-muted data-[active]:text-fg',
94
101
  // `active` is accepted both as an explicit prop (uic emits `data-active=""` when
@@ -194,7 +201,8 @@ export const UserMenu = ({ trigger, triggerLabel, onAction, children }: UserMenu
194
201
  >
195
202
  {trigger}
196
203
  </RACButton>
197
- <Popover className="min-w-40 rounded-md border border-border bg-surface p-1 shadow-lg">
204
+ {/* gs Hub account menu = the shared gs DropdownMenu panel, bottom-end aligned. */}
205
+ <Popover placement="bottom end" className={dropdownMenuPanelClass}>
198
206
  <RACMenu onAction={onAction} className="outline-none">
199
207
  {children}
200
208
  </RACMenu>
@@ -204,11 +212,46 @@ export const UserMenu = ({ trigger, triggerLabel, onAction, children }: UserMenu
204
212
 
205
213
  export const UserMenuItem = uic(RACMenuItem, {
206
214
  displayName: 'UserMenu.Item',
207
- baseClass:
208
- 'flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-small text-fg outline-none ' +
209
- 'data-[focused]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
215
+ baseClass: `${dropdownMenuItemClass} data-[disabled]:pointer-events-none`,
210
216
  }) as (props: MenuItemProps) => ReactNode
211
217
 
218
+ /**
219
+ * Two-line item copy (gs `.menuItemStack`): a primary label with a small muted hint
220
+ * underneath, e.g. "Manager / Current" or "Settings / Opens in GS Manager".
221
+ */
222
+ export const UserMenuItemText = ({ label, hint }: { label: ReactNode; hint?: ReactNode }) => (
223
+ <span className="flex min-w-0 flex-col items-start gap-1">
224
+ <span>{label}</span>
225
+ {hint ? <span className="text-micro leading-5 font-normal text-fg-muted">{hint}</span> : null}
226
+ </span>
227
+ )
228
+
229
+ /**
230
+ * Non-interactive identity block at the top of the account menu (gs
231
+ * `.userMenuLabel`): the person's name (13px medium) over a muted detail line.
232
+ */
233
+ export const UserMenuIdentity = ({ name, detail }: { name: ReactNode; detail?: ReactNode }) => (
234
+ <MenuSection>
235
+ <Header className="flex flex-col gap-1 px-4 py-3">
236
+ <span className="text-compact font-medium text-fg">{name}</span>
237
+ {detail ? <span className="mt-1 text-micro leading-5 font-medium text-fg-muted">{detail}</span> : null}
238
+ </Header>
239
+ </MenuSection>
240
+ )
241
+
242
+ export type UserMenuSectionProps = { label?: ReactNode; children: ReactNode; 'aria-label'?: string }
243
+
244
+ /** A labelled group of account-menu items (gs `.menuSectionLabel`, e.g. "Apps"). */
245
+ export const UserMenuSection = ({ label, children, ...props }: UserMenuSectionProps) => (
246
+ <MenuSection {...props}>
247
+ {label ? <Header className="px-2 pt-2 pb-1 text-micro leading-5 font-medium text-fg-muted">{label}</Header> : null}
248
+ {children}
249
+ </MenuSection>
250
+ )
251
+
252
+ /** 1px rule between account-menu groups (gs `.separator`). */
253
+ export const UserMenuSeparator = DropdownMenuSeparator
254
+
212
255
  export const Topbar = Object.assign(TopbarRoot, {
213
256
  Brand: TopbarBrand,
214
257
  Nav: TopbarNav,