@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,240 @@
1
+ import { createContext, useContext, useEffect, useId, useState, type ComponentProps, type CSSProperties, type ReactNode, type RefObject } from 'react'
2
+ import { Button } from './button'
3
+ import { Button as AriaButton, Disclosure as AriaDisclosure, DisclosurePanel as AriaDisclosurePanel } from 'react-aria-components'
4
+ import { ModalDialog, ModalOverlay, ModalSurface } from './dialog'
5
+ import { DisplayHeading, Heading } from './text'
6
+ import { uic } from '../utils/uic'
7
+
8
+ export const SETTINGS_DIALOG_VARIANTS = ['create', 'edit', 'catalog', 'csv', 'basic'] as const
9
+ export type SettingsDialogVariant = (typeof SETTINGS_DIALOG_VARIANTS)[number]
10
+
11
+ const LayoutContext = createContext({ variant: 'edit' as SettingsDialogVariant, compact: false })
12
+ const Panel = uic(ModalSurface, { displayName: 'SettingsDialogPanel', baseClass: 'box-border flex flex-col overflow-hidden p-0' })
13
+ const Layout = uic(ModalDialog, { displayName: 'SettingsDialogLayout', baseClass: 'relative flex min-h-0 flex-1 flex-col outline-none' })
14
+ const Container = uic('div', { displayName: 'SettingsDialogContainer', baseClass: 'box-border mx-auto w-full' })
15
+ const ScrollBody = uic('div', { displayName: 'SettingsDialogScrollBody', baseClass: 'min-h-0 flex-1 overflow-y-auto' })
16
+ const Body = uic('div', { displayName: 'SettingsDialogBody', baseClass: 'flex w-full flex-col' })
17
+ const Group = uic('div', { displayName: 'SettingsDialogGroup', baseClass: 'flex min-w-0 flex-col' })
18
+ const Columns = uic('div', { displayName: 'SettingsDialogColumns', baseClass: 'grid' })
19
+
20
+ /** Label-driven settings/catalog envelope. State, requests and selections belong to callers.
21
+ * Widths use the viewport-sized, unpadded overlay; rem caps use the shared spacing scale.
22
+ * Create and edit deliberately have different geometry, including below the 900px breakpoint.
23
+ */
24
+ export function SettingsDialogSurface({ variant, isOpen, onOpenChange, isPending = false,
25
+ label, title, closeLabel, children, footer, filters, bodyRef, bodyTestId, description, describedBy, isWide = false, headerBottomPadding = false,
26
+ }: {
27
+ variant: SettingsDialogVariant
28
+ isOpen: boolean
29
+ onOpenChange: (open: boolean) => void
30
+ isPending?: boolean
31
+ label?: string
32
+ title: ReactNode
33
+ description?: ReactNode
34
+ /** IDs of caller-owned concise descriptions, e.g. a dynamic calendar hint. */
35
+ describedBy?: string
36
+ /** CSV switches from an upload frame to a wide data preview without remounting. */
37
+ isWide?: boolean
38
+ /** Adds the source Basic Information title-to-body separation without changing other basic dialogs. */
39
+ headerBottomPadding?: boolean
40
+ closeLabel: string
41
+ children: ReactNode
42
+ footer: ReactNode
43
+ filters?: ReactNode
44
+ bodyRef?: RefObject<HTMLDivElement | null>
45
+ bodyTestId?: string
46
+ }) {
47
+ const titleId = useId()
48
+ const descriptionId = useId()
49
+ const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 900px)').matches)
50
+ useEffect(() => {
51
+ const media = window.matchMedia('(max-width: 900px)')
52
+ const change = () => setCompact(media.matches)
53
+ change()
54
+ media.addEventListener('change', change)
55
+ return () => media.removeEventListener('change', change)
56
+ }, [])
57
+ const create = variant === 'create'
58
+ const catalog = variant === 'catalog'
59
+ const csv = variant === 'csv'
60
+ const basic = variant === 'basic'
61
+ const containerClass = csv && isWide ? compact ? 'px-5' : 'px-6' : catalog ? '' : compact
62
+ ? create ? 'px-(--settings-dialog-gutter-inline)' : 'px-5'
63
+ : create ? 'max-w-200 px-(--settings-dialog-gutter-inline)' : 'max-w-192 px-6'
64
+ // Source uses spacing8 × .6 (spacing6 × .6 on narrow screens). Tailwind's
65
+ // numeric spacing utilities do not emit .8/.6 steps, so derive a named value
66
+ // from the shared spacing token rather than silently emitting no padding.
67
+ const layoutStyle: CSSProperties & { '--settings-dialog-gutter-inline': string } = {
68
+ letterSpacing: 0,
69
+ '--settings-dialog-gutter-inline': compact ? 'calc(var(--spacing) * 3.6)' : 'calc(var(--spacing) * 4.8)',
70
+ }
71
+ const panelClass = compact
72
+ ? catalog || (csv && isWide) ? 'h-23/25 w-24/25' : 'h-23/25 w-47/50'
73
+ : catalog ? 'h-9/10 max-h-324 w-9/10 max-w-432'
74
+ : csv && isWide ? 'h-9/10 max-h-264 w-9/10 max-w-432'
75
+ : create ? 'h-9/10 max-h-288 w-1/2 max-w-288' : 'h-9/10 max-h-264 w-9/10 max-w-204'
76
+ const layoutClass = catalog ? compact ? 'gap-3 p-4' : 'gap-4 p-6'
77
+ : csv || basic ? compact ? 'gap-8 py-6' : 'gap-10 py-10'
78
+ : create ? compact ? 'gap-4 py-7.5' : 'gap-5 py-10'
79
+ : compact ? 'py-6' : 'py-10'
80
+ const bodyClass = catalog ? '' : basic ? 'gap-8' : create ? compact ? 'gap-4' : 'gap-5' : compact ? 'gap-8' : 'gap-10'
81
+ return <LayoutContext.Provider value={{ variant, compact }}>
82
+ <ModalOverlay className="p-0" isOpen={isOpen} onOpenChange={open => { if (!isPending) onOpenChange(open) }} isDismissable={!isPending} isKeyboardDismissDisabled={isPending}>
83
+ <Panel className={panelClass} data-settings-variant={variant} data-settings-compact={compact}>
84
+ <Layout aria-label={label} aria-labelledby={label ? undefined : titleId} aria-describedby={[description ? descriptionId : undefined, describedBy].filter(Boolean).join(' ') || undefined} className={layoutClass} style={layoutStyle}>
85
+ <Button variant="ghost" aria-label={closeLabel} isDisabled={isPending} onPress={() => onOpenChange(false)} className="absolute end-4 top-3.5 z-10 h-8 w-8 rounded-md p-0 text-fg-subtle">
86
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg>
87
+ </Button>
88
+ <header className={catalog ? 'flex shrink-0 flex-col gap-2' : basic ? headerBottomPadding ? 'shrink-0 pb-4' : 'shrink-0' : create || csv ? 'shrink-0 pb-4' : 'shrink-0 pb-10 max-md:pb-8'}>
89
+ <Container className={containerClass}>
90
+ {catalog ? <Heading id={titleId} level="1" className="m-0 mb-4 max-w-5/6 whitespace-pre-line" style={{ letterSpacing: 0 }}>{title}</Heading>
91
+ : <DisplayHeading id={titleId} className={`m-0 whitespace-pre-line pb-0.5 text-fg-subtle ${create ? 'mb-4 max-w-5/6' : ''}`} style={{ letterSpacing: 0, ...(basic ? { maxWidth: '18ch' } : csv && isWide ? { maxWidth: '34ch' } : {}) }}>{title}</DisplayHeading>}
92
+ {description ? <p id={descriptionId} className="mb-0 mt-3 text-small font-normal text-fg-workflow-muted" style={{ maxWidth: '48ch' }}>{description}</p> : null}
93
+ </Container>
94
+ {filters ? <div className="w-full max-w-80">{filters}</div> : null}
95
+ </header>
96
+ <ScrollBody style={{ scrollbarGutter: 'stable' }}>
97
+ <Container className={containerClass}>
98
+ <Body ref={bodyRef} className={bodyClass} data-testid={bodyTestId}>{children}</Body>
99
+ </Container>
100
+ </ScrollBody>
101
+ <footer className={catalog ? 'shrink-0 border-t border-border bg-surface pt-3' : create ? 'shrink-0 border-t border-border bg-surface pt-4' : 'shrink-0 border-t border-border bg-surface pt-5'}>
102
+ <Container className={containerClass}>
103
+ <div className={`flex justify-end ${csv || basic ? 'items-center gap-1' : variant === 'edit' ? 'gap-3' : 'gap-2'}`}>{footer}</div>
104
+ </Container>
105
+ </footer>
106
+ </Layout>
107
+ </Panel>
108
+ </ModalOverlay>
109
+ </LayoutContext.Provider>
110
+ }
111
+
112
+ /** Multi-step form/catalog envelope. Switching width preserves mounted drafts. */
113
+ export const WizardForm = uic('form', { displayName: 'WizardForm', baseClass: 'flex flex-col gap-8' })
114
+ const BriefText = uic('p', { displayName: 'WizardBriefText', baseClass: 'm-0 min-h-32 rounded-lg bg-surface-card p-4 text-small text-fg-workflow-muted' })
115
+ export function WizardBrief({ label, children }: { label: ReactNode; children: ReactNode }) {
116
+ const id = useId()
117
+ return <Group className="gap-2" role="group" aria-labelledby={id}><SettingsDialogLabel id={id}>{label}</SettingsDialogLabel><BriefText>{children}</BriefText></Group>
118
+ }
119
+
120
+ export function WizardFormDialog({ isOpen, onOpenChange, isPending = false, isExpanded = false, title, closeLabel, children, footer }: {
121
+ isOpen: boolean; onOpenChange: (open: boolean) => void; isPending?: boolean; isExpanded?: boolean
122
+ title: ReactNode; closeLabel: string; children: ReactNode; footer: ReactNode
123
+ }) {
124
+ const titleId = useId()
125
+ return <ModalOverlay className="p-0" isOpen={isOpen} onOpenChange={open => { if (!isPending) onOpenChange(open) }} isDismissable={!isPending} isKeyboardDismissDisabled={isPending}>
126
+ <Panel data-wizard-expanded={isExpanded} className={`h-23/25 max-h-23/25 w-24/25 max-w-24/25 min-[901px]:h-9/10 min-[901px]:max-h-288 min-[901px]:w-9/10 ${isExpanded ? 'min-[901px]:max-w-432' : 'min-[901px]:max-w-204'}`}>
127
+ <Layout aria-labelledby={titleId} className="gap-8 py-6 min-[901px]:gap-10 min-[901px]:py-10">
128
+ <Button variant="ghost" aria-label={closeLabel} isDisabled={isPending} onPress={() => onOpenChange(false)} className="absolute end-4 top-3.5 z-10 h-8 w-8 rounded-md p-0 text-fg-subtle">
129
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg>
130
+ </Button>
131
+ <header className="shrink-0"><Container className="max-w-384 px-5 min-[901px]:px-6"><DisplayHeading id={titleId} className="m-0 whitespace-pre-line pb-0.5 text-fg-subtle" style={{ letterSpacing: 0 }}>{title}</DisplayHeading></Container></header>
132
+ <ScrollBody style={{ scrollbarGutter: 'stable' }}><Container className="max-w-384 px-5 min-[901px]:px-6"><Body className="gap-8">{children}</Body></Container></ScrollBody>
133
+ <footer className="shrink-0 border-t border-border bg-surface pt-5"><Container className="max-w-384 px-5 min-[901px]:px-6"><div className="flex flex-wrap justify-end gap-2">{footer}</div></Container></footer>
134
+ </Layout>
135
+ </Panel>
136
+ </ModalOverlay>
137
+ }
138
+
139
+ export function SettingsDialogIdentity({ children }: { children: ReactNode }) {
140
+ const { variant } = useContext(LayoutContext)
141
+ return <Group className={variant === 'create' ? 'gap-4 border-b border-border pb-3' : 'gap-8'}>{children}</Group>
142
+ }
143
+ export function SettingsDialogDescription({ children }: { children: ReactNode }) {
144
+ const { variant } = useContext(LayoutContext)
145
+ return <Group className={variant === 'create' ? 'mt-2' : variant === 'basic' ? 'gap-1' : undefined}>{children}</Group>
146
+ }
147
+ export function SettingsDialogColumns({ children, metadata = false }: { children: ReactNode; metadata?: boolean }) {
148
+ const { variant, compact } = useContext(LayoutContext)
149
+ if (metadata) return <Columns className={`${compact ? 'grid-cols-1' : 'grid-cols-3'} gap-3`}>{children}</Columns>
150
+ return <Columns className={`${compact ? 'grid-cols-1' : 'grid-cols-2'} ${variant === 'create' ? compact ? 'gap-3' : 'gap-4' : 'gap-8'}`}>{children}</Columns>
151
+ }
152
+ export function SettingsDialogGroup({ children }: { children: ReactNode }) {
153
+ const { variant } = useContext(LayoutContext)
154
+ return <Group className={variant === 'create' ? 'gap-3 border-b border-border pb-3' : undefined}>{children}</Group>
155
+ }
156
+
157
+ /** Two-column production metadata grid from the split settings family.
158
+ * Independent of SettingsDialogSurface's 900px breakpoint; this source uses1100px.
159
+ */
160
+ export function SettingsDialogProductionColumns({ children }: { children: ReactNode }) {
161
+ const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 1100px)').matches)
162
+ useEffect(() => {
163
+ const media = window.matchMedia('(max-width: 1100px)')
164
+ const change = () => setCompact(media.matches)
165
+ change()
166
+ media.addEventListener('change', change)
167
+ return () => media.removeEventListener('change', change)
168
+ }, [])
169
+ return <Columns data-settings-production-columns className={`${compact ? 'grid-cols-1' : 'grid-cols-2'} min-w-0 gap-4`}>{children}</Columns>
170
+ }
171
+ export function SettingsDialogControl({ children }: { children: ReactNode }) {
172
+ const { variant } = useContext(LayoutContext)
173
+ return <Group className={variant === 'edit' ? 'mt-6' : undefined}>{children}</Group>
174
+ }
175
+ export const SettingsDialogLabel = uic('p', { displayName: 'SettingsDialogLabel', baseClass: 'm-0 text-panel-heading font-medium text-fg' })
176
+ export const SettingsDialogEmphasis = uic('span', { displayName: 'SettingsDialogEmphasis', baseClass: 'text-fg' })
177
+ const Hint = uic('p', { displayName: 'SettingsDialogHint', baseClass: 'm-0 text-small font-normal text-fg-workflow-muted' })
178
+ export function SettingsDialogHint({ style, ...props }: ComponentProps<typeof Hint>) {
179
+ const { variant } = useContext(LayoutContext)
180
+ return <Hint {...props} style={{ ...(variant === 'basic' ? { maxWidth: '48ch' } : {}), ...style }} />
181
+ }
182
+ export const SettingsDialogAction = uic(Button, { displayName: 'SettingsDialogAction', baseClass: 'h-11.5 rounded-full px-6 text-base font-medium leading-5', style: { letterSpacing: 0 } })
183
+
184
+ const ProductionLayout = uic(ModalDialog, { displayName: 'ProductionSettingsLayout', baseClass: 'relative grid h-full min-h-0 w-full overflow-hidden outline-none' })
185
+ const ProductionHeader = uic('header', { displayName: 'ProductionSettingsHeader', baseClass: 'flex shrink-0 flex-col items-start gap-4 px-8 pb-6 pt-8' })
186
+ const ProductionTitle = uic('h1', { displayName: 'ProductionSettingsTitle', baseClass: 'm-0 max-w-132 text-display-large font-medium text-fg-muted', style: { letterSpacing: '-0.02em' } })
187
+ const ProductionDescription = uic('p', { displayName: 'ProductionSettingsDescription', baseClass: 'm-0 max-w-132 text-body font-normal text-fg' })
188
+ const ProductionFields = uic('div', { displayName: 'ProductionSettingsFields', baseClass: 'flex min-h-0 flex-col gap-6 px-8 pb-8' })
189
+ const ProductionFooter = uic('footer', { displayName: 'ProductionSettingsFooter', baseClass: 'relative z-10 flex shrink-0 flex-wrap items-center gap-2 bg-surface px-8 pb-8 pt-4' })
190
+ const ProductionPreview = uic('aside', { displayName: 'ProductionSettingsPreview', baseClass: 'relative grid min-h-0 min-w-0 place-items-center overflow-hidden p-10', style: { background: 'radial-gradient(circle at 50% 42%, color-mix(in srgb, var(--color-surface) 8%, transparent) 0, transparent 42%), color-mix(in srgb, var(--color-fg) 84%, var(--color-surface))' } })
191
+ const ProductionArtwork = uic('div', { displayName: 'ProductionSettingsArtwork', baseClass: 'min-h-0 min-w-0 overflow-hidden', style: { width: '80%', height: '80%' } })
192
+ export const ProductionSettingsAction = uic(Button, { displayName: 'ProductionSettingsAction', baseClass: 'min-h-10 rounded-full px-6 text-body font-medium' })
193
+
194
+ /** Wide production settings, not the content editor's independently tuned split. */
195
+ export function ProductionSettingsDialog({ isOpen, onOpenChange, prefix, title, description, closeLabel, children, preview, footer, testId = 'production-settings' }: {
196
+ isOpen: boolean; onOpenChange: (open: boolean) => void; prefix: ReactNode; title: ReactNode; description: ReactNode; closeLabel: string
197
+ children: ReactNode; preview: ReactNode; footer: ReactNode; testId?: string
198
+ }) {
199
+ const titleId = useId(), descriptionId = useId()
200
+ const query = '(max-width: 1100px), (orientation: portrait)'
201
+ const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia(query).matches)
202
+ useEffect(() => {
203
+ const media = window.matchMedia(query), change = () => setCompact(media.matches)
204
+ change(); media.addEventListener('change', change)
205
+ return () => media.removeEventListener('change', change)
206
+ }, [])
207
+ return <ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable className="p-0">
208
+ <Panel style={{ width: compact ? '96vw' : 'min(90vw, 110rem)', maxWidth: compact ? '96vw' : 'min(90vw, 110rem)', height: compact ? '92vh' : 'min(82vh, 63.125rem)', maxHeight: compact ? '92vh' : 'min(82vh, 63.125rem)' }}>
209
+ <ProductionLayout aria-labelledby={titleId} aria-describedby={descriptionId} data-testid={testId} data-production-compact={compact} style={{ gridTemplateColumns: compact ? 'minmax(0,1fr)' : 'minmax(calc(var(--spacing) * 90),2fr) minmax(0,4fr)', gridTemplateRows: compact ? 'minmax(18rem,44%) minmax(0,1fr)' : 'minmax(0,1fr)' }}>
210
+ <section className={`flex min-h-0 min-w-0 flex-col bg-surface ${compact ? 'row-start-2 overflow-y-auto' : 'overflow-hidden'}`} aria-labelledby={titleId}>
211
+ <ProductionHeader><ProductionTitle id={titleId}>{prefix}{' '}<span className="text-fg">{title}</span></ProductionTitle><ProductionDescription id={descriptionId}>{description}</ProductionDescription></ProductionHeader>
212
+ <ProductionFields data-testid={`${testId}-form`} className={compact ? 'shrink-0' : 'flex-1 overflow-y-auto'} style={{ scrollbarGutter: 'stable' }}>{children}</ProductionFields>
213
+ <ProductionFooter>{footer}</ProductionFooter>
214
+ </section>
215
+ <ProductionPreview className={compact ? 'col-start-1 row-start-1' : undefined} data-testid={`${testId}-preview`}><ProductionArtwork data-testid={`${testId}-artwork`}>{preview}</ProductionArtwork></ProductionPreview>
216
+ <Button variant="ghost" aria-label={closeLabel} onPress={() => onOpenChange(false)} className="absolute right-4 top-4 z-20 h-8 w-8 rounded-md p-0 text-white/60 hover:bg-transparent hover:text-white"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg></Button>
217
+ </ProductionLayout>
218
+ </Panel>
219
+ </ModalOverlay>
220
+ }
221
+
222
+ const DisclosureRoot = uic(AriaDisclosure, { displayName: 'SettingsDisclosureRoot', baseClass: 'group/settings-disclosure flex min-w-0 flex-col gap-3' })
223
+ const DisclosureTrigger = uic(AriaButton, { displayName: 'SettingsDisclosureTrigger', baseClass: 'flex w-full cursor-pointer items-start justify-between gap-3 bg-transparent p-0 text-left outline-none data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2' })
224
+ const DisclosureCopy = uic('span', { displayName: 'SettingsDisclosureCopy', baseClass: 'flex min-w-0 flex-col gap-1' })
225
+ const DisclosureTitle = uic('strong', { displayName: 'SettingsDisclosureTitle', baseClass: 'text-body font-medium leading-normal text-fg' })
226
+ const DisclosureHint = uic('span', { displayName: 'SettingsDisclosureHint', baseClass: 'text-body font-normal leading-normal text-fg-workflow-muted' })
227
+ const DisclosureBody = uic(AriaDisclosurePanel, { displayName: 'SettingsDisclosureBody', baseClass: 'flex min-w-0 flex-col gap-5 pt-3' })
228
+
229
+ /** Source outputTaskDropdownPanel: labelled, initially expanded settings group. */
230
+ export function SettingsDialogDisclosure({ title, description, children, defaultExpanded = true }: {
231
+ title: ReactNode; description?: ReactNode; children: ReactNode; defaultExpanded?: boolean
232
+ }) {
233
+ return <DisclosureRoot defaultExpanded={defaultExpanded}>
234
+ <DisclosureTrigger slot="trigger">
235
+ <DisclosureCopy><DisclosureTitle>{title}</DisclosureTitle>{description ? <DisclosureHint>{description}</DisclosureHint> : null}</DisclosureCopy>
236
+ <svg aria-hidden="true" width="15" height="15" viewBox="0 0 15 15" fill="none" className="mt-1 shrink-0 text-fg-workflow-muted transition-transform duration-150 group-data-[expanded]/settings-disclosure:rotate-180 motion-reduce:transition-none"><path d="m3.5 5.5 4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
237
+ </DisclosureTrigger>
238
+ <DisclosureBody>{children}</DisclosureBody>
239
+ </DisclosureRoot>
240
+ }
@@ -1,10 +1,12 @@
1
- import type { ReactNode } from 'react'
1
+ import type { ComponentProps } from 'react'
2
+
3
+ import { uic } from '../utils/uic'
2
4
 
3
5
  /**
4
- * Subtle — inline de-emphasised text (the light `fg-subtle` grey, #b3b3b3). Wraps its
5
- * children in a `text-fg-subtle` span; anything OUTSIDE it stays at the surrounding
6
- * full-contrast `fg`. Compose it freely to two-tone arbitrary runs of a line — unlike
7
- * a fixed-slot header it handles mid-line emphasis in ANY order and ANY language:
6
+ * Subtle — inline de-emphasised text. Wraps its children in a de-emphasised span;
7
+ * anything OUTSIDE it stays at the surrounding full-contrast `fg`. Compose it freely
8
+ * to two-tone arbitrary runs of a line — unlike a fixed-slot header it handles
9
+ * mid-line emphasis in ANY order and ANY language:
8
10
  *
9
11
  * <h1>
10
12
  * <Subtle>Good afternoon</Subtle> {name} 👋
@@ -15,7 +17,61 @@ import type { ReactNode } from 'react'
15
17
  * With i18n, put the `<subtle>…</subtle>` runs INSIDE the translation string and render
16
18
  * it via `<Trans components={{ subtle: <Subtle /> }} />`, so each locale places the
17
19
  * emphasis where its own word order needs it. Presentational only (hard rule #1).
20
+ *
21
+ * ## Tone
22
+ *
23
+ * `muted` (the default) is `fg-muted`. It clears WCAG 2.1 AA normal-text contrast on
24
+ * the READING surfaces — `surface` 5.98:1, `surface-card` 5.53:1, `surface-muted`
25
+ * 4.96:1 in light; 6.52:1 / 5.30:1 in dark. Meaningful copy — a greeting, a task
26
+ * count, a caption — belongs here.
27
+ *
28
+ * `muted` covers the reading surfaces and nothing else. Every surface that carries
29
+ * its OWN ink needs the matching tone, because `fg-muted` is below AA on all of
30
+ * them — `Tile.mutedTone()` has always encoded this pairing, and these two tones
31
+ * are the same rule made reachable from a bare run of text:
32
+ *
33
+ * surface-inverted (#242423 — does NOT flip, so it is a dark panel in the LIGHT
34
+ * theme too: a dark `Tile`, `Badge color="dark"`) fg-muted 2.60:1
35
+ * → `inverted`, `fg-inverted/60` — 6.49:1 light, 4.99:1 dark
36
+ *
37
+ * the FIXED brand fills, which never flip: brand-green (fg-muted 3.95:1),
38
+ * accent-yellow (4.01:1), brand-secondary (3.60:1) — a teal or yellow `Tile`,
39
+ * a `CtaPill`, a green/yellow `Badge`
40
+ * → `on-brand`, `fg-on-brand/70` — 6.08:1 / 6.11:1 / 5.78:1, theme-stable
41
+ *
42
+ * All four blends are asserted in @podoba/tokens `contrast.test.ts`.
43
+ *
44
+ * ## Migrating from ≤ 0.0.34
45
+ *
46
+ * BREAKING on `surface-inverted` ONLY. `fg-subtle` is light enough that it happened
47
+ * to PASS on that dark panel (7.41:1) while failing everywhere else, so a run that
48
+ * sat on a dark `Tile` had a working colour by accident and now needs
49
+ * `tone="inverted"` to keep it. Nothing detects this for you: the tone is a prop,
50
+ * and no lint rule knows which surface a run lands on — grep for `<Subtle` and
51
+ * `tone="subtle"` inside dark `Tile`s and `bg-surface-inverted` panels.
52
+ *
53
+ * Everywhere else the new default is an improvement, not a migration. On the
54
+ * reading surfaces it goes 2.10 → 5.98 (and 1.74 → 4.96 on `surface-muted`); on the
55
+ * brand fills 1.39 → 3.95, which is better but still short of AA — those runs were
56
+ * ALWAYS broken and `tone="on-brand"` is the fix, not a regression to undo.
57
+ *
58
+ * `decorative` is the lighter `fg-subtle` (#b3b3b3) this component used to apply
59
+ * unconditionally. It measures 2.10:1 on `surface` and 1.94:1 on `surface-card`, below
60
+ * even the 3:1 large-text floor, so it is ORNAMENTAL ONLY: separators, watermarks,
61
+ * repeated decorative runs that carry no information a reader would miss. Never put
62
+ * text a user has to read behind it.
18
63
  */
19
- export function Subtle({ children }: { children: ReactNode }) {
20
- return <span className="text-fg-subtle">{children}</span>
21
- }
64
+ export const Subtle = uic('span', {
65
+ displayName: 'Subtle',
66
+ variants: {
67
+ tone: {
68
+ muted: 'text-fg-muted',
69
+ inverted: 'text-fg-inverted/60',
70
+ 'on-brand': 'text-fg-on-brand/70',
71
+ decorative: 'text-fg-subtle',
72
+ },
73
+ },
74
+ defaultVariants: { tone: 'muted' },
75
+ })
76
+
77
+ export type SubtleProps = ComponentProps<typeof Subtle>
@@ -0,0 +1,9 @@
1
+ import { Table } from './table'
2
+ const columns = [{ key: 'name', header: 'Name' }, { key: 'city', header: 'City' }]
3
+ const data = [{ name: 'Annual report', city: 'Prague' }]
4
+ export const examples = {
5
+ default: () => <Table columns={columns} data={data} aria-label="Projects" />,
6
+ worksheet: () => <Table appearance="worksheet" columns={columns} data={data} aria-label="CSV preview" />,
7
+ empty: () => <Table appearance="worksheet" columns={columns} data={[]} emptyMessage="No preview rows available." aria-label="Empty CSV" />,
8
+ }
9
+ export const meta = { category: 'Composite', description: 'Default data table and the source Manager worksheet appearance with monospace column labels.' }
@@ -1,5 +1,7 @@
1
- import { useMemo, useState, type ReactNode } from 'react'
1
+ import { useMemo, useState, type CSSProperties, type HTMLAttributes, type ReactNode } from 'react'
2
2
  import { ChevronDownIcon, ChevronUpIcon } from './icons'
3
+ export const TABLE_APPEARANCES = ['default', 'worksheet', 'team'] as const
4
+ export type TableAppearance = (typeof TABLE_APPEARANCES)[number]
3
5
 
4
6
  /**
5
7
  * Table — the design-system data table (port of gs-platform's `GSTable`). A light,
@@ -35,10 +37,15 @@ export type TableColumn<Row> = {
35
37
  }
36
38
 
37
39
  export type TableProps<Row> = {
40
+ /** Worksheet matches Manager's plain GSTable; default preserves existing consumers. */
41
+ appearance?: TableAppearance
38
42
  columns: TableColumn<Row>[]
39
43
  data: Row[]
40
44
  /** Stable per-row key. Defaults to the row index (fine for static lists). */
41
45
  getRowKey?: (row: Row, index: number) => string
46
+ /** Additional semantic/event props for each rendered row. */
47
+ getRowProps?: (row: Row) => HTMLAttributes<HTMLTableRowElement>
48
+ columnTemplate?: string
42
49
  /** Enable client-side sorting on `sortable` columns. */
43
50
  enableSorting?: boolean
44
51
  /** Whole-row press handler — renders rows as interactive (hover + keyboard). */
@@ -69,10 +76,13 @@ export function Table<Row>({
69
76
  columns,
70
77
  data,
71
78
  getRowKey,
79
+ getRowProps,
80
+ columnTemplate,
72
81
  enableSorting = false,
73
82
  onRowClick,
74
83
  emptyMessage = 'No rows.',
75
84
  className,
85
+ appearance = 'default',
76
86
  ...aria
77
87
  }: TableProps<Row>) {
78
88
  const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' } | null>(null)
@@ -100,12 +110,15 @@ export function Table<Row>({
100
110
 
101
111
  const rowKey = getRowKey ?? ((_row: Row, index: number) => String(index))
102
112
  const interactive = Boolean(onRowClick)
113
+ const worksheet = appearance === 'worksheet'
114
+ const team = appearance === 'team'
115
+ const gridTemplate = columnTemplate ?? `repeat(${columns.length}, minmax(0, 1fr))`
103
116
 
104
117
  return (
105
118
  <div className={['w-full overflow-x-auto', className].filter(Boolean).join(' ')}>
106
- <table className="w-full border-collapse text-small" {...aria}>
107
- <thead>
108
- <tr className="border-b border-border">
119
+ <table style={team ? { '--table-columns': gridTemplate } as CSSProperties : undefined} className={`w-full border-collapse text-small ${worksheet ? 'border-b border-border font-normal leading-4.5' : ''} ${team ? 'block border-b border-border font-normal leading-4.5' : ''}`} {...aria}>
120
+ <thead className={team ? 'block' : undefined}>
121
+ <tr className={`${worksheet ? '' : 'border-b border-border'} ${team ? 'grid grid-cols-[var(--table-columns)] gap-3 bg-surface-muted px-4 max-[1023px]:hidden' : ''}`}>
109
122
  {columns.map((column) => {
110
123
  const align = column.align ?? 'left'
111
124
  const canSort = enableSorting && column.sortable
@@ -118,7 +131,7 @@ export function Table<Row>({
118
131
  aria-sort={
119
132
  canSort ? (active ? (sort?.dir === 'asc' ? 'ascending' : 'descending') : 'none') : undefined
120
133
  }
121
- className={`${ALIGN_CLASS[align]} px-4 py-3 text-label font-medium tracking-wide text-fg-muted uppercase`}
134
+ className={`${ALIGN_CLASS[align]} ${worksheet ? 'border-0 px-3 py-4 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case bg-surface whitespace-nowrap' : team ? 'border-0 px-4 py-3 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case whitespace-nowrap' : 'px-4 py-3 text-label font-medium tracking-wide text-fg-muted uppercase'}`}
122
135
  >
123
136
  {canSort ? (
124
137
  <button
@@ -145,17 +158,20 @@ export function Table<Row>({
145
158
  })}
146
159
  </tr>
147
160
  </thead>
148
- <tbody>
161
+ <tbody className={team ? 'block' : undefined}>
149
162
  {sorted.length === 0 ? (
150
163
  <tr>
151
- <td colSpan={columns.length} className="px-4 py-10 text-center text-small text-fg-muted">
164
+ <td colSpan={columns.length} className={worksheet ? 'border-y border-border px-6 py-4 text-small font-normal leading-4.5 text-fg' : 'px-4 py-10 text-center text-small text-fg-muted'}>
152
165
  {emptyMessage}
153
166
  </td>
154
167
  </tr>
155
168
  ) : (
156
- sorted.map((row, index) => (
169
+ sorted.map((row, index) => {
170
+ const rowProps = getRowProps?.(row)
171
+ return (
157
172
  <tr
158
173
  key={rowKey(row, index)}
174
+ {...rowProps}
159
175
  {...(interactive
160
176
  ? {
161
177
  tabIndex: 0,
@@ -169,22 +185,23 @@ export function Table<Row>({
169
185
  },
170
186
  }
171
187
  : {})}
172
- className={`border-b border-border/60 outline-none ${
188
+ className={`${worksheet ? 'min-h-15' : team ? 'grid grid-cols-[var(--table-columns)] gap-3 border-b border-border px-4 py-3 text-label font-normal leading-4.5 max-[1023px]:grid max-[1023px]:grid-cols-1 max-[1023px]:gap-2 max-[1023px]:p-4' : 'border-b border-border/60'} ${team ? 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring' : ''} outline-none ${
173
189
  interactive
174
190
  ? 'cursor-pointer transition-colors hover:bg-surface-muted focus-visible:bg-surface-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring'
175
191
  : ''
176
- }`}
192
+ }${rowProps?.className ? ` ${rowProps.className}` : ''}`}
177
193
  >
178
194
  {columns.map((column) => {
179
195
  const align = column.align ?? 'left'
180
196
  return (
181
- <td key={column.key} className={`${ALIGN_CLASS[align]} px-4 py-3 align-middle text-fg`}>
197
+ <td key={column.key} className={`${ALIGN_CLASS[align]} ${worksheet ? 'border border-border first:border-l-0 last:border-r-0 px-6 py-4 text-small font-normal leading-4.5' : team ? 'px-0 py-0 align-middle text-small font-normal leading-4.5 max-[1023px]:block' : 'px-4 py-3'} align-middle text-fg`}>
182
198
  {column.render ? column.render(row) : String(defaultSortValue(column, row))}
183
199
  </td>
184
200
  )
185
201
  })}
186
202
  </tr>
187
- ))
203
+ )
204
+ })
188
205
  )}
189
206
  </tbody>
190
207
  </table>
@@ -0,0 +1,25 @@
1
+ import { useState } from 'react'
2
+ import { CatalogMetricCard } from './template-catalog-card'
3
+ import { CatalogLaunchCard, CatalogSummaryBadge, CatalogSummaryGrid, CatalogSummaryCard, TemplateCatalogBrowseCard, TemplateCatalogBrowseGrid } from './template-catalog-card'
4
+ import { TemplateCatalogCard, TemplateCatalogGrid, TemplateReferenceGrid, TemplateReferenceList, TemplateReferenceItem, CatalogMetadataSummary } from './template-catalog-card'
5
+
6
+ function Example({ selected = false, locked = false, title = 'Annual report' }: { selected?: boolean; locked?: boolean; title?: string }) {
7
+ const [value, setValue] = useState(selected)
8
+ return <TemplateCatalogCard title={title} isSelected={value} isSelectionLocked={locked}
9
+ onToggle={() => setValue(!value)} preview={<span className="flex h-full aspect-3/4 items-center justify-center bg-surface text-fg">Aa</span>} />
10
+ }
11
+
12
+ export const examples = {
13
+ metrics: () => <CatalogSummaryGrid>{['0', '205', '—'].map(value => <CatalogMetricCard key={value} title="Templates" value={value} footer={value === '0' ? undefined : 'Catalog'} />)}</CatalogSummaryGrid>,
14
+ launch: () => <CatalogLaunchCard title="Annual report" description="Prepare the yearly report." details="2 sections · 3 outputs" action={<button type="button" aria-label="Start Annual report" className="absolute inset-0 rounded-lg focus-visible:ring-2 focus-visible:ring-ring" />} />,
15
+ launchWithoutBrief: () => <CatalogLaunchCard title="Annual report" details="2 sections · 3 outputs" action={<button type="button" disabled aria-label="Start Annual report" className="absolute inset-0 rounded-lg" />} />,
16
+ summary: () => <CatalogSummaryGrid>{['Campaign', 'Annual report', 'Social launch'].map(title => <CatalogSummaryCard key={title} title={title} badge={<CatalogSummaryBadge color="green">active</CatalogSummaryBadge>} description="Reusable production scenario" details="2 sections · 4 outputs" />)}</CatalogSummaryGrid>,
17
+ readOnly: () => <TemplateCatalogBrowseGrid><TemplateCatalogBrowseCard title="Annual report" templateId="annual" preview={<span>Aa</span>} /><TemplateCatalogBrowseCard title="A long template title that wraps onto multiple lines" templateId="long" preview={<span>Aa</span>} /></TemplateCatalogBrowseGrid>,
18
+ metadata: () => <CatalogMetadataSummary title="Summary" ariaLabel="Template summary" items={[{ label: 'Name', value: 'Annual report', fullWidth: true }, { label: 'Type', value: 'Print' }, { label: 'Format', value: 'A4' }, { label: 'Reference ID', value: 'long-reference-123456789012345678901234567890', fullWidth: true }]} />,
19
+ browsing: () => <TemplateReferenceGrid columns={2}><TemplateReferenceItem title="Annual report" meta="Template" templateId="report" view="preview_grid" onOpen={() => undefined} preview={<span>Aa</span>} /><TemplateReferenceItem title="Unavailable" meta="Template" templateId="missing" view="preview_grid" isDisabled onOpen={() => undefined} preview={<span>Un</span>} /></TemplateReferenceGrid>,
20
+ list: () => <TemplateReferenceList><TemplateReferenceItem title="Annual report" meta="Template" templateId="report" view="compact_list" onOpen={() => undefined} preview={<span>Aa</span>} /><TemplateReferenceItem title="Unavailable" meta="Template" templateId="missing" view="compact_list" isDisabled onOpen={() => undefined} preview={<span>Un</span>} /></TemplateReferenceList>,
21
+ default: () => <TemplateCatalogGrid><Example /></TemplateCatalogGrid>,
22
+ variants: () => <TemplateCatalogGrid><Example /><Example selected /></TemplateCatalogGrid>,
23
+ states: () => <TemplateCatalogGrid><Example selected locked /><Example title="A long template title that wraps instead of disappearing behind an ellipsis" /></TemplateCatalogGrid>,
24
+ }
25
+ export const meta = { category: 'Composite', description: 'Selection tiles with a contained preview, keyboard focus and locked assignments. Hover and selection share the original green fill.' }