Skip to content

Commit 4f1e635

Browse files
committed
feat(org): home composer, page shell, and completion blue
Organization home gets the workspace chat's empty state, copied rather than imported: the greeting, the composer with its resource, attachment, skill, voice, and send controls, and a "Get started" list in the suggested-actions chrome. Each step carries a ring that fills with a check once done; "Create a workspace" reads the organization's real workspace list, the rest wait on their signals. Integrations, Skills, and Workspaces render through one OrganizationPage shell: the workspace's top header bar, a fixed title/description/tabs/ search/action header in the home reading column, and a scroll region with the sidebar's edge fade and padding. The active tab and the search text live in the URL (`tab`, `q`) and are read back through useOrganizationPageFilters, so a tab switch keeps the criteria and the open field. Search expands leftward into a ChipInput with the canonical quiet icon close button. The emcn scroll-edge fade gains a horizontal axis (useScrollEdges `axis: 'x'`, scrollFadeXClass) so the tab row scrolls under a fade when it meets the controls beside it. `--brand-blue` joins the brand tokens as the one blue for progress and completion states.
1 parent 9810f1f commit 4f1e635

22 files changed

Lines changed: 725 additions & 28 deletions

File tree

.claude/rules/emcn-components.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
3232
- **`ChipDatePicker`** — chip-styled date field.
3333
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
3434
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
35-
- **`useScrollEdges` + `scrollFadeClass` / `scrollFadeAttributes`** — the canonical scroll-region edge treatment. The hook reports which edges hide content (tracking scroll and resizes; pass the element itself, held in state, when the region mounts after its owner, e.g. inside a Radix portal); the class and attributes fade a fixed 12px band at an active edge only, so a list that fits or sits at its top is never fogged. A floating control over the top edge sets `--scroll-fade-inset` to its height. Any divider beside the region belongs to the neighboring block (`border-b` above, `border-t` below), never to the masked element, and shows only while that edge is active. Never hand-roll a `mask-image` gradient for a scroll region.
35+
- **`useScrollEdges` + `scrollFadeClass` / `scrollFadeAttributes`** — the canonical scroll-region edge treatment. The hook reports which edges hide content (tracking scroll and resizes; pass the element itself, held in state, when the region mounts after its owner, e.g. inside a Radix portal); the class and attributes fade a fixed 12px band at an active edge only, so a list that fits or sits at its top is never fogged. A floating control over the top edge sets `--scroll-fade-inset` to its height. A region that scrolls sideways (a tab row, a chip strip) uses `useScrollEdges(ref, { axis: 'x' })` with `scrollFadeXClass`; the attributes helper is shared. Any divider beside the region belongs to the neighboring block (`border-b` above, `border-t` below), never to the masked element, and shows only while that edge is active. Never hand-roll a `mask-image` gradient for a scroll region.
3636
- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, fade-only clipping (never an ellipsis), the conditional 18px edge mask, and the full-value floating tooltip; consumers pass only layout/typography through `className`. `overflowTextClipClass` and `overflowTextFadeClass` are the complete base/faded treatments for the rare component that must own measurement itself; never pair either with `truncate`, `text-ellipsis`, or hover-time mask removal. Use `DropdownMenuItemLabel` for a menu label beside icons, checks, or actions. A non-editable `Combobox` passes the full visual value through `overlayLabel`; the combobox owns the visual overlay's fade and keeps its one accessible tooltip on the interactive layer. Keep ordinary `truncate` only for editable values, code/log/path content, dense or virtualized grids, and rich composite content that cannot supply a plain tooltip label. Multiline copy uses an intentional `line-clamp-*` treatment instead.
3737

3838
## Modal keyboard defaults

.claude/rules/sim-styling.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Headings inherit their weight. Tailwind preflight resets `h1`–`h6` to `font-we
7474

7575
## Color Tokens
7676

77-
Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; neutral borders and dividers `--border` (`--border-1` and `--border-muted` are legacy aliases resolving to it; `--divider` is retired); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces.
77+
Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; progress and completion (a checked step, a done state) `--brand-blue``--selection` stays the interactive highlight; neutral borders and dividers `--border` (`--border-1` and `--border-muted` are legacy aliases resolving to it; `--divider` is retired); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces.
7878

7979
### Line weight
8080

apps/sim/app/_styles/globals.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,9 @@ html[data-sim-desktop-title-bar="inset"]
725725
--brand-secondary: #33b4ff;
726726
--brand-accent: #33c482;
727727
--brand-accent-hover: #2dac72;
728+
/* Progress and completion — the checked step, the done state. Deeper and
729+
quieter than --selection, which stays the interactive highlight. */
730+
--brand-blue: #3b6fe0;
728731
--selection: #1a5cf6;
729732
--selection-muted: #1a5cf647;
730733
--warning: #ea580c;
@@ -886,6 +889,8 @@ html[data-sim-desktop-title-bar="inset"]
886889
--brand-secondary: #33b4ff;
887890
--brand-accent: #33c482;
888891
--brand-accent-hover: #2dac72;
892+
/* Lifted for contrast on dark surfaces, the same step --selection takes. */
893+
--brand-blue: #5b8def;
889894
--selection: #4b83f7;
890895
--selection-muted: #4b83f759;
891896
--warning: #ff6600;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { OrganizationPage, type OrganizationPageTab } from './organization-page'
2+
export { useOrganizationPageFilters } from './use-organization-page-filters'
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
'use client'
2+
3+
import { type ReactNode, useRef, useState } from 'react'
4+
import {
5+
Button,
6+
Chip,
7+
ChipInput,
8+
cn,
9+
scrollFadeAttributes,
10+
scrollFadeClass,
11+
scrollFadeXClass,
12+
useScrollEdges,
13+
} from '@sim/emcn'
14+
import { Search, X } from '@sim/emcn/icons'
15+
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
16+
import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
17+
import {
18+
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
19+
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
20+
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
21+
22+
/** The home surface's reading column, so every organization page shares its width. */
23+
const COLUMN_CLASS = 'mx-auto w-full max-w-chat px-6'
24+
25+
export interface OrganizationPageTab {
26+
id: string
27+
label: string
28+
}
29+
30+
interface OrganizationPageProps {
31+
title: string
32+
description?: string
33+
/** Header tabs; the first is the default. Omit for a page with one view. */
34+
tabs?: readonly OrganizationPageTab[]
35+
/** Label of the page's primary action chip. Omit for a page without one. */
36+
action?: string
37+
children?: ReactNode
38+
}
39+
40+
/**
41+
* The shell every organization page renders into: the top bar the workspace pages
42+
* wear, then a fixed page header — title, description, tabs, search, and the
43+
* optional action — over a scroll region that fades at both edges the way the
44+
* sidebar does. Pages supply their content and logic; nothing else.
45+
*
46+
* The shell paints at once and never waits on data: a page renders each piece —
47+
* a tab, a list, a count — the moment it is known and nothing before, with no
48+
* skeleton standing in for it. Pass `tabs` only once they are known; the row
49+
* simply gains them.
50+
*/
51+
export function OrganizationPage({
52+
title,
53+
description,
54+
tabs,
55+
action,
56+
children,
57+
}: OrganizationPageProps) {
58+
const scrollContainerRef = useRef<HTMLDivElement>(null)
59+
const scrollContentRef = useRef<HTMLDivElement>(null)
60+
const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef })
61+
const tabsRef = useRef<HTMLDivElement>(null)
62+
const tabEdges = useScrollEdges(tabsRef, { axis: 'x' })
63+
64+
const { tab, search, setTab, setSearch } = useOrganizationPageFilters()
65+
const defaultTab = tabs?.[0]?.id
66+
const activeTab = tab ?? defaultTab
67+
68+
/**
69+
* The field stays open while it holds text, across tab switches and reloads,
70+
* since the text lives in the URL; this only remembers an empty field the
71+
* viewer opened and has not dismissed.
72+
*/
73+
const [searchOpened, setSearchOpened] = useState(false)
74+
const searchOpen = searchOpened || search.length > 0
75+
76+
const closeSearch = () => {
77+
setSearch('')
78+
setSearchOpened(false)
79+
}
80+
81+
return (
82+
<div className='flex h-full flex-col bg-[var(--bg)]'>
83+
{/* Reserved even while empty so the page header sits where the workspace's does. */}
84+
<div className={PAGE_HEADER_BAR}>
85+
<div className={HEADER_ACTION_CLUSTER} />
86+
</div>
87+
88+
<div className={cn(COLUMN_CLASS, SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, 'flex shrink-0 flex-col')}>
89+
<div className='flex flex-col gap-1 pt-8'>
90+
<h1 className='text-[18px] text-[var(--text-primary)]'>{title}</h1>
91+
{description && <p className='text-[var(--text-muted)] text-small'>{description}</p>}
92+
</div>
93+
<div className='mt-4 flex items-center justify-between gap-2'>
94+
{/* The row yields to the controls beside it and scrolls sideways under a fade
95+
once it can no longer fit; the scrollbar itself never shows. */}
96+
<div
97+
ref={tabsRef}
98+
className={cn(
99+
scrollFadeXClass,
100+
'flex min-w-0 flex-1 items-center gap-[1px] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
101+
)}
102+
{...scrollFadeAttributes(tabEdges)}
103+
>
104+
{tabs?.map((item) => {
105+
const active = item.id === activeTab
106+
return (
107+
<Chip
108+
key={item.id}
109+
shape='round'
110+
active={active}
111+
aria-pressed={active}
112+
onClick={() => setTab(item.id === defaultTab ? null : item.id)}
113+
className='min-w-[44px] shrink-0 text-center'
114+
>
115+
{item.label}
116+
</Chip>
117+
)
118+
})}
119+
</div>
120+
<div className='flex shrink-0 items-center gap-1.5'>
121+
{searchOpen ? (
122+
<ChipInput
123+
autoFocus
124+
icon={Search}
125+
value={search}
126+
placeholder='Search'
127+
aria-label='Search'
128+
spellCheck={false}
129+
autoComplete='off'
130+
/* Grows leftward from the chip's spot, to this and no further. */
131+
className='w-[240px]'
132+
onChange={(event) => setSearch(event.target.value)}
133+
onKeyDown={(event) => {
134+
if (event.key === 'Escape') closeSearch()
135+
}}
136+
endAdornment={
137+
<Button
138+
type='button'
139+
variant='quiet'
140+
size='icon'
141+
className='-mr-1 shrink-0'
142+
aria-label='Close search'
143+
onClick={closeSearch}
144+
>
145+
<X className='size-[13px]' />
146+
</Button>
147+
}
148+
/>
149+
) : (
150+
<Chip leftIcon={Search} aria-label='Search' onClick={() => setSearchOpened(true)} />
151+
)}
152+
{action && <Chip variant='primary'>{action}</Chip>}
153+
</div>
154+
</div>
155+
</div>
156+
157+
<div
158+
ref={scrollContainerRef}
159+
className={cn(
160+
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
161+
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
162+
scrollFadeClass,
163+
'min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable_both-edges]'
164+
)}
165+
{...scrollFadeAttributes(scrollEdges)}
166+
>
167+
<div ref={scrollContentRef} className={COLUMN_CLASS}>
168+
{children}
169+
</div>
170+
</div>
171+
</div>
172+
)
173+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { parseAsString } from 'nuqs/server'
2+
3+
/**
4+
* Co-located, typed URL query-param definitions for an organization page's
5+
* header. Both are view state the page's content filters by, so a link carries
6+
* them and a tab switch keeps them.
7+
*
8+
* - `tab` is the active header tab. Absent, the page shows its first tab, so the
9+
* key only appears once the viewer leaves it.
10+
* - `q` is the search field's text, written raw (consumers trim on read) and
11+
* debounced on the way to the URL by `useDebouncedSearchSetter`.
12+
*/
13+
export const organizationPageParsers = {
14+
tab: parseAsString,
15+
q: parseAsString.withDefault(''),
16+
} as const
17+
18+
/** Tabs and search are filter-like view changes, not navigation: replace, and clear at the default. */
19+
export const organizationPageUrlKeys = {
20+
history: 'replace',
21+
clearOnDefault: true,
22+
} as const
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useCallback } from 'react'
2+
import { useQueryStates } from 'nuqs'
3+
import {
4+
organizationPageParsers,
5+
organizationPageUrlKeys,
6+
} from '@/app/o/[organizationId]/components/organization-page/search-params'
7+
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
8+
9+
/**
10+
* The header filters of the organization page the caller sits on. The shell
11+
* drives them; a page's content reads `tab` and `search` to filter its list, so
12+
* the same criteria apply whichever tab is showing.
13+
*/
14+
export function useOrganizationPageFilters() {
15+
const [{ tab, q }, setFilters] = useQueryStates(organizationPageParsers, organizationPageUrlKeys)
16+
17+
const setTab = useCallback((next: string | null) => setFilters({ tab: next }), [setFilters])
18+
const setSearch = useDebouncedSearchSetter((value, options) => setFilters({ q: value }, options))
19+
20+
return { tab, search: q, setTab, setSearch }
21+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { Button, cn, Tooltip } from '@sim/emcn'
5+
import { ArrowUp, Mic, Paperclip, Plus, Slash } from '@sim/emcn/icons'
6+
7+
const TOOL_BUTTON_CLASS = 'size-[28px] rounded-full p-0 hover-hover:bg-[var(--surface-hover)]'
8+
const TOOL_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]'
9+
10+
const SEND_BUTTON_BASE = 'size-[28px] rounded-full border-0 p-0 transition-colors'
11+
const SEND_BUTTON_ACTIVE =
12+
'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
13+
const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'
14+
15+
/**
16+
* The organization home composer. Mirrors the workspace chat input's chrome:
17+
* the framed field, the resource, attachment, and skill triggers on the left,
18+
* and voice input plus send on the right.
19+
*/
20+
export function Composer() {
21+
const [draft, setDraft] = useState('')
22+
const canSubmit = draft.trim().length > 0
23+
24+
return (
25+
<div className='relative z-10 mx-auto w-full max-w-chat cursor-text rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 shadow-ambient dark:bg-[var(--surface-4)]'>
26+
<div className='relative max-h-[200px] min-h-[56px] overflow-y-auto overflow-x-hidden [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
27+
<textarea
28+
value={draft}
29+
onChange={(event) => setDraft(event.target.value)}
30+
placeholder='Ask Sim to...'
31+
aria-label='Message'
32+
rows={1}
33+
className='field-sizing-content m-0 box-border min-h-[24px] w-full resize-none border-0 bg-transparent px-1 py-1 font-body text-[14px] text-[var(--text-primary)] leading-[24px] tracking-[-0.015em] outline-hidden [overflow-wrap:anywhere] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0'
34+
/>
35+
</div>
36+
37+
<div className='flex items-center justify-between'>
38+
<div className='flex items-center gap-1'>
39+
<Tooltip.Root>
40+
<Tooltip.Trigger asChild>
41+
<Button
42+
type='button'
43+
variant='ghost'
44+
aria-label='Add resources'
45+
className={TOOL_BUTTON_CLASS}
46+
>
47+
<Plus className={TOOL_ICON_CLASS} />
48+
</Button>
49+
</Tooltip.Trigger>
50+
<Tooltip.Content side='top'>Add resources</Tooltip.Content>
51+
</Tooltip.Root>
52+
<Tooltip.Root>
53+
<Tooltip.Trigger asChild>
54+
<Button
55+
type='button'
56+
variant='ghost'
57+
aria-label='Attach file'
58+
className={TOOL_BUTTON_CLASS}
59+
>
60+
<Paperclip className={TOOL_ICON_CLASS} />
61+
</Button>
62+
</Tooltip.Trigger>
63+
<Tooltip.Content side='top'>Attach file</Tooltip.Content>
64+
</Tooltip.Root>
65+
<Tooltip.Root>
66+
<Tooltip.Trigger asChild>
67+
<Button
68+
type='button'
69+
variant='ghost'
70+
aria-label='Skills'
71+
className={TOOL_BUTTON_CLASS}
72+
>
73+
<Slash className={TOOL_ICON_CLASS} />
74+
</Button>
75+
</Tooltip.Trigger>
76+
<Tooltip.Content side='top'>Skills</Tooltip.Content>
77+
</Tooltip.Root>
78+
</div>
79+
<div className='flex items-center gap-1.5'>
80+
<Tooltip.Root>
81+
<Tooltip.Trigger asChild>
82+
<Button
83+
type='button'
84+
variant='ghost'
85+
aria-label='Voice input'
86+
className={TOOL_BUTTON_CLASS}
87+
>
88+
<Mic className={TOOL_ICON_CLASS} />
89+
</Button>
90+
</Tooltip.Trigger>
91+
<Tooltip.Content side='top'>Voice input</Tooltip.Content>
92+
</Tooltip.Root>
93+
<Button
94+
type='button'
95+
variant='ghost'
96+
disabled={!canSubmit}
97+
aria-label='Send'
98+
className={cn(SEND_BUTTON_BASE, canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED)}
99+
>
100+
<ArrowUp className='block size-[16px] text-white dark:text-black' />
101+
</Button>
102+
</div>
103+
</div>
104+
</div>
105+
)
106+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { Composer } from './composer'

0 commit comments

Comments
 (0)