@podoba/react 0.0.34 → 0.0.35

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.
Files changed (67) hide show
  1. package/package.json +3 -3
  2. package/src/components/asset-masonry-grid.examples.tsx +9 -0
  3. package/src/components/asset-masonry-grid.tsx +62 -0
  4. package/src/components/asset-selection-surface.examples.tsx +32 -0
  5. package/src/components/asset-selection-surface.tsx +184 -0
  6. package/src/components/brand-page-header.tsx +60 -29
  7. package/src/components/button.tsx +27 -4
  8. package/src/components/combobox.tsx +1 -1
  9. package/src/components/compact-action-button.examples.tsx +7 -0
  10. package/src/components/compact-action-button.tsx +21 -0
  11. package/src/components/compact-settings-dialog.examples.tsx +15 -0
  12. package/src/components/compact-settings-dialog.tsx +48 -0
  13. package/src/components/context-action-glyph.examples.tsx +9 -0
  14. package/src/components/context-action-glyph.tsx +61 -0
  15. package/src/components/context-menu.tsx +191 -107
  16. package/src/components/context-search-panel.examples.tsx +9 -0
  17. package/src/components/context-search-panel.tsx +50 -0
  18. package/src/components/csv-binding-presentation.examples.tsx +14 -0
  19. package/src/components/csv-binding-presentation.tsx +40 -0
  20. package/src/components/dashboard-grid.tsx +1 -1
  21. package/src/components/date-picker.tsx +5 -3
  22. package/src/components/date-selection-calendar.examples.tsx +8 -0
  23. package/src/components/date-selection-calendar.tsx +47 -0
  24. package/src/components/delivery/send-to-print-modal.tsx +351 -84
  25. package/src/components/dialog-action-button.examples.tsx +7 -0
  26. package/src/components/dialog-action-button.tsx +22 -0
  27. package/src/components/dialog.tsx +32 -12
  28. package/src/components/document-upload-panel.examples.tsx +12 -0
  29. package/src/components/document-upload-panel.tsx +48 -0
  30. package/src/components/dropdown-menu.tsx +5 -3
  31. package/src/components/empty-panel-action.examples.tsx +7 -0
  32. package/src/components/empty-panel-action.tsx +8 -0
  33. package/src/components/field-appearance.ts +14 -0
  34. package/src/components/focus-field.tsx +3 -3
  35. package/src/components/icons.tsx +30 -0
  36. package/src/components/input.examples.tsx +8 -0
  37. package/src/components/input.tsx +13 -5
  38. package/src/components/media-asset-panel.examples.tsx +11 -0
  39. package/src/components/media-asset-panel.tsx +58 -0
  40. package/src/components/media-gallery.examples.tsx +12 -0
  41. package/src/components/media-gallery.tsx +53 -0
  42. package/src/components/media-settings-dialog.examples.tsx +21 -0
  43. package/src/components/media-settings-dialog.tsx +83 -0
  44. package/src/components/preview-info-card.examples.tsx +18 -0
  45. package/src/components/preview-info-card.tsx +27 -0
  46. package/src/components/reload-icon.examples.tsx +7 -0
  47. package/src/components/reload-icon.tsx +6 -0
  48. package/src/components/request-changes-modal.examples.tsx +26 -0
  49. package/src/components/request-changes-modal.tsx +36 -19
  50. package/src/components/rich-text-editor.tsx +1 -1
  51. package/src/components/select.examples.tsx +17 -0
  52. package/src/components/select.tsx +78 -22
  53. package/src/components/settings-dialog-surface.examples.tsx +44 -0
  54. package/src/components/settings-dialog-surface.tsx +240 -0
  55. package/src/components/subtle.tsx +64 -8
  56. package/src/components/table.examples.tsx +9 -0
  57. package/src/components/table.tsx +29 -12
  58. package/src/components/template-catalog-card.examples.tsx +25 -0
  59. package/src/components/template-catalog-card.tsx +178 -0
  60. package/src/components/text.tsx +14 -1
  61. package/src/components/textarea.examples.tsx +8 -0
  62. package/src/components/textarea.tsx +16 -6
  63. package/src/components/tile.tsx +16 -17
  64. package/src/editor/block-editor.tsx +56 -14
  65. package/src/index.ts +19 -0
  66. package/src/layout/app-shell.tsx +13 -9
  67. package/src/layout/topbar.tsx +9 -12
@@ -0,0 +1,178 @@
1
+ import type { CSSProperties, ReactNode } from 'react'
2
+ import { Button as AriaButton } from 'react-aria-components'
3
+ import { uic } from '../utils/uic'
4
+ import { Tile } from './tile'
5
+
6
+ export const TEMPLATE_CATALOG_SELECTIONS = ['available', 'selected'] as const
7
+ export type TemplateCatalogSelection = (typeof TEMPLATE_CATALOG_SELECTIONS)[number]
8
+
9
+ /** Legacy StatsCard label-1 value, distinct from the Tile heading ramp. */
10
+ export const CatalogMetricValue = uic('span', {
11
+ displayName: 'CatalogMetricValue',
12
+ baseClass: 'block w-full text-center text-display-large font-medium leading-(--line-height-display-large) tracking-wider',
13
+ })
14
+
15
+ export const CATALOG_METRIC_FOOTERS = ['present', 'missing'] as const
16
+ export type CatalogMetricFooter = (typeof CATALOG_METRIC_FOOTERS)[number]
17
+ const MetricContent = uic('div', {
18
+ displayName: 'CatalogMetricContent',
19
+ baseClass: 'flex min-h-0 w-full flex-1 flex-col items-center justify-center text-center',
20
+ variants: { footer: { present: '', missing: 'pb-12' } },
21
+ })
22
+
23
+ /** Compact source statistics: preserve the centered content band without a tail. */
24
+ export function CatalogMetricCard({ title, value, footer, onPress, 'aria-label': ariaLabel }: {
25
+ title: string; value: ReactNode; footer?: string; onPress?: () => void; 'aria-label'?: string
26
+ }) {
27
+ return <Tile theme="light" eyebrow={title} footer={footer || undefined} onPress={onPress}
28
+ aria-label={ariaLabel} className="h-52 min-h-52 max-h-52">
29
+ <MetricContent data-slot="catalog-metric-content" footer={footer ? 'present' : 'missing'}>
30
+ <CatalogMetricValue>{value}</CatalogMetricValue>
31
+ </MetricContent>
32
+ </Tile>
33
+ }
34
+
35
+ const SummaryCard = uic('article', { displayName: 'CatalogSummaryCard', baseClass: 'flex h-95 w-full min-w-0 flex-col gap-6 overflow-hidden rounded-lg bg-surface-card p-4 text-fg' })
36
+ export const CatalogSummaryGrid = uic('div', { displayName: 'CatalogSummaryGrid', baseClass: 'grid w-full min-w-0 grid-cols-1 gap-x-2 gap-y-4 overflow-x-hidden min-[768px]:grid-cols-3 min-[768px]:gap-x-3' })
37
+ const SummaryHead = uic('div', { displayName: 'CatalogSummaryHead', baseClass: 'flex w-full min-w-0 items-center justify-between gap-3' })
38
+ export const CatalogSummaryBadge = uic('span', { displayName: 'CatalogSummaryBadge', baseClass: 'ml-3 flex h-6 shrink-0 items-center rounded-2xl px-2.5 text-micro font-medium leading-5', style: { fontFeatureSettings: '"liga" off, "clig" off' }, variants: { color: { green: 'bg-brand-green text-fg-on-brand', grey: 'bg-surface-muted text-(--color-neutral-600)' } }, defaultVariants: { color: 'grey' } })
39
+ const SummaryTitle = uic('h2', { displayName: 'CatalogSummaryTitle', baseClass: 'm-0 min-w-0 truncate text-heading5 font-medium' })
40
+ const SummaryTail = uic('div', { displayName: 'CatalogSummaryTail', baseClass: 'mt-auto flex flex-col gap-1 text-small font-normal text-fg-workflow-muted' })
41
+ /** Display-only catalog tile. No synthetic imagery when a preview is absent. */
42
+ export function CatalogSummaryCard({ title, badge, description, details }: { title: string; badge: ReactNode; description?: string; details: ReactNode }) {
43
+ return <SummaryCard><SummaryHead><SummaryTitle>{title}</SummaryTitle><span className="shrink-0">{badge}</span></SummaryHead><SummaryTail>{description ? <p className="m-0">{description}</p> : null}<span>{details}</span></SummaryTail></SummaryCard>
44
+ }
45
+
46
+ /** Compact launcher card. Missing artwork is not replaced by synthetic imagery. */
47
+ export function CatalogLaunchCard({ title, description, details, action }: { title: string; description?: string; details: ReactNode; action: ReactNode }) {
48
+ return <SummaryCard data-testid="scenario-launch-card" className="relative h-70 min-h-70 max-h-70 transition-colors duration-200 focus-within:ring-2 focus-within:ring-ring">
49
+ <SummaryHead><SummaryTitle asChild><h3>{title}</h3></SummaryTitle></SummaryHead>
50
+ <SummaryTail>{description ? <p className="m-0">{description}</p> : null}<span>{details}</span></SummaryTail>
51
+ {action}
52
+ </SummaryCard>
53
+ }
54
+
55
+ const MetadataSection = uic('section', { displayName: 'CatalogMetadataSection', baseClass: 'm-0 w-full' })
56
+ /** Source metadata layout: full-width identity, paired attributes, wrapping IDs. */
57
+ export function CatalogMetadataSummary({ title, ariaLabel, items }: {
58
+ title: string; ariaLabel: string; items: { label: string; value: ReactNode; fullWidth?: boolean }[]
59
+ }) {
60
+ let column = 0
61
+ return <MetadataSection aria-label={ariaLabel}>
62
+ <h2 className="mb-3 mt-0 text-heading3 font-medium text-fg">{title}</h2>
63
+ <dl className="m-0 grid w-full grid-cols-1 border-t border-border min-[641px]:grid-cols-2">
64
+ {items.map(item => {
65
+ const second = !item.fullWidth && column % 2 === 1
66
+ column = item.fullWidth ? 0 : column + 1
67
+ return <div key={item.label} className={`flex min-w-0 flex-col gap-1 border-b border-border py-3 ${item.fullWidth ? 'col-span-full' : ''} ${second ? 'min-[641px]:border-l min-[641px]:pl-3' : ''}`}>
68
+ <dt className="m-0 text-body font-normal text-fg-subtle">{item.label}</dt>
69
+ <dd className="m-0 text-body font-normal text-fg [overflow-wrap:anywhere]">{item.value}</dd>
70
+ </div>
71
+ })}
72
+ </dl>
73
+ </MetadataSection>
74
+ }
75
+
76
+ export const TEMPLATE_REFERENCE_VIEWS = ['preview_grid', 'compact_list'] as const
77
+ export type TemplateReferenceView = (typeof TEMPLATE_REFERENCE_VIEWS)[number]
78
+ export const TemplateReferenceGrid = uic('div', {
79
+ displayName: 'TemplateReferenceGrid',
80
+ baseClass: 'grid w-full grid-cols-1 gap-3',
81
+ variants: { columns: { 1: 'grid-cols-1', 2: 'min-[761px]:grid-cols-2', 3: 'min-[761px]:grid-cols-3' } },
82
+ defaultVariants: { columns: 1 },
83
+ })
84
+ export const TemplateReferenceList = uic('div', {
85
+ displayName: 'TemplateReferenceList', baseClass: 'flex w-full flex-col gap-2',
86
+ })
87
+ const ReferenceButton = uic(AriaButton, {
88
+ displayName: 'TemplateReferenceButton',
89
+ baseClass: 'w-full cursor-pointer border-0 p-0 text-start outline-none disabled:cursor-default data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2',
90
+ variants: { view: {
91
+ preview_grid: 'flex aspect-square items-center justify-center overflow-hidden rounded-lg bg-surface-card',
92
+ compact_list: 'grid min-h-14 grid-cols-[3rem_minmax(0,1fr)] items-center gap-3 border-b border-border bg-transparent last:border-b-0',
93
+ } },
94
+ })
95
+ /** Read-only browsing surfaces: activation opens a preview, never selects it. */
96
+ export function TemplateReferenceItem({ title, meta, preview, view, isDisabled, onOpen, templateId }: {
97
+ title: string; meta: string; preview: ReactNode; view: TemplateReferenceView
98
+ isDisabled?: boolean; onOpen: () => void; templateId: string
99
+ }) {
100
+ return <ReferenceButton type="button" view={view} isDisabled={isDisabled} onPress={onOpen} aria-label={title} data-template-id={templateId}>
101
+ <span aria-hidden="true" className={view === 'compact_list' ? 'flex size-12 items-center justify-center overflow-hidden rounded-sm bg-surface-card [&>div]:w-full' : 'flex h-full w-full items-center justify-center [&>div]:w-full'}>{preview}</span>
102
+ {view === 'compact_list' ? <span className="flex min-w-0 flex-col gap-1"><span className="truncate text-small font-medium text-fg">{title}</span><span className="truncate text-label font-medium text-fg-subtle">{meta}</span></span> : null}
103
+ </ReferenceButton>
104
+ }
105
+
106
+ export const TemplateCatalogGrid = uic('div', {
107
+ displayName: 'TemplateCatalogGrid',
108
+ baseClass: 'grid w-full min-w-0 grid-cols-1 items-stretch gap-2 p-0 md:grid-cols-2 md:gap-3 lg:grid-cols-3',
109
+ })
110
+
111
+ const Card = uic(AriaButton, {
112
+ displayName: 'TemplateCatalogCardSurface',
113
+ baseClass: 'group box-border flex h-(--template-catalog-height) min-w-0 w-full cursor-pointer flex-col gap-6 overflow-hidden rounded-lg border-0 p-4 text-start outline-none transition-colors duration-200 motion-reduce:transition-none hover:bg-brand-green hover:text-fg-on-brand data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2',
114
+ variants: {
115
+ selection: {
116
+ available: 'bg-surface-card text-fg',
117
+ selected: 'bg-brand-green text-fg-on-brand',
118
+ },
119
+ },
120
+ defaultVariants: { selection: 'available' },
121
+ })
122
+ const Title = uic('span', {
123
+ displayName: 'TemplateCatalogCardTitle',
124
+ baseClass: 'block min-h-5 min-w-0 shrink-0 text-body font-medium leading-5',
125
+ })
126
+ const Preview = uic('span', {
127
+ displayName: 'TemplateCatalogCardPreview',
128
+ baseClass: 'box-border flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden rounded-md bg-transparent p-3 [&>div]:w-full',
129
+ })
130
+
131
+ const geometry: CSSProperties & { '--template-catalog-height': string } = {
132
+ '--template-catalog-height': 'clamp(calc(var(--spacing) * 64), 30vw, calc(var(--spacing) * 76))',
133
+ letterSpacing: 0,
134
+ }
135
+
136
+ export const TemplateCatalogBrowseGrid = uic('div', {
137
+ displayName: 'TemplateCatalogBrowseGrid',
138
+ baseClass: 'grid w-full min-w-0 grid-cols-1 items-stretch gap-2 p-0 md:grid-cols-2 md:gap-3 lg:grid-cols-4',
139
+ })
140
+ const BrowseCard = uic('div', {
141
+ displayName: 'TemplateCatalogBrowseSurface',
142
+ baseClass: 'box-border flex h-(--template-catalog-height) min-w-0 w-full cursor-default flex-col gap-6 overflow-hidden rounded-lg border-0 bg-surface-card p-4 text-start text-fg outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
143
+ })
144
+ /** Legacy readOnly catalog: no activation, hover shadow or extra card CTA. */
145
+ export function TemplateCatalogBrowseCard({ title, preview, templateId, id, testId }: {
146
+ title: string; preview: ReactNode; templateId: string; id?: string; testId?: string
147
+ }) {
148
+ return <BrowseCard role="group" tabIndex={0} aria-label={title} id={id} data-catalog-template-id={templateId} data-testid={testId} style={geometry}>
149
+ <Title>{title}</Title><Preview aria-hidden="true" className="pointer-events-none">{preview}</Preview>
150
+ </BrowseCard>
151
+ }
152
+
153
+ /** Selection-only catalog tile. No badge, selected ring, nested action, or artwork
154
+ * rounding is added. A locked assignment stays focusable and selected, just as in
155
+ * the original catalog; aria-disabled and the press guard prevent its removal.
156
+ * The caller owns selection and supplies a non-interactive, contain-fit preview.
157
+ */
158
+ export function TemplateCatalogCard({ title, preview, isSelected = false, isSelectionLocked = false,
159
+ onToggle, onOpen, templateId, testId,
160
+ }: {
161
+ title: string
162
+ preview: ReactNode
163
+ isSelected?: boolean
164
+ isSelectionLocked?: boolean
165
+ onToggle?: () => void
166
+ /** Browsing mode opens a preview, without announcing a toggle state. */
167
+ onOpen?: () => void
168
+ templateId?: string
169
+ testId?: string
170
+ }) {
171
+ return <Card type="button" selection={isSelected ? 'selected' : 'available'}
172
+ aria-label={title} aria-pressed={onOpen ? undefined : isSelected} aria-disabled={isSelectionLocked}
173
+ data-template-id={templateId} data-testid={testId} style={geometry}
174
+ onPress={() => { if (!isSelectionLocked) { if (onOpen) onOpen(); else onToggle?.() } }}>
175
+ <Title>{title}</Title>
176
+ <Preview aria-hidden="true">{preview}</Preview>
177
+ </Card>
178
+ }
@@ -68,11 +68,24 @@ export const Text = uic('span', {
68
68
  semibold: 'font-medium',
69
69
  bold: 'font-medium',
70
70
  },
71
+ // `fg-subtle` (#b3b3b3) is 2.10:1 on `surface` — below even the 3:1
72
+ // large-text floor — so it can never carry meaningful copy. `subtle` is
73
+ // therefore an AA-safe alias of `muted` (same approach as the semibold /
74
+ // bold aliases above: the key survives so consumers don't break), and the
75
+ // light grey moved behind the explicit `decorative` key. See <Subtle>.
76
+ // NOTE: `default`/`muted` are the READING-surface inks. Surfaces that carry
77
+ // their own ink need the matching tone — `fg-muted` is 2.60:1 on
78
+ // `surface-inverted` and 3.60–4.01:1 on the fixed brand fills. Pair
79
+ // `inverted` there, `on-brand` on brand-green / accent-yellow /
80
+ // brand-secondary. (`Subtle` has de-emphasised /60 · /70 variants of the
81
+ // same two for a softened run.)
71
82
  tone: {
72
83
  default: 'text-fg',
73
84
  muted: 'text-fg-muted',
74
- subtle: 'text-fg-subtle',
85
+ subtle: 'text-fg-muted',
86
+ decorative: 'text-fg-subtle',
75
87
  inverted: 'text-fg-inverted',
88
+ 'on-brand': 'text-fg-on-brand',
76
89
  },
77
90
  },
78
91
  defaultVariants: { size: 'body', weight: 'regular', tone: 'default' },
@@ -0,0 +1,8 @@
1
+ import { Textarea } from './textarea'
2
+
3
+ export const examples = {
4
+ default: () => <Textarea label="Project name" placeholder="Annual report" />,
5
+ variants: () => <><Textarea label="Outlined" /><Textarea appearance="filled" label="Filled" placeholder="Annual report" /></>,
6
+ states: () => <><Textarea appearance="filled" label="Disabled" defaultValue="Saved content" isDisabled /><Textarea appearance="filled" label="Invalid" isInvalid errorMessage="Check the value." /></>,
7
+ }
8
+ export const meta = { category: 'Form', description: 'Labelled textarea with outlined and Manager-style filled appearances, disabled/error states and keyboard focus.' }
@@ -8,6 +8,7 @@ import {
8
8
  type TextFieldProps,
9
9
  } from 'react-aria-components'
10
10
  import { uic } from '../utils/uic'
11
+ import { filledFieldClasses, type FieldAppearance } from './field-appearance'
11
12
 
12
13
  /**
13
14
  * Textarea — labelled multi-line text field.
@@ -26,16 +27,23 @@ const StyledTextArea = uic(RACTextArea, {
26
27
  // (surface), disabled = muted cream. border #eceae1 → border · hover #aba89c →
27
28
  // fg-subtle · focus #75e7b8 → brand-green · error → danger. min-h-[120px] is a
28
29
  // control dimension (not a design token) — gs uses a literal 120px here too.
29
- baseClass:
30
- 'min-h-[120px] w-full resize-y rounded-lg border border-border bg-surface px-4 py-3 text-small text-fg ' +
30
+ baseClass: 'min-h-30 w-full resize-y rounded-lg px-4 py-3',
31
+ variants: {
32
+ appearance: {
33
+ filled: filledFieldClasses,
34
+ outlined: 'border border-border bg-surface text-small text-fg ' +
31
35
  'outline-none transition-colors duration-200 placeholder:text-fg-muted ' +
32
36
  'data-[hovered]:border-fg-subtle ' +
33
37
  'data-[focused]:border-brand-green data-[focused]:ring-2 data-[focused]:ring-ring ' +
34
38
  'data-[invalid]:border-danger data-[invalid]:ring-danger ' +
35
39
  'data-[disabled]:bg-surface-muted data-[disabled]:opacity-60 data-[disabled]:pointer-events-none',
40
+ },
41
+ },
42
+ defaultVariants: { appearance: 'outlined' },
36
43
  })
37
44
 
38
45
  export type TextareaProps = TextFieldProps & {
46
+ appearance?: FieldAppearance
39
47
  /** Visible field label (required for accessibility). */
40
48
  label: ReactNode
41
49
  /** Helper text rendered under the field. */
@@ -45,12 +53,14 @@ export type TextareaProps = TextFieldProps & {
45
53
  placeholder?: string
46
54
  /** Initial visible row count (the field still grows / resizes vertically). */
47
55
  rows?: number
56
+ /** Optional class for the inner native textarea, used by source-specific modal layouts. */
57
+ textAreaClassName?: string
48
58
  }
49
59
 
50
- export const Textarea = ({ label, description, errorMessage, placeholder, rows, ...props }: TextareaProps) => (
51
- <TextField {...props} className="flex w-full flex-col gap-2">
52
- <Label className="text-small font-medium text-fg">{label}</Label>
53
- <StyledTextArea placeholder={placeholder} rows={rows} />
60
+ export const Textarea = ({ label, description, errorMessage, placeholder, rows, textAreaClassName, appearance = 'outlined', ...props }: TextareaProps) => (
61
+ <TextField {...props} className={`flex w-full flex-col ${appearance === 'filled' ? 'gap-3' : 'gap-2'}`}>
62
+ <Label className={`${appearance === 'filled' ? 'text-panel-heading' : 'text-small'} font-medium text-fg`}>{label}</Label>
63
+ <StyledTextArea className={textAreaClassName} placeholder={placeholder} rows={rows} appearance={appearance} />
54
64
  {description ? (
55
65
  <Text slot="description" className="text-label text-fg-muted">
56
66
  {description}
@@ -29,16 +29,14 @@ export type TileTheme = 'light' | 'dark' | 'teal' | 'yellow'
29
29
  export type TileAlign = 'top' | 'center'
30
30
 
31
31
  const THEME: Record<TileTheme, string> = {
32
- // Light tiles carry a visible hairline; dark/colored use a transparent border so
33
- // every tile keeps the same box size (no 1px shift between themes).
34
32
  // Every surface pairs with ITS semantic foreground (never raw white / bare fg):
35
33
  // `surface-inverted` flips with the theme so it needs the flipping `fg-inverted`;
36
34
  // teal/yellow are FIXED light surfaces so they need the stable `fg-on-brand` ink
37
35
  // (theme-flipping `fg` went white-on-pastel in a dark shell).
38
- light: 'border border-border bg-surface-card text-fg',
39
- dark: 'border border-transparent bg-surface-inverted text-fg-inverted',
40
- teal: 'border border-transparent bg-brand-secondary text-fg-on-brand',
41
- yellow: 'border border-transparent bg-accent-yellow text-fg-on-brand',
36
+ light: 'bg-surface-card text-fg',
37
+ dark: 'bg-surface-inverted text-fg-inverted',
38
+ teal: 'bg-brand-green text-fg-on-brand',
39
+ yellow: 'bg-accent-yellow text-fg-on-brand',
42
40
  }
43
41
 
44
42
  // The shared inner for EVERY tile (gs `DashboardContentCard`): 16px padding
@@ -46,10 +44,8 @@ const THEME: Record<TileTheme, string> = {
46
44
  // grid cell; `min-h` floors it.
47
45
  const baseSurface = 'flex h-full min-h-[200px] flex-col gap-6 overflow-hidden rounded-lg p-4'
48
46
 
49
- /** Hover-lift + focus ring applied only when the tile is interactive. */
50
- const interactive =
51
- 'no-underline outline-none transition-[transform,box-shadow] duration-150 ' +
52
- 'hover:-translate-y-1 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-ring'
47
+ /** Focus treatment for interactive tiles; generic tiles do not lift or cast a shadow. */
48
+ const interactive = 'no-underline outline-none focus-visible:ring-2 focus-visible:ring-ring'
53
49
 
54
50
  export type TileProps = {
55
51
  /** Surface theme (gs's Figma palette). */
@@ -105,10 +101,12 @@ function titleTone(theme: TileTheme, subtle: boolean): string {
105
101
  }
106
102
 
107
103
  /**
108
- * Muted supporting text / footer tone per surface. `fg-muted` is tuned for white
109
- * on the colored surfaces it drops below AA, so those blend their own paired ink
110
- * instead (/60 · /70 keep ≥4.5:1 in both themes verified by the contrast test
111
- * in @podoba/tokens).
104
+ * Muted supporting text / footer tone per surface. `fg-muted` is tuned for the READING
105
+ * surfaces after #19 it clears AA on all of them, white included, but the binding one
106
+ * is `surface-muted`/`border` #eceae1, not white. On the colored and inverted surfaces
107
+ * it still drops below AA (2.60:1 on `surface-inverted`), so those blend their own
108
+ * paired ink instead (/60 · /70 keep ≥4.5:1 in both themes — verified by the contrast
109
+ * test in @podoba/tokens).
112
110
  */
113
111
  function mutedTone(theme: TileTheme): string {
114
112
  if (theme === 'dark') return 'text-fg-inverted/60'
@@ -134,7 +132,7 @@ function TileBands({
134
132
  > & { theme: TileTheme }) {
135
133
  const titleNode =
136
134
  title != null ? (
137
- <p className={`${tileStatClass} ${theme === 'dark' ? 'tracking-normal' : 'tracking-wide'} ${titleTone(theme, subtleTitle)}`}>
135
+ <p className={`${tileStatClass} ${titleTone(theme, subtleTitle)}`}>
138
136
  {title}
139
137
  </p>
140
138
  ) : null
@@ -386,8 +384,9 @@ const BADGE: Record<BadgeColor, string> = {
386
384
  // brand green for every green tag (New, Active, Published), matching gs 1:1.
387
385
  // Pairing rule (same as Tile THEME): fixed light surfaces take the stable
388
386
  // `fg-on-brand` ink, the flipping inverted surface takes `fg-inverted`, and grey
389
- // takes full `fg` `fg-muted` on `surface-muted` was ~3.7:1, under the 4.5:1
390
- // AA floor for caption-size text.
387
+ // takes full `fg`. #19 lifted `fg-muted` on `surface-muted` from 3.64:1 to 4.96:1,
388
+ // so it is no longer disqualified outright — a Badge is caption-size and keeps the
389
+ // stronger ink deliberately, for the margin rather than for compliance.
391
390
  green: 'bg-brand-green text-fg-on-brand',
392
391
  yellow: 'bg-accent-yellow text-fg-on-brand',
393
392
  grey: 'bg-surface-muted text-fg',
@@ -84,6 +84,36 @@ const SLASH_COMMANDS: readonly SlashCommand[] = [
84
84
  * stored — so the menu can be re-placed on scroll/resize without stale coordinates. */
85
85
  type SlashState = { from: number; query: string; index: number }
86
86
 
87
+ /** Tallest the palette is ever allowed to grow; past this it scrolls. */
88
+ const MENU_MAX_H = 288
89
+
90
+ /** Caret box + palette size + viewport, in viewport coords. */
91
+ type PlaceInput = { caretTop: number; caretBottom: number; caretLeft: number; wanted: number; width: number; viewportW: number; viewportH: number }
92
+
93
+ /**
94
+ * Resolve the palette's fixed position from the caret. Below the caret by default;
95
+ * flipped above only when above is genuinely roomier; height always capped to the
96
+ * room on the chosen side so it scrolls rather than overflowing. Both branches are
97
+ * anchored to the caret, so no result ever needs clamping away from it.
98
+ * Exported for tests.
99
+ */
100
+ export function placeSlashMenu({ caretTop, caretBottom, caretLeft, wanted, width, viewportW, viewportH }: PlaceInput): { left: number; top: number; maxHeight: number } {
101
+ const gap = 6
102
+ const edge = 8
103
+ const below = viewportH - edge - (caretBottom + gap)
104
+ const above = caretTop - gap - edge
105
+ // Flip only when it buys room. Flipping on any bottom overflow and clamping the
106
+ // result to `edge` parked the palette at the top of the viewport — over the page
107
+ // header — whenever the caret sat high in a short viewport.
108
+ const flip = wanted > below && above > below
109
+ const maxHeight = Math.max(0, Math.min(wanted, flip ? above : below))
110
+ return {
111
+ left: Math.max(edge, Math.min(caretLeft, viewportW - width - edge)),
112
+ top: flip ? caretTop - gap - maxHeight : caretBottom + gap,
113
+ maxHeight,
114
+ }
115
+ }
116
+
87
117
  /** Filter the palette by title or keyword. Exported for tests. */
88
118
  export function filterCommands(query: string): SlashCommand[] {
89
119
  const q = query.trim().toLowerCase()
@@ -154,7 +184,7 @@ export function BlockEditor({ value, onChange, placeholder = "Write, or press '/
154
184
  const linkInputRef = useRef<HTMLInputElement>(null)
155
185
 
156
186
  const menuRef = useRef<HTMLDivElement>(null)
157
- const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null)
187
+ const [menuPos, setMenuPos] = useState<{ left: number; top: number; maxHeight: number } | null>(null)
158
188
  // The palette portals to <body>, so mount first — a portal has no server render.
159
189
  const [mounted, setMounted] = useState(false)
160
190
  useEffect(() => setMounted(true), [])
@@ -303,8 +333,7 @@ export function BlockEditor({ value, onChange, placeholder = "Write, or press '/
303
333
  )
304
334
  runSlashRef.current = runSlash
305
335
 
306
- // Place the palette from the caret's CURRENT viewport coords, clamped into the
307
- // viewport and flipped above the caret when it would overflow the bottom.
336
+ // Place the palette from the caret's CURRENT viewport coords.
308
337
  const placeMenu = useCallback(() => {
309
338
  const s = slashRef.current
310
339
  const el = menuRef.current
@@ -312,13 +341,19 @@ export function BlockEditor({ value, onChange, placeholder = "Write, or press '/
312
341
  const size = editor.state.doc.content.size
313
342
  if (s.from > size) return setSlash(null)
314
343
  const caret = editor.view.coordsAtPos(s.from)
315
- const rect = el.getBoundingClientRect()
316
- const gap = 6
317
- const edge = 8
318
- let top = caret.bottom + gap
319
- if (top + rect.height > window.innerHeight - edge) top = Math.max(edge, caret.top - gap - rect.height)
320
- const left = Math.max(edge, Math.min(caret.left, window.innerWidth - rect.width - edge))
321
- setMenuPos((prev) => (prev && prev.left === left && prev.top === top ? prev : { left, top }))
344
+ const next = placeSlashMenu({
345
+ caretTop: caret.top,
346
+ caretBottom: caret.bottom,
347
+ caretLeft: caret.left,
348
+ // Measure the CONTENT, not the box the box already carries the cap from the
349
+ // last placement, so measuring it would let one tight spot shrink every
350
+ // placement after it. +2 for the 1px border (scrollHeight omits borders).
351
+ wanted: Math.min(MENU_MAX_H, el.scrollHeight + 2),
352
+ width: el.getBoundingClientRect().width,
353
+ viewportW: window.innerWidth,
354
+ viewportH: window.innerHeight,
355
+ })
356
+ setMenuPos((prev) => (prev && prev.left === next.left && prev.top === next.top && prev.maxHeight === next.maxHeight ? prev : next))
322
357
  }, [editor, setSlash])
323
358
 
324
359
  useLayoutEffect(() => {
@@ -424,7 +459,7 @@ export function BlockEditor({ value, onChange, placeholder = "Write, or press '/
424
459
  aria-label="Link URL"
425
460
  aria-invalid={linkError ? true : undefined}
426
461
  placeholder="https://, mailto:, /path"
427
- className="h-8 w-56 rounded-md border border-border bg-surface px-2 text-small text-fg outline-none placeholder:text-fg-subtle focus:border-brand-green"
462
+ className="h-8 w-56 rounded-md border border-border bg-surface px-2 text-small text-fg outline-none placeholder:text-fg-muted focus:border-brand-green"
428
463
  onChange={(e) => {
429
464
  setLinkDraft(e.target.value)
430
465
  setLinkError(null)
@@ -493,8 +528,15 @@ export function BlockEditor({ value, onChange, placeholder = "Write, or press '/
493
528
  id={listboxId}
494
529
  role="listbox"
495
530
  aria-label="Insert block"
496
- className="fixed z-50 max-h-72 w-64 overflow-auto rounded-lg border border-border bg-surface-card p-1 shadow-md"
497
- style={{ left: menuPos?.left ?? 0, top: menuPos?.top ?? 0, visibility: menuPos ? 'visible' : 'hidden' }}
531
+ className="fixed z-50 w-64 overflow-auto rounded-lg border border-border bg-surface-card p-1 shadow-md"
532
+ style={{
533
+ left: menuPos?.left ?? 0,
534
+ top: menuPos?.top ?? 0,
535
+ // The cap is placement-derived (room on the chosen side), so it lives here
536
+ // rather than in a max-h-* class — MENU_MAX_H is the ceiling it clamps to.
537
+ maxHeight: menuPos?.maxHeight ?? MENU_MAX_H,
538
+ visibility: menuPos ? 'visible' : 'hidden',
539
+ }}
498
540
  >
499
541
  {filtered.map((cmd, i) => (
500
542
  // role=option must sit on a plain element — a <button> would override it.
@@ -509,7 +551,7 @@ export function BlockEditor({ value, onChange, placeholder = "Write, or press '/
509
551
  onClick={() => runSlash(cmd)}
510
552
  >
511
553
  <span className="text-small text-fg">{cmd.title}</span>
512
- <span className="text-caption text-fg-subtle">{cmd.hint}</span>
554
+ <span className="text-caption text-fg-muted">{cmd.hint}</span>
513
555
  </div>
514
556
  ))}
515
557
  </div>,
package/src/index.ts CHANGED
@@ -11,7 +11,9 @@ export { uic, uiconfig, type ConfigVariants, type NoInfer } from "./utils/uic";
11
11
 
12
12
  // --- primitives ---
13
13
  export * from "./components/button";
14
+ export * from "./components/compact-action-button";
14
15
  export * from "./components/input";
16
+ export * from "./components/field-appearance";
15
17
  export * from "./components/textarea";
16
18
  export * from "./components/rich-text-editor";
17
19
  export * from "./components/focus-field";
@@ -27,12 +29,22 @@ export * from "./components/search-field";
27
29
  export * from "./components/slider";
28
30
  export * from "./components/tag-group";
29
31
  export * from "./components/file-upload";
32
+ export * from "./components/document-upload-panel";
33
+ export * from "./components/csv-binding-presentation";
34
+ export * from "./components/dialog-action-button";
35
+ export * from "./components/reload-icon";
30
36
  export * from "./components/date-field";
31
37
  export * from "./components/date-picker";
32
38
  export * from "./components/dialog";
39
+ export { AssetSelectionSurface, AssetSelectionEmpty, AssetLibraryPreview, AssetSelectionFilters, AssetSelectionGrid, type AssetSelectionSurfaceProps } from "./components/asset-selection-surface";
40
+ export { AssetMasonryGrid, assetMasonryColumns, assetMasonryPositions } from './components/asset-masonry-grid';
33
41
  export * from "./components/side-panel";
34
42
  export * from "./components/dropdown-menu";
35
43
  export * from "./components/context-menu";
44
+ export * from "./components/context-search-panel";
45
+ export * from "./components/media-gallery";
46
+ export * from "./components/media-settings-dialog";
47
+ export * from "./components/media-asset-panel";
36
48
  export * from "./components/tooltip";
37
49
  export * from "./components/toast";
38
50
  export * from "./components/disclosure";
@@ -58,9 +70,14 @@ export * from "./components/count-up";
58
70
  export * from "./components/cta-pill";
59
71
  export * from "./components/stat-stack";
60
72
  export * from "./components/icons";
73
+ export * from "./components/context-action-glyph";
61
74
  export * from "./components/subtle";
62
75
  export * from "./components/stats-card";
76
+ export * from "./components/settings-dialog-surface";
77
+ export * from "./components/template-catalog-card";
63
78
  export * from "./components/tile";
79
+ export * from "./components/preview-info-card";
80
+ export * from "./components/empty-panel-action";
64
81
  export * from "./components/table";
65
82
  export * from "./components/dashboard-grid";
66
83
  export * from "./components/task-approval-modal";
@@ -69,3 +86,5 @@ export * from "./components/delivery/download-modal";
69
86
  export * from "./components/delivery/send-to-print-modal";
70
87
  export * from "./components/delivery/publish-modal";
71
88
  export * from "./components/delivery/delivery-status-module";
89
+ export * from './components/date-selection-calendar'
90
+ export * from './components/compact-settings-dialog'
@@ -151,14 +151,18 @@ export const AppShell = ({
151
151
  {banner ? <div className="shrink-0">{banner}</div> : null}
152
152
 
153
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">
154
+ scrolls past. The Topbar header now carries its own 24px horizontal
155
+ padding (matching `Main`) plus its height and bottom border, so this row
156
+ supplies NO padding of its own it would double up. The drawer toggle is
157
+ a sibling of the topbar, so it takes the same 24px gutter explicitly;
158
+ without it the toggle sat at 8px while every other page element started
159
+ at 24px. KNOWN SEAM: because the toggle sits outside the Topbar, the
160
+ Topbar's bottom border starts after it on mobile. Closing that would mean
161
+ restyling `topbar`, which is an opaque ReactNode here. `bg-surface` keeps
162
+ content from showing through while pinned. z-20 sits above scrolling
163
+ content but below the mobile drawer (z-40/z-50); gs's literal z-10001 is
164
+ to clear a fullscreen overlay we don't have. */}
165
+ <div className="sticky top-0 z-20 flex shrink-0 items-center bg-surface">
162
166
  {hasSidebar ? (
163
167
  <RACButton
164
168
  aria-label={drawerToggleLabel}
@@ -166,7 +170,7 @@ export const AppShell = ({
166
170
  aria-controls={drawerOpen ? drawerId : undefined}
167
171
  onPress={() => setDrawerOpen((open) => !open)}
168
172
  className={
169
- 'ml-2 flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-fg outline-none ' +
173
+ 'ms-6 flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-fg outline-none ' +
170
174
  'transition-colors hover:bg-surface-muted md:hidden ' +
171
175
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
172
176
  }
@@ -39,26 +39,25 @@ import { uic } from '../utils/uic'
39
39
  // right — with a 1px bottom border (`--color-border`). gs sets
40
40
  // `padding: 0 spacing-6`; the horizontal page padding is supplied by AppShell's
41
41
  // sticky topbar row, so only the fixed height + border live here. The border sits
42
- // on this header so it aligns (inset) with the padded content below. Height is the
43
- // gs AppHeader bar (~52px) `h-14` (56px).
42
+ // on this header so it aligns (inset) with the padded content below. The source
43
+ // Embedded Manager topbar contract is exactly 77px.
44
44
  const TopbarRoot = uic('header', {
45
45
  displayName: 'Topbar',
46
- baseClass: 'flex h-14 w-full items-center gap-5 border-b border-border',
46
+ baseClass: 'flex h-[77px] w-full items-center gap-5 border-b border-border px-6',
47
47
  })
48
48
 
49
- // gs-manager `.logo`: nav-tab-sized label (13px compact, weight-500, tight
50
- // tracking) — matches the AppHeader nav-item scale, NOT a page heading.
49
+ // Embedded Hub brand treatment: 14/18, medium, normal tracking.
51
50
  const TopbarBrand = uic('div', {
52
51
  displayName: 'Topbar.Brand',
53
52
  baseClass:
54
- 'flex min-w-0 items-center gap-3 text-compact leading-4 font-medium tracking-tight text-fg',
53
+ 'flex min-w-0 items-center gap-3 text-small leading-[18px] font-medium tracking-[0] text-fg',
55
54
  })
56
55
 
57
56
  // `ml-auto` pushes the nav (and the actions after it) to the right, matching gs's
58
57
  // title-left / nav-right layout.
59
58
  const TopbarNavBase = uic('nav', {
60
59
  displayName: 'Topbar.Nav',
61
- baseClass: 'ml-auto flex min-w-0 items-center gap-2 overflow-x-auto',
60
+ baseClass: 'ml-auto flex h-9 min-w-0 items-start gap-2 overflow-x-auto',
62
61
  })
63
62
 
64
63
  // `@app/ui` ships no i18n — the accessible name of the nav landmark is REQUIRED
@@ -73,10 +72,8 @@ const TopbarActions = uic('div', {
73
72
 
74
73
  /**
75
74
  * NavLink — a single primary-navigation item. Mirrors gs-manager's AppHeader
76
- * nav-tab styling (`Button.module.scss` `_tab`): `6px 13px` padding
77
- * (→ `py-[6px] px-[13px]`), `radius-sm` (2px) corners, 13px dense text, neutral-400
78
- * foreground → neutral-100 fill on hover/active. The active tab is the SAME weight
79
- * as the resting tab (only the fill + color change — gs `_tab` has no weight bump).
75
+ * nav-tab styling: 6px × 13px padding, 2px corners and 13/16 typography.
76
+ * Hover and active share the neutral-100 fill without changing font weight.
80
77
  * gs maps BOTH `--color-background-hover` and `--color-background-active` to
81
78
  * `--color-neutral-100`, so a single `surface-muted` token covers both states
82
79
  * (no distinct active token to map). Renders an `<a>` by default; pass `asChild`
@@ -90,7 +87,7 @@ const TopbarActions = uic('div', {
90
87
  const TopbarNavLink = uic('a', {
91
88
  displayName: 'Topbar.NavLink',
92
89
  baseClass:
93
- 'inline-flex items-center rounded-sm px-[13px] py-[6px] text-compact leading-4 whitespace-nowrap ' +
90
+ 'inline-flex items-center rounded-sm px-[13px] py-[6px] text-compact leading-4 tracking-[0] whitespace-nowrap ' +
94
91
  'text-fg no-underline transition-colors hover:bg-surface-muted hover:text-fg outline-none ' +
95
92
  'focus-visible:ring-2 focus-visible:ring-ring ' +
96
93
  'data-[active]:bg-surface-muted data-[active]:text-fg',