@omg-dev/pwa 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,122 @@
1
+ import { o as isInIframe } from "./core-YJoFChtB.mjs";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { createRoot } from "react-dom/client";
4
+ //#region src/remix-cta.tsx
5
+ const DISMISS_KEY = "vibes-remix:dismissed";
6
+ const REMIX_ORIGIN = typeof import.meta !== "undefined" && import.meta.env?.VITE_REMIX_ORIGIN || "https://omg.dev";
7
+ function appSlug() {
8
+ if (typeof import.meta === "undefined") return null;
9
+ const slug = import.meta.env?.VITE_APP_SLUG?.trim();
10
+ if (!slug || !/^[a-z0-9-]+$/.test(slug)) return null;
11
+ return slug;
12
+ }
13
+ function isDismissed() {
14
+ try {
15
+ return localStorage.getItem(DISMISS_KEY) === "1";
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+ function setDismissed() {
21
+ try {
22
+ localStorage.setItem(DISMISS_KEY, "1");
23
+ } catch {}
24
+ }
25
+ const STYLE_ID = "vibes-remix-cta-styles";
26
+ function ensureStyles() {
27
+ if (typeof document === "undefined") return;
28
+ if (document.getElementById(STYLE_ID)) return;
29
+ const el = document.createElement("style");
30
+ el.id = STYLE_ID;
31
+ el.textContent = `
32
+ .vremix {
33
+ position: fixed;
34
+ right: max(16px, env(safe-area-inset-right));
35
+ bottom: max(16px, env(safe-area-inset-bottom));
36
+ z-index: 2147482000;
37
+ display: inline-flex;
38
+ align-items: center;
39
+ gap: 8px;
40
+ padding: 9px 12px 9px 14px;
41
+ border-radius: 9999px;
42
+ font: 500 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
43
+ color: #fff;
44
+ background: #18181b;
45
+ border: 1px solid rgba(255, 255, 255, 0.12);
46
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.22);
47
+ text-decoration: none;
48
+ opacity: 0;
49
+ transform: translateY(8px);
50
+ transition: opacity 0.3s ease, transform 0.3s ease, background 0.15s ease;
51
+ -webkit-tap-highlight-color: transparent;
52
+ }
53
+ .vremix.vremix-in { opacity: 1; transform: translateY(0); }
54
+ .vremix:hover { background: #000; }
55
+ .vremix-spark { width: 14px; height: 14px; display: block; }
56
+ .vremix-close {
57
+ display: inline-flex; align-items: center; justify-content: center;
58
+ width: 18px; height: 18px; margin-left: 2px;
59
+ border-radius: 9999px; border: 0; cursor: pointer;
60
+ background: rgba(255, 255, 255, 0.14); color: #fff;
61
+ font: 600 12px/1 system-ui, sans-serif;
62
+ }
63
+ .vremix-close:hover { background: rgba(255, 255, 255, 0.26); }
64
+ @media (prefers-color-scheme: light) {
65
+ .vremix { background: #18181b; }
66
+ }`;
67
+ document.head.appendChild(el);
68
+ }
69
+ function RemixCta({ slug }) {
70
+ return /* @__PURE__ */ jsxs("a", {
71
+ className: "vremix",
72
+ href: `${REMIX_ORIGIN}/remix/${slug}?ref=pwa`,
73
+ ref: (node) => {
74
+ if (node) requestAnimationFrame(() => node.classList.add("vremix-in"));
75
+ },
76
+ children: [
77
+ /* @__PURE__ */ jsx("svg", {
78
+ className: "vremix-spark",
79
+ viewBox: "0 0 24 24",
80
+ fill: "none",
81
+ "aria-hidden": "true",
82
+ children: /* @__PURE__ */ jsx("path", {
83
+ d: "M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9L12 3z",
84
+ fill: "currentColor"
85
+ })
86
+ }),
87
+ "Make it mine",
88
+ /* @__PURE__ */ jsx("button", {
89
+ className: "vremix-close",
90
+ "aria-label": "Dismiss",
91
+ onClick: (e) => {
92
+ e.preventDefault();
93
+ e.stopPropagation();
94
+ setDismissed();
95
+ const host = document.getElementById("vibes-remix-cta");
96
+ if (host) host.remove();
97
+ },
98
+ children: "×"
99
+ })
100
+ ]
101
+ });
102
+ }
103
+ function shouldShow() {
104
+ if (typeof window === "undefined" || typeof document === "undefined") return false;
105
+ if (isInIframe()) return false;
106
+ if (isDismissed()) return false;
107
+ return appSlug() !== null;
108
+ }
109
+ function mount() {
110
+ if (!shouldShow()) return;
111
+ const slug = appSlug();
112
+ if (!slug) return;
113
+ ensureStyles();
114
+ const host = document.createElement("div");
115
+ host.id = "vibes-remix-cta";
116
+ document.body.appendChild(host);
117
+ createRoot(host).render(/* @__PURE__ */ jsx(RemixCta, { slug }));
118
+ }
119
+ if (typeof window !== "undefined") if (document.readyState === "complete") setTimeout(mount, 1800);
120
+ else window.addEventListener("load", () => setTimeout(mount, 1800), { once: true });
121
+ //#endregion
122
+ export {};
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@omg-dev/pwa",
3
+ "version": "0.4.24",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./src/index.ts",
8
+ "default": "./dist/index.mjs"
9
+ },
10
+ "./auto": {
11
+ "types": "./src/auto.tsx",
12
+ "default": "./dist/auto.mjs"
13
+ },
14
+ "./remix-cta": {
15
+ "types": "./src/remix-cta.tsx",
16
+ "default": "./dist/remix-cta.mjs"
17
+ }
18
+ },
19
+ "peerDependencies": {
20
+ "react": ">=18",
21
+ "react-dom": ">=18"
22
+ },
23
+ "devDependencies": {
24
+ "@types/react": "^19.2.14",
25
+ "@types/react-dom": "^19.2.3",
26
+ "typescript": "^5.4.5"
27
+ },
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/BennyKok/vibes.git"
32
+ },
33
+ "homepage": "https://docs.omg.dev",
34
+ "files": [
35
+ "dist",
36
+ "src"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "registry": "https://registry.npmjs.org/"
41
+ }
42
+ }
package/src/auto.tsx ADDED
@@ -0,0 +1,55 @@
1
+ // @omg-dev/pwa/auto — soft install prompt, injected into published builds by
2
+ // @omg-dev/vite-plugin (appended import in the app entry; see its pwa.ts).
3
+ //
4
+ // Mounts OUTSIDE the app's React tree (own root, own DOM node) so it
5
+ // survives whatever the build agent does to App.tsx/main.tsx. Policy is
6
+ // deliberately conservative:
7
+ // - never in an iframe (dashboard preview pane)
8
+ // - never when already running standalone / installed
9
+ // - mobile platforms only — desktop visitors aren't nagged
10
+ // - second visit onwards, shown ONCE; an explicit dismiss is forever
11
+ //
12
+ // Apps that want more control: vibes({ pwa: { autoPrompt: false } }) in
13
+ // vite.config.ts and place <VibesInstallPrompt/> from @omg-dev/pwa manually.
14
+
15
+ import { createRoot } from "react-dom/client"
16
+ import {
17
+ countVisit,
18
+ detectPlatform,
19
+ isDismissed,
20
+ isInIframe,
21
+ isStandalone,
22
+ setAutoShown,
23
+ wasAutoShown,
24
+ } from "./core"
25
+ import { VibesInstallPrompt } from "./react"
26
+
27
+ function shouldAutoShow(): boolean {
28
+ if (typeof window === "undefined" || typeof document === "undefined") return false
29
+ if (isInIframe()) return false
30
+ if (isStandalone()) return false
31
+ if (isDismissed() || wasAutoShown()) return false
32
+ const platform = detectPlatform()
33
+ if (platform !== "ios-safari" && platform !== "ios-chrome" && platform !== "ios-other" && platform !== "android") {
34
+ return false
35
+ }
36
+ return countVisit() >= 2
37
+ }
38
+
39
+ function mount() {
40
+ if (!shouldAutoShow()) return
41
+ setAutoShown()
42
+ const host = document.createElement("div")
43
+ host.id = "vibes-pwa-auto"
44
+ document.body.appendChild(host)
45
+ createRoot(host).render(<VibesInstallPrompt variant="floating" />)
46
+ }
47
+
48
+ if (typeof window !== "undefined") {
49
+ // Give the app a moment to settle before sliding anything in.
50
+ if (document.readyState === "complete") {
51
+ setTimeout(mount, 2500)
52
+ } else {
53
+ window.addEventListener("load", () => setTimeout(mount, 2500), { once: true })
54
+ }
55
+ }
package/src/core.ts ADDED
@@ -0,0 +1,197 @@
1
+ // @omg-dev/pwa — platform detection + beforeinstallprompt capture.
2
+ //
3
+ // The capture listener must be installed before Chrome fires
4
+ // `beforeinstallprompt` (shortly after load), so this module attaches it at
5
+ // import time. The store lives on `window` so the auto-injected entry and an
6
+ // app-imported copy of this module always share one capture — a second copy
7
+ // evaluating is a no-op.
8
+
9
+ export interface BeforeInstallPromptEvent extends Event {
10
+ prompt(): Promise<void>
11
+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>
12
+ }
13
+
14
+ export type InstallPlatform =
15
+ | "ios-safari" // Safari on iPhone/iPad — Share (toolbar) → Add to Home Screen
16
+ | "ios-chrome" // Chrome on iOS — Share (top bar) → Add to Home Screen
17
+ | "ios-other" // other iOS browsers — share sheet → Add to Home Screen (iOS ≥16.4)
18
+ | "android" // Chromium-family mobile — native prompt or ⋮ → Add to Home screen
19
+ | "desktop-chrome" // Chrome/Edge desktop — omnibox install icon
20
+ | "desktop-safari" // macOS Safari — File → Add to Dock
21
+ | "unsupported" // desktop Firefox, webviews, …
22
+
23
+ export function detectPlatform(): InstallPlatform {
24
+ if (typeof navigator === "undefined") return "unsupported"
25
+ const ua = navigator.userAgent
26
+ const isIOS =
27
+ /iPad|iPhone|iPod/.test(ua) ||
28
+ // iPadOS reports itself as a Mac; touch points give it away.
29
+ (/Mac/.test(ua) && navigator.maxTouchPoints > 1)
30
+ if (isIOS) {
31
+ if (/CriOS/.test(ua)) return "ios-chrome"
32
+ if (/FxiOS|EdgiOS|OPiOS|OPT\//.test(ua)) return "ios-other"
33
+ if (/Safari/.test(ua)) return "ios-safari"
34
+ // In-app webviews (Instagram, Gmail, …) have no share-sheet install row.
35
+ return "unsupported"
36
+ }
37
+ if (/Android/.test(ua)) {
38
+ // Android webviews can't install.
39
+ if (/; wv\)/.test(ua)) return "unsupported"
40
+ return "android"
41
+ }
42
+ if (/Edg\//.test(ua)) return "desktop-chrome"
43
+ if (/Chrome|Chromium/.test(ua)) return "desktop-chrome"
44
+ if (/Safari/.test(ua)) return "desktop-safari"
45
+ return "unsupported"
46
+ }
47
+
48
+ export function isStandalone(): boolean {
49
+ if (typeof window === "undefined") return false
50
+ if (window.matchMedia?.("(display-mode: standalone)").matches) return true
51
+ return (navigator as { standalone?: boolean }).standalone === true
52
+ }
53
+
54
+ /** True inside any iframe — e.g. the omg dashboard's preview pane. The
55
+ * install UI must never render there: the iframe URL is not the app URL. */
56
+ export function isInIframe(): boolean {
57
+ try {
58
+ return window.self !== window.top
59
+ } catch {
60
+ return true // cross-origin parent → definitely framed
61
+ }
62
+ }
63
+
64
+ // ── install-prompt store (window singleton) ─────────────────────────────────
65
+
66
+ export interface InstallState {
67
+ canPrompt: boolean
68
+ installed: boolean
69
+ }
70
+
71
+ interface PwaStore {
72
+ bip: BeforeInstallPromptEvent | null
73
+ snapshot: InstallState
74
+ subs: Set<() => void>
75
+ }
76
+
77
+ const SERVER_SNAPSHOT: InstallState = { canPrompt: false, installed: false }
78
+
79
+ function getStore(): PwaStore | null {
80
+ if (typeof window === "undefined") return null
81
+ const w = window as typeof window & { __vibesPwaStore?: PwaStore }
82
+ if (!w.__vibesPwaStore) {
83
+ const store: PwaStore = {
84
+ bip: null,
85
+ snapshot: { canPrompt: false, installed: isStandalone() },
86
+ subs: new Set(),
87
+ }
88
+ w.__vibesPwaStore = store
89
+ window.addEventListener("beforeinstallprompt", (e) => {
90
+ e.preventDefault() // suppress Chrome's mini-infobar; we prompt on tap
91
+ store.bip = e as BeforeInstallPromptEvent
92
+ publish(store)
93
+ })
94
+ window.addEventListener("appinstalled", () => {
95
+ store.bip = null
96
+ store.snapshot = { canPrompt: false, installed: true }
97
+ for (const cb of store.subs) cb()
98
+ })
99
+ }
100
+ return w.__vibesPwaStore
101
+ }
102
+
103
+ function publish(store: PwaStore) {
104
+ store.snapshot = { canPrompt: !!store.bip, installed: store.snapshot.installed }
105
+ for (const cb of store.subs) cb()
106
+ }
107
+
108
+ // Attach the capture listener as early as possible.
109
+ getStore()
110
+
111
+ export function subscribeInstallState(cb: () => void): () => void {
112
+ const store = getStore()
113
+ if (!store) return () => {}
114
+ store.subs.add(cb)
115
+ return () => store.subs.delete(cb)
116
+ }
117
+
118
+ export function getInstallState(): InstallState {
119
+ return getStore()?.snapshot ?? SERVER_SNAPSHOT
120
+ }
121
+
122
+ export function getServerInstallState(): InstallState {
123
+ return SERVER_SNAPSHOT
124
+ }
125
+
126
+ /** Fire the captured native install prompt (Chromium only). Resolves
127
+ * "unavailable" when no prompt was captured OR prompt() itself fails
128
+ * (emulated/headless contexts) — callers fall back to the guide. */
129
+ export async function promptNativeInstall(): Promise<"accepted" | "dismissed" | "unavailable"> {
130
+ const store = getStore()
131
+ if (!store?.bip) return "unavailable"
132
+ const ev = store.bip
133
+ store.bip = null
134
+ try {
135
+ await ev.prompt()
136
+ const { outcome } = await ev.userChoice
137
+ if (outcome === "accepted") store.snapshot = { canPrompt: false, installed: true }
138
+ publish(store)
139
+ return outcome
140
+ } catch {
141
+ publish(store)
142
+ return "unavailable"
143
+ }
144
+ }
145
+
146
+ // ── persistence (per-origin localStorage) ────────────────────────────────────
147
+
148
+ const KEY_DISMISSED = "vibes-pwa:dismissed"
149
+ const KEY_VISITS = "vibes-pwa:visits"
150
+ const KEY_AUTO_SHOWN = "vibes-pwa:auto-shown"
151
+ const SESSION_COUNTED = "vibes-pwa:counted"
152
+
153
+ function lsGet(key: string): string | null {
154
+ try {
155
+ return localStorage.getItem(key)
156
+ } catch {
157
+ return null // private mode / storage disabled
158
+ }
159
+ }
160
+
161
+ function lsSet(key: string, value: string) {
162
+ try {
163
+ localStorage.setItem(key, value)
164
+ } catch {
165
+ /* private mode */
166
+ }
167
+ }
168
+
169
+ export function isDismissed(): boolean {
170
+ return lsGet(KEY_DISMISSED) === "1"
171
+ }
172
+
173
+ export function setDismissed() {
174
+ lsSet(KEY_DISMISSED, "1")
175
+ }
176
+
177
+ export function wasAutoShown(): boolean {
178
+ return lsGet(KEY_AUTO_SHOWN) === "1"
179
+ }
180
+
181
+ export function setAutoShown() {
182
+ lsSet(KEY_AUTO_SHOWN, "1")
183
+ }
184
+
185
+ /** Count one visit per browser session; returns the running total. */
186
+ export function countVisit(): number {
187
+ const prev = Number(lsGet(KEY_VISITS) ?? "0") || 0
188
+ try {
189
+ if (sessionStorage.getItem(SESSION_COUNTED) === "1") return prev
190
+ sessionStorage.setItem(SESSION_COUNTED, "1")
191
+ } catch {
192
+ return prev + 1 // can't dedupe — count it, don't persist double
193
+ }
194
+ const next = prev + 1
195
+ lsSet(KEY_VISITS, String(next))
196
+ return next
197
+ }
package/src/icons.tsx ADDED
@@ -0,0 +1,134 @@
1
+ // @omg-dev/pwa — inline SVG glyphs (lucide-style strokes, currentColor).
2
+ // Dependency-free so the package adds nothing to user-app bundles.
3
+
4
+ interface IconProps {
5
+ size?: number
6
+ className?: string
7
+ }
8
+
9
+ function svgProps({ size = 18, className }: IconProps) {
10
+ return {
11
+ width: size,
12
+ height: size,
13
+ viewBox: "0 0 24 24",
14
+ fill: "none",
15
+ stroke: "currentColor",
16
+ strokeWidth: 2,
17
+ strokeLinecap: "round" as const,
18
+ strokeLinejoin: "round" as const,
19
+ className,
20
+ "aria-hidden": true,
21
+ }
22
+ }
23
+
24
+ /** iOS share: square with arrow up through the top. */
25
+ export function ShareIcon(p: IconProps) {
26
+ return (
27
+ <svg {...svgProps(p)}>
28
+ <path d="M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7" />
29
+ <polyline points="16 6 12 2 8 6" />
30
+ <line x1="12" y1="2" x2="12" y2="15" />
31
+ </svg>
32
+ )
33
+ }
34
+
35
+ export function SquarePlusIcon(p: IconProps) {
36
+ return (
37
+ <svg {...svgProps(p)}>
38
+ <rect width="18" height="18" x="3" y="3" rx="3" />
39
+ <path d="M8 12h8" />
40
+ <path d="M12 8v8" />
41
+ </svg>
42
+ )
43
+ }
44
+
45
+ export function EllipsisVerticalIcon(p: IconProps) {
46
+ return (
47
+ <svg {...svgProps(p)}>
48
+ <circle cx="12" cy="5" r="1" fill="currentColor" />
49
+ <circle cx="12" cy="12" r="1" fill="currentColor" />
50
+ <circle cx="12" cy="19" r="1" fill="currentColor" />
51
+ </svg>
52
+ )
53
+ }
54
+
55
+ export function ChevronLeftIcon(p: IconProps) {
56
+ return (
57
+ <svg {...svgProps(p)}>
58
+ <path d="m15 18-6-6 6-6" />
59
+ </svg>
60
+ )
61
+ }
62
+
63
+ export function ChevronRightIcon(p: IconProps) {
64
+ return (
65
+ <svg {...svgProps(p)}>
66
+ <path d="m9 18 6-6-6-6" />
67
+ </svg>
68
+ )
69
+ }
70
+
71
+ export function BookIcon(p: IconProps) {
72
+ return (
73
+ <svg {...svgProps(p)}>
74
+ <path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" />
75
+ </svg>
76
+ )
77
+ }
78
+
79
+ export function CopySquaresIcon(p: IconProps) {
80
+ return (
81
+ <svg {...svgProps(p)}>
82
+ <rect width="14" height="14" x="8" y="8" rx="2" />
83
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
84
+ </svg>
85
+ )
86
+ }
87
+
88
+ /** Chrome's omnibox install glyph: monitor with a down arrow. */
89
+ export function MonitorDownIcon(p: IconProps) {
90
+ return (
91
+ <svg {...svgProps(p)}>
92
+ <path d="M12 13V7" />
93
+ <path d="m15 10-3 3-3-3" />
94
+ <rect width="20" height="14" x="2" y="3" rx="2" />
95
+ <path d="M12 17v4" />
96
+ <path d="M8 21h8" />
97
+ </svg>
98
+ )
99
+ }
100
+
101
+ export function SmartphoneIcon(p: IconProps) {
102
+ return (
103
+ <svg {...svgProps(p)}>
104
+ <rect width="14" height="20" x="5" y="2" rx="2" />
105
+ <path d="M12 18h.01" />
106
+ </svg>
107
+ )
108
+ }
109
+
110
+ export function XIcon(p: IconProps) {
111
+ return (
112
+ <svg {...svgProps(p)}>
113
+ <path d="M18 6 6 18" />
114
+ <path d="m6 6 12 12" />
115
+ </svg>
116
+ )
117
+ }
118
+
119
+ export function CheckIcon(p: IconProps) {
120
+ return (
121
+ <svg {...svgProps(p)}>
122
+ <path d="M20 6 9 17l-5-5" />
123
+ </svg>
124
+ )
125
+ }
126
+
127
+ export function LockIcon(p: IconProps) {
128
+ return (
129
+ <svg {...svgProps(p)}>
130
+ <rect width="18" height="11" x="3" y="11" rx="2" />
131
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
132
+ </svg>
133
+ )
134
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ // @omg-dev/pwa — universal "Add to Home Screen" onboarding.
2
+ //
3
+ // Shared between the omg dashboard (workspace:*) and user-generated apps
4
+ // (registry + template bake). Self-contained styling — no Tailwind classes,
5
+ // no CSS imports; themes itself from the host's shadcn CSS variables with
6
+ // sane light/dark fallbacks.
7
+
8
+ export {
9
+ detectPlatform,
10
+ isStandalone,
11
+ isInIframe,
12
+ promptNativeInstall,
13
+ isDismissed,
14
+ setDismissed,
15
+ countVisit,
16
+ type InstallPlatform,
17
+ type InstallState,
18
+ type BeforeInstallPromptEvent,
19
+ } from "./core"
20
+
21
+ export { useInstallPrompt, InstallGuide, VibesInstallPrompt, defaultAppName } from "./react"