Skip to content

Commit e973676

Browse files
zeekayHanzo Dev
andcommitted
feat(ui): an org wears its OWN mark — one treatment, and a switcher that is the account control's peer
A customer's console must show the CUSTOMER's identity. `OrgMark` is now the one organization treatment: the org's own logo when IAM carries one, else its MONOGRAM on a neutral tile — the same rule @hanzo/iam's account widget applies to a person, so a workspace and a user read as one system. It is never a house glyph. The switcher's private copy of that avatar is gone (it split words on whitespace alone, so `acme-labs` read "A" instead of "AL"). The switcher trigger is sized as the PEER of the account control — 44px tall, a 30px mark, the same type and the same hit area — because "which workspace" and "who I am" are two halves of one identity, not a caption over a control. New optional `current` prop: a host that has already resolved the org (display name + logo) injects it, so the switcher and the chrome's org mark can never disagree — and a user with no cross-tenant list still gets their own logo. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
1 parent 6801a35 commit e973676

5 files changed

Lines changed: 144 additions & 31 deletions

File tree

pkg/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hanzo/ui",
3-
"version": "8.0.10",
3+
"version": "8.0.11",
44
"type": "module",
55
"description": "Hanzo UI \u2014 the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
66
"exports": {

pkg/ui/src/product/OrgMark.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { monogram } from './OrgMark'
4+
5+
describe('monogram', () => {
6+
it('takes the first letter of the first two words', () => {
7+
expect(monogram('Acme Robotics')).toBe('AR')
8+
expect(monogram('North Star Labs Incorporated')).toBe('NS')
9+
})
10+
11+
it('breaks on the separators an org id carries, not whitespace alone', () => {
12+
// The old switcher split on \s+ only, so every one of these collapsed to the
13+
// first two LETTERS of one word — `acme-labs` read "AC", not "AL".
14+
expect(monogram('acme-labs')).toBe('AL')
15+
expect(monogram('acme_labs')).toBe('AL')
16+
expect(monogram('acme.labs')).toBe('AL')
17+
expect(monogram('acme/labs')).toBe('AL')
18+
})
19+
20+
it('gives a single word its one initial — the account widget’s own rule', () => {
21+
expect(monogram('hanzo')).toBe('H')
22+
expect(monogram('maxpower')).toBe('M')
23+
})
24+
25+
it('is stable on degenerate names', () => {
26+
expect(monogram('')).toBe('')
27+
expect(monogram(' ')).toBe('')
28+
expect(monogram('x')).toBe('X')
29+
expect(monogram(' spaced out ')).toBe('SO')
30+
expect(monogram('---')).toBe('--')
31+
})
32+
})

pkg/ui/src/product/OrgMark.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
'use client'
2+
3+
/**
4+
* OrgMark — the ONE organization identity treatment.
5+
*
6+
* An org shows its OWN logo when IAM carries one; when it does not, it shows its
7+
* MONOGRAM on a neutral tile — the same treatment the account widget gives a
8+
* person, so a workspace and a user read as peers. It is never a house mark: a
9+
* customer's console must show the customer's identity, not ours.
10+
*
11+
* Monochrome by construction (`$color4` tile, `$color12` glyph) so it belongs to
12+
* the chrome; colour stays with content.
13+
*/
14+
import { Text, YStack } from '@hanzo/gui'
15+
16+
import type { Org } from './scope'
17+
18+
/**
19+
* The monogram for a name: the first letter of each of the first two words,
20+
* uppercased — the SAME rule @hanzo/iam's account widget applies to a person, so
21+
* a workspace and a user wear one treatment. Words break on whitespace and on the
22+
* separators an org id carries (`.`, `_`, `-`, `/`), so `acme-labs` reads AL
23+
* while a single word reads its one initial.
24+
*/
25+
export function monogram(name: string): string {
26+
const words = name.trim().split(/[\s._/-]+/).filter(Boolean)
27+
const letters = words
28+
.slice(0, 2)
29+
.map((w) => w[0] ?? '')
30+
.join('')
31+
return (letters || name.trim().slice(0, 2)).toUpperCase()
32+
}
33+
34+
export type OrgMarkProps = {
35+
/** The org to mark — its `logo` wins, else the monogram of its display name. */
36+
org: Org
37+
/** Edge of the square tile / height of the logo. Default 22. */
38+
size?: number
39+
/**
40+
* Widest a LOGO may run, for a wordmark that is not square. Omit to keep the
41+
* logo square like the monogram (a switcher row, an avatar slot).
42+
*/
43+
maxW?: number
44+
}
45+
46+
export function OrgMark({ org, size = 22, maxW }: OrgMarkProps) {
47+
if (org.logo) {
48+
// An arbitrary tenant-supplied URL — a raw <img>, since next/image would need
49+
// a remote allow-list per customer domain.
50+
// eslint-disable-next-line @next/next/no-img-element
51+
return (
52+
<img
53+
src={org.logo}
54+
alt=""
55+
style={{
56+
height: size,
57+
width: maxW ? 'auto' : size,
58+
maxWidth: maxW ?? size,
59+
objectFit: 'contain',
60+
display: 'block',
61+
borderRadius: 6,
62+
flexShrink: 0,
63+
}}
64+
/>
65+
)
66+
}
67+
return (
68+
<YStack
69+
width={size}
70+
height={size}
71+
rounded="$3"
72+
bg="$color4"
73+
items="center"
74+
justify="center"
75+
style={{ flexShrink: 0 }}
76+
>
77+
<Text fontSize={Math.round(size * 0.4)} fontWeight="800" color="$color12">
78+
{monogram(org.displayName || org.name)}
79+
</Text>
80+
</YStack>
81+
)
82+
}

pkg/ui/src/product/OrgSwitcher.tsx

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1818
import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
1919
import { Check, ChevronsUpDown, LayoutGrid, Plus, Search } from '@hanzogui/lucide-icons-2'
2020

21+
import { OrgMark } from './OrgMark'
2122
import { filterOrgs, type Org, type OrgScope } from './scope'
2223

2324
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
24-
const initialsOf = (o: Org) =>
25-
(o.displayName || o.name)
26-
.split(/\s+/)
27-
.map((w) => w[0])
28-
.join('')
29-
.slice(0, 2)
30-
.toUpperCase()
31-
32-
/** An org's avatar — its logo when set, else a monogram tile. */
33-
function OrgAvatar({ org, size = 22 }: { org: Org; size?: number }) {
34-
if (org.logo) {
35-
// eslint-disable-next-line @next/next/no-img-element
36-
return <img src={org.logo} alt="" style={{ height: size, width: size, objectFit: 'contain', display: 'block', borderRadius: 6 }} />
37-
}
38-
return (
39-
<YStack width={size} height={size} rounded="$3" bg="$color4" items="center" justify="center">
40-
<Text fontSize="$1" fontWeight="800" color="$color12">
41-
{initialsOf(org)}
42-
</Text>
43-
</YStack>
44-
)
45-
}
4625

4726
export type OrgSwitcherProps = {
4827
/** The active-org contract (see `orgScope`). */
@@ -55,13 +34,20 @@ export type OrgSwitcherProps = {
5534
orgs?: (page: number, query: string) => Promise<Org[]>
5635
/** Rows per page the loader returns. Default 20. */
5736
pageSize?: number
37+
/**
38+
* The org the surface is scoped to, already resolved (display name + logo).
39+
* Omit and the trigger synthesizes it from the scope id alone — enough to name
40+
* the org, but it cannot know a logo. Pass it wherever the host already
41+
* resolves the org, so the switcher and the chrome's org mark agree.
42+
*/
43+
current?: Org
5844
/** Create-org hook → the created org's id; omit to hide the affordance. */
5945
create?: (name: string) => Promise<string>
6046
/** Show the "All organizations" de-scope row (`scope.leaveOrg`). */
6147
picker?: boolean
6248
}
6349

64-
export function OrgSwitcher({ scope, orgs, pageSize = 20, create, picker = false }: OrgSwitcherProps) {
50+
export function OrgSwitcher({ scope, orgs, pageSize = 20, current: given, create, picker = false }: OrgSwitcherProps) {
6551
const currentId = scope.currentOrg()
6652

6753
const [open, setOpen] = useState(false)
@@ -133,10 +119,13 @@ export function OrgSwitcher({ scope, orgs, pageSize = 20, create, picker = false
133119
return [{ name: currentId, displayName: titleCase(currentId) }]
134120
}, [orgs, rows, query, currentId])
135121

136-
const current: Org = useMemo(
137-
() => rows.find((o) => o.name === currentId) ?? { name: currentId, displayName: titleCase(currentId) },
138-
[rows, currentId],
139-
)
122+
// The host's resolved org wins (it alone can carry the logo), then the loaded
123+
// row, then a name synthesized from the scope id — never nothing.
124+
const current: Org = useMemo(() => {
125+
if (given && given.name === currentId) return given
126+
return rows.find((o) => o.name === currentId) ?? { name: currentId, displayName: titleCase(currentId) }
127+
}, [given, rows, currentId])
128+
const currentLabel = current.displayName || titleCase(current.name)
140129

141130
const select = useCallback(
142131
(org: string) => {
@@ -163,8 +152,17 @@ export function OrgSwitcher({ scope, orgs, pageSize = 20, create, picker = false
163152
return (
164153
<Popover open={open} onOpenChange={setOpen} placement="bottom-start">
165154
<Popover.Trigger asChild>
166-
<Button size="$2" chromeless icon={<OrgAvatar org={current} size={18} />} iconAfter={<ChevronsUpDown size={13} />}>
167-
{current.displayName || titleCase(current.name)}
155+
{/* Sized as the PEER of the account control — same height, same mark,
156+
same type, same hit area — so "which workspace" and "who I am" read
157+
as the two halves of one identity, not a caption over a control. */}
158+
<Button chromeless height={44} px="$2" justify="flex-start" aria-label={`${currentLabel} · switch organization`}>
159+
<XStack items="center" gap="$2.5" flex={1} minW={0}>
160+
<OrgMark org={current} size={30} />
161+
<Text flex={1} minW={0} fontSize="$4" fontWeight="800" color="$color12" numberOfLines={1}>
162+
{currentLabel}
163+
</Text>
164+
<ChevronsUpDown size={15} color="$color9" />
165+
</XStack>
168166
</Button>
169167
</Popover.Trigger>
170168
<Popover.Content bordered elevate p="$2" width={300} bg="$color2" borderColor="$borderColor">
@@ -251,7 +249,7 @@ export function OrgSwitcher({ scope, orgs, pageSize = 20, create, picker = false
251249
bg={org.name === currentId ? '$color4' : 'transparent'}
252250
hoverStyle={{ bg: '$color5' }}
253251
>
254-
<OrgAvatar org={org} />
252+
<OrgMark org={org} />
255253
<Text flex={1} fontSize="$2" color="$color12" numberOfLines={1}>
256254
{org.displayName || org.name}
257255
</Text>

pkg/ui/src/product/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export * from './combobox/filter'
4444
export * from './surfaces.data'
4545
export * from './AppHeader'
4646
export * from './BrandMark'
47+
export * from './OrgMark'
4748
export * from './OrgSwitcher'
4849
export * from './scope'
4950

0 commit comments

Comments
 (0)