@podoba/react 0.0.7 → 0.0.9

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,113 @@
1
+ // @app/ui — delivery status module (issue 17).
2
+ //
3
+ // PRESENTATIONAL "black status module" — the signature delivery-UI surface ported
4
+ // from the gs-platform `FinalOutputsHandoffPanel` (`.statusModule`): a dark
5
+ // inverted surface (white-on-#242423) carrying a status header row, muted status
6
+ // text, an optional progress track with a GREEN progress bar, and optional
7
+ // trailing "ready" pills. Used by BOTH the final-outputs handoff panel and the
8
+ // live delivery-status cards so the two read as one visual language.
9
+ //
10
+ // APP-AGNOSTIC (hard rule #1): no apps/web imports, no API hooks, no i18n. Every
11
+ // string is passed in; the owning surface in apps/web supplies the i18n copy and
12
+ // wires behaviour. Pure markup + Tailwind classes bound to @app/tokens CSS vars.
13
+ //
14
+ // ── Styling (hard rule #3) ────────────────────────────────────────────────────
15
+ // Tailwind + @app/tokens only — NO SCSS, NO raw hex.
16
+ // gs-platform `--color-text-primary` (#000 module surface) → our `surface-inverted`
17
+ // gs-platform `--color-background` (white text) → our `fg-inverted`
18
+ // gs-platform `--color-brand-green` (#22c55e progress) → our `success`
19
+ // gs-platform `--radius-full` (track / pills) → `rounded-full`
20
+ // gs-platform `--radius-panel-sm` (module) → `rounded-md`
21
+ //
22
+ // ── a11y ──────────────────────────────────────────────────────────────────────
23
+ // White (#fff) on the dark surface (#242423) ≈ 14.9:1 — passes WCAG AA. The muted
24
+ // status text uses `text-white/70` (white at 70% over the dark surface) ≈ 8.6:1 —
25
+ // still AA. (We use the stock `white`/opacity utilities for the muted tints rather
26
+ // than `fg-inverted/NN`: Tailwind cannot inject an alpha channel into an arbitrary
27
+ // `var(--color-…)`, so an opacity modifier on `fg-inverted` would be a no-op; the
28
+ // `fg-inverted` token IS white, so `text-white` is the same colour, alpha-capable.)
29
+ // The green bar (#22c55e on #242423) is a NON-TEXT graphical indicator (~5.6:1,
30
+ // above the 3:1 non-text minimum). The progress track is `aria-hidden`; when a
31
+ // numeric value matters, expose it in the header (text), not the bar.
32
+
33
+ import type { ReactNode } from 'react'
34
+ import { uic } from '../../utils/uic'
35
+
36
+ /** The dark status-module shell (inverted surface + white text). */
37
+ export const DeliveryStatusModuleRoot = uic('div', {
38
+ displayName: 'DeliveryStatusModule',
39
+ baseClass:
40
+ 'grid gap-3 rounded-md bg-surface-inverted p-4 text-fg-inverted',
41
+ })
42
+
43
+ export interface DeliveryStatusModuleProps {
44
+ /** Status header — left-aligned headline (e.g. "Files ready"). */
45
+ heading: ReactNode
46
+ /** Optional right-aligned header annotation (e.g. "50 %", "3 ready"). */
47
+ headingMeta?: ReactNode
48
+ /** Muted status sentence under the header. */
49
+ statusText?: ReactNode
50
+ /**
51
+ * Optional progress fraction in [0, 1]. When provided, renders the green
52
+ * progress track. Clamped; the track itself is decorative (`aria-hidden`).
53
+ */
54
+ progress?: number
55
+ /** Optional trailing pills / actions row (e.g. "ready" chips). */
56
+ children?: ReactNode
57
+ /** Forwarded test id. */
58
+ 'data-testid'?: string
59
+ }
60
+
61
+ /**
62
+ * Black status module with an optional green progress bar. The visual heart of the
63
+ * delivery UI — stack it under a white output card or use it as a job-status card.
64
+ */
65
+ export function DeliveryStatusModule({
66
+ heading,
67
+ headingMeta,
68
+ statusText,
69
+ progress,
70
+ children,
71
+ 'data-testid': testId,
72
+ }: DeliveryStatusModuleProps): React.ReactNode {
73
+ const hasProgress = progress != null
74
+ const pct = hasProgress ? Math.max(0, Math.min(1, progress)) * 100 : 0
75
+
76
+ return (
77
+ <DeliveryStatusModuleRoot data-testid={testId}>
78
+ <div className="flex items-center justify-between gap-4 text-sm font-medium">
79
+ <span>{heading}</span>
80
+ {headingMeta != null ? <span>{headingMeta}</span> : null}
81
+ </div>
82
+
83
+ {statusText != null ? (
84
+ <p className="m-0 text-xs text-white/70">{statusText}</p>
85
+ ) : null}
86
+
87
+ {hasProgress ? (
88
+ <div
89
+ className="h-2 overflow-hidden rounded-full bg-white/25"
90
+ aria-hidden="true"
91
+ >
92
+ <span
93
+ className="block h-full rounded-full bg-success transition-[width]"
94
+ style={{ width: `${pct}%` }}
95
+ />
96
+ </div>
97
+ ) : null}
98
+
99
+ {children != null ? (
100
+ <div className="flex flex-wrap items-center gap-2 text-xs text-white/80">
101
+ {children}
102
+ </div>
103
+ ) : null}
104
+ </DeliveryStatusModuleRoot>
105
+ )
106
+ }
107
+
108
+ /** A hairline "ready" pill for the trailing actions row (inverted palette). */
109
+ export const DeliveryStatusPill = uic('span', {
110
+ displayName: 'DeliveryStatusPill',
111
+ baseClass:
112
+ 'inline-flex min-h-5 items-center rounded-full border border-white/30 px-3',
113
+ })
@@ -0,0 +1,204 @@
1
+ // @app/ui — delivery download modal (issue 14).
2
+ //
3
+ // PRESENTATIONAL download-confirmation dialog built on the @app/ui <Dialog>
4
+ // (React Aria Modal — focus trap, Esc, aria-modal). The user picks a format
5
+ // (PNG / PDF), confirms, and a `download` delivery job is created. While the
6
+ // job's artifact is generating the modal shows a progress note; once the
7
+ // artifact is ready the modal surfaces a download link to the artifact URL.
8
+ //
9
+ // APP-AGNOSTIC (hard rule #1: @app/ui never imports apps/web or calls API
10
+ // hooks). Every string comes through `labels`; the action is delegated via
11
+ // `onConfirm(format)`; the job status / artifact URL are passed in by the
12
+ // owning surface in apps/web, which wires the i18n strings + the REST mutation
13
+ // (`useDeliveryJobMutations().create`) + invalidation.
14
+ //
15
+ // a11y: the format radiogroup is label-associated; the progress note is
16
+ // role="status"; the server error is role="alert"; confirm shows a pending
17
+ // state and disables while in flight.
18
+
19
+ import { useState } from 'react'
20
+ import {
21
+ Label,
22
+ Radio,
23
+ RadioGroup,
24
+ type Key,
25
+ } from 'react-aria-components'
26
+ import { Button } from '../button'
27
+ import { Dialog } from '../dialog'
28
+
29
+ /** The two download formats a `download` job can produce. */
30
+ export const DOWNLOAD_FORMATS = ['png', 'pdf'] as const
31
+ export type DownloadFormat = (typeof DOWNLOAD_FORMATS)[number]
32
+
33
+ /** Strings the download modal renders — supplied by the app (i18n), not hardcoded. */
34
+ export interface DownloadModalLabels {
35
+ /** Dialog heading. */
36
+ title: string
37
+ /** Explanatory body copy under the title. */
38
+ body: string
39
+ /** Label for the format radiogroup. */
40
+ formatLabel: string
41
+ /** Label for the PNG option. */
42
+ formatPng: string
43
+ /** Label for the PDF option. */
44
+ formatPdf: string
45
+ /** Cancel button. */
46
+ cancel: string
47
+ /** Confirm (create download job) button — idle state. */
48
+ confirm: string
49
+ /** Confirm button — pending state. */
50
+ submitting: string
51
+ /** Progress note while the artifact is generating. */
52
+ preparing: string
53
+ /** Download-ready action label (links to the artifact). */
54
+ download: string
55
+ /** Note shown once the artifact is ready. */
56
+ ready: string
57
+ }
58
+
59
+ export interface DownloadModalProps {
60
+ /** Controlled open state. */
61
+ isOpen: boolean
62
+ /** Notified on open/close (false on Esc / click-outside / cancel). */
63
+ onOpenChange: (isOpen: boolean) => void
64
+ /** Create the `download` delivery job for the chosen format. */
65
+ onConfirm: (format: DownloadFormat) => void
66
+ /** Whether the confirm mutation is in flight (disables + shows pending). */
67
+ isPending?: boolean
68
+ /** Server / mutation error message to surface inline (role="alert"). */
69
+ error?: string
70
+ /**
71
+ * Once the job is created and its artifact is ready, the URL to download.
72
+ * While null/undefined the modal shows the create form (or the preparing
73
+ * note when `isPreparing`).
74
+ */
75
+ artifactUrl?: string | null
76
+ /** Whether a created job's artifact is still generating (shows the progress note). */
77
+ isPreparing?: boolean
78
+ /** App-supplied i18n strings. */
79
+ labels: DownloadModalLabels
80
+ /** Optional test id forwarded to the dialog. */
81
+ 'data-testid'?: string
82
+ }
83
+
84
+ /** Download modal with a PNG/PDF format choice and a ready-artifact download link. */
85
+ export function DownloadModal({
86
+ isOpen,
87
+ onOpenChange,
88
+ onConfirm,
89
+ isPending = false,
90
+ error,
91
+ artifactUrl,
92
+ isPreparing = false,
93
+ labels,
94
+ 'data-testid': testId,
95
+ }: DownloadModalProps): React.ReactNode {
96
+ const [format, setFormat] = useState<DownloadFormat>('png')
97
+
98
+ const handleConfirm = (): void => {
99
+ onConfirm(format)
100
+ }
101
+
102
+ const hasArtifact = artifactUrl != null && artifactUrl.length > 0
103
+
104
+ return (
105
+ <Dialog
106
+ title={labels.title}
107
+ data-testid={testId ?? 'delivery-download-modal'}
108
+ isOpen={isOpen}
109
+ onOpenChange={onOpenChange}
110
+ >
111
+ <div className="flex flex-col gap-3">
112
+ <p className="text-sm text-fg-muted">{labels.body}</p>
113
+
114
+ {hasArtifact ? (
115
+ <>
116
+ <p data-testid="delivery-download-ready" role="status" className="text-sm text-fg">
117
+ {labels.ready}
118
+ </p>
119
+ <div className="mt-2 flex justify-end gap-2">
120
+ <Button variant="secondary" size="sm" onPress={() => onOpenChange(false)}>
121
+ {labels.cancel}
122
+ </Button>
123
+ {/* React Aria <Button> is a real <button>; a download is a navigation,
124
+ so we use a styled anchor (the only anchor in these modals). */}
125
+ <a
126
+ data-testid="delivery-download-link"
127
+ href={artifactUrl ?? undefined}
128
+ download
129
+ className="inline-flex h-8 items-center justify-center gap-2 rounded-md bg-brand-primary px-3 text-sm font-medium text-fg-inverted no-underline outline-none transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
130
+ >
131
+ {labels.download}
132
+ </a>
133
+ </div>
134
+ </>
135
+ ) : isPreparing ? (
136
+ <>
137
+ <p data-testid="delivery-download-preparing" role="status" className="text-sm text-fg-muted">
138
+ {labels.preparing}
139
+ </p>
140
+ <div className="mt-2 flex justify-end gap-2">
141
+ <Button variant="secondary" size="sm" onPress={() => onOpenChange(false)}>
142
+ {labels.cancel}
143
+ </Button>
144
+ </div>
145
+ </>
146
+ ) : (
147
+ <>
148
+ <RadioGroup
149
+ aria-label={labels.formatLabel}
150
+ value={format}
151
+ onChange={(value: string) => setFormat(value as DownloadFormat)}
152
+ className="flex flex-col gap-2"
153
+ >
154
+ <Label className="text-sm font-medium text-fg">{labels.formatLabel}</Label>
155
+ <div className="flex gap-2">
156
+ <DownloadFormatOption value="png" label={labels.formatPng} />
157
+ <DownloadFormatOption value="pdf" label={labels.formatPdf} />
158
+ </div>
159
+ </RadioGroup>
160
+
161
+ {error ? (
162
+ <p data-testid="delivery-download-error" role="alert" className="text-sm text-danger">
163
+ {error}
164
+ </p>
165
+ ) : null}
166
+
167
+ <div className="mt-2 flex justify-end gap-2">
168
+ <Button
169
+ variant="secondary"
170
+ size="sm"
171
+ isDisabled={isPending}
172
+ onPress={() => onOpenChange(false)}
173
+ >
174
+ {labels.cancel}
175
+ </Button>
176
+ <Button
177
+ data-testid="delivery-download-confirm"
178
+ variant="primary"
179
+ size="sm"
180
+ isDisabled={isPending}
181
+ isPending={isPending}
182
+ onPress={handleConfirm}
183
+ >
184
+ {isPending ? labels.submitting : labels.confirm}
185
+ </Button>
186
+ </div>
187
+ </>
188
+ )}
189
+ </div>
190
+ </Dialog>
191
+ )
192
+ }
193
+
194
+ /** One radio chip in the download-format group. */
195
+ function DownloadFormatOption({ value, label }: { value: Key; label: string }): React.ReactNode {
196
+ return (
197
+ <Radio
198
+ value={String(value)}
199
+ className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-sm text-fg outline-none transition-colors data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[selected]:border-brand-primary data-[selected]:font-medium"
200
+ >
201
+ {label}
202
+ </Radio>
203
+ )
204
+ }
@@ -0,0 +1,170 @@
1
+ // @app/ui — publish modal (issue 14).
2
+ //
3
+ // PRESENTATIONAL publish dialog built on the @app/ui <Dialog> (React Aria Modal
4
+ // — focus trap, Esc, aria-modal). Collects a destination + channel and, on
5
+ // confirm, the owning surface in apps/web creates a `publish` delivery job.
6
+ //
7
+ // VALIDATION: destination + channel are both required (non-blank, trimmed) —
8
+ // confirm stays DISABLED until both hold a value. The channel is a constrained
9
+ // choice (the app supplies the option list as `labels.channels`).
10
+ //
11
+ // APP-AGNOSTIC (hard rule #1): every string is a prop; the action is delegated
12
+ // via `onConfirm(payload)`; apps/web wires i18n + the REST mutation (`publish`
13
+ // kind) + invalidation.
14
+ //
15
+ // a11y: the destination input is label-associated; the channel select is a RAC
16
+ // Select (listbox ARIA); the server error is role="alert"; confirm shows a
17
+ // pending state and disables while in flight / invalid.
18
+
19
+ import { useState } from 'react'
20
+ import { Button } from '../button'
21
+ import { Dialog } from '../dialog'
22
+ import { Input } from '../input'
23
+ import { Select, SelectItem } from '../select'
24
+
25
+ /** One publish channel option (value + visible label), supplied by the app. */
26
+ export interface PublishChannelOption {
27
+ /** Stable channel id stored on the job (e.g. "instagram", "linkedin"). */
28
+ value: string
29
+ /** Translated, human-readable channel name. */
30
+ label: string
31
+ }
32
+
33
+ /** The publish payload the modal yields on confirm. */
34
+ export interface PublishPayload {
35
+ /** Destination label (e.g. an account / page name) — non-blank. */
36
+ destination: string
37
+ /** Selected channel value. */
38
+ channel: string
39
+ }
40
+
41
+ /** Strings the publish modal renders — supplied by the app (i18n). */
42
+ export interface PublishModalLabels {
43
+ /** Dialog heading. */
44
+ title: string
45
+ /** Explanatory body copy under the title. */
46
+ body: string
47
+ /** Destination field label + placeholder. */
48
+ destinationLabel: string
49
+ destinationPlaceholder: string
50
+ /** Channel select label + placeholder. */
51
+ channelLabel: string
52
+ channelPlaceholder: string
53
+ /** Cancel button. */
54
+ cancel: string
55
+ /** Confirm (create publish job) button — idle state. */
56
+ confirm: string
57
+ /** Confirm button — pending state. */
58
+ submitting: string
59
+ /** The channel options to choose from. */
60
+ channels: PublishChannelOption[]
61
+ }
62
+
63
+ export interface PublishModalProps {
64
+ /** Controlled open state. */
65
+ isOpen: boolean
66
+ /** Notified on open/close (false on Esc / click-outside / cancel). */
67
+ onOpenChange: (isOpen: boolean) => void
68
+ /** Create the `publish` delivery job with the chosen destination + channel. */
69
+ onConfirm: (payload: PublishPayload) => void
70
+ /** Whether the confirm mutation is in flight (disables + shows pending). */
71
+ isPending?: boolean
72
+ /** Server / mutation error message to surface inline (role="alert"). */
73
+ error?: string
74
+ /** App-supplied i18n strings. */
75
+ labels: PublishModalLabels
76
+ /** Optional test id forwarded to the dialog. */
77
+ 'data-testid'?: string
78
+ }
79
+
80
+ /** Publish modal with a destination input + a channel choice. */
81
+ export function PublishModal({
82
+ isOpen,
83
+ onOpenChange,
84
+ onConfirm,
85
+ isPending = false,
86
+ error,
87
+ labels,
88
+ 'data-testid': testId,
89
+ }: PublishModalProps): React.ReactNode {
90
+ const [destination, setDestination] = useState('')
91
+ const [channel, setChannel] = useState<string>('')
92
+
93
+ const reset = (): void => {
94
+ setDestination('')
95
+ setChannel('')
96
+ }
97
+
98
+ const canConfirm = !isPending && destination.trim().length > 0 && channel.trim().length > 0
99
+
100
+ const handleOpenChange = (open: boolean): void => {
101
+ if (!open) reset()
102
+ onOpenChange(open)
103
+ }
104
+
105
+ const handleConfirm = (): void => {
106
+ if (!canConfirm) return
107
+ onConfirm({ destination: destination.trim(), channel })
108
+ }
109
+
110
+ return (
111
+ <Dialog
112
+ title={labels.title}
113
+ data-testid={testId ?? 'delivery-publish-modal'}
114
+ isOpen={isOpen}
115
+ onOpenChange={handleOpenChange}
116
+ >
117
+ <div className="flex flex-col gap-3">
118
+ <p className="text-sm text-fg-muted">{labels.body}</p>
119
+
120
+ <Input
121
+ label={labels.destinationLabel}
122
+ placeholder={labels.destinationPlaceholder}
123
+ value={destination}
124
+ onChange={setDestination}
125
+ data-testid="delivery-publish-destination"
126
+ />
127
+ <Select
128
+ label={labels.channelLabel}
129
+ placeholder={labels.channelPlaceholder}
130
+ selectedKey={channel.length > 0 ? channel : null}
131
+ onSelectionChange={(key) => setChannel(String(key))}
132
+ data-testid="delivery-publish-channel"
133
+ >
134
+ {labels.channels.map((option) => (
135
+ <SelectItem key={option.value} id={option.value}>
136
+ {option.label}
137
+ </SelectItem>
138
+ ))}
139
+ </Select>
140
+
141
+ {error ? (
142
+ <p data-testid="delivery-publish-error" role="alert" className="text-sm text-danger">
143
+ {error}
144
+ </p>
145
+ ) : null}
146
+
147
+ <div className="mt-2 flex justify-end gap-2">
148
+ <Button
149
+ variant="secondary"
150
+ size="sm"
151
+ isDisabled={isPending}
152
+ onPress={() => handleOpenChange(false)}
153
+ >
154
+ {labels.cancel}
155
+ </Button>
156
+ <Button
157
+ data-testid="delivery-publish-confirm"
158
+ variant="primary"
159
+ size="sm"
160
+ isDisabled={!canConfirm}
161
+ isPending={isPending}
162
+ onPress={handleConfirm}
163
+ >
164
+ {isPending ? labels.submitting : labels.confirm}
165
+ </Button>
166
+ </div>
167
+ </div>
168
+ </Dialog>
169
+ )
170
+ }