@podoba/react 0.0.1

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.
@@ -0,0 +1,101 @@
1
+ import type { ReactNode } from 'react'
2
+ import { ToggleButton, ToggleButtonGroup } from 'react-aria-components'
3
+ import { Tooltip, TooltipTrigger } from './tooltip'
4
+
5
+ /**
6
+ * ViewToggle — controlled segmented icon-toggle (port of gs-platform's
7
+ * `ViewToggle`).
8
+ *
9
+ * gs renders N 36px-square icon buttons; exactly one is active. The Tasks /
10
+ * Projects screens use it as the view-switcher (Table / Kanban / Calendar), so
11
+ * the API is kept generic: pass any list of `{ id, icon, label }` options.
12
+ *
13
+ * Built on React Aria Components `ToggleButtonGroup` + `ToggleButton` (single
14
+ * selection, empty-selection disallowed). RAC supplies the roving-tabindex
15
+ * radiogroup-style keyboard model (arrow keys move + select), `aria-pressed`
16
+ * state and focus management for free. Each option's `label` is its accessible
17
+ * name (`aria-label`) and its hover/focus tooltip.
18
+ *
19
+ * gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule
20
+ * #3). The gs active treatment is a blue `#007bff` border — flagged OFF-brand,
21
+ * so the active segment uses our brand treatment instead (`fg` border +
22
+ * `surface-muted` fill). No blue anywhere.
23
+ * - resting border `rgba(0,0,0,.1)` → `border-fg/10` (translucent black, closest)
24
+ * - radius `4px` → `rounded-[4px]`
25
+ * - hover bg `#f9f9f9` → `surface-card` (#f7f6f2)
26
+ * - active border (gs blue) → `fg` (#0d0d0d) — on-brand
27
+ * - active bg → `surface-muted` (#eceae1)
28
+ * - active press `scale(.95)` → `data-[pressed]:scale-95`
29
+ * - icon box `16px` → `h-4 w-4`
30
+ */
31
+
32
+ export type ViewToggleOption = {
33
+ /** Stable identifier — matched against `value` and returned by `onChange`. */
34
+ id: string
35
+ /** Icon node rendered inside the 16px box (decorative; `label` names it). */
36
+ icon: ReactNode
37
+ /** Accessible name + hover/focus tooltip for the option. */
38
+ label: string
39
+ }
40
+
41
+ export type ViewToggleProps = {
42
+ /** The selectable view options, rendered left-to-right. */
43
+ options: ViewToggleOption[]
44
+ /** Currently-active option id (controlled). */
45
+ value: string
46
+ /** Fired with the newly-selected option id. */
47
+ onChange: (id: string) => void
48
+ /** Accessible name for the whole group (e.g. "Switch view"). */
49
+ 'aria-label': string
50
+ /** Disable the entire group. */
51
+ isDisabled?: boolean
52
+ className?: string
53
+ }
54
+
55
+ const buttonClass =
56
+ 'inline-flex h-9 w-9 shrink-0 cursor-pointer select-none items-center justify-center rounded-[4px] ' +
57
+ 'border border-fg/10 bg-surface text-fg outline-none transition-all duration-200 ' +
58
+ 'data-[hovered]:bg-surface-card ' +
59
+ // On-brand active treatment (NOT gs blue): fg border + surface-muted fill.
60
+ 'data-[selected]:border-fg data-[selected]:bg-surface-muted ' +
61
+ 'data-[pressed]:scale-95 ' +
62
+ 'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
63
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50'
64
+
65
+ export function ViewToggle({
66
+ options,
67
+ value,
68
+ onChange,
69
+ isDisabled,
70
+ className,
71
+ 'aria-label': ariaLabel,
72
+ }: ViewToggleProps) {
73
+ return (
74
+ <ToggleButtonGroup
75
+ aria-label={ariaLabel}
76
+ selectionMode="single"
77
+ disallowEmptySelection
78
+ isDisabled={isDisabled}
79
+ selectedKeys={[value]}
80
+ onSelectionChange={keys => {
81
+ const next = [...keys][0]
82
+ if (next != null && String(next) !== value) onChange(String(next))
83
+ }}
84
+ className={['inline-flex items-center gap-1', className].filter(Boolean).join(' ')}
85
+ >
86
+ {options.map(option => (
87
+ <TooltipTrigger key={option.id}>
88
+ <ToggleButton id={option.id} aria-label={option.label} className={buttonClass}>
89
+ <span
90
+ className="flex h-4 w-4 items-center justify-center [&_svg]:h-4 [&_svg]:w-4"
91
+ aria-hidden="true"
92
+ >
93
+ {option.icon}
94
+ </span>
95
+ </ToggleButton>
96
+ <Tooltip>{option.label}</Tooltip>
97
+ </TooltipTrigger>
98
+ ))}
99
+ </ToggleButtonGroup>
100
+ )
101
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ // @podoba/react — the universal component library.
2
+ //
3
+ // React Aria Components + Tailwind, composed with `uic`. Seeded from graphic-standard's
4
+ // @app/ui — atomic primitives + layout ONLY. Product-specific components (schema
5
+ // renderer, delivery/approval modals, brand headers) stay in GS and consume this.
6
+ // See ../../EXTRACTION.md.
7
+
8
+ // --- factory ---
9
+ export { uic, uiconfig, type ConfigVariants, type NoInfer } from "./utils/uic";
10
+
11
+ // --- primitives ---
12
+ export * from "./components/button";
13
+ export * from "./components/input";
14
+ export * from "./components/textarea";
15
+ export * from "./components/checkbox";
16
+ export * from "./components/radio";
17
+ export * from "./components/switch";
18
+ export * from "./components/select";
19
+ export * from "./components/dialog";
20
+ export * from "./components/dropdown-menu";
21
+ export * from "./components/context-menu";
22
+ export * from "./components/tooltip";
23
+ export * from "./components/toast";
24
+ export * from "./components/disclosure";
25
+ export * from "./components/tabs";
26
+ export * from "./components/section-tabs";
27
+ export * from "./components/separator";
28
+ export * from "./components/text";
29
+ export * from "./components/view-toggle";
30
+
31
+ // --- layout ---
32
+ export * from "./layout/card";
33
+ export * from "./layout/section";
34
+ export * from "./layout/page-container";
35
+ export * from "./layout/app-shell";
36
+ export * from "./layout/topbar";
37
+ export * from "./layout/persistent-page-shell";
@@ -0,0 +1,184 @@
1
+ import { type ReactNode, useId, useState } from 'react'
2
+ import {
3
+ Button as RACButton,
4
+ Dialog as RACDialog,
5
+ Modal as RACModal,
6
+ ModalOverlay as RACModalOverlay,
7
+ } from 'react-aria-components'
8
+ import { uic } from '../utils/uic'
9
+
10
+ /**
11
+ * AppShell — top-level application layout (ported pattern from gs-manager's
12
+ * `AppShell` + `AppShell.module.scss`).
13
+ *
14
+ * gs-manager used a full-height flex column (`100vh`, `overflow: hidden`) with
15
+ * a sticky `<header>` topbar and a single scrolling `<main>`. The Phase-1 GS
16
+ * Platform shell adds a left sidebar, so the SCSS intent is reimplemented in
17
+ * Tailwind as a CSS grid:
18
+ *
19
+ * grid-rows-[auto_1fr] — topbar row sizes to content, content fills the rest
20
+ * grid-cols-[auto_1fr] — sidebar takes its intrinsic width, main fills
21
+ *
22
+ * Slots (all optional except `main`):
23
+ * banner — full-bleed bar at the very top of the scroll column; scrolls away
24
+ * topbar — sticky header that pins to the top once the banner scrolls past
25
+ * sidebar — left rail; `<aside>` landmark. Collapses to a drawer on mobile
26
+ * main — `<main>` landmark content region
27
+ *
28
+ * Scroll model: the shell is `h-screen overflow-hidden`; the content side is a
29
+ * single vertical scroll column holding [full-bleed banner · sticky topbar ·
30
+ * main]. The banner sits at the top of that column so it SCROLLS AWAY with the
31
+ * content, while the topbar is `position: sticky; top: 0` and PINS to the top of
32
+ * the viewport once the banner scrolls past it (the classic announcement-bar +
33
+ * sticky-header pattern). The sidebar is a separate full-height rail outside the
34
+ * scroller, so it never scrolls with the content.
35
+ *
36
+ * Responsive: below `md` the sidebar leaves the grid flow and becomes an
37
+ * off-canvas drawer toggled by a hamburger button rendered in the topbar row.
38
+ * The drawer is a React Aria `ModalOverlay`/`Dialog` (accessibility.md): focus
39
+ * moves into the dialog on open and restores to the trigger on close, focus is
40
+ * trapped while open, and `Esc` / clicking the dimmed backdrop dismisses. The
41
+ * trigger advertises the drawer via `aria-expanded` + `aria-controls`.
42
+ *
43
+ * i18n: `@app/ui` ships no i18n, so when `sidebar` is passed the accessible
44
+ * names (`sidebarLabel`, `drawerToggleLabel`) are REQUIRED from the consumer —
45
+ * translated there, never English defaults baked in here.
46
+ */
47
+ const Root = uic('div', {
48
+ displayName: 'AppShell',
49
+ baseClass:
50
+ 'grid h-screen w-full overflow-hidden bg-surface ' +
51
+ // one full-height row; sidebar (auto) + content (1fr) columns on md+
52
+ 'grid-rows-[100%] grid-cols-[1fr] md:grid-cols-[auto_1fr]',
53
+ })
54
+
55
+ const SidebarRail = uic('aside', {
56
+ displayName: 'AppShell.Sidebar',
57
+ baseClass: 'h-full w-64 shrink-0 overflow-y-auto border-r border-border bg-surface',
58
+ })
59
+
60
+ const Main = uic('main', {
61
+ displayName: 'AppShell.Main',
62
+ // gs-manager `.content`: white, 24px horizontal page padding (page-edge-padding-
63
+ // double) so page content is inset from the viewport edge — not flush left.
64
+ // Vertical padding gives every page breathing room. The SCROLL lives on the
65
+ // parent column (so the banner can scroll away under the sticky topbar), not here.
66
+ baseClass: 'min-w-0 flex-1 bg-surface px-6 pb-6 pt-6',
67
+ })
68
+
69
+ export type AppShellProps = {
70
+ /**
71
+ * Full-bleed banner slot rendered ABOVE the topbar, edge-to-edge (e.g. the
72
+ * demo-workspace bar). Takes no height when its content is null.
73
+ */
74
+ banner?: ReactNode
75
+ /** Topbar slot — typically `<Topbar>…</Topbar>`. Spans the full width. */
76
+ topbar?: ReactNode
77
+ /** Main scrollable content region (`<main>` landmark). */
78
+ children: ReactNode
79
+ className?: string
80
+ } & (
81
+ | {
82
+ /** Left sidebar; collapses to a drawer below `md`. */
83
+ sidebar: ReactNode
84
+ /** Accessible name of the sidebar landmark + its mobile drawer dialog (translated by the consumer). */
85
+ sidebarLabel: string
86
+ /** Accessible label for the mobile drawer toggle button (translated by the consumer). */
87
+ drawerToggleLabel: string
88
+ }
89
+ | {
90
+ sidebar?: undefined
91
+ sidebarLabel?: undefined
92
+ drawerToggleLabel?: undefined
93
+ }
94
+ )
95
+
96
+ export const AppShell = ({
97
+ banner,
98
+ topbar,
99
+ sidebar,
100
+ sidebarLabel,
101
+ drawerToggleLabel,
102
+ children,
103
+ className,
104
+ }: AppShellProps) => {
105
+ const [drawerOpen, setDrawerOpen] = useState(false)
106
+ const drawerId = useId()
107
+ const hasSidebar = Boolean(sidebar)
108
+
109
+ return (
110
+ <Root className={className}>
111
+ {/* Desktop sidebar — a full-height rail OUTSIDE the scroll column, so it
112
+ never scrolls with the content. */}
113
+ {hasSidebar ? (
114
+ <SidebarRail aria-label={sidebarLabel} className="hidden md:block">
115
+ {sidebar}
116
+ </SidebarRail>
117
+ ) : null}
118
+
119
+ {/* Mobile drawer — off-canvas RAC modal dialog toggled by the hamburger.
120
+ ModalOverlay supplies the focus trap, initial focus move, focus restore,
121
+ Esc-to-close and backdrop dismissal; the visual (dimmed backdrop +
122
+ left-anchored rail with shadow) matches the previous hand-rolled drawer. */}
123
+ {hasSidebar ? (
124
+ <RACModalOverlay
125
+ isOpen={drawerOpen}
126
+ onOpenChange={setDrawerOpen}
127
+ isDismissable
128
+ className="fixed inset-0 z-40 bg-black/40 md:hidden"
129
+ >
130
+ <RACModal className="absolute inset-y-0 left-0 z-50 h-full outline-none">
131
+ <RACDialog id={drawerId} aria-label={sidebarLabel} className="h-full outline-none">
132
+ <SidebarRail aria-label={sidebarLabel} className="shadow-lg">
133
+ {sidebar}
134
+ </SidebarRail>
135
+ </RACDialog>
136
+ </RACModal>
137
+ </RACModalOverlay>
138
+ ) : null}
139
+
140
+ {/* Content column — the single vertical scroller. Holds the full-bleed
141
+ banner, the sticky topbar, and main. Spans both columns when there is
142
+ no sidebar so it always fills the viewport. */}
143
+ <div
144
+ className={
145
+ 'flex min-h-0 min-w-0 flex-col overflow-y-auto overflow-x-hidden bg-surface' +
146
+ (hasSidebar ? '' : ' col-span-full')
147
+ }
148
+ >
149
+ {/* Full-bleed banner (edge-to-edge, no page padding). Sits at the top of
150
+ the scroll column so it scrolls away with the content. */}
151
+ {banner ? <div className="shrink-0">{banner}</div> : null}
152
+
153
+ {/* Sticky topbar — pins to the top of the viewport once the banner
154
+ scrolls past. 24px horizontal page padding (gs `.topbar` `padding: 0
155
+ spacing-6`, zero vertical — the Topbar header carries its own 72px
156
+ height + bottom border). The border lives on the Topbar header so it
157
+ aligns (inset) with the padded content below. `bg-surface` keeps content
158
+ from showing through while pinned. z-20 sits above scrolling content but
159
+ below the mobile drawer (z-40/z-50); gs's literal z-10001 is to clear a
160
+ fullscreen overlay we don't have. */}
161
+ <div className="sticky top-0 z-20 flex shrink-0 items-center bg-surface px-6">
162
+ {hasSidebar ? (
163
+ <RACButton
164
+ aria-label={drawerToggleLabel}
165
+ aria-expanded={drawerOpen}
166
+ aria-controls={drawerOpen ? drawerId : undefined}
167
+ onPress={() => setDrawerOpen((open) => !open)}
168
+ className={
169
+ 'ml-2 flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-fg outline-none ' +
170
+ 'transition-colors hover:bg-surface-muted md:hidden ' +
171
+ 'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
172
+ }
173
+ >
174
+ <span aria-hidden="true">☰</span>
175
+ </RACButton>
176
+ ) : null}
177
+ <div className="min-w-0 flex-1">{topbar}</div>
178
+ </div>
179
+
180
+ <Main>{children}</Main>
181
+ </div>
182
+ </Root>
183
+ )
184
+ }
@@ -0,0 +1,41 @@
1
+ import { uic } from '../utils/uic'
2
+
3
+ /**
4
+ * Card — surface panel (port of gs-manager's `Card`).
5
+ *
6
+ * gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule #3):
7
+ * - `--radius-lg` (8px) → `rounded-lg` (our `--radius-lg` is 0.5rem = 8px). NB the
8
+ * `rounded-panel` token is 1.1rem (~17.6px), NOT the 8px card radius — gs's Card
9
+ * used `--radius-lg`, so this maps to `rounded-lg`.
10
+ * - `--color-background-secondary` (the light card fill) → `bg-surface-card`
11
+ * (our `--color-surface-card` = #f7f6f2).
12
+ * - `outlined` → 1px `--color-border` (#eceae1) → `border border-border`.
13
+ * - `elevated` → `card-shadow('md')` → `shadow-md`.
14
+ * - padding sm/md/lg = 16/24/32px → `p-4` / `p-6` / `p-8`.
15
+ *
16
+ * Presentational only (hard rule #1) — no app imports. `relative` is preserved from
17
+ * gs so absolutely-positioned children (e.g. a Kanban overlay) anchor to the card.
18
+ */
19
+ export const Card = uic('div', {
20
+ displayName: 'Card',
21
+ baseClass: 'relative rounded-lg bg-surface-card',
22
+ variants: {
23
+ variant: {
24
+ plain: '',
25
+ outlined: 'border border-border',
26
+ elevated: 'shadow-md',
27
+ },
28
+ padding: {
29
+ none: 'p-0',
30
+ sm: 'p-4',
31
+ md: 'p-6',
32
+ lg: 'p-8',
33
+ },
34
+ },
35
+ defaultVariants: {
36
+ variant: 'plain',
37
+ padding: 'md',
38
+ },
39
+ })
40
+
41
+ export type CardProps = React.ComponentProps<typeof Card>
@@ -0,0 +1,36 @@
1
+ import { uic } from '../utils/uic'
2
+
3
+ /**
4
+ * PageContainer — max-width page shell (port of gs-manager's `PageContainer`).
5
+ *
6
+ * gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule #3).
7
+ * No `size` → full-width with no constraint (horizontal padding is owned by the
8
+ * AppShell content, as in gs). The `size` scale maps to Tailwind's stock screen
9
+ * max-widths, matching gs's pixel values exactly:
10
+ * - sm → 640px, centred → `max-w-screen-sm mx-auto`
11
+ * - md → 768px, centred → `max-w-screen-md mx-auto`
12
+ * - lg → 1024px, centred → `max-w-screen-lg mx-auto`
13
+ * - xl → 1280px, LEFT-aligned to match the header, then released to full width
14
+ * at ≥1200px → `max-w-screen-xl ml-0 mr-0 min-[1200px]:max-w-full`
15
+ * - 2xl → 1536px, centred, then released to full-width left-aligned at ≥1600px →
16
+ * `max-w-screen-2xl mx-auto min-[1600px]:max-w-full min-[1600px]:mx-0`
17
+ * - full → no constraint → `max-w-full`
18
+ *
19
+ * Presentational only (hard rule #1) — no app imports.
20
+ */
21
+ export const PageContainer = uic('div', {
22
+ displayName: 'PageContainer',
23
+ baseClass: 'w-full',
24
+ variants: {
25
+ size: {
26
+ sm: 'max-w-screen-sm mx-auto',
27
+ md: 'max-w-screen-md mx-auto',
28
+ lg: 'max-w-screen-lg mx-auto',
29
+ xl: 'max-w-screen-xl ml-0 mr-0 min-[1200px]:max-w-full',
30
+ '2xl': 'max-w-screen-2xl mx-auto min-[1600px]:max-w-full min-[1600px]:mx-0',
31
+ full: 'max-w-full',
32
+ },
33
+ },
34
+ })
35
+
36
+ export type PageContainerProps = React.ComponentProps<typeof PageContainer>
@@ -0,0 +1,134 @@
1
+ import { type ReactNode, useEffect } from 'react'
2
+ import { uic } from '../utils/uic'
3
+
4
+ /**
5
+ * PersistentPageShell — page-level shell with a persistent header (hero +
6
+ * actions + navigation) and a content region that swaps between ready / loading
7
+ * / empty / error states without the header shifting (ported pattern from
8
+ * gs-manager's `PersistentPageShell`).
9
+ *
10
+ * gs-manager SCSS intent → Tailwind:
11
+ * .shell flex column, gap-6, full width → `flex flex-col gap-6 w-full`
12
+ * .header flex column header cluster, optionally sticky
13
+ * .topRow grid 2fr/1fr (hero | actions) → `grid grid-cols-[2fr_1fr]`
14
+ * .actionSlotPlaceholder reserves action space → `invisible pointer-events-none`
15
+ * .navigationSlotEmpty reserves nav row height → `min-h-10`
16
+ * .contentSlot `<main>` content region → `w-full`
17
+ *
18
+ * "Persistent" = the header (hero/actions/navigation) stays mounted while the
19
+ * content region swaps via `contentState`; reserving action/nav space (the
20
+ * placeholder modifiers) prevents layout shift when those slots are empty,
21
+ * which is also what keeps viewport-locked modals from nudging the page.
22
+ *
23
+ * Page title: pass `title` and it is written to `document.title` (React 19,
24
+ * SSR-safe via `useEffect` so `renderToString` is a no-op).
25
+ */
26
+ export type PersistentPageShellContentState = 'ready' | 'loading' | 'empty' | 'error'
27
+ export type PersistentPageShellNavigationMode = 'auto' | 'preserve' | 'hidden'
28
+
29
+ export type PersistentPageShellProps = {
30
+ className?: string
31
+ /** Page heading / title region (left of the top row). */
32
+ heroSlot?: ReactNode
33
+ /** Primary actions (right of the top row). Space is reserved if absent. */
34
+ actionSlot?: ReactNode
35
+ /** Secondary navigation row beneath the top row (tabs, breadcrumbs…). */
36
+ navigationSlot?: ReactNode
37
+ /** Main content, shown when `contentState === 'ready'`. */
38
+ contentSlot: ReactNode
39
+ /** When set, drives which slot the content region renders. */
40
+ contentState?: PersistentPageShellContentState
41
+ loadingSlot?: ReactNode
42
+ emptySlot?: ReactNode
43
+ errorSlot?: ReactNode
44
+ /** `preserve` always reserves the nav row; `auto` only when content exists. */
45
+ navigationMode?: PersistentPageShellNavigationMode
46
+ /** Reserve horizontal space for actions even when `actionSlot` is empty. */
47
+ preserveActionSlotSpace?: boolean
48
+ /** Keep the header pinned to the top while content scrolls. */
49
+ sticky?: boolean
50
+ /** Document title; written to `document.title` on mount (React 19). */
51
+ title?: string
52
+ }
53
+
54
+ const defaultLoadingSlot = <div className="text-sm text-fg-muted">Loading…</div>
55
+ const defaultEmptySlot = <div className="text-sm text-fg-muted">No content available.</div>
56
+ const defaultErrorSlot = <div className="text-sm text-danger">Failed to load content.</div>
57
+
58
+ const Header = uic('header', {
59
+ displayName: 'PersistentPageShell.Header',
60
+ baseClass: 'flex w-full flex-col gap-6 bg-surface',
61
+ variants: {
62
+ sticky: { true: 'sticky top-0 z-[1]', false: '' },
63
+ },
64
+ defaultVariants: { sticky: false },
65
+ })
66
+
67
+ export const PersistentPageShell = ({
68
+ className,
69
+ heroSlot,
70
+ actionSlot,
71
+ navigationSlot,
72
+ contentSlot,
73
+ contentState = 'ready',
74
+ loadingSlot,
75
+ emptySlot,
76
+ errorSlot,
77
+ navigationMode = 'preserve',
78
+ preserveActionSlotSpace = true,
79
+ sticky = false,
80
+ title,
81
+ }: PersistentPageShellProps) => {
82
+ useEffect(() => {
83
+ if (title !== undefined && typeof document !== 'undefined') {
84
+ document.title = title
85
+ }
86
+ }, [title])
87
+
88
+ const shouldRenderActionSlot = Boolean(actionSlot) || preserveActionSlotSpace
89
+ const shouldRenderNavigation =
90
+ navigationMode === 'preserve' || (navigationMode === 'auto' && Boolean(navigationSlot))
91
+
92
+ const resolvedContent =
93
+ contentState === 'loading'
94
+ ? (loadingSlot ?? defaultLoadingSlot)
95
+ : contentState === 'empty'
96
+ ? (emptySlot ?? defaultEmptySlot)
97
+ : contentState === 'error'
98
+ ? (errorSlot ?? defaultErrorSlot)
99
+ : contentSlot
100
+
101
+ return (
102
+ <div className={['flex w-full flex-col gap-6', className].filter(Boolean).join(' ')}>
103
+ <Header sticky={sticky}>
104
+ <div className="grid w-full grid-cols-1 items-stretch gap-4 md:grid-cols-[2fr_1fr]">
105
+ <div className="min-w-0">{heroSlot}</div>
106
+ {shouldRenderActionSlot ? (
107
+ <div
108
+ className={[
109
+ 'flex min-w-0 items-stretch justify-end',
110
+ actionSlot ? '' : 'invisible pointer-events-none',
111
+ ]
112
+ .filter(Boolean)
113
+ .join(' ')}
114
+ aria-hidden={actionSlot ? undefined : true}
115
+ >
116
+ {actionSlot}
117
+ </div>
118
+ ) : null}
119
+ </div>
120
+
121
+ {shouldRenderNavigation ? (
122
+ <div
123
+ className={['min-w-0', navigationSlot ? '' : 'min-h-10'].filter(Boolean).join(' ')}
124
+ aria-hidden={navigationSlot ? undefined : true}
125
+ >
126
+ {navigationSlot}
127
+ </div>
128
+ ) : null}
129
+ </Header>
130
+
131
+ <main className="w-full min-w-0">{resolvedContent}</main>
132
+ </div>
133
+ )
134
+ }
@@ -0,0 +1,35 @@
1
+ import { uic } from '../utils/uic'
2
+
3
+ /**
4
+ * Section — vertical rhythm wrapper (port of gs-manager's `Section`).
5
+ *
6
+ * gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule #3):
7
+ * - full-width block, `overflow` left visible so focus rings / negative-margin
8
+ * sticky headers on children aren't clipped (gs comment) → `w-full`.
9
+ * - vertical padding scale (gs `--spacing-*`, responsive @640px = Tailwind `sm:`):
10
+ * none → `py-0`
11
+ * sm → `py-4`
12
+ * md → `py-6 sm:py-8` (the gs default)
13
+ * lg → `py-8 sm:py-12`
14
+ * xl → `py-12 sm:py-16`
15
+ *
16
+ * Presentational only (hard rule #1) — no app imports.
17
+ */
18
+ export const Section = uic('section', {
19
+ displayName: 'Section',
20
+ baseClass: 'w-full',
21
+ variants: {
22
+ padding: {
23
+ none: 'py-0',
24
+ sm: 'py-4',
25
+ md: 'py-6 sm:py-8',
26
+ lg: 'py-8 sm:py-12',
27
+ xl: 'py-12 sm:py-16',
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ padding: 'md',
32
+ },
33
+ })
34
+
35
+ export type SectionProps = React.ComponentProps<typeof Section>