@proteos/ui 0.47.0 → 0.49.0

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": "@proteos/ui",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "license": "FSL-1.1-Apache-2.0",
5
5
  "description": "Proteos React component library (React Aria Components + Tailwind v4).",
6
6
  "repository": {
@@ -55,7 +55,8 @@
55
55
  "tailwind-merge": "^3.5.0",
56
56
  "tailwind-variants": "^3.2.2",
57
57
  "tailwindcss": "^4.2.4",
58
- "@proteos/sdk": "0.47.0"
58
+ "thinking-orbs": "^0.3.1",
59
+ "@proteos/sdk": "0.49.0"
59
60
  },
60
61
  "devDependencies": {
61
62
  "@biomejs/biome": "^2.4.13",
@@ -77,8 +78,8 @@
77
78
  "typescript": "^6.0.3",
78
79
  "vite": "^8.0.10",
79
80
  "vitest": "^3.2.4",
80
- "@proteos/biome-config": "0.0.0",
81
- "@proteos/tsconfig": "0.0.0"
81
+ "@proteos/tsconfig": "0.0.0",
82
+ "@proteos/biome-config": "0.0.0"
82
83
  },
83
84
  "scripts": {
84
85
  "lint": "biome check .",
@@ -9,6 +9,7 @@ import {
9
9
  MessageCircle,
10
10
  MessagesSquare,
11
11
  Mic,
12
+ Phone,
12
13
  Send,
13
14
  Video,
14
15
  } from 'lucide-react'
@@ -41,6 +42,7 @@ const CHANNEL_ICONS: Readonly<Record<string, LucideIcon>> = {
41
42
  'teams-meeting': Video,
42
43
  'webex-meeting': Video,
43
44
  adhoc: AudioLines,
45
+ phone: Phone,
44
46
  }
45
47
 
46
48
  /** The channel's glyph component. Unknown channels get the generic one. */
@@ -0,0 +1,2 @@
1
+ export type { OrbProps, OrbSizeToken } from './orb'
2
+ export { Orb } from './orb'
@@ -0,0 +1,25 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { Orb } from './orb'
4
+
5
+ const meta: Meta<typeof Orb> = {
6
+ title: 'Components/Orb',
7
+ component: Orb,
8
+ tags: ['autodocs'],
9
+ parameters: { layout: 'padded' },
10
+ }
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof Orb>
14
+
15
+ export const Sizes: Story = {
16
+ render: () => (
17
+ <div className="flex items-center gap-4">
18
+ <Orb size="xs" label="Agent working" />
19
+ <Orb size="sm" label="Agent working" />
20
+ <Orb size="md" label="Agent working" />
21
+ <Orb size="lg" label="Agent working" />
22
+ <span className="text-[13px] text-ink-3">Agent is composing…</span>
23
+ </div>
24
+ ),
25
+ }
@@ -0,0 +1,57 @@
1
+ import { type OrbState, type OrbTheme, ThinkingOrb } from 'thinking-orbs'
2
+
3
+ import { cn } from '../../lib/cn'
4
+
5
+ /* =========================================================================
6
+ Orb — the "agent is working" indicator, backed by the `thinking-orbs`
7
+ package (https://orbs.jakubantalik.com): a canvas-rendered dotted orb
8
+ (state: solving), monochrome ink that auto-resolves against `data-theme`
9
+ / OS scheme, with a reduced-motion static frame built in.
10
+
11
+ The package ships two hand-tuned designs — 20px (inline) and 64px
12
+ (avatar). `sm` and `lg` render them at native size; `xs` gently
13
+ downscales the 20px design, `md` downscales the 64px one (downscaling
14
+ stays crisp — the canvas buffer is the larger design).
15
+
16
+ Purely decorative by default — pass `label` to expose it as an img with
17
+ an accessible name (e.g. "Agent working").
18
+ ========================================================================= */
19
+
20
+ export type OrbSizeToken = 'xs' | 'sm' | 'md' | 'lg'
21
+
22
+ /** CSS box per token. sm/lg match the native designs exactly (no scaling). */
23
+ const SIZE_PX: Record<OrbSizeToken, number> = { xs: 12, sm: 20, md: 40, lg: 64 }
24
+
25
+ /** Which native design each token rides. */
26
+ const DESIGN: Record<OrbSizeToken, 20 | 64> = { xs: 20, sm: 20, md: 64, lg: 64 }
27
+
28
+ export interface OrbProps {
29
+ /** xs 12 · sm 20 (native inline) · md 40 · lg 64 (native avatar). Default `sm`. */
30
+ size?: OrbSizeToken
31
+ /** Animation state; defaults to `solving` (the working indicator). */
32
+ state?: OrbState
33
+ /** Speed multiplier (1 = tuned speed); e.g. 0.2 for a slow idle shimmer. */
34
+ speed?: number
35
+ /** Pin the ink instead of auto-detecting — `dark` renders light dots (for
36
+ * a dark chip like the launcher's black circle). */
37
+ theme?: OrbTheme
38
+ className?: string
39
+ /** Accessible name; omitting it hides the orb from assistive tech. */
40
+ label?: string
41
+ }
42
+
43
+ export function Orb({ size = 'sm', state = 'solving', speed, theme, className, label }: OrbProps) {
44
+ const px = SIZE_PX[size]
45
+ const design = DESIGN[size]
46
+ return (
47
+ <ThinkingOrb
48
+ state={state}
49
+ size={design}
50
+ {...(speed !== undefined ? { speed } : {})}
51
+ {...(theme !== undefined ? { theme } : {})}
52
+ className={cn('inline-block shrink-0', className)}
53
+ {...(px !== design ? { style: { width: px, height: px } } : {})}
54
+ {...(label ? { 'aria-label': label } : { 'aria-hidden': true })}
55
+ />
56
+ )
57
+ }
@@ -124,7 +124,7 @@ function ChipItem({
124
124
  }
125
125
  // Lib default: enum → Badge with variant lookup; other → truncated mono text.
126
126
  if (field.type === 'enum') {
127
- const option = field.options?.find((o) => o.value === value)
127
+ const option = findEnumOption(field, value)
128
128
  return (
129
129
  <Badge
130
130
  variant={option?.variant}
@@ -146,6 +146,13 @@ function Em({ children }: { children: string }) {
146
146
  return <span className="font-mono text-[12px] text-ink-4">{children}</span>
147
147
  }
148
148
 
149
+ /** The declared option for an enum value, if the field carries one. Central so
150
+ * the badge, the multi-value summary and the single-value summary agree on
151
+ * what a stored value is called. */
152
+ function findEnumOption(field: FilterField, value: string) {
153
+ return field.options?.find((option) => option.value === value)
154
+ }
155
+
149
156
  export function formatValue(
150
157
  field: FilterField,
151
158
  operator: ComparisonOperator,
@@ -161,7 +168,7 @@ export function formatValue(
161
168
  : []
162
169
  if (arr.length === 0) return '—'
163
170
  if (field.type === 'enum') {
164
- const labels = arr.map((v) => field.options?.find((x) => x.value === v)?.label ?? v)
171
+ const labels = arr.map((v) => findEnumOption(field, v)?.label ?? v)
165
172
  return labels.length <= 2
166
173
  ? labels.join(', ')
167
174
  : `${labels[0]}, ${labels[1]} +${labels.length - 2}`
@@ -170,6 +177,14 @@ export function formatValue(
170
177
  }
171
178
  if (field.type === 'boolean') return value === true || value === 'true' ? 'true' : 'false'
172
179
  if (value === '' || value == null) return '—'
180
+ // An enum stores a system value ("technical_decider") and displays its
181
+ // declared label ("Technical Decider"). The multi-value branch above and the
182
+ // badge below already do this; a single value read as the raw slug, so the
183
+ // same filter said "is technical_decider" or "is any of Technical Decider"
184
+ // depending only on the operator.
185
+ if (field.type === 'enum') {
186
+ return findEnumOption(field, String(value))?.label ?? String(value)
187
+ }
173
188
  if (field.type === 'number' || field.type === 'integer') {
174
189
  const n = Number(value)
175
190
  return Number.isFinite(n) ? n.toLocaleString() : String(value)
@@ -0,0 +1,29 @@
1
+ /* =========================================================================
2
+ Field-label composition — how a nested or related field reads in the
3
+ picker, the chips rail and the operator row.
4
+
5
+ A `FilterField` path is hierarchical: an object leaf (`address.city`) and
6
+ a field reached through a relation (`company_id.name`) both render as a
7
+ breadcrumb. The separator lives here because BOTH sides compose it — the
8
+ consumer builds `FilterField.label` from an entity, and the chips rail
9
+ builds its own breadcrumb from `parentLabel` — and a mismatch between the
10
+ two shows up as one chip reading "Company · Name" next to another reading
11
+ "Company / Name".
12
+ ========================================================================= */
13
+
14
+ /**
15
+ * U+00B7 MIDDLE DOT, spaced. The same separator the app already uses for
16
+ * breadcrumb-ish text ("Segment · SaaS decision makers"), and quieter than a
17
+ * slash at the small type sizes these labels render at — the eye reads the
18
+ * words, not the punctuation.
19
+ */
20
+ export const FIELD_LABEL_SEPARATOR = ' · '
21
+
22
+ /**
23
+ * Joins breadcrumb parts with {@link FIELD_LABEL_SEPARATOR}, skipping empties
24
+ * so a field with no parent renders as its own label rather than with a
25
+ * leading separator.
26
+ */
27
+ export function joinFieldLabel(...parts: ReadonlyArray<string | undefined | null>): string {
28
+ return parts.filter((part): part is string => !!part && part.length > 0).join(FIELD_LABEL_SEPARATOR)
29
+ }
@@ -1,4 +1,5 @@
1
1
  export { AttributePicker, type AttributePickerProps } from './attribute-picker'
2
+ export { FIELD_LABEL_SEPARATOR, joinFieldLabel } from './field-label'
2
3
  export {
3
4
  ChipValue,
4
5
  type ChipValueItemRenderer,
@@ -14,6 +14,7 @@ import { OperatorPicker } from './operator-picker'
14
14
  import { operatorById, operatorsForType } from './operators'
15
15
  import { useShowDetails } from './show-details-store'
16
16
  import { AttributeTypeIcon } from '../attribute-type-icon'
17
+ import { FIELD_LABEL_SEPARATOR } from './field-label'
17
18
  import type { ComparisonOperator, FilterField, FilterGroup, FilterValue } from './types'
18
19
 
19
20
  /* =========================================================================
@@ -370,7 +371,12 @@ function Chip({
370
371
  </span>
371
372
  ) : null}
372
373
  <span className="font-sans text-[12.5px] text-ink-2">
373
- {field.parentLabel ? <span className="text-ink-4">{field.parentLabel} / </span> : null}
374
+ {field.parentLabel ? (
375
+ <span className="text-ink-4">
376
+ {field.parentLabel}
377
+ {FIELD_LABEL_SEPARATOR}
378
+ </span>
379
+ ) : null}
374
380
  <span className="text-ink">{field.shortLabel}</span>
375
381
  </span>
376
382
  <span className="font-mono text-[11px] text-ink-3">{op?.label}</span>
@@ -52,14 +52,14 @@ const customerFields: FilterField[] = [
52
52
  path: 'owner.name',
53
53
  shortLabel: 'Name',
54
54
  parentLabel: 'Owner',
55
- label: 'Owner / Name',
55
+ label: 'Owner · Name',
56
56
  type: 'string',
57
57
  },
58
58
  {
59
59
  path: 'owner.email',
60
60
  shortLabel: 'Email',
61
61
  parentLabel: 'Owner',
62
- label: 'Owner / Email',
62
+ label: 'Owner · Email',
63
63
  type: 'string',
64
64
  inputType: 'email',
65
65
  },
@@ -67,7 +67,7 @@ const customerFields: FilterField[] = [
67
67
  path: 'address.country',
68
68
  shortLabel: 'Country',
69
69
  parentLabel: 'Address',
70
- label: 'Address / Country',
70
+ label: 'Address · Country',
71
71
  type: 'enum',
72
72
  options: [
73
73
  { value: 'us', label: 'United States' },
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
5
 
6
6
  import { RecordFilterBuilderPanel } from './record-filter-builder-panel'
7
7
  import { RecordFilterButton } from './record-filter-button'
8
+ import { formatValue } from './chip-value'
9
+ import { FIELD_LABEL_SEPARATOR, joinFieldLabel } from './field-label'
8
10
  import { RecordFilterChips } from './record-filter-chips'
9
11
  import { __resetShowDetailsStores } from './show-details-store'
10
12
  import type { FilterField, FilterGroup } from './types'
@@ -435,3 +437,87 @@ describe('RecordFilterChips — nested groups', () => {
435
437
  expect(screen.getByText(/· 2/)).toBeInTheDocument()
436
438
  })
437
439
  })
440
+
441
+ /* The chip rail composes its own breadcrumb from `parentLabel`, while the
442
+ consumer composes `label` when it builds the field list. Both must use
443
+ FIELD_LABEL_SEPARATOR, or one chip reads "Company · Name" beside another
444
+ reading "Company / Name". */
445
+ describe('RecordFilterChips — breadcrumb separator', () => {
446
+ const nestedFields: FilterField[] = [
447
+ {
448
+ path: 'billing_address.city',
449
+ label: `Billing Address${FIELD_LABEL_SEPARATOR}City`,
450
+ shortLabel: 'City',
451
+ parentLabel: 'Billing Address',
452
+ type: 'string',
453
+ },
454
+ ]
455
+
456
+ it('separates the parent from the leaf with a middle dot', () => {
457
+ // Chips only render when expanded; the store defaults to collapsed.
458
+ window.localStorage.setItem('rf.showDetails', '1')
459
+ render(
460
+ <RecordFilterChips
461
+ fields={nestedFields}
462
+ filter={{
463
+ logicalOperator: 'and',
464
+ elements: [{ field: 'billing_address.city', operator: 'eq', value: 'Berlin' }],
465
+ }}
466
+ onChange={() => undefined}
467
+ />,
468
+ )
469
+
470
+ expect(screen.getByText('Billing Address', { exact: false }).textContent).toContain(
471
+ FIELD_LABEL_SEPARATOR,
472
+ )
473
+ expect(document.body.textContent).not.toContain('Billing Address / ')
474
+ })
475
+
476
+ it('exposes the separator as the middle dot', () => {
477
+ expect(FIELD_LABEL_SEPARATOR).toBe(' · ')
478
+ expect(joinFieldLabel('Company', undefined, 'Name')).toBe('Company · Name')
479
+ })
480
+ })
481
+
482
+ /* An enum stores a system value and displays a declared label. Both the
483
+ multi-value branch and the chip badge already resolved it; a single value
484
+ did not, so the same filter read "is technical_decider" or "is any of
485
+ Technical Decider" depending only on the operator. */
486
+ describe('formatValue — enum labels', () => {
487
+ const roleField: FilterField = {
488
+ path: 'buying_role',
489
+ label: 'Buying Role',
490
+ shortLabel: 'Buying Role',
491
+ type: 'enum',
492
+ options: [
493
+ { value: 'technical_decider', label: 'Technical Decider' },
494
+ { value: 'economic_decider', label: 'Economic Decider' },
495
+ ],
496
+ }
497
+
498
+ it('renders the label for a single value', () => {
499
+ expect(formatValue(roleField, 'eq', 'technical_decider')).toBe('Technical Decider')
500
+ expect(formatValue(roleField, 'ne', 'economic_decider')).toBe('Economic Decider')
501
+ })
502
+
503
+ it('renders labels for a multi-value operator', () => {
504
+ expect(formatValue(roleField, 'in', ['technical_decider', 'economic_decider'])).toBe(
505
+ 'Technical Decider, Economic Decider',
506
+ )
507
+ })
508
+
509
+ // A value the entity no longer declares still has to render as something.
510
+ it('falls back to the stored value when no option matches', () => {
511
+ expect(formatValue(roleField, 'eq', 'retired_role')).toBe('retired_role')
512
+ })
513
+
514
+ it('leaves non-enum fields alone', () => {
515
+ const nameField: FilterField = {
516
+ path: 'name',
517
+ label: 'Name',
518
+ shortLabel: 'Name',
519
+ type: 'string',
520
+ }
521
+ expect(formatValue(nameField, 'eq', 'technical_decider')).toBe('technical_decider')
522
+ })
523
+ })
@@ -34,7 +34,8 @@ export type FilterFieldOption = {
34
34
  export type FilterField = {
35
35
  /** Dot-path to the attribute, e.g. `'owner.email'`. Stable identifier. */
36
36
  path: string
37
- /** Full nested label, e.g. `'Owner / Email'`. Used in pickers. */
37
+ /** Full nested label, e.g. `'Owner · Email'` — parts joined with
38
+ * `FIELD_LABEL_SEPARATOR`. Used in pickers. */
38
39
  label: string
39
40
  /** Leaf label only, e.g. `'Email'`. Used in chips. */
40
41
  shortLabel: string
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export * from './components/message-composer'
33
33
  export * from './components/modal'
34
34
  export * from './components/multi-select'
35
35
  export * from './components/nav'
36
+ export * from './components/orb'
36
37
  export * from './components/popover'
37
38
  export * from './components/progress-bar'
38
39
  export * from './components/radio-group'
package/src/styles.css CHANGED
@@ -122,6 +122,7 @@
122
122
  /* Status pop — one-shot scale+fade entrance for the large success/error
123
123
  status icons. Soft ease, no overshoot (nothing bounces). */
124
124
  --animate-status-pop: status-pop var(--duration-slow) var(--ease-out-soft) both;
125
+
125
126
  }
126
127
 
127
128
  @keyframes indeterminate-progress {