@estiva-app/ui 0.38.0 → 0.39.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": "@estiva-app/ui",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.38.0",
7
+ "packageVersion": "0.39.0",
8
8
  "exports": 88,
9
9
  "typeExports": 97,
10
10
  "files": null
@@ -21,12 +21,12 @@
21
21
  "kind": "component",
22
22
  "importPath": "@estiva-app/ui",
23
23
  "sourceFile": "src/AppShell.tsx",
24
- "purpose": "The frame of an app, in the two manners that pair: `solid` — the structured frame, a solid TopBar over a Sidebar and the content beside it — and `floating` — the TopBar floating over the top edge, a Rail standing on the background, and the content living in the rounded card.",
24
+ "purpose": "The frame of an app, in three manners: `solid` — the structured frame, a solid TopBar over a Sidebar and the content beside it — `inset` — the same frame the other way round, the TopBar and the Sidebar standing on the ground and the content a lighter card beside them — and `floating` — the TopBar floating over the top edge, a Rail standing on the background, and the content living in the rounded card.",
25
25
  "purposeFrom": "page",
26
26
  "props": [
27
27
  {
28
28
  "name": "variant",
29
- "takes": "solid | floating",
29
+ "takes": "solid | floating | inset",
30
30
  "required": false,
31
31
  "note": null
32
32
  },
@@ -78,7 +78,8 @@
78
78
  "prop": "variant",
79
79
  "values": [
80
80
  "solid",
81
- "floating"
81
+ "floating",
82
+ "inset"
82
83
  ]
83
84
  }
84
85
  ],
@@ -5412,7 +5413,7 @@
5412
5413
  "props": [
5413
5414
  {
5414
5415
  "name": "variant",
5415
- "takes": "solid | floating",
5416
+ "takes": "solid | floating | inset",
5416
5417
  "required": false,
5417
5418
  "note": null
5418
5419
  },
@@ -5452,7 +5453,8 @@
5452
5453
  "prop": "variant",
5453
5454
  "values": [
5454
5455
  "solid",
5455
- "floating"
5456
+ "floating",
5457
+ "inset"
5456
5458
  ]
5457
5459
  }
5458
5460
  ],
package/src/AppShell.mdx CHANGED
@@ -5,14 +5,18 @@ import * as AppShellStories from './AppShell.stories'
5
5
 
6
6
  # AppShell
7
7
 
8
- The frame of an app, in the two manners that pair: **`solid`** — the
9
- structured frame, a solid TopBar over a Sidebar and the content beside
10
- it — and **`floating`** — the TopBar floating over the top edge, a Rail
8
+ The frame of an app, in three manners: **`solid`** — the structured
9
+ frame, a solid TopBar over a Sidebar and the content beside it —
10
+ **`inset`** — the same frame the other way round, the TopBar and the
11
+ Sidebar standing on the ground and the content a lighter card beside
12
+ them — and **`floating`** — the TopBar floating over the top edge, a Rail
11
13
  standing on the background, and the content living in the rounded card.
12
14
  Layout only: no data, no routes.
13
15
 
14
16
  <Canvas of={AppShellStories.Solid} />
15
17
 
18
+ <Canvas of={AppShellStories.Inset} />
19
+
16
20
  <Canvas of={AppShellStories.Floating} />
17
21
 
18
22
  ## When
@@ -20,6 +24,10 @@ Layout only: no data, no routes.
20
24
  - **`solid` + Sidebar** — a place-by-place app: labelled entries, counts,
21
25
  groups. Sidebar and main scroll independently, so a long list never
22
26
  scrolls the navigation away.
27
+ - **`inset` + Sidebar** — the same place-by-place app, when its pages
28
+ read as one sheet on a desk: the content is a card 16px from the right
29
+ and bottom, with 8px corners, and the Sidebar drops its line and surface
30
+ by itself. Any theme: the ground and the card are its base and surface.
23
31
  - **`floating` + Rail** — an app with a handful of top-level places and
24
32
  its content in one card; the `menu` button in the bar collapses the
25
33
  rail (the collapsing stays the app's).
@@ -95,6 +95,25 @@ export const SolidWithBanner: Story = {
95
95
  ),
96
96
  }
97
97
 
98
+ /** The structured frame the other way round: the bar and the sidebar on the ground, the content a lighter card beside them. */
99
+ export const Inset: Story = {
100
+ args: { variant: 'inset' },
101
+ render: (args) => (
102
+ <AppShell {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} identity={identity} nav={sidebar}>
103
+ <EmptyState message="Nothing here yet." />
104
+ </AppShell>
105
+ ),
106
+ }
107
+
108
+ export const InsetWithBanner: Story = {
109
+ args: { variant: 'inset' },
110
+ render: (args) => (
111
+ <AppShell {...args} logo="Estiva" identity={identity} nav={sidebar} banner={<Banner tone="ok">Public key copied.</Banner>}>
112
+ <EmptyState message="Nothing here yet." />
113
+ </AppShell>
114
+ ),
115
+ }
116
+
98
117
  /** The floating frame: the bar floats over the top edge, the rail stands on the background, and the content lives in the rounded card. */
99
118
  export const Floating: Story = {
100
119
  render: (args) => (
@@ -9,6 +9,7 @@
9
9
  import { afterEach, describe, expect, it } from 'vitest'
10
10
  import { cleanup, render, screen } from '@testing-library/react'
11
11
  import { AppShell } from './AppShell'
12
+ import { Sidebar } from './Sidebar'
12
13
 
13
14
  afterEach(cleanup)
14
15
 
@@ -26,6 +27,25 @@ describe('AppShell', () => {
26
27
  expect(main.className).not.toContain('overflow-y-auto')
27
28
  })
28
29
 
30
+ it('stands the sidebar on the ground and makes the content a panel in the inset manner, and only there', () => {
31
+ const frame = (variant: 'solid' | 'inset') =>
32
+ render(
33
+ <AppShell variant={variant} nav={<Sidebar>Rows</Sidebar>}>
34
+ <p>Page</p>
35
+ </AppShell>,
36
+ )
37
+ const { unmount } = frame('solid')
38
+ expect(screen.getByRole('navigation').className).toContain('border-r')
39
+ expect(screen.getByRole('banner').className).toContain('border-b')
40
+ unmount()
41
+ frame('inset')
42
+ expect(screen.getByRole('navigation').className).not.toMatch(/border-r|bg-bg-surface/)
43
+ expect(screen.getByRole('banner').className).not.toMatch(/border-b|bg-bg-surface/)
44
+ const panel = screen.getByRole('main').closest('.rounded-lg')
45
+ expect(panel?.className).toContain('bg-bg-surface')
46
+ expect(panel?.className).toContain('mr-4')
47
+ })
48
+
29
49
  it('leaves the floating manner’s card as it was', () => {
30
50
  render(
31
51
  <AppShell variant="floating" nav={<nav>Nav</nav>}>
package/src/AppShell.tsx CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
+ import { Card } from './Card'
3
4
  import { ScrollArea } from './ScrollArea'
5
+ import { SidebarOnGround } from './Sidebar'
4
6
  import { TopBar } from './TopBar'
5
7
 
6
8
  /**
7
- * The frame of an app, in the two manners that pair with the TopBar's
9
+ * The frame of an app, in the manners that pair with the TopBar's
8
10
  * (Katerina, 2026-09-02: the two shells go together — the solid bar with
9
11
  * the sidebar, the floating bar with the rail):
10
12
  *
@@ -15,6 +17,9 @@ import { TopBar } from './TopBar'
15
17
  * edge, the rail stands on the app background, and the content lives
16
18
  * in the rounded card beside it (2026-09-02, the floating app's frame
17
19
  * geometry; its collapse animation and launcher stay in the app).
20
+ * - `inset` — the structured frame, the other way round: the TopBar and
21
+ * the Sidebar stand on the ground, and the content is a lighter card
22
+ * beside them (Katerina, 28 September: Linear's layout, in any theme).
18
23
  *
19
24
  * The banner belongs to the content area (her ruling, 2026-09-02): it
20
25
  * renders at the top of the main column — inside the card, in the
@@ -24,7 +29,7 @@ import { TopBar } from './TopBar'
24
29
  * has no data and no routes.
25
30
  */
26
31
  export interface AppShellProps {
27
- variant?: 'solid' | 'floating'
32
+ variant?: 'solid' | 'floating' | 'inset'
28
33
  /** The top bar's menu button, in an app whose navigation collapses. */
29
34
  menu?: ReactNode
30
35
  /** The top bar's left: the app's logo mark, or its name as text. */
@@ -42,6 +47,7 @@ export interface AppShellProps {
42
47
 
43
48
  export function AppShell({ variant = 'solid', menu, logo, search, identity, banner, nav, children }: AppShellProps) {
44
49
  const bar = <TopBar variant={variant} menu={menu} logo={logo} search={search} right={identity} />
50
+ const inset = variant === 'inset'
45
51
 
46
52
  if (variant === 'floating') {
47
53
  return (
@@ -66,6 +72,30 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
66
72
  )
67
73
  }
68
74
 
75
+ const column = (
76
+ <>
77
+ {banner}
78
+ {/* The frame owns the page's scrollbar (Katerina, 2026-09-09: the
79
+ Issues page still had a native bar — D40 said everywhere, and the
80
+ one place every page passes through had been left out). A
81
+ `ScrollArea` here means no page can forget it. The content box is
82
+ at least the viewport's height and grows with a tall page, so Base
83
+ UI sees its size change (the morning's phantom bar was a content
84
+ box it could not watch); `main` fills it as a flex column. The
85
+ page contract (the AppShell page says it): a page is a flex
86
+ child of `main` — `flex-1` to fill and scroll here; `flex-1
87
+ min-h-0 [contain:size]` to take exactly the frame's height and
88
+ scroll inside itself, as Ship's detail grids do — and the outer
89
+ region, with nothing to scroll, passes the wheel through. Never
90
+ `h-full`: a box that is *at least* the viewport's height gives a
91
+ percentage nothing to resolve against — measured, all four Ship
92
+ pages, 2026-09-09. */}
93
+ <ScrollArea className="min-h-0 flex-1" contentClassName="flex min-h-full flex-col">
94
+ <main className="flex min-w-0 flex-1 flex-col">{children}</main>
95
+ </ScrollArea>
96
+ </>
97
+ )
98
+
69
99
  return (
70
100
  /* `relative overflow-hidden` is the seal the floating manner already has
71
101
  (and the ScrollArea's own root is `relative`, so an absolutely placed
@@ -79,28 +109,17 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
79
109
  <div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-bg-base text-text-primary">
80
110
  {bar}
81
111
  <div className="flex min-h-0 flex-1">
82
- {nav}
83
- <div className="flex min-h-0 min-w-0 flex-1 flex-col">
84
- {banner}
85
- {/* The frame owns the page's scrollbar (Katerina, 2026-09-09: the
86
- Issues page still had a native bar — D40 said everywhere, and the
87
- one place every page passes through had been left out). A
88
- `ScrollArea` here means no page can forget it. The content box is
89
- at least the viewport's height and grows with a tall page, so Base
90
- UI sees its size change (the morning's phantom bar was a content
91
- box it could not watch); `main` fills it as a flex column. The
92
- page contract (the AppShell page says it): a page is a flex
93
- child of `main` — `flex-1` to fill and scroll here; `flex-1
94
- min-h-0 [contain:size]` to take exactly the frame's height and
95
- scroll inside itself, as Ship's detail grids do — and the outer
96
- region, with nothing to scroll, passes the wheel through. Never
97
- `h-full`: a box that is *at least* the viewport's height gives a
98
- percentage nothing to resolve against — measured, all four Ship
99
- pages, 2026-09-09. */}
100
- <ScrollArea className="min-h-0 flex-1" contentClassName="flex min-h-full flex-col">
101
- <main className="flex min-w-0 flex-1 flex-col">{children}</main>
102
- </ScrollArea>
103
- </div>
112
+ <SidebarOnGround.Provider value={inset}>{nav}</SidebarOnGround.Provider>
113
+ {/* `inset`: the content is a card on the ground, the Card's own surface look,
114
+ 16px from the right and bottom as the floating card is, 8px corners (Katerina,
115
+ 28 September). */}
116
+ {inset ? (
117
+ <Card fill="surface" clip className="mb-4 mr-4 flex min-h-0 min-w-0 flex-1 flex-col">
118
+ {column}
119
+ </Card>
120
+ ) : (
121
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">{column}</div>
122
+ )}
104
123
  </div>
105
124
  </div>
106
125
  )
package/src/Sidebar.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
  import { ScrollArea } from './ScrollArea'
4
4
 
@@ -10,6 +10,10 @@ import { ScrollArea } from './ScrollArea'
10
10
  * What goes inside is the caller's: NavItem rows, a SectionLabel heading
11
11
  * in a 32px row over a group. Desktop only — there is no narrow-screen
12
12
  * drawer.
13
+ *
14
+ * In AppShell's `inset` frame it stands on the ground instead: no hairline,
15
+ * no surface, the content's card beside it (Katerina, 28 September). The
16
+ * frame tells it so; the caller passes nothing.
13
17
  */
14
18
  export interface SidebarProps {
15
19
  /** Names the navigation region for assistive tech. */
@@ -18,11 +22,15 @@ export interface SidebarProps {
18
22
  className?: string
19
23
  }
20
24
 
25
+ /** Set by AppShell's `inset` frame: the sidebar stands on the ground, with no line or surface of its own. */
26
+ export const SidebarOnGround = createContext(false)
27
+
21
28
  export function Sidebar({ 'aria-label': ariaLabel = 'Workspace', children, className }: SidebarProps) {
29
+ const onGround = useContext(SidebarOnGround)
22
30
  return (
23
31
  <nav
24
32
  aria-label={ariaLabel}
25
- className={cn('flex w-60 shrink-0 flex-col border-r border-border-default bg-bg-surface', className)}
33
+ className={cn('flex w-60 shrink-0 flex-col', !onGround && 'border-r border-border-default bg-bg-surface', className)}
26
34
  >
27
35
  {/* The rows scroll in a ScrollArea: the bar takes no width, so the
28
36
  column's padding reads the same with a long list as with a short
package/src/TopBar.mdx CHANGED
@@ -6,10 +6,10 @@ import * as TopBarStories from './TopBar.stories'
6
6
  # TopBar
7
7
 
8
8
  The 52px top bar: `menu` and `logo` on the left, a `search` slot in the
9
- centre, the identity cluster on the right. Two manners — **`solid`**, in
10
- flow with a hairline and a surface, and **`floating`**, an overlay with
11
- neither, whose bar ignores the pointer so the content underneath stays
12
- reachable.
9
+ centre, the identity cluster on the right. Three manners — **`solid`**,
10
+ in flow with a hairline and a surface; **`inset`**, in flow with neither,
11
+ standing on the ground; and **`floating`**, an overlay with neither, whose
12
+ bar ignores the pointer so the content underneath stays reachable.
13
13
 
14
14
  <Canvas of={TopBarStories.Solid} />
15
15
 
@@ -19,6 +19,8 @@ reachable.
19
19
 
20
20
  - **`solid`** — the structured frame's first row, above the sidebar
21
21
  (AppShell's `solid` variant wires exactly this).
22
+ - **`inset`** — the first row of AppShell's `inset` frame, on the ground
23
+ above a sidebar that is on the ground too.
22
24
  - **`floating`** — an app whose content runs to the top edge; the bar
23
25
  floats over it and only its clusters catch clicks (AppShell's
24
26
  `floating` variant).
package/src/TopBar.tsx CHANGED
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
 
4
4
  /**
5
- * The 52px top bar, in two manners:
5
+ * The 52px top bar, in three manners:
6
6
  *
7
7
  * - `solid` — in flow: a hairline under it, the surface behind it, the
8
8
  * frame's first row (2026-09-02, verbatim from the structured app; its
@@ -11,6 +11,9 @@ import { cn } from './cn'
11
11
  * background, and the bar itself ignores the pointer — only its clusters
12
12
  * catch clicks, so the content underneath stays reachable (2026-09-02,
13
13
  * verbatim from the floating app).
14
+ * - `inset` — in flow like `solid`, but standing on the ground: no hairline
15
+ * and no surface, above a sidebar on the ground and the content's card
16
+ * (Katerina, 28 September).
14
17
  *
15
18
  * Geometry both apps agreed on before extraction: 52px tall, `pl-5
16
19
  * pr-[26px]`, right cluster `gap-[6px]`. The left region is two slots
@@ -20,7 +23,7 @@ import { cn } from './cn'
20
23
  * searched; it only knows where those go.
21
24
  */
22
25
  export interface TopBarProps {
23
- variant?: 'solid' | 'floating'
26
+ variant?: 'solid' | 'floating' | 'inset'
24
27
  /** Far left: the menu button, in an app whose navigation collapses. Stays the caller's — the bar only places it. */
25
28
  menu?: ReactNode
26
29
  /** Beside the menu button: the app's logo mark, or its name as text. */
@@ -40,7 +43,7 @@ export function TopBar({ variant = 'solid', menu, logo, search, right, className
40
43
  'flex h-[52px] items-center pl-5 pr-[26px]',
41
44
  floating
42
45
  ? 'pointer-events-none absolute left-0 right-0 top-0 z-10'
43
- : 'shrink-0 gap-4 border-b border-border-default bg-bg-surface',
46
+ : cn('shrink-0 gap-4', variant === 'solid' && 'border-b border-border-default bg-bg-surface'),
44
47
  className,
45
48
  )}
46
49
  >