rei-kit 0.12.0 → 0.13.0

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,26 @@
1
+ /**
2
+ * The card that offers to install the app, and remembers being turned down.
3
+ *
4
+ * Both phone apps had this, 111 lines, differing in a storage key and a colour.
5
+ * What is here is the part that was the same: the three-way platform check, the
6
+ * week-long snooze, and the shape.
7
+ *
8
+ * Every string is a prop. The kit does not know a language, and "Add to Home
9
+ * Screen" is a sentence about a platform, not about this component.
10
+ */
11
+ type __VLS_Props = {
12
+ /** Where the dismissal is remembered. Namespace it: `hibi-install-nudge`. */
13
+ storageKey: string;
14
+ title: string;
15
+ body: string;
16
+ /** The install button. Not shown on iOS, which has no API to call. */
17
+ action: string;
18
+ later: string;
19
+ /** Shown instead of `title` where the user has to use the Share menu. */
20
+ iosTitle?: string | undefined;
21
+ iosBody?: string | undefined;
22
+ snoozeDays?: number | undefined;
23
+ };
24
+ declare const __VLS_export: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
25
+ declare const _default: typeof __VLS_export;
26
+ export default _default;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The card that says a new version is waiting.
3
+ *
4
+ * `registerType: 'prompt'` means a new service worker waits rather than taking
5
+ * over, and this is what asks whether to apply it. Asking rather than reloading
6
+ * is the decision worth keeping: an automatic swap mid-sentence loses whatever
7
+ * was being typed.
8
+ *
9
+ * **The service worker stays the app's.** `virtual:pwa-register/vue` is a
10
+ * build-time module from `vite-plugin-pwa`, and a library cannot import one —
11
+ * so the app owns `useRegisterSW` and hands the answer down as `open`. That
12
+ * split is also honest: whether an update is waiting is the app's business,
13
+ * and what the card looks like is this component's.
14
+ *
15
+ * @example
16
+ * ```vue
17
+ * <script setup>
18
+ * const { needRefresh, updateServiceWorker } = useRegisterSW()
19
+ * <\/script>
20
+ *
21
+ * <UpdatePrompt
22
+ * :open="needRefresh"
23
+ * :title="t('pwa.updateTitle')"
24
+ * :body="t('pwa.updateBody')"
25
+ * :action="t('pwa.reload')"
26
+ * :dismiss-label="t('pwa.later')"
27
+ * @update="updateServiceWorker(true)"
28
+ * @dismiss="needRefresh = false"
29
+ * />
30
+ * ```
31
+ */
32
+ type __VLS_Props = {
33
+ open: boolean;
34
+ title: string;
35
+ body: string;
36
+ action: string;
37
+ /** The X's accessible name. An X on its own has none. */
38
+ dismissLabel: string;
39
+ };
40
+ declare const __VLS_export: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
41
+ dismiss: () => any;
42
+ update: () => any;
43
+ }, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
44
+ onDismiss?: () => any;
45
+ onUpdate?: () => any;
46
+ }>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
47
+ declare const _default: typeof __VLS_export;
48
+ export default _default;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * rei-kit/pwa — installing the app, and updating it.
3
+ *
4
+ * A separate entry because these reach for `beforeinstallprompt` and a service
5
+ * worker: an app that is a website should not download them, and the main
6
+ * barrel has to stay importable on a server.
7
+ *
8
+ * Both phone apps had all of this, 95–100% identical — the same three-way
9
+ * platform check, the same week-long snooze, the same two cards. What differed
10
+ * was a storage key and a colour.
11
+ */
12
+ export { useInstall, watchInstallability } from './use-install';
13
+ export { useSnooze } from './use-snooze';
14
+ export { default as InstallPrompt } from './InstallPrompt.vue';
15
+ export { default as UpdatePrompt } from './UpdatePrompt.vue';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Starts listening for the install event.
3
+ *
4
+ * **Call this from the app's entry file, not from a component.**
5
+ * `beforeinstallprompt` fires once, early, and only once per page load — a
6
+ * listener attached when a component mounts has usually already missed it.
7
+ *
8
+ * It is a function rather than module-scope side effects so the package can be
9
+ * imported on a server: the phone apps this came from ran it at module scope
10
+ * and the kit spent a release learning why that is not free.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // main.ts
15
+ * import { watchInstallability } from 'rei-kit/pwa'
16
+ * watchInstallability()
17
+ * ```
18
+ */
19
+ export declare function watchInstallability(): void;
20
+ /**
21
+ * Adding the app to the Home Screen.
22
+ *
23
+ * Three states, because the platforms genuinely differ: Chromium hands over an
24
+ * event that can be triggered from a button, Safari on iOS has no API at all
25
+ * and needs the user walked through Share → Add to Home Screen, and everything
26
+ * else can only be told that installing is possible.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const install = useInstall()
31
+ * if (install.canPrompt.value) await install.prompt()
32
+ * ```
33
+ */
34
+ export declare function useInstall(): {
35
+ isInstalled: import('vue').ComputedRef<boolean>;
36
+ /** A button can open the real install sheet. */
37
+ canPrompt: import('vue').ComputedRef<boolean>;
38
+ /** No API — the user has to be shown the Share menu. */
39
+ needsManualSteps: import('vue').ComputedRef<boolean>;
40
+ prompt: () => Promise<boolean>;
41
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A nudge that stops asking for a while after it is dismissed.
3
+ *
4
+ * Both phone apps had this inside their install card, identically: a date in
5
+ * `localStorage`, compared against today. Pulled out because the install card
6
+ * is not the only thing that should stop asking — a notification nudge wants
7
+ * exactly the same behaviour, and had exactly the same code.
8
+ *
9
+ * Storage is wrapped in try/catch on both sides. A private window or a browser
10
+ * set to block site data throws on access, and the honest failure there is a
11
+ * nudge that reappears next session rather than one that crashes the screen it
12
+ * is asking from.
13
+ *
14
+ * @param key - Where the date is kept. Namespace it to the app.
15
+ * @param days - How long to stay quiet after a dismissal.
16
+ */
17
+ export declare function useSnooze(key: string, days?: number): {
18
+ /** False while the nudge is snoozed. */
19
+ isOver: import('vue').ComputedRef<boolean>;
20
+ /** Stop asking for `days`. */
21
+ snooze(): void;
22
+ };
package/dist/pwa.js ADDED
@@ -0,0 +1,236 @@
1
+ import { a as needsIosInstall, i as isInstalled, n as BaseButton_default, o as addDays, p as todayKey, t as _plugin_vue_export_helper_default } from "./_plugin-vue_export-helper-3AcMDTtW.js";
2
+ import { Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, openBlock, ref, resolveDynamicComponent, toDisplayString, unref, withCtx } from "vue";
3
+ import { Download, RefreshCw, Share, X } from "lucide-vue-next";
4
+ //#region src/pwa/use-install.ts
5
+ var deferred = ref(null);
6
+ var installed = ref(false);
7
+ var listening = false;
8
+ /**
9
+ * Starts listening for the install event.
10
+ *
11
+ * **Call this from the app's entry file, not from a component.**
12
+ * `beforeinstallprompt` fires once, early, and only once per page load — a
13
+ * listener attached when a component mounts has usually already missed it.
14
+ *
15
+ * It is a function rather than module-scope side effects so the package can be
16
+ * imported on a server: the phone apps this came from ran it at module scope
17
+ * and the kit spent a release learning why that is not free.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * // main.ts
22
+ * import { watchInstallability } from 'rei-kit/pwa'
23
+ * watchInstallability()
24
+ * ```
25
+ */
26
+ function watchInstallability() {
27
+ if (listening || typeof window === "undefined") return;
28
+ listening = true;
29
+ installed.value = isInstalled();
30
+ window.addEventListener("beforeinstallprompt", (event) => {
31
+ event.preventDefault();
32
+ deferred.value = event;
33
+ });
34
+ window.addEventListener("appinstalled", () => {
35
+ installed.value = true;
36
+ deferred.value = null;
37
+ });
38
+ }
39
+ /**
40
+ * Adding the app to the Home Screen.
41
+ *
42
+ * Three states, because the platforms genuinely differ: Chromium hands over an
43
+ * event that can be triggered from a button, Safari on iOS has no API at all
44
+ * and needs the user walked through Share → Add to Home Screen, and everything
45
+ * else can only be told that installing is possible.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * const install = useInstall()
50
+ * if (install.canPrompt.value) await install.prompt()
51
+ * ```
52
+ */
53
+ function useInstall() {
54
+ async function prompt() {
55
+ const event = deferred.value;
56
+ if (!event) return false;
57
+ await event.prompt();
58
+ const { outcome } = await event.userChoice;
59
+ deferred.value = null;
60
+ return outcome === "accepted";
61
+ }
62
+ return {
63
+ isInstalled: computed(() => installed.value),
64
+ /** A button can open the real install sheet. */
65
+ canPrompt: computed(() => !installed.value && deferred.value !== null),
66
+ /** No API — the user has to be shown the Share menu. */
67
+ needsManualSteps: computed(() => !installed.value && needsIosInstall()),
68
+ prompt
69
+ };
70
+ }
71
+ //#endregion
72
+ //#region src/pwa/use-snooze.ts
73
+ /**
74
+ * A nudge that stops asking for a while after it is dismissed.
75
+ *
76
+ * Both phone apps had this inside their install card, identically: a date in
77
+ * `localStorage`, compared against today. Pulled out because the install card
78
+ * is not the only thing that should stop asking — a notification nudge wants
79
+ * exactly the same behaviour, and had exactly the same code.
80
+ *
81
+ * Storage is wrapped in try/catch on both sides. A private window or a browser
82
+ * set to block site data throws on access, and the honest failure there is a
83
+ * nudge that reappears next session rather than one that crashes the screen it
84
+ * is asking from.
85
+ *
86
+ * @param key - Where the date is kept. Namespace it to the app.
87
+ * @param days - How long to stay quiet after a dismissal.
88
+ */
89
+ function useSnooze(key, days = 7) {
90
+ const read = () => {
91
+ try {
92
+ return localStorage.getItem(key) ?? "";
93
+ } catch {
94
+ return "";
95
+ }
96
+ };
97
+ const until = ref(read());
98
+ return {
99
+ /** False while the nudge is snoozed. */
100
+ isOver: computed(() => todayKey() >= until.value),
101
+ /** Stop asking for `days`. */
102
+ snooze() {
103
+ const next = addDays(todayKey(), days);
104
+ until.value = next;
105
+ try {
106
+ localStorage.setItem(key, next);
107
+ } catch {}
108
+ }
109
+ };
110
+ }
111
+ //#endregion
112
+ //#region src/pwa/InstallPrompt.vue?vue&type=script&setup=true&lang.ts
113
+ var _hoisted_1$1 = {
114
+ key: 0,
115
+ class: "border-positive/25 bg-positive/5 rounded-card flex gap-3 border p-3.5"
116
+ };
117
+ var _hoisted_2$1 = {
118
+ class: "bg-positive/15 text-positive flex size-10 shrink-0 items-center justify-center rounded-xl",
119
+ "aria-hidden": "true"
120
+ };
121
+ var _hoisted_3$1 = { class: "flex min-w-0 flex-1 flex-col gap-2" };
122
+ var _hoisted_4$1 = { class: "text-ink text-sm font-semibold" };
123
+ var _hoisted_5$1 = { class: "text-ink-soft mt-0.5 text-xs leading-relaxed" };
124
+ var _hoisted_6 = { class: "flex items-center gap-2" };
125
+ //#endregion
126
+ //#region src/pwa/InstallPrompt.vue
127
+ var InstallPrompt_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__ */ defineComponent({
128
+ __name: "InstallPrompt",
129
+ props: {
130
+ storageKey: {},
131
+ title: {},
132
+ body: {},
133
+ action: {},
134
+ later: {},
135
+ iosTitle: { default: "" },
136
+ iosBody: { default: "" },
137
+ snoozeDays: { default: 7 }
138
+ },
139
+ setup(__props) {
140
+ const { canPrompt, needsManualSteps, prompt } = useInstall();
141
+ const { isOver, snooze } = useSnooze(__props.storageKey, __props.snoozeDays);
142
+ const visible = computed(() => (canPrompt.value || needsManualSteps.value) && isOver.value);
143
+ async function install() {
144
+ await prompt();
145
+ snooze();
146
+ }
147
+ return (_ctx, _cache) => {
148
+ return openBlock(), createBlock(Transition, { name: "install" }, {
149
+ default: withCtx(() => [visible.value ? (openBlock(), createElementBlock("section", _hoisted_1$1, [createElementVNode("span", _hoisted_2$1, [(openBlock(), createBlock(resolveDynamicComponent(unref(needsManualSteps) ? unref(Share) : unref(Download)), { class: "size-5" }))]), createElementVNode("div", _hoisted_3$1, [createElementVNode("div", null, [createElementVNode("p", _hoisted_4$1, toDisplayString(unref(needsManualSteps) && __props.iosTitle ? __props.iosTitle : __props.title), 1), createElementVNode("p", _hoisted_5$1, toDisplayString(unref(needsManualSteps) && __props.iosBody ? __props.iosBody : __props.body), 1)]), createElementVNode("div", _hoisted_6, [unref(canPrompt) ? (openBlock(), createBlock(BaseButton_default, {
150
+ key: 0,
151
+ variant: "positive",
152
+ pill: "",
153
+ size: "xs",
154
+ class: "font-semibold",
155
+ onClick: install
156
+ }, {
157
+ default: withCtx(() => [createTextVNode(toDisplayString(__props.action), 1)]),
158
+ _: 1
159
+ })) : createCommentVNode("", true), createVNode(BaseButton_default, {
160
+ pill: "",
161
+ size: "xs",
162
+ variant: "quiet",
163
+ onClick: unref(snooze)
164
+ }, {
165
+ default: withCtx(() => [createTextVNode(toDisplayString(__props.later), 1)]),
166
+ _: 1
167
+ }, 8, ["onClick"])])])])) : createCommentVNode("", true)]),
168
+ _: 1
169
+ });
170
+ };
171
+ }
172
+ }), [["__scopeId", "data-v-34c86300"]]);
173
+ //#endregion
174
+ //#region src/pwa/UpdatePrompt.vue?vue&type=script&setup=true&lang.ts
175
+ var _hoisted_1 = {
176
+ key: 0,
177
+ class: "update-card",
178
+ role: "status"
179
+ };
180
+ var _hoisted_2 = {
181
+ class: "update-icon",
182
+ "aria-hidden": "true"
183
+ };
184
+ var _hoisted_3 = { class: "min-w-0 flex-1" };
185
+ var _hoisted_4 = { class: "text-ink text-sm font-semibold" };
186
+ var _hoisted_5 = { class: "text-ink-soft text-xs leading-snug" };
187
+ //#endregion
188
+ //#region src/pwa/UpdatePrompt.vue
189
+ var UpdatePrompt_default = /*#__PURE__*/ _plugin_vue_export_helper_default(/* @__PURE__ */ defineComponent({
190
+ __name: "UpdatePrompt",
191
+ props: {
192
+ open: { type: Boolean },
193
+ title: {},
194
+ body: {},
195
+ action: {},
196
+ dismissLabel: {}
197
+ },
198
+ emits: ["update", "dismiss"],
199
+ setup(__props) {
200
+ return (_ctx, _cache) => {
201
+ return openBlock(), createBlock(Transition, { name: "update" }, {
202
+ default: withCtx(() => [__props.open ? (openBlock(), createElementBlock("aside", _hoisted_1, [
203
+ createElementVNode("span", _hoisted_2, [createVNode(unref(RefreshCw), { class: "size-4" })]),
204
+ createElementVNode("div", _hoisted_3, [createElementVNode("p", _hoisted_4, toDisplayString(__props.title), 1), createElementVNode("p", _hoisted_5, toDisplayString(__props.body), 1)]),
205
+ createVNode(BaseButton_default, {
206
+ variant: "primary",
207
+ pill: "",
208
+ size: "xs",
209
+ class: "shrink-0 font-semibold",
210
+ onClick: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("update"))
211
+ }, {
212
+ default: withCtx(() => [createTextVNode(toDisplayString(__props.action), 1)]),
213
+ _: 1
214
+ }),
215
+ createVNode(BaseButton_default, {
216
+ variant: "quiet",
217
+ icon: "",
218
+ pill: "",
219
+ size: "sm",
220
+ class: "shrink-0",
221
+ "aria-label": __props.dismissLabel,
222
+ onClick: _cache[1] || (_cache[1] = ($event) => _ctx.$emit("dismiss"))
223
+ }, {
224
+ default: withCtx(() => [createVNode(unref(X), { class: "size-4" })]),
225
+ _: 1
226
+ }, 8, ["aria-label"])
227
+ ])) : createCommentVNode("", true)]),
228
+ _: 1
229
+ });
230
+ };
231
+ }
232
+ }), [["__scopeId", "data-v-7cf60526"]]);
233
+ //#endregion
234
+ export { InstallPrompt_default as InstallPrompt, UpdatePrompt_default as UpdatePrompt, useInstall, useSnooze, watchInstallability };
235
+
236
+ //# sourceMappingURL=pwa.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pwa.js","names":["$emit"],"sources":["../src/pwa/use-install.ts","../src/pwa/use-snooze.ts","../src/pwa/InstallPrompt.vue","../src/pwa/InstallPrompt.vue","../src/pwa/UpdatePrompt.vue","../src/pwa/UpdatePrompt.vue"],"sourcesContent":["import { computed, ref } from 'vue'\n\nimport { isInstalled, needsIosInstall } from '../utils/platform'\n\n/**\n * The event Chromium fires when it decides the app is installable.\n *\n * Not in lib.dom yet, and it is the only way to trigger the install sheet from\n * the app's own button rather than the browser's.\n */\ninterface BeforeInstallPromptEvent extends Event {\n prompt: () => Promise<void>\n userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>\n}\n\nconst deferred = ref<BeforeInstallPromptEvent | null>(null)\nconst installed = ref(false)\n\nlet listening = false\n\n/**\n * Starts listening for the install event.\n *\n * **Call this from the app's entry file, not from a component.**\n * `beforeinstallprompt` fires once, early, and only once per page load — a\n * listener attached when a component mounts has usually already missed it.\n *\n * It is a function rather than module-scope side effects so the package can be\n * imported on a server: the phone apps this came from ran it at module scope\n * and the kit spent a release learning why that is not free.\n *\n * @example\n * ```ts\n * // main.ts\n * import { watchInstallability } from 'rei-kit/pwa'\n * watchInstallability()\n * ```\n */\nexport function watchInstallability(): void {\n if (listening || typeof window === 'undefined') return\n\n listening = true\n installed.value = isInstalled()\n\n window.addEventListener('beforeinstallprompt', (event) => {\n // Without this the browser shows its own bar, and then the app's card and\n // the browser's bar are both on screen saying the same thing.\n event.preventDefault()\n deferred.value = event as BeforeInstallPromptEvent\n })\n\n window.addEventListener('appinstalled', () => {\n installed.value = true\n deferred.value = null\n })\n}\n\n/**\n * Adding the app to the Home Screen.\n *\n * Three states, because the platforms genuinely differ: Chromium hands over an\n * event that can be triggered from a button, Safari on iOS has no API at all\n * and needs the user walked through Share → Add to Home Screen, and everything\n * else can only be told that installing is possible.\n *\n * @example\n * ```ts\n * const install = useInstall()\n * if (install.canPrompt.value) await install.prompt()\n * ```\n */\nexport function useInstall() {\n async function prompt(): Promise<boolean> {\n const event = deferred.value\n if (!event) return false\n\n await event.prompt()\n const { outcome } = await event.userChoice\n\n // The event is single-use: Chromium refuses a second prompt() on it.\n deferred.value = null\n\n return outcome === 'accepted'\n }\n\n return {\n isInstalled: computed(() => installed.value),\n /** A button can open the real install sheet. */\n canPrompt: computed(() => !installed.value && deferred.value !== null),\n /** No API — the user has to be shown the Share menu. */\n needsManualSteps: computed(() => !installed.value && needsIosInstall()),\n prompt,\n }\n}\n","import { computed, ref } from 'vue'\n\nimport { addDays, todayKey } from '../utils/date'\n\n/**\n * A nudge that stops asking for a while after it is dismissed.\n *\n * Both phone apps had this inside their install card, identically: a date in\n * `localStorage`, compared against today. Pulled out because the install card\n * is not the only thing that should stop asking — a notification nudge wants\n * exactly the same behaviour, and had exactly the same code.\n *\n * Storage is wrapped in try/catch on both sides. A private window or a browser\n * set to block site data throws on access, and the honest failure there is a\n * nudge that reappears next session rather than one that crashes the screen it\n * is asking from.\n *\n * @param key - Where the date is kept. Namespace it to the app.\n * @param days - How long to stay quiet after a dismissal.\n */\nexport function useSnooze(key: string, days = 7) {\n const read = (): string => {\n try {\n return localStorage.getItem(key) ?? ''\n } catch {\n return ''\n }\n }\n\n const until = ref(read())\n\n return {\n /** False while the nudge is snoozed. */\n isOver: computed(() => todayKey() >= until.value),\n\n /** Stop asking for `days`. */\n snooze(): void {\n const next = addDays(todayKey(), days)\n until.value = next\n\n try {\n localStorage.setItem(key, next)\n } catch {\n // Storage blocked; it reappears next session rather than never.\n }\n },\n }\n}\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { Download, Share } from 'lucide-vue-next'\n\nimport BaseButton from '../components/BaseButton.vue'\nimport { useInstall } from './use-install'\nimport { useSnooze } from './use-snooze'\n\n/**\n * The card that offers to install the app, and remembers being turned down.\n *\n * Both phone apps had this, 111 lines, differing in a storage key and a colour.\n * What is here is the part that was the same: the three-way platform check, the\n * week-long snooze, and the shape.\n *\n * Every string is a prop. The kit does not know a language, and \"Add to Home\n * Screen\" is a sentence about a platform, not about this component.\n */\nconst {\n storageKey,\n title,\n body,\n action,\n later,\n iosTitle = '',\n iosBody = '',\n snoozeDays = 7,\n} = defineProps<{\n /** Where the dismissal is remembered. Namespace it: `hibi-install-nudge`. */\n storageKey: string\n title: string\n body: string\n /** The install button. Not shown on iOS, which has no API to call. */\n action: string\n later: string\n /** Shown instead of `title` where the user has to use the Share menu. */\n iosTitle?: string | undefined\n iosBody?: string | undefined\n snoozeDays?: number | undefined\n}>()\n\nconst { canPrompt, needsManualSteps, prompt } = useInstall()\nconst { isOver, snooze } = useSnooze(storageKey, snoozeDays)\n\nconst visible = computed(() => (canPrompt.value || needsManualSteps.value) && isOver.value)\n\nasync function install() {\n await prompt()\n\n // Accepted or dismissed, stop asking for a week. The prompt is what gets\n // tiring, not the answer.\n snooze()\n}\n</script>\n\n<template>\n <Transition name=\"install\">\n <section\n v-if=\"visible\"\n class=\"border-positive/25 bg-positive/5 rounded-card flex gap-3 border p-3.5\"\n >\n <span\n class=\"bg-positive/15 text-positive flex size-10 shrink-0 items-center justify-center rounded-xl\"\n aria-hidden=\"true\"\n >\n <component :is=\"needsManualSteps ? Share : Download\" class=\"size-5\" />\n </span>\n\n <div class=\"flex min-w-0 flex-1 flex-col gap-2\">\n <div>\n <p class=\"text-ink text-sm font-semibold\">\n {{ needsManualSteps && iosTitle ? iosTitle : title }}\n </p>\n <p class=\"text-ink-soft mt-0.5 text-xs leading-relaxed\">\n {{ needsManualSteps && iosBody ? iosBody : body }}\n </p>\n </div>\n\n <div class=\"flex items-center gap-2\">\n <!-- No button on iOS: Safari exposes no way to open the Share sheet\n from script, so a button here could only fail. -->\n <BaseButton\n v-if=\"canPrompt\"\n variant=\"positive\"\n pill\n size=\"xs\"\n class=\"font-semibold\"\n @click=\"install\"\n >\n {{ action }}\n </BaseButton>\n\n <BaseButton pill size=\"xs\" variant=\"quiet\" @click=\"snooze\">{{ later }}</BaseButton>\n </div>\n </div>\n </section>\n </Transition>\n</template>\n\n<style scoped>\n.install-enter-active,\n.install-leave-active {\n transition:\n opacity 200ms ease,\n transform 200ms ease;\n}\n\n.install-enter-from,\n.install-leave-to {\n opacity: 0;\n transform: translateY(-4px);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .install-enter-from,\n .install-leave-to {\n transform: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { Download, Share } from 'lucide-vue-next'\n\nimport BaseButton from '../components/BaseButton.vue'\nimport { useInstall } from './use-install'\nimport { useSnooze } from './use-snooze'\n\n/**\n * The card that offers to install the app, and remembers being turned down.\n *\n * Both phone apps had this, 111 lines, differing in a storage key and a colour.\n * What is here is the part that was the same: the three-way platform check, the\n * week-long snooze, and the shape.\n *\n * Every string is a prop. The kit does not know a language, and \"Add to Home\n * Screen\" is a sentence about a platform, not about this component.\n */\nconst {\n storageKey,\n title,\n body,\n action,\n later,\n iosTitle = '',\n iosBody = '',\n snoozeDays = 7,\n} = defineProps<{\n /** Where the dismissal is remembered. Namespace it: `hibi-install-nudge`. */\n storageKey: string\n title: string\n body: string\n /** The install button. Not shown on iOS, which has no API to call. */\n action: string\n later: string\n /** Shown instead of `title` where the user has to use the Share menu. */\n iosTitle?: string | undefined\n iosBody?: string | undefined\n snoozeDays?: number | undefined\n}>()\n\nconst { canPrompt, needsManualSteps, prompt } = useInstall()\nconst { isOver, snooze } = useSnooze(storageKey, snoozeDays)\n\nconst visible = computed(() => (canPrompt.value || needsManualSteps.value) && isOver.value)\n\nasync function install() {\n await prompt()\n\n // Accepted or dismissed, stop asking for a week. The prompt is what gets\n // tiring, not the answer.\n snooze()\n}\n</script>\n\n<template>\n <Transition name=\"install\">\n <section\n v-if=\"visible\"\n class=\"border-positive/25 bg-positive/5 rounded-card flex gap-3 border p-3.5\"\n >\n <span\n class=\"bg-positive/15 text-positive flex size-10 shrink-0 items-center justify-center rounded-xl\"\n aria-hidden=\"true\"\n >\n <component :is=\"needsManualSteps ? Share : Download\" class=\"size-5\" />\n </span>\n\n <div class=\"flex min-w-0 flex-1 flex-col gap-2\">\n <div>\n <p class=\"text-ink text-sm font-semibold\">\n {{ needsManualSteps && iosTitle ? iosTitle : title }}\n </p>\n <p class=\"text-ink-soft mt-0.5 text-xs leading-relaxed\">\n {{ needsManualSteps && iosBody ? iosBody : body }}\n </p>\n </div>\n\n <div class=\"flex items-center gap-2\">\n <!-- No button on iOS: Safari exposes no way to open the Share sheet\n from script, so a button here could only fail. -->\n <BaseButton\n v-if=\"canPrompt\"\n variant=\"positive\"\n pill\n size=\"xs\"\n class=\"font-semibold\"\n @click=\"install\"\n >\n {{ action }}\n </BaseButton>\n\n <BaseButton pill size=\"xs\" variant=\"quiet\" @click=\"snooze\">{{ later }}</BaseButton>\n </div>\n </div>\n </section>\n </Transition>\n</template>\n\n<style scoped>\n.install-enter-active,\n.install-leave-active {\n transition:\n opacity 200ms ease,\n transform 200ms ease;\n}\n\n.install-enter-from,\n.install-leave-to {\n opacity: 0;\n transform: translateY(-4px);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .install-enter-from,\n .install-leave-to {\n transform: none;\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { RefreshCw, X } from 'lucide-vue-next'\n\nimport BaseButton from '../components/BaseButton.vue'\n\n/**\n * The card that says a new version is waiting.\n *\n * `registerType: 'prompt'` means a new service worker waits rather than taking\n * over, and this is what asks whether to apply it. Asking rather than reloading\n * is the decision worth keeping: an automatic swap mid-sentence loses whatever\n * was being typed.\n *\n * **The service worker stays the app's.** `virtual:pwa-register/vue` is a\n * build-time module from `vite-plugin-pwa`, and a library cannot import one —\n * so the app owns `useRegisterSW` and hands the answer down as `open`. That\n * split is also honest: whether an update is waiting is the app's business,\n * and what the card looks like is this component's.\n *\n * @example\n * ```vue\n * <script setup>\n * const { needRefresh, updateServiceWorker } = useRegisterSW()\n * <\\/script>\n *\n * <UpdatePrompt\n * :open=\"needRefresh\"\n * :title=\"t('pwa.updateTitle')\"\n * :body=\"t('pwa.updateBody')\"\n * :action=\"t('pwa.reload')\"\n * :dismiss-label=\"t('pwa.later')\"\n * @update=\"updateServiceWorker(true)\"\n * @dismiss=\"needRefresh = false\"\n * />\n * ```\n */\ndefineProps<{\n open: boolean\n title: string\n body: string\n action: string\n /** The X's accessible name. An X on its own has none. */\n dismissLabel: string\n}>()\n\ndefineEmits<{ update: []; dismiss: [] }>()\n</script>\n\n<template>\n <Transition name=\"update\">\n <aside v-if=\"open\" class=\"update-card\" role=\"status\">\n <span class=\"update-icon\" aria-hidden=\"true\">\n <RefreshCw class=\"size-4\" />\n </span>\n\n <div class=\"min-w-0 flex-1\">\n <p class=\"text-ink text-sm font-semibold\">{{ title }}</p>\n <p class=\"text-ink-soft text-xs leading-snug\">{{ body }}</p>\n </div>\n\n <BaseButton\n variant=\"primary\"\n pill\n size=\"xs\"\n class=\"shrink-0 font-semibold\"\n @click=\"$emit('update')\"\n >\n {{ action }}\n </BaseButton>\n\n <BaseButton\n variant=\"quiet\"\n icon\n pill\n size=\"sm\"\n class=\"shrink-0\"\n :aria-label=\"dismissLabel\"\n @click=\"$emit('dismiss')\"\n >\n <X class=\"size-4\" />\n </BaseButton>\n </aside>\n </Transition>\n</template>\n\n<style scoped>\n/* Sits above the tab bar and the action button, because it outranks both — but\n absolutely inside its container, so on a desktop-sized shell it does not\n float off into the page. Give the shell `relative`. */\n.update-card {\n border-color: var(--color-hair);\n background: color-mix(in srgb, var(--color-surface) 95%, transparent);\n border-radius: var(--radius-card);\n position: absolute;\n left: 50%;\n z-index: 50;\n display: flex;\n width: 100%;\n max-width: 360px;\n align-items: center;\n gap: 0.75rem;\n border-width: 1px;\n padding: 0.75rem;\n backdrop-filter: blur(12px);\n box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1);\n transform: translateX(-50%);\n bottom: calc(1rem + env(safe-area-inset-bottom, 0px));\n}\n\n.update-icon {\n background: color-mix(in srgb, var(--color-primary) 15%, transparent);\n color: var(--color-primary);\n display: flex;\n height: 2.25rem;\n width: 2.25rem;\n flex-shrink: 0;\n align-items: center;\n justify-content: center;\n border-radius: 0.75rem;\n}\n\n.update-enter-active,\n.update-leave-active {\n transition:\n opacity 250ms ease,\n transform 250ms cubic-bezier(0.32, 0.72, 0, 1);\n}\n\n.update-enter-from,\n.update-leave-to {\n opacity: 0;\n transform: translate(-50%, 1rem);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .update-enter-from,\n .update-leave-to {\n transform: translate(-50%, 0);\n }\n}\n</style>\n","<script setup lang=\"ts\">\nimport { RefreshCw, X } from 'lucide-vue-next'\n\nimport BaseButton from '../components/BaseButton.vue'\n\n/**\n * The card that says a new version is waiting.\n *\n * `registerType: 'prompt'` means a new service worker waits rather than taking\n * over, and this is what asks whether to apply it. Asking rather than reloading\n * is the decision worth keeping: an automatic swap mid-sentence loses whatever\n * was being typed.\n *\n * **The service worker stays the app's.** `virtual:pwa-register/vue` is a\n * build-time module from `vite-plugin-pwa`, and a library cannot import one —\n * so the app owns `useRegisterSW` and hands the answer down as `open`. That\n * split is also honest: whether an update is waiting is the app's business,\n * and what the card looks like is this component's.\n *\n * @example\n * ```vue\n * <script setup>\n * const { needRefresh, updateServiceWorker } = useRegisterSW()\n * <\\/script>\n *\n * <UpdatePrompt\n * :open=\"needRefresh\"\n * :title=\"t('pwa.updateTitle')\"\n * :body=\"t('pwa.updateBody')\"\n * :action=\"t('pwa.reload')\"\n * :dismiss-label=\"t('pwa.later')\"\n * @update=\"updateServiceWorker(true)\"\n * @dismiss=\"needRefresh = false\"\n * />\n * ```\n */\ndefineProps<{\n open: boolean\n title: string\n body: string\n action: string\n /** The X's accessible name. An X on its own has none. */\n dismissLabel: string\n}>()\n\ndefineEmits<{ update: []; dismiss: [] }>()\n</script>\n\n<template>\n <Transition name=\"update\">\n <aside v-if=\"open\" class=\"update-card\" role=\"status\">\n <span class=\"update-icon\" aria-hidden=\"true\">\n <RefreshCw class=\"size-4\" />\n </span>\n\n <div class=\"min-w-0 flex-1\">\n <p class=\"text-ink text-sm font-semibold\">{{ title }}</p>\n <p class=\"text-ink-soft text-xs leading-snug\">{{ body }}</p>\n </div>\n\n <BaseButton\n variant=\"primary\"\n pill\n size=\"xs\"\n class=\"shrink-0 font-semibold\"\n @click=\"$emit('update')\"\n >\n {{ action }}\n </BaseButton>\n\n <BaseButton\n variant=\"quiet\"\n icon\n pill\n size=\"sm\"\n class=\"shrink-0\"\n :aria-label=\"dismissLabel\"\n @click=\"$emit('dismiss')\"\n >\n <X class=\"size-4\" />\n </BaseButton>\n </aside>\n </Transition>\n</template>\n\n<style scoped>\n/* Sits above the tab bar and the action button, because it outranks both — but\n absolutely inside its container, so on a desktop-sized shell it does not\n float off into the page. Give the shell `relative`. */\n.update-card {\n border-color: var(--color-hair);\n background: color-mix(in srgb, var(--color-surface) 95%, transparent);\n border-radius: var(--radius-card);\n position: absolute;\n left: 50%;\n z-index: 50;\n display: flex;\n width: 100%;\n max-width: 360px;\n align-items: center;\n gap: 0.75rem;\n border-width: 1px;\n padding: 0.75rem;\n backdrop-filter: blur(12px);\n box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1);\n transform: translateX(-50%);\n bottom: calc(1rem + env(safe-area-inset-bottom, 0px));\n}\n\n.update-icon {\n background: color-mix(in srgb, var(--color-primary) 15%, transparent);\n color: var(--color-primary);\n display: flex;\n height: 2.25rem;\n width: 2.25rem;\n flex-shrink: 0;\n align-items: center;\n justify-content: center;\n border-radius: 0.75rem;\n}\n\n.update-enter-active,\n.update-leave-active {\n transition:\n opacity 250ms ease,\n transform 250ms cubic-bezier(0.32, 0.72, 0, 1);\n}\n\n.update-enter-from,\n.update-leave-to {\n opacity: 0;\n transform: translate(-50%, 1rem);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .update-enter-from,\n .update-leave-to {\n transform: translate(-50%, 0);\n }\n}\n</style>\n"],"mappings":"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"}
package/dist/styles.css CHANGED
@@ -359,4 +359,73 @@
359
359
  syntax: "*";
360
360
  inherits: false
361
361
  }
362
+
363
+ .install-enter-active[data-v-34c86300],
364
+ .install-leave-active[data-v-34c86300] {
365
+ transition:
366
+ opacity 200ms ease,
367
+ transform 200ms ease;
368
+ }
369
+ .install-enter-from[data-v-34c86300],
370
+ .install-leave-to[data-v-34c86300] {
371
+ opacity: 0;
372
+ transform: translateY(-4px);
373
+ }
374
+ @media (prefers-reduced-motion: reduce) {
375
+ .install-enter-from[data-v-34c86300],
376
+ .install-leave-to[data-v-34c86300] {
377
+ transform: none;
378
+ }
379
+ }
380
+
381
+ /* Sits above the tab bar and the action button, because it outranks both — but
382
+ absolutely inside its container, so on a desktop-sized shell it does not
383
+ float off into the page. Give the shell `relative`. */
384
+ .update-card[data-v-7cf60526] {
385
+ border-color: var(--color-hair);
386
+ background: color-mix(in srgb, var(--color-surface) 95%, transparent);
387
+ border-radius: var(--radius-card);
388
+ position: absolute;
389
+ left: 50%;
390
+ z-index: 50;
391
+ display: flex;
392
+ width: 100%;
393
+ max-width: 360px;
394
+ align-items: center;
395
+ gap: 0.75rem;
396
+ border-width: 1px;
397
+ padding: 0.75rem;
398
+ backdrop-filter: blur(12px);
399
+ box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1);
400
+ transform: translateX(-50%);
401
+ bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
402
+ }
403
+ .update-icon[data-v-7cf60526] {
404
+ background: color-mix(in srgb, var(--color-primary) 15%, transparent);
405
+ color: var(--color-primary);
406
+ display: flex;
407
+ height: 2.25rem;
408
+ width: 2.25rem;
409
+ flex-shrink: 0;
410
+ align-items: center;
411
+ justify-content: center;
412
+ border-radius: 0.75rem;
413
+ }
414
+ .update-enter-active[data-v-7cf60526],
415
+ .update-leave-active[data-v-7cf60526] {
416
+ transition:
417
+ opacity 250ms ease,
418
+ transform 250ms cubic-bezier(0.32, 0.72, 0, 1);
419
+ }
420
+ .update-enter-from[data-v-7cf60526],
421
+ .update-leave-to[data-v-7cf60526] {
422
+ opacity: 0;
423
+ transform: translate(-50%, 1rem);
424
+ }
425
+ @media (prefers-reduced-motion: reduce) {
426
+ .update-enter-from[data-v-7cf60526],
427
+ .update-leave-to[data-v-7cf60526] {
428
+ transform: translate(-50%, 0);
429
+ }
430
+ }
362
431
  /*$vite$:1*/
@@ -0,0 +1,94 @@
1
+ import { ref, watch } from "vue";
2
+ //#region src/composables/use-theme.ts
3
+ /**
4
+ * Namespaced by the app, not by this package.
5
+ *
6
+ * Two rei-kit apps served from the same origin would otherwise share one theme
7
+ * setting — and during development on localhost, they will be.
8
+ */
9
+ var storageKey = "rei-theme";
10
+ function isThemePreference(value) {
11
+ return value === "system" || value === "light" || value === "dark";
12
+ }
13
+ /** Reads the stored preference, falling back to `system`. */
14
+ function readStoredTheme() {
15
+ try {
16
+ const stored = localStorage.getItem(storageKey);
17
+ return isThemePreference(stored) ? stored : "system";
18
+ } catch {
19
+ return "system";
20
+ }
21
+ }
22
+ function storeTheme(preference) {
23
+ try {
24
+ localStorage.setItem(storageKey, preference);
25
+ } catch {}
26
+ }
27
+ /**
28
+ * Does the environment prefer a dark scheme?
29
+ *
30
+ * `matchMedia` is checked for on its own rather than inferred from `document`.
31
+ * Having one does not imply having the other: jsdom supplies a document and no
32
+ * `matchMedia`, so a consumer's component test that so much as mounts something
33
+ * calling `useTheme` threw — and some embedded webviews are the same. Where
34
+ * there is nothing to ask, the answer is no rather than an exception.
35
+ */
36
+ function prefersDarkScheme() {
37
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-color-scheme: dark)").matches : false;
38
+ }
39
+ /**
40
+ * Adds or removes `.dark` on `<html>`, resolving `system` against the OS.
41
+ *
42
+ * A no-op without a document. There is no OS preference to read on a server and
43
+ * no `<html>` to write to, so a prerender leaves the class off and the app
44
+ * decides the theme before hydration — see the note in the README.
45
+ */
46
+ function applyTheme(preference) {
47
+ if (typeof document === "undefined") return;
48
+ const isDark = preference === "dark" || preference === "system" && prefersDarkScheme();
49
+ document.documentElement.classList.toggle("dark", isDark);
50
+ }
51
+ /**
52
+ * The shared preference, created on first use rather than at import.
53
+ *
54
+ * Lazy on purpose: reading storage at import time would lock in the default key
55
+ * before an app had a chance to set its own, leaving the controller reading one
56
+ * key and writing another.
57
+ */
58
+ var preference = null;
59
+ function controller() {
60
+ if (preference) return preference;
61
+ preference = ref(readStoredTheme());
62
+ watch(preference, (next) => {
63
+ storeTheme(next);
64
+ applyTheme(next);
65
+ }, { immediate: true });
66
+ if (typeof window !== "undefined" && typeof window.matchMedia === "function") window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
67
+ if (preference?.value === "system") applyTheme("system");
68
+ });
69
+ return preference;
70
+ }
71
+ /**
72
+ * Sets where the preference is stored.
73
+ *
74
+ * Safe in either order: called before the first `useTheme()` it simply changes
75
+ * the key, and called after it re-reads under the new one, so the controller
76
+ * never reads from one key while writing to another.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * setThemeStorageKey('hibi-theme') // once, at startup
81
+ * ```
82
+ */
83
+ function setThemeStorageKey(key) {
84
+ storageKey = key;
85
+ if (preference) preference.value = readStoredTheme();
86
+ }
87
+ /** @returns The shared preference ref; assigning to it stores and applies it. */
88
+ function useTheme() {
89
+ return controller();
90
+ }
91
+ //#endregion
92
+ export { useTheme as a, setThemeStorageKey as i, isThemePreference as n, readStoredTheme as r, applyTheme as t };
93
+
94
+ //# sourceMappingURL=use-theme-_MnaDD5v.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-theme-_MnaDD5v.js","names":[],"sources":["../src/composables/use-theme.ts"],"sourcesContent":["import { ref, watch } from 'vue'\nimport type { Ref } from 'vue'\n\n/** What the user asked for; `system` follows the OS. */\nexport type ThemePreference = 'system' | 'light' | 'dark'\n\n/**\n * Namespaced by the app, not by this package.\n *\n * Two rei-kit apps served from the same origin would otherwise share one theme\n * setting — and during development on localhost, they will be.\n */\nlet storageKey = 'rei-theme'\n\nexport function isThemePreference(value: unknown): value is ThemePreference {\n return value === 'system' || value === 'light' || value === 'dark'\n}\n\n/** Reads the stored preference, falling back to `system`. */\nexport function readStoredTheme(): ThemePreference {\n try {\n const stored = localStorage.getItem(storageKey)\n\n return isThemePreference(stored) ? stored : 'system'\n } catch {\n return 'system'\n }\n}\n\nfunction storeTheme(preference: ThemePreference): void {\n try {\n localStorage.setItem(storageKey, preference)\n } catch {\n // Private mode or blocked storage: the choice just will not persist.\n }\n}\n\n/**\n * Does the environment prefer a dark scheme?\n *\n * `matchMedia` is checked for on its own rather than inferred from `document`.\n * Having one does not imply having the other: jsdom supplies a document and no\n * `matchMedia`, so a consumer's component test that so much as mounts something\n * calling `useTheme` threw — and some embedded webviews are the same. Where\n * there is nothing to ask, the answer is no rather than an exception.\n */\nfunction prefersDarkScheme(): boolean {\n return typeof window !== 'undefined' && typeof window.matchMedia === 'function'\n ? window.matchMedia('(prefers-color-scheme: dark)').matches\n : false\n}\n\n/**\n * Adds or removes `.dark` on `<html>`, resolving `system` against the OS.\n *\n * A no-op without a document. There is no OS preference to read on a server and\n * no `<html>` to write to, so a prerender leaves the class off and the app\n * decides the theme before hydration — see the note in the README.\n */\nexport function applyTheme(preference: ThemePreference): void {\n if (typeof document === 'undefined') return\n\n const isDark = preference === 'dark' || (preference === 'system' && prefersDarkScheme())\n\n document.documentElement.classList.toggle('dark', isDark)\n}\n\n/**\n * The shared preference, created on first use rather than at import.\n *\n * Lazy on purpose: reading storage at import time would lock in the default key\n * before an app had a chance to set its own, leaving the controller reading one\n * key and writing another.\n */\nlet preference: Ref<ThemePreference> | null = null\n\nfunction controller(): Ref<ThemePreference> {\n if (preference) return preference\n\n preference = ref<ThemePreference>(readStoredTheme())\n\n watch(\n preference,\n (next) => {\n storeTheme(next)\n applyTheme(next)\n },\n { immediate: true },\n )\n\n // While on `system`, follow the OS if the user flips it at night. Only where\n // there is something to listen to; see `prefersDarkScheme`.\n if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {\n window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {\n if (preference?.value === 'system') applyTheme('system')\n })\n }\n\n return preference\n}\n\n/**\n * Sets where the preference is stored.\n *\n * Safe in either order: called before the first `useTheme()` it simply changes\n * the key, and called after it re-reads under the new one, so the controller\n * never reads from one key while writing to another.\n *\n * @example\n * ```ts\n * setThemeStorageKey('hibi-theme') // once, at startup\n * ```\n */\nexport function setThemeStorageKey(key: string): void {\n storageKey = key\n if (preference) preference.value = readStoredTheme()\n}\n\n/** @returns The shared preference ref; assigning to it stores and applies it. */\nexport function useTheme(): Ref<ThemePreference> {\n return controller()\n}\n"],"mappings":";;;;;;;;AAYA,IAAI,aAAa;AAEjB,SAAgB,kBAAkB,OAA0C;CAC1E,OAAO,UAAU,YAAY,UAAU,WAAW,UAAU;AAC9D;;AAGA,SAAgB,kBAAmC;CACjD,IAAI;EACF,MAAM,SAAS,aAAa,QAAQ,UAAU;EAE9C,OAAO,kBAAkB,MAAM,IAAI,SAAS;CAC9C,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,WAAW,YAAmC;CACrD,IAAI;EACF,aAAa,QAAQ,YAAY,UAAU;CAC7C,QAAQ,CAER;AACF;;;;;;;;;;AAWA,SAAS,oBAA6B;CACpC,OAAO,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe,aACjE,OAAO,WAAW,8BAA8B,CAAC,CAAC,UAClD;AACN;;;;;;;;AASA,SAAgB,WAAW,YAAmC;CAC5D,IAAI,OAAO,aAAa,aAAa;CAErC,MAAM,SAAS,eAAe,UAAW,eAAe,YAAY,kBAAkB;CAEtF,SAAS,gBAAgB,UAAU,OAAO,QAAQ,MAAM;AAC1D;;;;;;;;AASA,IAAI,aAA0C;AAE9C,SAAS,aAAmC;CAC1C,IAAI,YAAY,OAAO;CAEvB,aAAa,IAAqB,gBAAgB,CAAC;CAEnD,MACE,aACC,SAAS;EACR,WAAW,IAAI;EACf,WAAW,IAAI;CACjB,GACA,EAAE,WAAW,KAAK,CACpB;CAIA,IAAI,OAAO,WAAW,eAAe,OAAO,OAAO,eAAe,YAChE,OAAO,WAAW,8BAA8B,CAAC,CAAC,iBAAiB,gBAAgB;EACjF,IAAI,YAAY,UAAU,UAAU,WAAW,QAAQ;CACzD,CAAC;CAGH,OAAO;AACT;;;;;;;;;;;;;AAcA,SAAgB,mBAAmB,KAAmB;CACpD,aAAa;CACb,IAAI,YAAY,WAAW,QAAQ,gBAAgB;AACrD;;AAGA,SAAgB,WAAiC;CAC/C,OAAO,WAAW;AACpB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rei-kit",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Vue 3 and Tailwind 4 design system and shared runtime. Extracted from Hibi.",
5
5
  "license": "MIT",
6
6
  "author": "Ramazan Doğan",
@@ -24,6 +24,14 @@
24
24
  "./shell/mobile.css": "./dist/shell/mobile.css",
25
25
  "./shell/web.css": "./dist/shell/web.css",
26
26
  "./styles.css": "./dist/styles.css",
27
+ "./app": {
28
+ "types": "./dist/app/index.d.ts",
29
+ "import": "./dist/app.js"
30
+ },
31
+ "./pwa": {
32
+ "types": "./dist/pwa/index.d.ts",
33
+ "import": "./dist/pwa.js"
34
+ },
27
35
  "./supabase": {
28
36
  "types": "./dist/supabase/index.d.ts",
29
37
  "import": "./dist/supabase.js"