@ripple-ui/svelte 0.8.2 → 0.9.0-next.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.
Files changed (71) hide show
  1. package/dist/discover/CreatorAvatar.svelte +37 -0
  2. package/dist/discover/CreatorAvatar.svelte.d.ts +10 -0
  3. package/dist/discover/CreatorLine.svelte +18 -0
  4. package/dist/discover/CreatorLine.svelte.d.ts +8 -0
  5. package/dist/discover/DiscoverChips.svelte +15 -2
  6. package/dist/discover/DiscoverFilters.svelte +56 -41
  7. package/dist/discover/DiscoverHeader.svelte +33 -10
  8. package/dist/discover/DiscoverHeader.svelte.d.ts +3 -0
  9. package/dist/discover/DiscoverRow.svelte +36 -10
  10. package/dist/discover/DiscoverRow.svelte.d.ts +7 -0
  11. package/dist/discover/DiscoverSection.svelte +18 -10
  12. package/dist/discover/DiscoverSkeleton.svelte +5 -3
  13. package/dist/discover/DiscoverTile.svelte +52 -17
  14. package/dist/discover/DiscoverTile.svelte.d.ts +7 -0
  15. package/dist/discover/ItemActions.svelte +46 -17
  16. package/dist/discover/ItemActions.svelte.d.ts +6 -0
  17. package/dist/discover/ItemDetail.svelte +280 -0
  18. package/dist/discover/ItemDetail.svelte.d.ts +32 -0
  19. package/dist/discover/ItemMenu.svelte +61 -0
  20. package/dist/discover/ItemMenu.svelte.d.ts +13 -0
  21. package/dist/discover/LiveFrame.svelte +162 -0
  22. package/dist/discover/LiveFrame.svelte.d.ts +21 -0
  23. package/dist/discover/WhatsAppQrCard.svelte +302 -0
  24. package/dist/discover/WhatsAppQrCard.svelte.d.ts +18 -0
  25. package/dist/discover/frame.d.ts +7 -0
  26. package/dist/discover/frame.js +27 -0
  27. package/dist/discover/href.d.ts +7 -1
  28. package/dist/discover/href.js +16 -6
  29. package/dist/discover/index.d.ts +7 -2
  30. package/dist/discover/index.js +15 -7
  31. package/dist/discover/item.d.ts +14 -0
  32. package/dist/discover/item.js +41 -2
  33. package/dist/discover/layout.d.ts +1 -1
  34. package/dist/discover/layout.js +1 -1
  35. package/dist/discover/sections.d.ts +39 -0
  36. package/dist/discover/sections.js +84 -0
  37. package/dist/discover/types.d.ts +17 -0
  38. package/dist/discover/types.js +5 -2
  39. package/dist/discover/whatsapp-card-export.d.ts +9 -0
  40. package/dist/discover/whatsapp-card-export.js +72 -0
  41. package/dist/discover/whatsapp-card.d.ts +106 -0
  42. package/dist/discover/whatsapp-card.js +226 -0
  43. package/dist/discover/whatsapp-qr.d.ts +7 -0
  44. package/dist/discover/whatsapp-qr.js +20 -0
  45. package/dist/figures/bike-gears/figure.js +2 -6
  46. package/dist/figures/fission-reactor/figure.js +2 -6
  47. package/dist/figures/heart/figure.js +2 -6
  48. package/dist/figures/ion-thruster/figure.js +2 -6
  49. package/dist/figures/particle-collider/figure.js +2 -6
  50. package/dist/figures/rocket-engine/figure.js +2 -6
  51. package/dist/figures/style.d.ts +2 -0
  52. package/dist/figures/style.js +18 -0
  53. package/dist/figures/tokamak/figure.js +2 -6
  54. package/dist/manifest/entries/chart.js +1 -1
  55. package/dist/manifest/entries/markdown.js +2 -2
  56. package/dist/manifest.json +5 -5
  57. package/dist/styles.css +11 -10
  58. package/dist/utils/markdown-ext.d.ts +6 -0
  59. package/dist/utils/markdown-ext.js +68 -0
  60. package/dist/utils/mermaid.d.ts +94 -0
  61. package/dist/utils/mermaid.js +144 -0
  62. package/dist/utils/sanitize-html.d.ts +6 -0
  63. package/dist/utils/sanitize-html.js +15 -0
  64. package/dist/version.d.ts +2 -2
  65. package/dist/version.js +2 -2
  66. package/dist/widgets/data/Chart.svelte +34 -6
  67. package/dist/widgets/data/chart-layout.d.ts +4 -4
  68. package/dist/widgets/data/chart-layout.js +9 -8
  69. package/dist/widgets/display/Markdown.svelte +147 -3
  70. package/dist/widgets/display/Markdown.svelte.d.ts +1 -1
  71. package/package.json +4 -3
@@ -0,0 +1,162 @@
1
+ <!-- discover/LiveFrame.svelte — a Discover item's live page (a site, a game, a
2
+ tool) inside the detail view. By default it starts as the poster (ItemArt)
3
+ and loads nothing: the iframe is created only when the visitor presses
4
+ "Load site", "Play" or "Open tool", so a grid, overlay or sheet never
5
+ pulls in a stranger's page on view. `autoload` (the item's own page)
6
+ renders the iframe straight away, in the server HTML too, so it loads
7
+ with JS off. `tall` sizes the frame for that page, min(72vh, 760px) high,
8
+ instead of 16:9. Sites get a Desktop / Phone width toggle once loaded
9
+ (phone is a 390px centred frame). "Open full screen" always links to the
10
+ page. Only an http(s) url is framed or linked; anything else leaves the
11
+ poster.
12
+
13
+ Server-safe: nothing touches window. The load button is drawn after
14
+ mount, so with JS off (and no autoload) the poster and the "Open full
15
+ screen" link are what shows, and the link works.
16
+
17
+ Sandbox (frameSandbox in frame.ts): allow-scripts allow-forms
18
+ allow-popups allow-pointer-lock, plus allow-same-origin only for an https
19
+ page on a subdomain of LIVE_FRAME_TRUSTED_HOST_SUFFIX
20
+ (.pawsites.workers.dev). Scripts plus same-origin only escape the sandbox
21
+ when the page shares the host's origin; each published site is its own
22
+ subdomain on a registrable domain apart from the website and the app, so
23
+ it keeps its own origin and storage (games save scores). Any other host
24
+ runs opaque, where storage throws; it still works through "Open full
25
+ screen". The link is user-submitted content, so it carries
26
+ rel="nofollow ugc noopener noreferrer". -->
27
+ <script lang="ts">
28
+ import { safeUrl } from '@ripple-ui/core';
29
+ import { onMount } from 'svelte';
30
+ import ExternalLink from '@lucide/svelte/icons/external-link';
31
+ import Monitor from '@lucide/svelte/icons/monitor';
32
+ import Play from '@lucide/svelte/icons/play';
33
+ import Smartphone from '@lucide/svelte/icons/smartphone';
34
+ import { Button } from '../components/ui/button/index.js';
35
+ import { cn } from '../utils.js';
36
+ import ItemArt from './ItemArt.svelte';
37
+ import { frameSandbox } from './frame.js';
38
+ import { httpUrl } from './item.js';
39
+ import type { DiscoverKind } from './types.js';
40
+
41
+ let {
42
+ url,
43
+ title,
44
+ kind,
45
+ poster = null,
46
+ tint,
47
+ initial,
48
+ autoload = false,
49
+ tall = false,
50
+ class: className,
51
+ }: {
52
+ /** The live page. Only http(s) is framed. */
53
+ url: string;
54
+ /** The item's title: the iframe's accessible name and the poster's alt. */
55
+ title: string;
56
+ kind: DiscoverKind;
57
+ /** The preview picture shown until the page loads. */
58
+ poster?: string | null;
59
+ /** The placeholder's tint and glyph when there is no poster (tintFor / initialFor). */
60
+ tint: string;
61
+ initial: string;
62
+ /** Render the iframe at once, server HTML included (the item's own page). */
63
+ autoload?: boolean;
64
+ /** The page's tall frame instead of a 16:9 strip. */
65
+ tall?: boolean;
66
+ class?: string;
67
+ } = $props();
68
+
69
+ const LOAD_LABEL: Partial<Record<DiscoverKind, string>> = { site: 'Load site', game: 'Play', tool: 'Open tool' };
70
+
71
+ const src = $derived(httpUrl(url));
72
+ // From the url, never a prop: see the header.
73
+ const sandbox = $derived(frameSandbox(src));
74
+ const loadLabel = $derived(LOAD_LABEL[kind] ?? 'Load');
75
+ let ready = $state(false);
76
+ let pressed = $state(false);
77
+ const loaded = $derived(autoload || pressed);
78
+ const TALL = 'h-[min(72vh,760px)] min-h-80';
79
+ let phone = $state(false);
80
+
81
+ onMount(() => {
82
+ ready = true;
83
+ });
84
+ </script>
85
+
86
+ <div class={cn('flex flex-col gap-2', className)} data-testid="discover-live-frame">
87
+ {#if loaded && src}
88
+ <div
89
+ class={cn(
90
+ 'overflow-hidden rounded-[10px] border border-border bg-background',
91
+ phone
92
+ ? tall
93
+ ? cn(TALL, 'mx-auto aspect-[390/844] w-auto max-w-full')
94
+ : 'mx-auto aspect-[390/844] max-h-[75vh] w-[390px] max-w-full'
95
+ : tall
96
+ ? cn(TALL, 'w-full')
97
+ : 'aspect-video w-full',
98
+ )}
99
+ >
100
+ <iframe
101
+ class="block h-full w-full border-0"
102
+ src={safeUrl(src, { kind: 'resource' })}
103
+ {title}
104
+ {sandbox}
105
+ allow="fullscreen; gamepad; autoplay"
106
+ loading={autoload ? 'eager' : 'lazy'}
107
+ referrerpolicy="no-referrer"
108
+ data-testid="discover-live-iframe"
109
+ ></iframe>
110
+ </div>
111
+ {:else}
112
+ <div class="relative">
113
+ <ItemArt imageUrl={poster} {title} {tint} {initial} class={tall ? cn(TALL, 'aspect-auto') : undefined} />
114
+ {#if ready && src}
115
+ <div class="absolute inset-0 grid place-items-center">
116
+ <Button size="lg" class="rounded-full px-5 shadow-md" aria-label={`${loadLabel}: ${title}`} onclick={() => (pressed = true)}>
117
+ <Play class="size-4" aria-hidden="true" />{loadLabel}
118
+ </Button>
119
+ </div>
120
+ {/if}
121
+ </div>
122
+ {/if}
123
+ {#if src}
124
+ <div class="flex flex-wrap items-center justify-between gap-2">
125
+ {#if kind === 'site' && loaded}
126
+ <div class="inline-flex rounded-lg border border-border p-0.5" role="group" aria-label="Preview width">
127
+ <Button
128
+ size="xs"
129
+ variant={phone ? 'ghost' : 'secondary'}
130
+ class={phone ? 'text-muted-foreground' : 'text-foreground'}
131
+ aria-pressed={!phone}
132
+ onclick={() => (phone = false)}
133
+ >
134
+ <Monitor aria-hidden="true" />Desktop
135
+ </Button>
136
+ <Button
137
+ size="xs"
138
+ variant={phone ? 'secondary' : 'ghost'}
139
+ class={phone ? 'text-foreground' : 'text-muted-foreground'}
140
+ aria-pressed={phone}
141
+ onclick={() => (phone = true)}
142
+ >
143
+ <Smartphone aria-hidden="true" />Phone
144
+ </Button>
145
+ </div>
146
+ {:else}
147
+ <span></span>
148
+ {/if}
149
+ <Button
150
+ variant="ghost"
151
+ size="sm"
152
+ class="text-foreground"
153
+ href={safeUrl(src)}
154
+ target="_blank"
155
+ rel="nofollow ugc noopener noreferrer"
156
+ aria-label={`Open ${title} full screen (opens in a new tab)`}
157
+ >
158
+ <ExternalLink aria-hidden="true" />Open full screen
159
+ </Button>
160
+ </div>
161
+ {/if}
162
+ </div>
@@ -0,0 +1,21 @@
1
+ import type { DiscoverKind } from './types.js';
2
+ type $$ComponentProps = {
3
+ /** The live page. Only http(s) is framed. */
4
+ url: string;
5
+ /** The item's title: the iframe's accessible name and the poster's alt. */
6
+ title: string;
7
+ kind: DiscoverKind;
8
+ /** The preview picture shown until the page loads. */
9
+ poster?: string | null;
10
+ /** The placeholder's tint and glyph when there is no poster (tintFor / initialFor). */
11
+ tint: string;
12
+ initial: string;
13
+ /** Render the iframe at once, server HTML included (the item's own page). */
14
+ autoload?: boolean;
15
+ /** The page's tall frame instead of a 16:9 strip. */
16
+ tall?: boolean;
17
+ class?: string;
18
+ };
19
+ declare const LiveFrame: import("svelte").Component<$$ComponentProps, {}, "">;
20
+ type LiveFrame = ReturnType<typeof LiveFrame>;
21
+ export default LiveFrame;
@@ -0,0 +1,302 @@
1
+ <!-- discover/WhatsAppQrCard.svelte — the Discover hero's "Try one · no sign-up" widget:
2
+ a working WhatsApp QR card for a shop counter. The app's /discover and the public
3
+ Discover page both mount this one component (in DiscoverHeader's `aside`).
4
+
5
+ The shop name, a calling code (Select; default from the browser's languages, read
6
+ on mount, else India) and the number build a wa.me link with an optional
7
+ pre-filled message; a pasted "+44 7700 900123" picks the country itself. Until the
8
+ number is valid the preview shows a placeholder and every export is disabled, so
9
+ no QR ever points at a wrong number; the reason shows under the field (aria-live)
10
+ once the field is left. The preview is the printed card as it downloads: white
11
+ paper and dark ink in every theme (white/neutral classes, since paper is not
12
+ themed), tilted like a card on a counter (flat with reduced motion).
13
+
14
+ Server render: India and the example "Sharma Sweets" / "98765 43210", so a page
15
+ with JS off shows a real example card and link. On mount the number is cleared
16
+ and the country follows the browser. All logic is in ./whatsapp-card; exports in
17
+ ./whatsapp-card-export (PNG 300 dpi, SVG in mm, Print one 148 × 105 mm page).
18
+ Host hooks: `oncopy`, `onopenlink`, `ondownload` replace the browser defaults
19
+ (clipboard, a target=_blank link, an anchor download); the "Make a full menu or
20
+ site" action renders only with `onmake` or `makeHref` (painted text-primary, the
21
+ host's own accent, so it reads on a host whose ripple tokens are the light set);
22
+ `brand` names the product.
23
+
24
+ Collapsed on narrow: the card is a <details> whose summary is a one-line
25
+ teaser ("Make a WhatsApp QR card for your shop" and a chevron). It sizes
26
+ off its own box (container `qr-card`): under 30rem (a phone) the details
27
+ start closed and the teaser opens them; from 30rem the teaser hides and
28
+ the content shows with the details still closed, through
29
+ ::details-content, so the server markup is the same at every width and
30
+ hydration never jumps. Works with JS off. A browser without
31
+ ::details-content keeps the teaser at every width; on mount, a wide one
32
+ opens the details instead. -->
33
+ <script lang="ts">
34
+ import { onMount } from 'svelte';
35
+ import { safeUrl } from '@ripple-ui/core';
36
+ import Check from '@lucide/svelte/icons/check';
37
+ import ChevronDown from '@lucide/svelte/icons/chevron-down';
38
+ import Copy from '@lucide/svelte/icons/copy';
39
+ import Download from '@lucide/svelte/icons/download';
40
+ import ExternalLink from '@lucide/svelte/icons/external-link';
41
+ import Printer from '@lucide/svelte/icons/printer';
42
+ import QrCode from '@lucide/svelte/icons/qr-code';
43
+ import { cn } from '../utils.js';
44
+ import { Badge } from '../components/ui/badge/index.js';
45
+ import { Button } from '../components/ui/button/index.js';
46
+ import { Input } from '../components/ui/input/index.js';
47
+ import { Label } from '../components/ui/label/index.js';
48
+ import * as Select from '../components/ui/select/index.js';
49
+ import {
50
+ DEFAULT_BRAND,
51
+ DEFAULT_DIAL_ISO,
52
+ DEFAULT_MESSAGE,
53
+ DEFAULT_SHOP,
54
+ DIAL_CODES,
55
+ EXAMPLE_PHONE,
56
+ MESSAGE_MAX,
57
+ SHOP_MAX,
58
+ cardFilename,
59
+ cardFooter,
60
+ cardLayout,
61
+ checkPhone,
62
+ defaultDialIso,
63
+ dialCodeFor,
64
+ splitInternational,
65
+ waLink,
66
+ } from './whatsapp-card.js';
67
+ import { cardPng, cardSvgBlob, downloadBlob, printCard } from './whatsapp-card-export.js';
68
+
69
+ let {
70
+ brand = DEFAULT_BRAND,
71
+ oncopy,
72
+ onopenlink,
73
+ ondownload,
74
+ onmake,
75
+ makeHref,
76
+ class: className,
77
+ }: {
78
+ /** The product named in "Make a full menu or site with …" and on the card's foot. */
79
+ brand?: string;
80
+ /** Copy the wa.me link. Default: the clipboard API. */
81
+ oncopy?: (text: string) => Promise<void> | void;
82
+ /** Open the wa.me link ("Test it"). Default: a plain target=_blank link. */
83
+ onopenlink?: (url: string) => void;
84
+ /** Save a PNG or SVG. Default: an anchor download of a blob URL. */
85
+ ondownload?: (blob: Blob, filename: string) => void;
86
+ /** "Make a full menu or site", with the typed shop name. */
87
+ onmake?: (shop: string) => void;
88
+ /** The same action as a link, for a host without a callback. */
89
+ makeHref?: string;
90
+ class?: string;
91
+ } = $props();
92
+
93
+ const uid = $props.id();
94
+
95
+ let shop = $state(DEFAULT_SHOP);
96
+ let iso = $state(DEFAULT_DIAL_ISO);
97
+ let phone = $state(EXAMPLE_PHONE);
98
+ let message = $state(DEFAULT_MESSAGE);
99
+ let touched = $state(false);
100
+ let copied = $state(false);
101
+ let note = $state<string | null>(null);
102
+ let details = $state<HTMLDetailsElement>();
103
+
104
+ /** Width (px) of the card's own box from which it shows in full: 30rem. */
105
+ const FULL_FROM = 480;
106
+
107
+ onMount(() => {
108
+ iso = defaultDialIso(navigator.languages);
109
+ phone = '';
110
+ // Without ::details-content the CSS cannot show a closed card's body, so a
111
+ // wide card opens itself. Browsers that have it never change state here.
112
+ const box = details?.parentElement;
113
+ if (details && box && !CSS.supports?.('selector(::details-content)') && box.clientWidth >= FULL_FROM) details.open = true;
114
+ });
115
+
116
+ const dial = $derived(dialCodeFor(iso));
117
+ const check = $derived(checkPhone(dial.code, phone));
118
+ const link = $derived(check.ok ? waLink(check.digits, message) : null);
119
+ const layout = $derived(check.ok && link ? cardLayout({ shop, line: check.line, link, brand }) : null);
120
+ const error = $derived(!check.ok && touched ? check.error : null);
121
+
122
+ /** A pasted or typed international number picks its country. */
123
+ function onPhoneInput(e: Event & { currentTarget: HTMLInputElement }) {
124
+ const split = splitInternational(e.currentTarget.value, iso);
125
+ if (!split) return;
126
+ iso = split.iso;
127
+ phone = split.national;
128
+ }
129
+
130
+ const save = (blob: Blob, filename: string) => (ondownload ?? downloadBlob)(blob, filename);
131
+
132
+ async function savePng() {
133
+ if (!layout) return;
134
+ note = null;
135
+ try {
136
+ save(await cardPng(layout), cardFilename(shop, 'png'));
137
+ } catch {
138
+ note = "Couldn't make the PNG. Try the SVG or Print.";
139
+ }
140
+ }
141
+ function saveSvg() {
142
+ if (layout) save(cardSvgBlob(layout), cardFilename(shop, 'svg'));
143
+ }
144
+ function print() {
145
+ if (layout) printCard(layout, `${shop.trim() || 'Shop'} WhatsApp QR card`);
146
+ }
147
+ async function copy(text: string) {
148
+ note = null;
149
+ try {
150
+ await (oncopy ?? ((t: string) => navigator.clipboard.writeText(t)))(text);
151
+ copied = true;
152
+ setTimeout(() => (copied = false), 1500);
153
+ } catch {
154
+ note = "Couldn't copy the link.";
155
+ }
156
+ }
157
+ </script>
158
+
159
+ <div class={cn('@container/qr-card w-full min-w-0 justify-self-center', className)} data-testid="discover-qr-card">
160
+ <details class="qr-details group/qr mx-auto w-full max-w-[440px]" bind:this={details} data-testid="discover-qr-details">
161
+ <summary
162
+ class="qr-teaser flex cursor-pointer list-none items-center gap-3 rounded-2xl border border-border bg-card px-4 py-3 text-card-foreground shadow-sm select-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none group-open/qr:mb-3 [&::-webkit-details-marker]:hidden"
163
+ data-testid="discover-qr-teaser"
164
+ >
165
+ <span class="grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-foreground" aria-hidden="true"><QrCode class="size-4" /></span>
166
+ <span class="min-w-0 flex-1 text-callout font-medium text-foreground">Make a WhatsApp QR card for your shop</span>
167
+ <ChevronDown
168
+ class="size-4 shrink-0 text-muted-foreground transition-transform duration-150 group-open/qr:rotate-180 motion-reduce:transition-none"
169
+ aria-hidden="true"
170
+ />
171
+ </summary>
172
+ <div class="relative w-full pt-3">
173
+ <Badge class="absolute top-0 left-6 z-10 uppercase">Try one · no sign-up</Badge>
174
+ <div class="flex flex-col gap-3 rounded-2xl border border-border bg-card p-5 text-card-foreground shadow-sm">
175
+ <div>
176
+ <h2 class="text-title-3 font-semibold text-foreground">WhatsApp QR card</h2>
177
+ <p class="text-subheadline text-muted-foreground">Customers scan it and land in your WhatsApp chat.</p>
178
+ </div>
179
+
180
+ <div class="grid items-start gap-3 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
181
+ <div class="flex min-w-0 flex-col gap-1.5">
182
+ <Label for="{uid}-shop">Shop name</Label>
183
+ <Input id="{uid}-shop" bind:value={shop} maxlength={SHOP_MAX} placeholder="Your shop name" />
184
+ </div>
185
+ <div class="flex min-w-0 flex-col gap-1.5">
186
+ <Label for="{uid}-phone">WhatsApp number</Label>
187
+ <div class="flex gap-1.5">
188
+ <Select.Root type="single" value={iso} onValueChange={(v) => (iso = v)}>
189
+ <Select.Trigger class="w-[5.5rem] shrink-0 tabular-nums" aria-label={`Country code, ${dial.name}`} data-testid="discover-qr-country">
190
+ {dial.iso} +{dial.code}
191
+ </Select.Trigger>
192
+ <Select.Content class="max-h-72">
193
+ {#each DIAL_CODES as d (d.iso)}
194
+ <Select.Item value={d.iso} label={`${d.name} +${d.code}`}>{d.name} <span class="text-muted-foreground tabular-nums">+{d.code}</span></Select.Item>
195
+ {/each}
196
+ </Select.Content>
197
+ </Select.Root>
198
+ <Input
199
+ id="{uid}-phone"
200
+ class="min-w-0 tabular-nums"
201
+ bind:value={phone}
202
+ oninput={onPhoneInput}
203
+ onblur={() => (touched = true)}
204
+ inputmode="tel"
205
+ autocomplete="tel-national"
206
+ maxlength={24}
207
+ placeholder={EXAMPLE_PHONE}
208
+ aria-invalid={error ? true : undefined}
209
+ aria-describedby="{uid}-phone-note"
210
+ />
211
+ </div>
212
+ <p id="{uid}-phone-note" class={cn(error ? 'text-footnote text-ripple-error-text' : 'sr-only')} aria-live="polite">{error ?? ''}</p>
213
+ </div>
214
+ </div>
215
+
216
+ <div class="flex flex-col gap-1.5">
217
+ <Label for="{uid}-message">First message <span class="font-normal text-muted-foreground">optional</span></Label>
218
+ <Input id="{uid}-message" bind:value={message} maxlength={MESSAGE_MAX} placeholder="What customers send when they scan" />
219
+ </div>
220
+
221
+ <div
222
+ class="relative mx-auto grid aspect-[1.75/1] w-full -rotate-1 grid-cols-[1fr_auto] items-center gap-3.5 rounded-md border border-neutral-200 bg-white px-5 pt-4 pb-7 text-neutral-900 shadow-md transition-transform duration-150 hover:rotate-0 motion-reduce:rotate-0 motion-reduce:transition-none sm:w-[88%]"
223
+ aria-live="polite"
224
+ data-testid="discover-qr-preview"
225
+ >
226
+ <div class="min-w-0">
227
+ <div class="text-title-3 font-bold! break-words sm:text-title-2" data-testid="discover-qr-shop">{shop.trim() || 'Your shop'}</div>
228
+ <div class="mt-1.5 text-callout text-neutral-800 tabular-nums" data-testid="discover-qr-line">
229
+ {check.ok ? check.line : 'Add your WhatsApp number'}
230
+ </div>
231
+ <div class="mt-3 text-footnote font-bold text-neutral-900">Scan to order on WhatsApp →</div>
232
+ </div>
233
+ {#if layout}
234
+ <svg
235
+ class="size-20 bg-white text-neutral-900 sm:size-24"
236
+ viewBox="0 0 {layout.qr.path.size} {layout.qr.path.size}"
237
+ shape-rendering="crispEdges"
238
+ role="img"
239
+ aria-label={`QR code that opens a WhatsApp chat with ${check.ok ? check.line : ''}`}
240
+ data-testid="discover-qr-code"
241
+ >
242
+ <path d={layout.qr.path.d} fill="currentColor" />
243
+ </svg>
244
+ {:else}
245
+ <div
246
+ class="grid size-20 place-items-center rounded-sm border-2 border-dashed border-neutral-300 bg-neutral-50 px-1 text-center text-caption-1 text-neutral-600 sm:size-24"
247
+ data-testid="discover-qr-placeholder"
248
+ >
249
+ QR appears when the number is complete
250
+ </div>
251
+ {/if}
252
+ <div class="absolute bottom-1.5 left-5 text-caption-1 text-neutral-500">{cardFooter(brand)}</div>
253
+ </div>
254
+
255
+ <div class="flex flex-wrap gap-2">
256
+ <Button disabled={!layout} onclick={() => void savePng()} data-testid="discover-qr-png"><Download />Download PNG</Button>
257
+ <Button variant="outline" disabled={!layout} onclick={saveSvg} data-testid="discover-qr-svg">SVG</Button>
258
+ <Button variant="outline" disabled={!layout} onclick={print} data-testid="discover-qr-print"><Printer />Print</Button>
259
+ </div>
260
+ {#if note}<p class="text-footnote text-ripple-error-text" role="status">{note}</p>{/if}
261
+
262
+ {#if link}
263
+ <div class="flex min-w-0 items-center gap-1 rounded-lg bg-muted px-2.5 py-1 text-footnote">
264
+ <span class="min-w-0 flex-1 truncate text-muted-foreground" title={link} data-testid="discover-qr-link">{link}</span>
265
+ <Button variant="ghost" size="icon-xs" aria-label="Copy the WhatsApp link" onclick={() => void copy(link)}>
266
+ {#if copied}<Check />{:else}<Copy />{/if}
267
+ </Button>
268
+ {#if onopenlink}
269
+ <Button variant="ghost" size="xs" onclick={() => onopenlink(link)}><ExternalLink />Test it</Button>
270
+ {:else}
271
+ <Button variant="ghost" size="xs" href={safeUrl(link)} target="_blank" rel="noopener noreferrer"><ExternalLink />Test it</Button>
272
+ {/if}
273
+ </div>
274
+ {/if}
275
+
276
+ <div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-footnote text-muted-foreground">
277
+ <span>Free · runs in your browser</span>
278
+ {#if onmake}
279
+ <Button variant="link" size="xs" class="h-auto px-0 text-primary" onclick={() => onmake(shop)}>Make a full menu or site with {brand}</Button>
280
+ {:else if makeHref}
281
+ <Button variant="link" size="xs" class="h-auto px-0 text-primary" href={safeUrl(makeHref)}>Make a full menu or site with {brand}</Button>
282
+ {/if}
283
+ </div>
284
+ </div>
285
+ </div>
286
+ </details>
287
+ </div>
288
+
289
+ <style>
290
+ /* From 30rem of the card's own box: no teaser, the body shows while the
291
+ details stay closed (so the server markup never changes with width). */
292
+ @container qr-card (min-width: 30rem) {
293
+ @supports selector(::details-content) {
294
+ .qr-details > summary {
295
+ display: none;
296
+ }
297
+ .qr-details::details-content {
298
+ content-visibility: visible;
299
+ }
300
+ }
301
+ }
302
+ </style>
@@ -0,0 +1,18 @@
1
+ type $$ComponentProps = {
2
+ /** The product named in "Make a full menu or site with …" and on the card's foot. */
3
+ brand?: string;
4
+ /** Copy the wa.me link. Default: the clipboard API. */
5
+ oncopy?: (text: string) => Promise<void> | void;
6
+ /** Open the wa.me link ("Test it"). Default: a plain target=_blank link. */
7
+ onopenlink?: (url: string) => void;
8
+ /** Save a PNG or SVG. Default: an anchor download of a blob URL. */
9
+ ondownload?: (blob: Blob, filename: string) => void;
10
+ /** "Make a full menu or site", with the typed shop name. */
11
+ onmake?: (shop: string) => void;
12
+ /** The same action as a link, for a host without a callback. */
13
+ makeHref?: string;
14
+ class?: string;
15
+ };
16
+ declare const WhatsAppQrCard: import("svelte").Component<$$ComponentProps, {}, "">;
17
+ type WhatsAppQrCard = ReturnType<typeof WhatsAppQrCard>;
18
+ export default WhatsAppQrCard;
@@ -0,0 +1,7 @@
1
+ /** Hostname suffix whose pages keep their own origin in the frame. The leading dot
2
+ * makes it a subdomain match: the bare domain and look-alikes do not qualify. */
3
+ export declare const LIVE_FRAME_TRUSTED_HOST_SUFFIX = ".pawsites.workers.dev";
4
+ /** True when `url` is https on a subdomain of the trusted suffix. */
5
+ export declare function isTrustedFrameHost(url: string | null | undefined): boolean;
6
+ /** The iframe sandbox for a live page at `url`. */
7
+ export declare const frameSandbox: (url: string | null | undefined) => string;
@@ -0,0 +1,27 @@
1
+ // discover/frame.ts — the sandbox LiveFrame puts on a live page. Every page
2
+ // gets allow-scripts allow-forms allow-popups allow-pointer-lock. A page on a
3
+ // trusted host (https, hostname ending in LIVE_FRAME_TRUSTED_HOST_SUFFIX) also
4
+ // gets allow-same-origin, so it keeps its own storage: games save scores in
5
+ // localStorage. Scripts plus same-origin only escape the sandbox when the
6
+ // framed page shares the embedder's origin; each published site is its own
7
+ // subdomain of pawsites.workers.dev, a registrable domain apart from
8
+ // pocketpaw.xyz and the app, so its origin is never the host's. Any other
9
+ // host stays opaque.
10
+ /** Hostname suffix whose pages keep their own origin in the frame. The leading dot
11
+ * makes it a subdomain match: the bare domain and look-alikes do not qualify. */
12
+ export const LIVE_FRAME_TRUSTED_HOST_SUFFIX = '.pawsites.workers.dev';
13
+ const BASE_SANDBOX = 'allow-scripts allow-forms allow-popups allow-pointer-lock';
14
+ /** True when `url` is https on a subdomain of the trusted suffix. */
15
+ export function isTrustedFrameHost(url) {
16
+ if (!url)
17
+ return false;
18
+ try {
19
+ const u = new URL(url);
20
+ return u.protocol === 'https:' && u.hostname.endsWith(LIVE_FRAME_TRUSTED_HOST_SUFFIX);
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /** The iframe sandbox for a live page at `url`. */
27
+ export const frameSandbox = (url) => isTrustedFrameHost(url) ? `${BASE_SANDBOX} allow-same-origin` : BASE_SANDBOX;
@@ -1,2 +1,8 @@
1
1
  import type { DiscoverKind } from './types.js';
2
- export declare function itemHref(kind: DiscoverKind, idOrSlug: string): string;
2
+ /** Where items of each kind used to live, before every kind moved to /discover/. */
3
+ export declare const LEGACY_ITEM_PREFIXES: Readonly<Partial<Record<DiscoverKind, string>>>;
4
+ /** The item's page. `kind` is kept so call sites read the same as before; every kind routes alike. */
5
+ export declare function itemHref(_kind: DiscoverKind, idOrSlug: string): string;
6
+ /** A plain primary-button click with no modifier: the one a card may take over to
7
+ * open an overlay. Ctrl/cmd/shift/alt-clicks and middle clicks keep the link. */
8
+ export declare function isPlainClick(e: Pick<MouseEvent, 'button' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'defaultPrevented'>): boolean;
@@ -1,10 +1,20 @@
1
- // discover/href.ts — the public route for one Discover item, by kind. Tools live
2
- // under /free-tools/ because the docs site owns /tools/*.
3
- const PREFIX = {
1
+ // discover/href.ts — the public route for one Discover item: /discover/<slug>
2
+ // for every kind, so the website and the app agree on one URL per item. The
3
+ // old per-kind folders are kept as LEGACY_ITEM_PREFIXES for the website's
4
+ // redirects from the routes it used to serve. isPlainClick decides when a card
5
+ // link may be taken over to open an overlay instead.
6
+ /** Where items of each kind used to live, before every kind moved to /discover/. */
7
+ export const LEGACY_ITEM_PREFIXES = Object.freeze({
4
8
  tool: '/free-tools/',
5
9
  game: '/play/',
6
10
  site: '/templates/',
7
- };
8
- export function itemHref(kind, idOrSlug) {
9
- return `${PREFIX[kind] ?? '/discover/'}${encodeURIComponent(idOrSlug)}`;
11
+ });
12
+ /** The item's page. `kind` is kept so call sites read the same as before; every kind routes alike. */
13
+ export function itemHref(_kind, idOrSlug) {
14
+ return `/discover/${encodeURIComponent(idOrSlug)}`;
15
+ }
16
+ /** A plain primary-button click with no modifier: the one a card may take over to
17
+ * open an overlay. Ctrl/cmd/shift/alt-clicks and middle clicks keep the link. */
18
+ export function isPlainClick(e) {
19
+ return e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.defaultPrevented;
10
20
  }
@@ -2,6 +2,8 @@ export { default as DiscoverTile } from './DiscoverTile.svelte';
2
2
  export { default as DiscoverRow } from './DiscoverRow.svelte';
3
3
  export { default as ItemArt } from './ItemArt.svelte';
4
4
  export { default as DetailMedia } from './DetailMedia.svelte';
5
+ export { default as ItemDetail } from './ItemDetail.svelte';
6
+ export { default as LiveFrame } from './LiveFrame.svelte';
5
7
  export { default as DiscoverHeader } from './DiscoverHeader.svelte';
6
8
  export { default as DiscoverSearch } from './DiscoverSearch.svelte';
7
9
  export { default as DiscoverChips } from './DiscoverChips.svelte';
@@ -11,8 +13,11 @@ export { default as DiscoverGrid } from './DiscoverGrid.svelte';
11
13
  export { default as DiscoverSkeleton } from './DiscoverSkeleton.svelte';
12
14
  export { default as DiscoverEmpty } from './DiscoverEmpty.svelte';
13
15
  export { default as DiscoverPublish } from './DiscoverPublish.svelte';
14
- export { itemHref } from './href.js';
16
+ export { default as WhatsAppQrCard } from './WhatsAppQrCard.svelte';
17
+ export { itemHref, isPlainClick, LEGACY_ITEM_PREFIXES } from './href.js';
15
18
  export { tintFor, initialFor, CARD_PALETTE } from './art.js';
16
- export { isStudioKind, primaryFor, mediaFor, usageLabel, remixLabel, usedLabel, type DiscoverPrimary } from './item.js';
19
+ export { isStudioKind, httpUrl, primaryFor, mediaFor, rendererFor, usageLabel, showUsage, remixLabel, usedLabel, formatDiscoverDate, publishedDate, updatedDate, creatorInitials, type DiscoverPrimary, type DiscoverRenderer, } from './item.js';
20
+ export { DISCOVER_TYPES, DISCOVER_AUDIENCES, discoverSections, groupSections, sectionFilter, relatedHeading, type DiscoverSectionKey, type DiscoverAudience, type DiscoverSectionCopy, type GroupableItem, } from './sections.js';
21
+ export { LIVE_FRAME_TRUSTED_HOST_SUFFIX, frameSandbox, isTrustedFrameHost } from './frame.js';
17
22
  export { DISCOVER_VIEWS, type DiscoverView, type DiscoverOption } from './layout.js';
18
23
  export type { DiscoverItem, DiscoverKind, DiscoverMediaKind, StudioKind } from './types.js';
@@ -1,13 +1,18 @@
1
1
  // discover/index.ts — the `./discover` export subpath: a server-renderable
2
- // Discover page for a public catalogue. The cards (tile, row, art, detail
3
- // media), the page layout around them (header, search, chips, type bar,
4
- // sections, skeleton, empty state, publish band), the item type they render,
5
- // and the route helper for an item's own page. Pure components: props in,
6
- // markup out; every control works by callback or as a plain link.
2
+ // Discover library for a public catalogue. The cards (tile, row, art), the
3
+ // item view (ItemDetail with its live frame and media player), the page
4
+ // layout around them (header, search, chips, type bar, sections, skeleton,
5
+ // empty state, publish band), the hero's WhatsApp QR card (WhatsAppQrCard,
6
+ // the one stateful widget: a form, rendering an example card on the server),
7
+ // the shared catalogue copy and grouping, the item type they render, and the
8
+ // route helper for an item's own page. Pure components: props in, markup out;
9
+ // every control works by callback or as a plain link.
7
10
  export { default as DiscoverTile } from './DiscoverTile.svelte';
8
11
  export { default as DiscoverRow } from './DiscoverRow.svelte';
9
12
  export { default as ItemArt } from './ItemArt.svelte';
10
13
  export { default as DetailMedia } from './DetailMedia.svelte';
14
+ export { default as ItemDetail } from './ItemDetail.svelte';
15
+ export { default as LiveFrame } from './LiveFrame.svelte';
11
16
  export { default as DiscoverHeader } from './DiscoverHeader.svelte';
12
17
  export { default as DiscoverSearch } from './DiscoverSearch.svelte';
13
18
  export { default as DiscoverChips } from './DiscoverChips.svelte';
@@ -17,7 +22,10 @@ export { default as DiscoverGrid } from './DiscoverGrid.svelte';
17
22
  export { default as DiscoverSkeleton } from './DiscoverSkeleton.svelte';
18
23
  export { default as DiscoverEmpty } from './DiscoverEmpty.svelte';
19
24
  export { default as DiscoverPublish } from './DiscoverPublish.svelte';
20
- export { itemHref } from './href.js';
25
+ export { default as WhatsAppQrCard } from './WhatsAppQrCard.svelte';
26
+ export { itemHref, isPlainClick, LEGACY_ITEM_PREFIXES } from './href.js';
21
27
  export { tintFor, initialFor, CARD_PALETTE } from './art.js';
22
- export { isStudioKind, primaryFor, mediaFor, usageLabel, remixLabel, usedLabel } from './item.js';
28
+ export { isStudioKind, httpUrl, primaryFor, mediaFor, rendererFor, usageLabel, showUsage, remixLabel, usedLabel, formatDiscoverDate, publishedDate, updatedDate, creatorInitials, } from './item.js';
29
+ export { DISCOVER_TYPES, DISCOVER_AUDIENCES, discoverSections, groupSections, sectionFilter, relatedHeading, } from './sections.js';
30
+ export { LIVE_FRAME_TRUSTED_HOST_SUFFIX, frameSandbox, isTrustedFrameHost } from './frame.js';
23
31
  export { DISCOVER_VIEWS } from './layout.js';