@omg-dev/sdk 0.4.24

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,366 @@
1
+ import { useId, useState, type CSSProperties } from "react"
2
+ import { VibesFeedback } from "../feedback/VibesFeedback"
3
+
4
+ export interface OmgBadgeProps {
5
+ /** Link used by the dialog CTA. Default: https://omg.dev */
6
+ href?: string
7
+ /** Accessible label for the floating badge button. */
8
+ label?: string
9
+ }
10
+
11
+ // The one brand colour. Don't recolour it — the coral IS the brand.
12
+ const BRAND = "#FF5530"
13
+ const Z_BADGE = 2147482000
14
+ const Z_DIALOG = 2147483100
15
+
16
+ // Cross-process signal the brand badge dispatches to summon the (button-less)
17
+ // feedback sheet it mounts. Bundling feedback INTO this badge is deliberate:
18
+ // one floating control in the corner, not three.
19
+ export const FEEDBACK_OPEN_EVENT = "vibes:feedback:open"
20
+
21
+ // Build-time gate (set by @omg-dev/vite-plugin via `define`). True when the app
22
+ // has feedback enabled AND the brand badge owns it. Falls back to true when the
23
+ // badge is used outside the plugin (it's only auto-injected by the plugin, so
24
+ // in practice the define is always present in published bundles).
25
+ declare const __VIBES_BRAND_FEEDBACK__: boolean | undefined
26
+ function feedbackBundled(): boolean {
27
+ try {
28
+ return typeof __VIBES_BRAND_FEEDBACK__ === "undefined" ? true : __VIBES_BRAND_FEEDBACK__
29
+ } catch {
30
+ return true
31
+ }
32
+ }
33
+
34
+ // ── Remix ("Make it mine") gating ────────────────────────────────────────────
35
+ // The badge's first job on a PUBLISHED app is the recipient→creator hinge: a
36
+ // visitor who only has the shared link can fork the app back on omg.dev. Once
37
+ // they dismiss that, the SAME badge collapses to plain branding — the omg mark
38
+ // never leaves, only the call-to-action does.
39
+ const DISMISS_KEY = "vibes-remix:dismissed"
40
+ const REMIX_ORIGIN =
41
+ (typeof import.meta !== "undefined" &&
42
+ (import.meta as { env?: Record<string, string | undefined> }).env?.VITE_REMIX_ORIGIN) ||
43
+ "https://omg.dev"
44
+
45
+ function appSlug(): string | null {
46
+ if (typeof import.meta === "undefined") return null
47
+ const env = (import.meta as { env?: Record<string, string | undefined> }).env
48
+ const slug = env?.VITE_APP_SLUG?.trim()
49
+ if (!slug || !/^[a-z0-9-]+$/.test(slug)) return null
50
+ return slug
51
+ }
52
+
53
+ function isDismissed(): boolean {
54
+ try {
55
+ return localStorage.getItem(DISMISS_KEY) === "1"
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
61
+ function setDismissedFlag() {
62
+ try {
63
+ localStorage.setItem(DISMISS_KEY, "1")
64
+ } catch {
65
+ /* private mode */
66
+ }
67
+ }
68
+
69
+ export function OmgBadge({
70
+ href = "https://omg.dev",
71
+ label = "What is omg?",
72
+ }: OmgBadgeProps) {
73
+ const [open, setOpen] = useState(false)
74
+ // "Make it mine" shows only on a published app (slug baked in) that the
75
+ // visitor hasn't dismissed yet. Dismissing keeps the badge — it just drops to
76
+ // the branding state — so the omg mark stays put.
77
+ const slug = appSlug()
78
+ const [remix, setRemix] = useState(() => slug !== null && !isDismissed())
79
+ const titleId = useId()
80
+ const descriptionId = useId()
81
+ const markId = useId().replace(/[^a-zA-Z0-9_-]/g, "")
82
+
83
+ function dismissRemix() {
84
+ setDismissedFlag()
85
+ setRemix(false)
86
+ }
87
+
88
+ function summonFeedback() {
89
+ setOpen(false)
90
+ if (typeof window !== "undefined") {
91
+ window.dispatchEvent(new CustomEvent(FEEDBACK_OPEN_EVENT))
92
+ }
93
+ }
94
+
95
+ return (
96
+ <>
97
+ {remix && slug ? (
98
+ // Remix state: the omg mark + "Make it mine" CTA. Tapping the pill body
99
+ // navigates to the dashboard to fork the app; the ✕ collapses to the
100
+ // branding badge. ?ref=pwa attributes the click on /remix.
101
+ <a
102
+ href={`${REMIX_ORIGIN}/remix/${slug}?ref=pwa`}
103
+ aria-label="Make this app mine"
104
+ data-vibes-feedback=""
105
+ style={badgeStyle}
106
+ >
107
+ <OmgMark size={18} id={`remix-${markId}`} />
108
+ <span style={badgeTextStyle}>Make it mine</span>
109
+ <button
110
+ type="button"
111
+ aria-label="Dismiss"
112
+ onClick={(event) => {
113
+ event.preventDefault()
114
+ event.stopPropagation()
115
+ dismissRemix()
116
+ }}
117
+ style={dismissStyle}
118
+ >
119
+ <svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none">
120
+ <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
121
+ </svg>
122
+ </button>
123
+ </a>
124
+ ) : (
125
+ // Branding state: the omg mark + "omg". Opens the "Built with omg"
126
+ // dialog, which also hosts the feedback entry.
127
+ <button
128
+ type="button"
129
+ aria-label={label}
130
+ data-vibes-feedback=""
131
+ onClick={() => setOpen(true)}
132
+ style={badgeStyle}
133
+ >
134
+ <OmgMark size={18} id={`badge-${markId}`} />
135
+ <span style={badgeTextStyle}>omg</span>
136
+ </button>
137
+ )}
138
+
139
+ {open && (
140
+ <div
141
+ data-vibes-feedback=""
142
+ role="presentation"
143
+ style={backdropStyle}
144
+ onMouseDown={(event) => {
145
+ if (event.target === event.currentTarget) setOpen(false)
146
+ }}
147
+ >
148
+ <div
149
+ role="dialog"
150
+ aria-modal="true"
151
+ aria-labelledby={titleId}
152
+ aria-describedby={descriptionId}
153
+ style={dialogStyle}
154
+ >
155
+ <button
156
+ type="button"
157
+ aria-label="Close"
158
+ onClick={() => setOpen(false)}
159
+ style={closeButtonStyle}
160
+ >
161
+ <svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none">
162
+ <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
163
+ </svg>
164
+ </button>
165
+
166
+ <div style={markWrapStyle}>
167
+ <OmgMark size={34} id={`dialog-${markId}`} />
168
+ </div>
169
+ <h2 id={titleId} style={titleStyle}>
170
+ Built with omg
171
+ </h2>
172
+ <p id={descriptionId} style={bodyStyle}>
173
+ omg turns a plain-language idea into a real app, then hosts it with sign-in,
174
+ data storage, and publishing already handled.
175
+ </p>
176
+ <a href={href} target="_blank" rel="noopener noreferrer" style={ctaStyle}>
177
+ Learn about omg
178
+ </a>
179
+ {feedbackBundled() && (
180
+ <button type="button" onClick={summonFeedback} style={secondaryStyle}>
181
+ Send feedback
182
+ </button>
183
+ )}
184
+ </div>
185
+ </div>
186
+ )}
187
+
188
+ {/* Feedback is bundled into the badge — a button-less sheet summoned from
189
+ the dialog (or by gesture), so there's no second floating control. */}
190
+ {feedbackBundled() && (
191
+ <VibesFeedback showButton={false} accent={BRAND} title="Send feedback" />
192
+ )}
193
+ </>
194
+ )
195
+ }
196
+
197
+ // The Gasp — a filled coral disc with a single circular bite at the top-right
198
+ // (the startled "o" mouth). Matches the canonical mark in apps/web; the bite
199
+ // radius nudges up at small sizes so the gap survives anti-aliasing. The old
200
+ // badge mark cut a centred hole + a side dot, which was NOT the brand mark.
201
+ function OmgMark({ size, id }: { size: number; id: string }) {
202
+ const maskId = `omg-mark-cut-${id}`
203
+ const biteR = size <= 16 ? 16.5 : size <= 20 ? 15.2 : size <= 32 ? 14 : 13.2
204
+ return (
205
+ <svg
206
+ aria-hidden="true"
207
+ width={size}
208
+ height={size}
209
+ viewBox="0 0 100 100"
210
+ fill="none"
211
+ style={{ display: "block", flex: "0 0 auto" }}
212
+ >
213
+ <mask id={maskId}>
214
+ <rect width="100" height="100" fill="white" />
215
+ <circle cx="71" cy="29" r={biteR} fill="black" />
216
+ </mask>
217
+ <circle cx="50" cy="50" r="44" fill={BRAND} mask={`url(#${maskId})`} />
218
+ </svg>
219
+ )
220
+ }
221
+
222
+ const font = "system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
223
+
224
+ const badgeStyle: CSSProperties = {
225
+ position: "fixed",
226
+ right: "max(14px, env(safe-area-inset-right))",
227
+ bottom: "calc(16px + env(safe-area-inset-bottom))",
228
+ zIndex: Z_BADGE,
229
+ height: 38,
230
+ padding: "0 8px 0 10px",
231
+ border: "1px solid rgba(20, 16, 12, 0.12)",
232
+ borderRadius: 999,
233
+ background: "rgba(255, 255, 255, 0.94)",
234
+ color: "#15110d",
235
+ boxShadow: "0 8px 28px rgba(0, 0, 0, 0.16)",
236
+ display: "inline-flex",
237
+ alignItems: "center",
238
+ gap: 7,
239
+ fontFamily: font,
240
+ cursor: "pointer",
241
+ textDecoration: "none",
242
+ WebkitTapHighlightColor: "transparent",
243
+ backdropFilter: "blur(10px)",
244
+ }
245
+
246
+ const badgeTextStyle: CSSProperties = {
247
+ fontSize: 13,
248
+ lineHeight: "18px",
249
+ fontWeight: 700,
250
+ letterSpacing: 0,
251
+ padding: "0 3px",
252
+ }
253
+
254
+ const dismissStyle: CSSProperties = {
255
+ width: 22,
256
+ height: 22,
257
+ flex: "0 0 auto",
258
+ borderRadius: 999,
259
+ border: "none",
260
+ background: "rgba(20, 16, 12, 0.06)",
261
+ color: "#6b6459",
262
+ display: "grid",
263
+ placeItems: "center",
264
+ cursor: "pointer",
265
+ padding: 0,
266
+ }
267
+
268
+ const backdropStyle: CSSProperties = {
269
+ position: "fixed",
270
+ inset: 0,
271
+ zIndex: Z_DIALOG,
272
+ display: "grid",
273
+ placeItems: "center",
274
+ padding: "24px 16px calc(24px + env(safe-area-inset-bottom))",
275
+ background: "rgba(15, 13, 11, 0.34)",
276
+ boxSizing: "border-box",
277
+ }
278
+
279
+ const dialogStyle: CSSProperties = {
280
+ position: "relative",
281
+ width: "min(100%, 360px)",
282
+ borderRadius: 18,
283
+ padding: "26px 22px 20px",
284
+ background: "#fffaf4",
285
+ color: "#15110d",
286
+ boxShadow: "0 24px 70px rgba(0, 0, 0, 0.28)",
287
+ border: "1px solid rgba(21, 17, 13, 0.08)",
288
+ fontFamily: font,
289
+ boxSizing: "border-box",
290
+ }
291
+
292
+ const closeButtonStyle: CSSProperties = {
293
+ position: "absolute",
294
+ top: 10,
295
+ right: 10,
296
+ width: 34,
297
+ height: 34,
298
+ borderRadius: 999,
299
+ border: "none",
300
+ background: "transparent",
301
+ color: "#6b6459",
302
+ display: "grid",
303
+ placeItems: "center",
304
+ cursor: "pointer",
305
+ }
306
+
307
+ const markWrapStyle: CSSProperties = {
308
+ width: 52,
309
+ height: 52,
310
+ borderRadius: 16,
311
+ display: "grid",
312
+ placeItems: "center",
313
+ background: "#fff",
314
+ boxShadow: "inset 0 0 0 1px rgba(21, 17, 13, 0.08)",
315
+ marginBottom: 16,
316
+ }
317
+
318
+ const titleStyle: CSSProperties = {
319
+ margin: 0,
320
+ color: "#15110d",
321
+ fontSize: 23,
322
+ lineHeight: "28px",
323
+ fontWeight: 750,
324
+ letterSpacing: 0,
325
+ }
326
+
327
+ const bodyStyle: CSSProperties = {
328
+ margin: "10px 0 18px",
329
+ color: "#5f574d",
330
+ fontSize: 15,
331
+ lineHeight: "22px",
332
+ letterSpacing: 0,
333
+ }
334
+
335
+ const ctaStyle: CSSProperties = {
336
+ display: "inline-flex",
337
+ alignItems: "center",
338
+ justifyContent: "center",
339
+ width: "100%",
340
+ height: 44,
341
+ borderRadius: 12,
342
+ background: "#15110d",
343
+ color: "#fffaf4",
344
+ fontSize: 15,
345
+ fontWeight: 700,
346
+ textDecoration: "none",
347
+ letterSpacing: 0,
348
+ }
349
+
350
+ const secondaryStyle: CSSProperties = {
351
+ display: "inline-flex",
352
+ alignItems: "center",
353
+ justifyContent: "center",
354
+ width: "100%",
355
+ height: 44,
356
+ marginTop: 8,
357
+ borderRadius: 12,
358
+ background: "transparent",
359
+ border: "1px solid rgba(21, 17, 13, 0.14)",
360
+ color: "#15110d",
361
+ fontSize: 15,
362
+ fontWeight: 650,
363
+ cursor: "pointer",
364
+ fontFamily: font,
365
+ letterSpacing: 0,
366
+ }
@@ -0,0 +1,35 @@
1
+ // @omg-dev/sdk/brand/auto — the single omg badge for published apps.
2
+ //
3
+ // Injected into published builds by @omg-dev/vite-plugin. It mounts outside the
4
+ // app's React tree so it survives user app rewrites and does not require each
5
+ // generated app to carry attribution code in its source.
6
+ //
7
+ // One control, not three: this badge is the "Make it mine" remix CTA on a fresh
8
+ // visit (same omg mark), collapses to the plain branding badge on dismiss, and
9
+ // bundles the (button-less) feedback sheet, summoned from its dialog. The
10
+ // vite-plugin therefore skips the standalone feedback/auto + remix-cta imports
11
+ // whenever this badge is enabled (the default).
12
+
13
+ import { createRoot } from "react-dom/client"
14
+ import { OmgBadge } from "./OmgBadge"
15
+
16
+ const HOST_ID = "vibes-brand-badge-auto"
17
+
18
+ function mount() {
19
+ if (typeof window === "undefined" || typeof document === "undefined") return
20
+ if (document.getElementById(HOST_ID)) return
21
+ const host = document.createElement("div")
22
+ host.id = HOST_ID
23
+ // Feedback screenshots should capture the app, not platform chrome.
24
+ host.setAttribute("data-vibes-feedback", "")
25
+ document.body.appendChild(host)
26
+ createRoot(host).render(<OmgBadge />)
27
+ }
28
+
29
+ if (typeof window !== "undefined") {
30
+ if (document.readyState === "loading") {
31
+ document.addEventListener("DOMContentLoaded", mount, { once: true })
32
+ } else {
33
+ mount()
34
+ }
35
+ }
@@ -0,0 +1 @@
1
+ export { OmgBadge, type OmgBadgeProps } from "./OmgBadge"