@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,37 @@
1
+ <!-- discover/CreatorAvatar.svelte — a creator's round avatar for the Discover
2
+ cards and the detail view: ripple's Avatar with the picture (only an
3
+ http(s) avatarUrl, through safeUrl) and the name's initials as the
4
+ fallback, which is what the server markup and a broken picture show.
5
+ Decorative: the name always sits beside it in text. Internal to the card
6
+ set (not exported). -->
7
+ <script lang="ts">
8
+ import { safeUrl } from '@ripple-ui/core';
9
+ import * as Avatar from '../components/ui/avatar/index.js';
10
+ import { cn } from '../utils.js';
11
+ import { creatorInitials, httpUrl } from './item.js';
12
+
13
+ let {
14
+ name,
15
+ avatarUrl,
16
+ size = 'sm',
17
+ class: className,
18
+ }: {
19
+ name: string;
20
+ avatarUrl?: string | null;
21
+ /** sm: 20px on cards; lg: 32px on the detail view. */
22
+ size?: 'sm' | 'lg';
23
+ class?: string;
24
+ } = $props();
25
+
26
+ const src = $derived(httpUrl(avatarUrl));
27
+ </script>
28
+
29
+ <Avatar.Root
30
+ size={size === 'lg' ? 'default' : 'sm'}
31
+ class={cn(size === 'sm' ? 'size-5 data-[size=sm]:size-5' : 'size-8', className)}
32
+ aria-hidden="true"
33
+ data-testid="discover-creator-avatar"
34
+ >
35
+ {#if src}<Avatar.Image src={safeUrl(src, { kind: 'resource' })} alt="" />{/if}
36
+ <Avatar.Fallback class={cn('bg-secondary font-semibold text-secondary-foreground', size === 'sm' ? 'text-[9px]' : 'text-xs')}>{creatorInitials(name)}</Avatar.Fallback>
37
+ </Avatar.Root>
@@ -0,0 +1,10 @@
1
+ type $$ComponentProps = {
2
+ name: string;
3
+ avatarUrl?: string | null;
4
+ /** sm: 20px on cards; lg: 32px on the detail view. */
5
+ size?: 'sm' | 'lg';
6
+ class?: string;
7
+ };
8
+ declare const CreatorAvatar: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type CreatorAvatar = ReturnType<typeof CreatorAvatar>;
10
+ export default CreatorAvatar;
@@ -0,0 +1,18 @@
1
+ <!-- discover/CreatorLine.svelte — "by <name>" with the creator's small avatar,
2
+ under a card's title (tile and row). Draws nothing without a creator name,
3
+ so a listing whose maker did not opt in leaves no gap or placeholder.
4
+ Internal to the card set (not exported). -->
5
+ <script lang="ts">
6
+ import CreatorAvatar from './CreatorAvatar.svelte';
7
+ import type { DiscoverItem } from './types.js';
8
+
9
+ let { creator, class: className }: { creator: DiscoverItem['creator']; class?: string } = $props();
10
+ </script>
11
+
12
+ {#if creator?.name}
13
+ <!-- Not cn(): tailwind-merge reads text-footnote and text-muted-foreground as one group. -->
14
+ <p class={`m-0 flex min-w-0 items-center gap-1.5 text-footnote text-muted-foreground ${className ?? ''}`} data-testid="discover-creator">
15
+ <CreatorAvatar name={creator.name} avatarUrl={creator.avatarUrl} />
16
+ <span class="truncate">by {creator.name}</span>
17
+ </p>
18
+ {/if}
@@ -0,0 +1,8 @@
1
+ import type { DiscoverItem } from './types.js';
2
+ type $$ComponentProps = {
3
+ creator: DiscoverItem['creator'];
4
+ class?: string;
5
+ };
6
+ declare const CreatorLine: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type CreatorLine = ReturnType<typeof CreatorLine>;
8
+ export default CreatorLine;
@@ -4,7 +4,12 @@
4
4
  a server-rendered page; to make it toggle off, point the selected chip's href
5
5
  at the address without that filter. A chip without `href` is a button
6
6
  (aria-pressed) that calls `onchange` with its value; the consumer decides
7
- what a second press means. -->
7
+ what a second press means.
8
+
9
+ The chips sit in ONE row that scrolls sideways (snap, no visible bar) with
10
+ a fade on the trailing edge; the end padding keeps the last chip clear of
11
+ the fade once scrolled to. Every chip is a real link or button, so Tab
12
+ reaches each one and the browser scrolls it into view. -->
8
13
  <script lang="ts">
9
14
  import { safeUrl } from '@ripple-ui/core';
10
15
  import { Button } from '../components/ui/button/index.js';
@@ -26,7 +31,15 @@
26
31
  } = $props();
27
32
  </script>
28
33
 
29
- <div class={cn('flex flex-wrap gap-2', className)} role="group" aria-label={label}>
34
+ <div
35
+ class={cn(
36
+ '-m-1 flex snap-x scroll-px-1 flex-nowrap gap-2 overflow-x-auto p-1 pr-8 [mask-image:linear-gradient(to_right,black_calc(100%-2rem),transparent)] [scrollbar-width:none] *:shrink-0 *:snap-start [&::-webkit-scrollbar]:hidden',
37
+ className,
38
+ )}
39
+ role="group"
40
+ aria-label={label}
41
+ data-testid="discover-chips"
42
+ >
30
43
  {#each options as o (o.value)}
31
44
  {@const on = value === o.value}
32
45
  {#if o.href}
@@ -8,7 +8,13 @@
8
8
  icons up by name in the whole Lucide barrel (~800 KB a consumer cannot tree
9
9
  shake); the view icons here are imported one by one. The view toggle shows
10
10
  only with `onview` or `viewHref`. The bar paints bg-background; the app adds
11
- its glass class through `class`. -->
11
+ its glass class through `class`.
12
+
13
+ The kind tabs scroll sideways in their own track (snap, trailing fade, no
14
+ visible bar); the selected segment paints its own surface, so any widths
15
+ line up and the server markup is final. The view toggle never shrinks and
16
+ goes icon-only (labels kept for screen readers) when the bar is under
17
+ 36rem, so it stays on screen on a phone. -->
12
18
  <script lang="ts">
13
19
  import { safeUrl } from '@ripple-ui/core';
14
20
  import type { Component } from 'svelte';
@@ -48,59 +54,68 @@
48
54
  DISCOVER_VIEWS.map((v) => ({ value: v, label: VIEW_META[v].label, href: viewHref?.(v), Icon: VIEW_META[v].Icon })),
49
55
  );
50
56
 
57
+ /** On mount, slide the kind track so a selected tab past its edge is in view
58
+ * (a link-mode page opened at ?kind=music). Horizontal only: the page never jumps. */
59
+ function revealSelected(track: HTMLElement) {
60
+ const on = track.querySelector<HTMLElement>('[aria-current="true"], [aria-checked="true"]');
61
+ if (!on) return;
62
+ const end = on.offsetLeft + on.offsetWidth;
63
+ if (end > track.scrollLeft + track.clientWidth - 24) track.scrollLeft = end - track.clientWidth + 24;
64
+ }
65
+
51
66
  function pickView(v: string) {
52
67
  const next = DISCOVER_VIEWS.find((x) => x === v);
53
68
  if (next) onview?.(next);
54
69
  }
55
70
  </script>
56
71
 
57
- <!-- Segmented's size="sm" track, thumb and segment classes. With `onpick` the
58
- segments are radios (Segmented's own semantics); without it they are links. -->
59
- {#snippet pills(options: readonly (DiscoverOption & { Icon?: Component })[], selected: string, onpick?: (value: string) => void)}
60
- {@const index = options.findIndex((o) => o.value === selected)}
61
- <div class="flex flex-col gap-1.5">
62
- <div
63
- role={onpick ? 'radiogroup' : undefined}
64
- class="relative inline-grid h-7 w-fit max-w-full overflow-x-auto rounded-full bg-ripple-border/60 p-0.5 text-[12px] select-none [scrollbar-width:none]"
65
- style="grid-template-columns: repeat({Math.max(1, options.length)}, 1fr);"
66
- >
67
- {#if index >= 0}
68
- <span
69
- aria-hidden="true"
70
- class="pointer-events-none absolute inset-y-0.5 left-0.5 rounded-full bg-ripple-surface ring-1 ring-ripple-border transition-transform duration-200 ease-ripple-out motion-reduce:transition-none"
71
- style="width: calc((100% - 4px) / {Math.max(1, options.length)}); transform: translateX({index * 100}%);"
72
- ></span>
72
+ <!-- Segmented's size="sm" track and segment classes. With `onpick` the
73
+ segments are radios (Segmented's own semantics); without it they are links.
74
+ The selected segment paints its own surface (no sliding thumb), so the
75
+ server markup is already right and segments of any width line up. -->
76
+ {#snippet pills(options: readonly (DiscoverOption & { Icon?: Component })[], selected: string, onpick?: (value: string) => void, iconOnly = false)}
77
+ <div
78
+ role={onpick ? 'radiogroup' : undefined}
79
+ class="inline-flex h-7 w-max items-stretch rounded-full bg-ripple-border/60 p-0.5 text-[12px] select-none"
80
+ >
81
+ {#each options as o (o.value)}
82
+ {@const on = o.value === selected}
83
+ {@const segment = cn(
84
+ 'inline-flex shrink-0 snap-start items-center justify-center gap-1.5 rounded-full px-2.5 font-medium whitespace-nowrap transition-colors duration-150 ease-ripple-out focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none motion-reduce:transition-none',
85
+ on ? 'bg-ripple-surface text-ripple-surface-foreground ring-1 ring-ripple-border' : 'text-ripple-muted-foreground hover:text-ripple-surface-foreground',
86
+ iconOnly && o.Icon && 'px-2 @xl/discover-filters:px-2.5',
87
+ )}
88
+ {#snippet body()}
89
+ {#if o.Icon}<o.Icon size={14} aria-hidden="true" />{/if}
90
+ <span class={cn(iconOnly && o.Icon && 'sr-only @xl/discover-filters:not-sr-only')}>{o.label}</span>
91
+ {/snippet}
92
+ {#if onpick}
93
+ <button type="button" role="radio" aria-checked={on} class={segment} title={iconOnly ? o.label : undefined} onclick={() => onpick(o.value)}>
94
+ {@render body()}
95
+ </button>
96
+ {:else}
97
+ <a href={safeUrl(o.href)} aria-current={on ? 'true' : undefined} class={segment} title={iconOnly ? o.label : undefined}>
98
+ {@render body()}
99
+ </a>
73
100
  {/if}
74
- {#each options as o (o.value)}
75
- {@const on = o.value === selected}
76
- {@const segment = cn(
77
- 'relative z-10 inline-flex items-center justify-center gap-1.5 rounded-full px-2.5 font-medium whitespace-nowrap transition-colors duration-150 ease-ripple-out',
78
- on ? 'text-ripple-surface-foreground' : 'text-ripple-muted-foreground hover:text-ripple-surface-foreground',
79
- )}
80
- {#if onpick}
81
- <button type="button" role="radio" aria-checked={on} class={segment} onclick={() => onpick(o.value)}>
82
- {#if o.Icon}<o.Icon size={14} />{/if}
83
- {o.label}
84
- </button>
85
- {:else}
86
- <a href={safeUrl(o.href)} aria-current={on ? 'true' : undefined} class={segment}>
87
- {#if o.Icon}<o.Icon size={14} />{/if}
88
- {o.label}
89
- </a>
90
- {/if}
91
- {/each}
92
- </div>
101
+ {/each}
93
102
  </div>
94
103
  {/snippet}
95
104
 
96
- <div class={cn('sticky top-0 z-10 mt-6 border-b border-border bg-background', className)}>
97
- <div class="mx-auto flex w-full max-w-[1280px] items-center gap-3 overflow-x-auto px-6 py-2.5">
98
- <div role="group" aria-label="Filter by type">
105
+ <div class={cn('@container/discover-filters sticky top-0 z-10 mt-6 border-b border-border bg-background', className)} data-testid="discover-filters">
106
+ <div class="mx-auto flex w-full max-w-[1280px] items-center gap-2 px-6 py-2.5 @xl/discover-filters:gap-3">
107
+ <div
108
+ class="relative -my-1 min-w-0 flex-1 snap-x scroll-px-1 overflow-x-auto py-1 pr-6 [mask-image:linear-gradient(to_right,black_calc(100%-1.5rem),transparent)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
109
+ role="group"
110
+ aria-label="Filter by type"
111
+ data-testid="discover-kinds"
112
+ use:revealSelected
113
+ >
99
114
  {@render pills(kinds, kind, kindLinks ? undefined : (v) => onkind?.(v))}
100
115
  </div>
101
116
  {#if viewHref || onview}
102
- <div class="ml-auto shrink-0" role="group" aria-label="View">
103
- {@render pills(viewOptions, view, viewHref ? undefined : pickView)}
117
+ <div class="shrink-0" role="group" aria-label="View">
118
+ {@render pills(viewOptions, view, viewHref ? undefined : pickView, true)}
104
119
  </div>
105
120
  {/if}
106
121
  </div>
@@ -1,8 +1,17 @@
1
1
  <!-- discover/DiscoverHeader.svelte — the top of the Discover page: the pitch
2
2
  (a title whose `accent` half is painted in the primary colour, then a lead
3
3
  paragraph), the search and audience chips as children, and an optional
4
- `aside` beside it on wide screens (the app's QR card). The heading is an h2
5
- under an app page header; a page without one passes as="h1". -->
4
+ `aside` beside it on wide containers (the app's QR card). The heading is
5
+ an h2 under an app page header; a page without one passes as="h1".
6
+
7
+ It sizes off its own box (container `discover-header`), not the viewport,
8
+ because the app shell and the public page give it different widths. Narrow
9
+ (under 36rem): a title-2 heading, the lead hidden (it stays in the markup;
10
+ `narrowLead="full"` keeps it, for a landing hero whose lead is the pitch),
11
+ tighter gaps, and the aside stacked under the pitch; the QR card collapses
12
+ itself to a teaser row there. From 56rem the aside sits beside the pitch.
13
+ `class` lands on the inner grid (so `lg:items-start` and the like still
14
+ work); the container is a plain full-width wrapper around it. -->
6
15
  <script lang="ts">
7
16
  import type { Snippet } from 'svelte';
8
17
  import { cn } from '../utils.js';
@@ -12,6 +21,7 @@
12
21
  accent,
13
22
  lead,
14
23
  as = 'h2',
24
+ narrowLead = 'hide',
15
25
  class: className,
16
26
  children,
17
27
  aside,
@@ -19,8 +29,11 @@
19
29
  title: string;
20
30
  /** Drawn after the title in the primary colour. */
21
31
  accent?: string;
32
+ /** Shown on containers 36rem and wider; below that, see `narrowLead`. */
22
33
  lead?: string;
23
34
  as?: 'h1' | 'h2';
35
+ /** The lead under 36rem: 'hide' (default; Discover's search and chips come first) or 'full'. */
36
+ narrowLead?: 'hide' | 'full';
24
37
  class?: string;
25
38
  /** Search, chips: stacked under the lead. */
26
39
  children?: Snippet;
@@ -28,13 +41,23 @@
28
41
  } = $props();
29
42
  </script>
30
43
 
31
- <div class={cn('mx-auto grid w-full max-w-[1280px] items-center gap-9 px-6 pt-4 lg:grid-cols-[1.1fr_0.9fr]', className)}>
32
- <div class="flex flex-col gap-4">
33
- <svelte:element this={as} class="text-title-1 font-semibold tracking-tight text-foreground">
34
- {title}{#if accent}{' '}<span class="text-primary">{accent}</span>{/if}
35
- </svelte:element>
36
- {#if lead}<p class="max-w-prose text-body text-muted-foreground">{lead}</p>{/if}
37
- {@render children?.()}
44
+ <div class="@container/discover-header w-full" data-testid="discover-header">
45
+ <div
46
+ class={cn(
47
+ 'mx-auto grid w-full max-w-[1280px] items-center gap-4 px-6 pt-2 @xl/discover-header:gap-9 @xl/discover-header:pt-4 @4xl/discover-header:grid-cols-[1.1fr_0.9fr]',
48
+ className,
49
+ )}
50
+ >
51
+ <div class="flex min-w-0 flex-col gap-3 @xl/discover-header:gap-4">
52
+ <svelte:element this={as} class="text-title-2 font-semibold tracking-tight text-balance text-foreground @xl/discover-header:text-title-1">
53
+ {title}{#if accent}{' '}<span class="text-primary">{accent}</span>{/if}
54
+ </svelte:element>
55
+ {#if lead}
56
+ <!-- Not cn(): tailwind-merge reads text-body and text-muted-foreground as one group. -->
57
+ <p class={`max-w-prose text-body text-muted-foreground${narrowLead === 'hide' ? ' hidden @xl/discover-header:block' : ''}`}>{lead}</p>
58
+ {/if}
59
+ {@render children?.()}
60
+ </div>
61
+ {@render aside?.()}
38
62
  </div>
39
- {@render aside?.()}
40
63
  </div>
@@ -3,8 +3,11 @@ type $$ComponentProps = {
3
3
  title: string;
4
4
  /** Drawn after the title in the primary colour. */
5
5
  accent?: string;
6
+ /** Shown on containers 36rem and wider; below that, see `narrowLead`. */
6
7
  lead?: string;
7
8
  as?: 'h1' | 'h2';
9
+ /** The lead under 36rem: 'hide' (default; Discover's search and chips come first) or 'full'. */
10
+ narrowLead?: 'hide' | 'full';
8
11
  class?: string;
9
12
  /** Search, chips: stacked under the lead. */
10
13
  children?: Snippet;
@@ -1,10 +1,13 @@
1
1
  <!-- discover/DiscoverRow.svelte — one Discover item in a list: a small copy of
2
- the same picture, title and one line, the usage count, then the actions.
2
+ the same picture, title, one line and the creator line (only with a
3
+ creator), the usage count and published date, then the actions.
3
4
  Built for scanning many items at once. With `href`, the art and the title are
4
5
  links; the art link is decorative. `target` and `rel` pass through to
5
- both, for consumers whose item pages are off-site. Renders fully on the
6
- server. Type scale as in the app's own list rows: text-body title,
7
- text-footnote line, text-caption-1 usage. -->
6
+ both, for consumers whose item pages are off-site; with `onopen` too, a
7
+ plain click opens the item in place. The meta column (usage, left out at
8
+ zero, over the published date) stays, empty if need be, so rows align. Renders fully on the server. Type scale as in the
9
+ app's own list rows: text-body title, text-footnote line, text-caption-1
10
+ usage. -->
8
11
  <script lang="ts">
9
12
  import { safeUrl } from '@ripple-ui/core';
10
13
  import GitFork from '@lucide/svelte/icons/git-fork';
@@ -13,7 +16,9 @@
13
16
  import ItemActions from './ItemActions.svelte';
14
17
  import ItemArt from './ItemArt.svelte';
15
18
  import { initialFor, tintFor } from './art.js';
16
- import { usageLabel } from './item.js';
19
+ import { isPlainClick } from './href.js';
20
+ import { publishedDate, showUsage, usageLabel } from './item.js';
21
+ import CreatorLine from './CreatorLine.svelte';
17
22
  import type { DiscoverItem } from './types.js';
18
23
 
19
24
  let {
@@ -21,9 +26,11 @@
21
26
  href,
22
27
  target,
23
28
  rel,
29
+ remixHref,
24
30
  onremix,
25
31
  onreport,
26
32
  onopen,
33
+ oncopylink,
27
34
  busy = false,
28
35
  class: className,
29
36
  }: {
@@ -33,12 +40,27 @@
33
40
  /** Passed to both links. Off-site consumers pass target="_blank" rel="noopener". */
34
41
  target?: string;
35
42
  rel?: string;
43
+ /** Claim / Use this recipe and Remix as links (the website, to the app). Wins over `onremix`. */
44
+ remixHref?: string;
36
45
  onremix?: (item: DiscoverItem) => void;
37
46
  onreport?: (item: DiscoverItem) => void;
47
+ /** Opens the item in place. With `href`, a plain click on the art or title calls
48
+ * it instead of following the link; modified clicks and JS-off still navigate.
49
+ * For studio items it also drives the Play / View button. */
38
50
  onopen?: (item: DiscoverItem) => void;
51
+ /** Copy link in the ⋯ menu, shown with `href`. Gets the item and that href. */
52
+ oncopylink?: (item: DiscoverItem, href: string) => void;
39
53
  busy?: boolean;
40
54
  class?: string;
41
55
  } = $props();
56
+
57
+ const published = $derived(publishedDate(item));
58
+
59
+ function openInPlace(e: MouseEvent) {
60
+ if (!onopen || !isPlainClick(e)) return;
61
+ e.preventDefault();
62
+ onopen(item);
63
+ }
42
64
  </script>
43
65
 
44
66
  <div class={cn('flex items-center gap-4 px-3 py-2.5', className)} role="listitem" data-testid={`discover-item-${item.id}`}>
@@ -47,7 +69,7 @@
47
69
  <ItemArt imageUrl={item.imageUrl} title={item.title} mediaKind={item.mediaKind} tint={tintFor(item.id)} initial={initialFor(item.title)} />
48
70
  {/snippet}
49
71
  {#if href}
50
- <a href={safeUrl(href)} {target} {rel} tabindex="-1" class="block">{@render art()}</a>
72
+ <a href={safeUrl(href)} {target} {rel} onclick={openInPlace} tabindex="-1" class="block">{@render art()}</a>
51
73
  {:else}
52
74
  {@render art()}
53
75
  {/if}
@@ -55,16 +77,20 @@
55
77
  <div class="flex min-w-0 flex-1 flex-col">
56
78
  <span class="flex min-w-0 items-center gap-2">
57
79
  {#if href}
58
- <a href={safeUrl(href)} {target} {rel} class="truncate text-body font-semibold text-foreground hover:underline">{item.title}</a>
80
+ <a href={safeUrl(href)} {target} {rel} onclick={openInPlace} class="truncate text-body font-semibold text-foreground hover:underline">{item.title}</a>
59
81
  {:else}
60
82
  <span class="truncate text-body font-semibold text-foreground">{item.title}</span>
61
83
  {/if}
62
84
  {#if item.featured}<Chip size="sm" variant="warning" label="Staff pick" />{/if}
63
85
  </span>
64
86
  <span class="truncate text-footnote text-muted-foreground">{item.desc}</span>
87
+ <CreatorLine creator={item.creator} class="mt-0.5" />
65
88
  </div>
66
- <div class="hidden w-40 shrink-0 text-caption-1 text-muted-foreground md:flex">
67
- <span class="inline-flex items-center gap-1 tabular-nums"><GitFork class="size-3" aria-hidden="true" />{usageLabel(item)}</span>
89
+ <div class="hidden w-40 shrink-0 flex-col gap-0.5 text-caption-1 text-muted-foreground md:flex" data-testid="discover-meta">
90
+ {#if showUsage(item)}
91
+ <span class="inline-flex items-center gap-1 tabular-nums"><GitFork class="size-3" aria-hidden="true" />{usageLabel(item)}</span>
92
+ {/if}
93
+ {#if published}<span class="tabular-nums">{published}</span>{/if}
68
94
  </div>
69
- <ItemActions {item} {onremix} {onreport} {onopen} {busy} />
95
+ <ItemActions {item} pageHref={href} {remixHref} {onremix} {onreport} {onopen} {oncopylink} {busy} />
70
96
  </div>
@@ -6,9 +6,16 @@ type $$ComponentProps = {
6
6
  /** Passed to both links. Off-site consumers pass target="_blank" rel="noopener". */
7
7
  target?: string;
8
8
  rel?: string;
9
+ /** Claim / Use this recipe and Remix as links (the website, to the app). Wins over `onremix`. */
10
+ remixHref?: string;
9
11
  onremix?: (item: DiscoverItem) => void;
10
12
  onreport?: (item: DiscoverItem) => void;
13
+ /** Opens the item in place. With `href`, a plain click on the art or title calls
14
+ * it instead of following the link; modified clicks and JS-off still navigate.
15
+ * For studio items it also drives the Play / View button. */
11
16
  onopen?: (item: DiscoverItem) => void;
17
+ /** Copy link in the ⋯ menu, shown with `href`. Gets the item and that href. */
18
+ oncopylink?: (item: DiscoverItem, href: string) => void;
12
19
  busy?: boolean;
13
20
  class?: string;
14
21
  };
@@ -2,7 +2,12 @@
2
2
  heading and its blurb, an optional "see all" link, then the items in a
3
3
  DiscoverGrid for the given view. Children are the tiles (or rows, for the
4
4
  list view). The "see all" control is a link with `moreHref` and a button
5
- with `onmore`; with neither it is not drawn. Ids follow the app:
5
+ with `onmore`; with neither it is not drawn. It sits on the title's row,
6
+ right-aligned, and wraps under the title (still right-aligned) only when
7
+ the row is too narrow; the blurb runs under both. It paints text-primary,
8
+ the host's own accent, which reads on the app's and the public site's
9
+ surfaces alike (the link button's ripple accent token is the light set's
10
+ dark blue on a host that only defines its own tokens). Ids follow the app:
6
11
  discover-h-{id} on the heading, discover-section-{id} as the test id. -->
7
12
  <script lang="ts">
8
13
  import { safeUrl } from '@ripple-ui/core';
@@ -35,19 +40,22 @@
35
40
  class?: string;
36
41
  children: Snippet;
37
42
  } = $props();
43
+
44
+ // No side padding (the label lines up with the grid's edge), the host accent.
45
+ const moreClass = 'ml-auto h-auto px-0 text-primary';
38
46
  </script>
39
47
 
40
48
  <section class={cn('flex flex-col gap-4', className)} aria-labelledby={`discover-h-${id}`} data-testid={`discover-section-${id}`}>
41
- <div class="flex flex-wrap items-end justify-between gap-2">
42
- <div>
43
- <h2 id={`discover-h-${id}`} class="text-title-3 font-semibold text-foreground">{title}</h2>
44
- {#if blurb}<p class="text-subheadline text-muted-foreground">{blurb}</p>{/if}
49
+ <div class="flex flex-col gap-0.5">
50
+ <div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
51
+ <h2 id={`discover-h-${id}`} class="min-w-0 text-title-3 font-semibold text-foreground">{title}</h2>
52
+ {#if more && moreHref}
53
+ <Button variant="link" size="sm" class={moreClass} href={safeUrl(moreHref)} data-testid={`discover-more-${id}`}>{more} →</Button>
54
+ {:else if more && onmore}
55
+ <Button variant="link" size="sm" class={moreClass} onclick={onmore} data-testid={`discover-more-${id}`}>{more} →</Button>
56
+ {/if}
45
57
  </div>
46
- {#if more && moreHref}
47
- <Button variant="link" size="sm" href={safeUrl(moreHref)}>{more} →</Button>
48
- {:else if more && onmore}
49
- <Button variant="link" size="sm" onclick={onmore}>{more} →</Button>
50
- {/if}
58
+ {#if blurb}<p class="m-0 text-subheadline text-muted-foreground">{blurb}</p>{/if}
51
59
  </div>
52
60
  <DiscoverGrid {view} label={title}>{@render children()}</DiscoverGrid>
53
61
  </section>
@@ -1,7 +1,8 @@
1
1
  <!-- discover/DiscoverSkeleton.svelte — the Discover loading state in the exact
2
2
  geometry of what replaces it: skeleton rows in the bordered list for the list
3
3
  view, tile skeletons (16:9 picture, title bar, line bar) in the grid
4
- otherwise. One role="status" wrapper carries the label; the shapes are
4
+ otherwise; under 15rem a tile skeleton takes the compact tile's shape
5
+ (title bar, then a small action pill instead of the line). One role="status" wrapper carries the label; the shapes are
5
6
  decorative. Classes are paw-enterprise's Skeleton and TemplateTileSkeleton. -->
6
7
  <script lang="ts">
7
8
  import { cn } from '../utils.js';
@@ -39,10 +40,11 @@
39
40
  {:else}
40
41
  <div class={GRID_CLASS}>
41
42
  {#each slots as i (i)}
42
- <div class="flex flex-col gap-3" aria-hidden="true">
43
+ <div class="@container/tile flex flex-col gap-3 @max-[15rem]/tile:gap-2" aria-hidden="true">
43
44
  <div data-slot="skeleton" class={cn(bone, 'aspect-video w-full rounded-2xl')}></div>
44
45
  <div data-slot="skeleton" class={cn(bone, 'h-4 w-2/3 rounded-md')}></div>
45
- <div data-slot="skeleton" class={cn(bone, 'h-3 w-full rounded-md')}></div>
46
+ <div data-slot="skeleton" class={cn(bone, 'h-3 w-full rounded-md @max-[15rem]/tile:hidden')}></div>
47
+ <div data-slot="skeleton" class={cn(bone, 'hidden h-7 w-16 rounded-full @max-[15rem]/tile:block')}></div>
46
48
  </div>
47
49
  {/each}
48
50
  </div>
@@ -1,11 +1,22 @@
1
1
  <!-- discover/DiscoverTile.svelte — one Discover item in a grid or shelf: the
2
- picture as a borderless rounded tile, then title, one line and the usage
3
- line, with the actions beside them. The type scale (text-headline,
2
+ picture as a borderless rounded tile, then title, one line, the creator
3
+ line (avatar and "by <name>", only when the listing has a creator) and a
4
+ quiet meta line (usage, left out at zero, and the published date), with
5
+ the actions beside them.
6
+
7
+ The tile sizes off its own width (container `tile`), since the same tile
8
+ sits in a two-column phone grid, a shelf and a related row. Under 15rem it
9
+ goes compact: the title clamps to two lines, the line and the meta line
10
+ hide, and the actions drop under the text as a small primary button and
11
+ the ⋯ menu (the Remix icon hides there; Remix is in ⋯). The type scale (text-headline,
4
12
  text-footnote, text-caption-1), the 10px picture radius and the hover lift
5
- match paw-enterprise's TemplateTile, the app's own tile. With `href`, the
6
- art and the title are links; the art link is decorative (the title carries
7
- the name). `target` and `rel` pass through to both, for consumers whose
8
- item pages are off-site. Renders fully on the server. -->
13
+ match paw-enterprise's TemplateTile, the app's own tile. The usage line is
14
+ left out at zero. With `href`, the art and the title are links; the art
15
+ link is decorative (the title carries the name). `target` and `rel` pass
16
+ through to both, for consumers whose item pages are off-site. With
17
+ `onopen` too, a plain click on either opens the item in place (an
18
+ overlay) and a modified click still follows the link. Renders fully on
19
+ the server. -->
9
20
  <script lang="ts">
10
21
  import { safeUrl } from '@ripple-ui/core';
11
22
  import GitFork from '@lucide/svelte/icons/git-fork';
@@ -14,7 +25,9 @@
14
25
  import ItemActions from './ItemActions.svelte';
15
26
  import ItemArt from './ItemArt.svelte';
16
27
  import { initialFor, tintFor } from './art.js';
17
- import { usageLabel } from './item.js';
28
+ import { isPlainClick } from './href.js';
29
+ import { publishedDate, showUsage, usageLabel } from './item.js';
30
+ import CreatorLine from './CreatorLine.svelte';
18
31
  import type { DiscoverItem } from './types.js';
19
32
 
20
33
  let {
@@ -22,9 +35,11 @@
22
35
  href,
23
36
  target,
24
37
  rel,
38
+ remixHref,
25
39
  onremix,
26
40
  onreport,
27
41
  onopen,
42
+ oncopylink,
28
43
  busy = false,
29
44
  class: className,
30
45
  }: {
@@ -34,15 +49,30 @@
34
49
  /** Passed to both links. Off-site consumers pass target="_blank" rel="noopener". */
35
50
  target?: string;
36
51
  rel?: string;
52
+ /** Claim / Use this recipe and Remix as links (the website, to the app). Wins over `onremix`. */
53
+ remixHref?: string;
37
54
  onremix?: (item: DiscoverItem) => void;
38
55
  onreport?: (item: DiscoverItem) => void;
56
+ /** Opens the item in place. With `href`, a plain click on the art or title calls
57
+ * it instead of following the link; modified clicks and JS-off still navigate.
58
+ * For studio items it also drives the Play / View button. */
39
59
  onopen?: (item: DiscoverItem) => void;
60
+ /** Copy link in the ⋯ menu, shown with `href`. Gets the item and that href. */
61
+ oncopylink?: (item: DiscoverItem, href: string) => void;
40
62
  busy?: boolean;
41
63
  class?: string;
42
64
  } = $props();
65
+
66
+ const published = $derived(publishedDate(item));
67
+
68
+ function openInPlace(e: MouseEvent) {
69
+ if (!onopen || !isPlainClick(e)) return;
70
+ e.preventDefault();
71
+ onopen(item);
72
+ }
43
73
  </script>
44
74
 
45
- <article class={cn('group/tile flex min-w-0 flex-col gap-3', className)} data-testid={`discover-item-${item.id}`}>
75
+ <article class={cn('group/tile @container/tile flex min-w-0 flex-col gap-3 @max-[15rem]/tile:gap-2', className)} data-testid={`discover-item-${item.id}`}>
46
76
  <div
47
77
  class="relative overflow-hidden rounded-2xl transition-transform duration-150 ease-out group-focus-within/tile:-translate-y-px group-hover/tile:-translate-y-px motion-reduce:transition-none motion-reduce:group-focus-within/tile:translate-y-0 motion-reduce:group-hover/tile:translate-y-0"
48
78
  >
@@ -50,7 +80,7 @@
50
80
  <ItemArt imageUrl={item.imageUrl} title={item.title} mediaKind={item.mediaKind} tint={tintFor(item.id)} initial={initialFor(item.title)} />
51
81
  {/snippet}
52
82
  {#if href}
53
- <a href={safeUrl(href)} {target} {rel} tabindex="-1" aria-hidden="true" class="block">{@render art()}</a>
83
+ <a href={safeUrl(href)} {target} {rel} onclick={openInPlace} tabindex="-1" aria-hidden="true" class="block">{@render art()}</a>
54
84
  {:else}
55
85
  {@render art()}
56
86
  {/if}
@@ -58,18 +88,23 @@
58
88
  <Chip size="sm" variant="warning" label="Staff pick" class="absolute top-2 left-2" />
59
89
  {/if}
60
90
  </div>
61
- <div class="flex items-start gap-3">
91
+ <div class="flex items-start gap-3 @max-[15rem]/tile:flex-col @max-[15rem]/tile:items-stretch @max-[15rem]/tile:gap-2">
62
92
  <div class="flex min-w-0 flex-1 flex-col">
63
- <h3 class="m-0 truncate text-headline text-foreground">
64
- {#if href}<a href={safeUrl(href)} {target} {rel} class="hover:underline">{item.title}</a>{:else}{item.title}{/if}
93
+ <h3 class="m-0 truncate text-headline text-foreground @max-[15rem]/tile:line-clamp-2 @max-[15rem]/tile:whitespace-normal">
94
+ {#if href}<a href={safeUrl(href)} {target} {rel} onclick={openInPlace} class="hover:underline">{item.title}</a>{:else}{item.title}{/if}
65
95
  </h3>
66
96
  {#if item.desc}
67
- <p class="m-0 truncate text-footnote text-muted-foreground">{item.desc}</p>
97
+ <p class="m-0 truncate text-footnote text-muted-foreground @max-[15rem]/tile:hidden">{item.desc}</p>
98
+ {/if}
99
+ <CreatorLine creator={item.creator} class="mt-1" />
100
+ {#if showUsage(item) || published}
101
+ <p class="m-0 mt-1 flex flex-wrap items-center gap-x-1.5 text-caption-1 text-muted-foreground tabular-nums @max-[15rem]/tile:hidden" data-testid="discover-meta">
102
+ {#if showUsage(item)}<span class="inline-flex items-center gap-1.5 whitespace-nowrap"><GitFork class="size-3" aria-hidden="true" />{usageLabel(item)}</span>{/if}
103
+ {#if showUsage(item) && published}<span aria-hidden="true">·</span>{/if}
104
+ {#if published}<span class="whitespace-nowrap">{published}</span>{/if}
105
+ </p>
68
106
  {/if}
69
- <p class="m-0 mt-1 flex items-center gap-1.5 text-caption-1 text-muted-foreground tabular-nums">
70
- <GitFork class="size-3" aria-hidden="true" />{usageLabel(item)}
71
- </p>
72
107
  </div>
73
- <ItemActions {item} {onremix} {onreport} {onopen} {busy} />
108
+ <ItemActions {item} pageHref={href} {remixHref} {onremix} {onreport} {onopen} {oncopylink} {busy} class="@max-[15rem]/tile:justify-between" />
74
109
  </div>
75
110
  </article>