@hyvor/design 2.1.13 → 2.1.15

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.
@@ -1,13 +1,14 @@
1
1
  <script lang="ts">
2
2
  interface Props {
3
3
  column?: boolean;
4
+ divider?: boolean;
4
5
  children?: import('svelte').Snippet;
5
6
  }
6
7
 
7
- let { column = false, children }: Props = $props();
8
+ let { column = false, divider = true, children }: Props = $props();
8
9
  </script>
9
10
 
10
- <div class="button-group" class:column>
11
+ <div class="button-group" class:column class:no-divider={!divider}>
11
12
  {@render children?.()}
12
13
  </div>
13
14
 
@@ -57,7 +58,7 @@
57
58
  margin-top: -1px;
58
59
  }
59
60
 
60
- .button-group:not(.column) :global(> .button:not(.outline):not(.outline-fill):not(:first-child))::before {
61
+ .button-group:not(.no-divider):not(.column) :global(> .button:not(.outline):not(.outline-fill):not(:first-child))::before {
61
62
  content: "";
62
63
  position: absolute;
63
64
  top: 22%;
@@ -69,7 +70,7 @@
69
70
  pointer-events: none;
70
71
  }
71
72
 
72
- .button-group.column :global(> .button:not(.outline):not(.outline-fill):not(:first-child))::before {
73
+ .button-group.column:not(.no-divider) :global(> .button:not(.outline):not(.outline-fill):not(:first-child))::before {
73
74
  content: "";
74
75
  position: absolute;
75
76
  left: 22%;
@@ -1,5 +1,6 @@
1
1
  interface Props {
2
2
  column?: boolean;
3
+ divider?: boolean;
3
4
  children?: import('svelte').Snippet;
4
5
  }
5
6
  declare const ButtonGroup: import("svelte").Component<Props, {}, "">;
@@ -46,13 +46,10 @@
46
46
 
47
47
  if (position === 'bottom') {
48
48
  contentWrap.style.top = triggerRect.bottom + GAP + 'px';
49
- if (contentRect.height + triggerRect.bottom > window.innerHeight) {
50
- contentWrap.style.bottom = SPACE_AROUND + 'px';
51
- } else {
52
- contentWrap.style.bottom = 'auto';
53
- }
49
+ contentWrap.style.bottom = 'auto';
54
50
  } else if (position === 'top') {
55
- contentWrap.style.top = triggerRect.top - contentRect.height - GAP + 'px';
51
+ contentWrap.style.top =
52
+ Math.max(triggerRect.top - contentRect.height - GAP, SPACE_AROUND) + 'px';
56
53
  } else if (position === 'left') {
57
54
  contentWrap.style.left = triggerRect.left - width - GAP + 'px';
58
55
  } else if (position === 'right') {
@@ -88,6 +85,17 @@
88
85
  } else {
89
86
  contentWrap.style.width = width + 'px';
90
87
  }
88
+
89
+ // cap to viewport height; the panel scrolls internally past that
90
+ let available: number;
91
+ if (position === 'bottom') {
92
+ available = window.innerHeight - (triggerRect.bottom + GAP) - SPACE_AROUND;
93
+ } else if (position === 'top') {
94
+ available = triggerRect.top - GAP - SPACE_AROUND;
95
+ } else {
96
+ available = window.innerHeight - SPACE_AROUND * 2;
97
+ }
98
+ contentWrap.style.maxHeight = Math.max(available, 120) + 'px';
91
99
  }
92
100
 
93
101
  $effect(() => {
@@ -137,4 +145,12 @@
137
145
  <style>.content-wrap {
138
146
  position: fixed;
139
147
  z-index: 1000000;
148
+ display: flex;
149
+ flex-direction: column;
150
+ }
151
+
152
+ .content {
153
+ overflow-y: auto;
154
+ min-height: 0;
155
+ overscroll-behavior: contain;
140
156
  }</style>
@@ -20,7 +20,7 @@
20
20
  nextPage === 1 ? (isLoading = true) : (isLoadingMore = true);
21
21
 
22
22
  config
23
- .mediaLoad(nextPage)
23
+ .mediaLoad(nextPage, config.type)
24
24
  .then((results) => {
25
25
  items = nextPage === 1 ? results : [...items, ...results];
26
26
  hasMore = results.length > 0;
@@ -30,7 +30,7 @@ export interface FileUploaderConfig {
30
30
  }>;
31
31
  allowedMimeTypes?: string[];
32
32
  maxFileSizeInMB?: number;
33
- mediaLoad?: (page: number) => Promise<MediaItem[]>;
33
+ mediaLoad?: (page: number, type: UploadType) => Promise<MediaItem[]>;
34
34
  unsplashSearch?: (search: string, page: number) => Promise<UnsplashImage[]>;
35
35
  excalidraw?: boolean;
36
36
  }
@@ -244,8 +244,38 @@
244
244
  }
245
245
 
246
246
  @media (max-width: 768px) {
247
+ .hds-all-features-accordion {
248
+ padding: 56px 0;
249
+ }
250
+
251
+ .section-header {
252
+ margin-bottom: 32px;
253
+ }
254
+
255
+ .section-header h2 {
256
+ font-size: 26px;
257
+ }
258
+
259
+ .section-header p {
260
+ font-size: 0.9rem;
261
+ }
262
+
263
+ .accordion-trigger {
264
+ gap: 12px;
265
+ padding: 16px 14px;
266
+ }
267
+
268
+ .cat-label {
269
+ font-size: 15px;
270
+ }
271
+
272
+ .accordion-content {
273
+ padding: 4px 14px 20px;
274
+ }
275
+
247
276
  .features-grid {
248
277
  grid-template-columns: 1fr;
278
+ gap: 18px;
249
279
  }
250
280
  }
251
281
  </style>
@@ -60,11 +60,24 @@
60
60
  @media (max-width: 992px) {
61
61
  .feature {
62
62
  width: 100%;
63
- text-align: center;
64
63
  }
65
64
 
66
65
  .icon-bg {
67
- display: none;
66
+ opacity: 0.1;
67
+ }
68
+
69
+ .icon-bg :global(svg) {
70
+ width: 22px;
71
+ height: 22px;
72
+ }
73
+
74
+ .title {
75
+ font-size: 15px;
76
+ }
77
+
78
+ .description {
79
+ font-size: 13px;
80
+ margin-top: 4px;
68
81
  }
69
82
  }
70
83
  </style>
@@ -374,7 +374,9 @@
374
374
  }
375
375
 
376
376
  .footer-center {
377
- display: flex;
377
+ display: grid;
378
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
379
+ gap: 40px;
378
380
  }
379
381
 
380
382
  .footer-center:not(:empty) {
@@ -474,5 +476,30 @@
474
476
  flex-direction: column;
475
477
  align-items: flex-start;
476
478
  }
479
+
480
+ .top-row-right {
481
+ gap: 16px;
482
+ }
483
+
484
+ .footer-center {
485
+ grid-template-columns: repeat(2, minmax(0, 1fr));
486
+ gap: 32px 20px;
487
+ }
488
+
489
+ .footer-center:not(:empty) {
490
+ padding: 36px 0;
491
+ }
492
+
493
+ .bottom-bar {
494
+ flex-direction: column;
495
+ align-items: flex-start;
496
+ gap: 16px;
497
+ }
498
+ }
499
+
500
+ @media (max-width: 360px) {
501
+ .footer-center {
502
+ grid-template-columns: minmax(0, 1fr);
503
+ }
477
504
  }
478
505
  </style>
@@ -17,7 +17,7 @@
17
17
  <style>
18
18
  .link-list {
19
19
  flex: 1;
20
- min-width: 150px;
20
+ min-width: 0;
21
21
  }
22
22
 
23
23
  .title {
@@ -27,18 +27,22 @@
27
27
  letter-spacing: 0.05em;
28
28
  color: var(--footer-muted, var(--text-light));
29
29
  margin-bottom: 14px;
30
+ overflow-wrap: break-word;
30
31
  }
31
32
 
32
33
  .links {
33
34
  display: flex;
34
35
  flex-direction: column;
35
36
  align-items: flex-start;
37
+ min-width: 0;
36
38
  }
37
39
 
38
40
  .links :global(a) {
39
41
  font-size: 14px;
40
42
  color: var(--footer-text, var(--text-light));
41
43
  margin-top: 10px;
44
+ max-width: 100%;
45
+ overflow-wrap: break-word;
42
46
  }
43
47
  .links :global(a:hover) {
44
48
  color: var(--footer-text-strong, var(--text));
@@ -114,7 +114,9 @@
114
114
  </div>
115
115
  {/if}
116
116
  {#if end}
117
- <div class="mobile-divider"></div>
117
+ {#if center}
118
+ <div class="mobile-divider"></div>
119
+ {/if}
118
120
  <div class="mobile-inner end">
119
121
  {@render end()}
120
122
  </div>
@@ -240,15 +242,30 @@
240
242
  display: flex;
241
243
  }
242
244
 
245
+ /* make every nav link a full-width list row inside the mobile menu */
246
+ .mobile-content :global(.header-nav-link) {
247
+ width: 100%;
248
+ justify-content: flex-start;
249
+ padding: 8px 10px;
250
+ border-radius: 12px;
251
+ }
252
+
253
+ /* hide submenu link icon in the mobile menu */
254
+ .mobile-content :global(.header-nav-link .icon-box),
255
+ .mobile-content :global(.header-nav-link .start) {
256
+ display: none;
257
+ }
258
+
259
+ .mobile-content :global(.header-nav-link.rich) {
260
+ gap: 0;
261
+ }
262
+
243
263
  .mobile-divider {
244
264
  height: 1px;
245
265
  background: var(--border);
246
266
  margin: 6px 4px;
247
267
  }
248
268
 
249
- /*
250
- used to prevent the content from being hidden behind the header
251
- */
252
269
  :global(html) {
253
270
  scroll-padding-top: calc(var(--header-height) + 20px);
254
271
  }
@@ -1,11 +1,14 @@
1
1
  <script lang="ts">
2
+ import { MediaQuery } from 'svelte/reactivity';
2
3
  import Dropdown from '../../components/Dropdown/Dropdown.svelte';
3
4
  import HeaderNavLink from './HeaderNavLink.svelte';
5
+ import HeaderMobileSection from './HeaderMobileSection.svelte';
4
6
  import type {
5
7
  DropdownAlign,
6
8
  DropdownPosition
7
9
  } from '../../components/Dropdown/dropdown.types.js';
8
10
  import type { LanguageOption } from './language.js';
11
+ import { HEADER_MOBILE_BREAKPOINT } from './breakpoint.js';
9
12
 
10
13
  interface Props {
11
14
  languages: LanguageOption[];
@@ -29,9 +32,12 @@
29
32
 
30
33
  const currentLanguage = $derived(languages.find((l) => l.code === current) ?? languages[0]);
31
34
 
35
+ // below the breakpoint: render as a collapsible inline section, not a dropdown
36
+ const mobile = new MediaQuery(`(max-width: ${HEADER_MOBILE_BREAKPOINT}px)`);
37
+
32
38
  let show = $state(false);
33
39
 
34
- // close the dropdown once a language link inside it is clicked
40
+ // close the dropdown when a language link inside it is clicked
35
41
  function closeOnLinkClick(e: MouseEvent) {
36
42
  const target = e.target as HTMLElement;
37
43
  if (target.tagName === 'A' || target.closest('a')) {
@@ -40,36 +46,51 @@
40
46
  }
41
47
  </script>
42
48
 
49
+ {#snippet languageLink(language: LanguageOption)}
50
+ <HeaderNavLink
51
+ href={href(language.code)}
52
+ active={language.code === current}
53
+ data-lang={language.code}
54
+ menu
55
+ >
56
+ <span class="lang-row">
57
+ <span class="flag">{language.flag}</span>
58
+ <span class="name">{language.name}</span>
59
+ <span class="code">{language.code.toUpperCase()}</span>
60
+ </span>
61
+ </HeaderNavLink>
62
+ {/snippet}
63
+
43
64
  {#if currentLanguage}
44
- <div class="header-language-toggle">
45
- <Dropdown bind:show {align} {position} contentPadding={8}>
46
- {#snippet trigger()}
47
- <HeaderNavLink aria-label={label} aria-expanded={show}>
48
- <span class="flag">{currentLanguage.flag}</span>
49
- {#if showName}{currentLanguage.name}{/if}
50
- </HeaderNavLink>
51
- {/snippet}
52
- {#snippet content()}
53
- <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
54
- <div class="menu" onclick={closeOnLinkClick}>
55
- {#each languages as language (language.code)}
56
- <HeaderNavLink
57
- href={href(language.code)}
58
- active={language.code === current}
59
- data-lang={language.code}
60
- menu
61
- >
62
- <span class="lang-row">
63
- <span class="flag">{language.flag}</span>
64
- <span class="name">{language.name}</span>
65
- <span class="code">{language.code.toUpperCase()}</span>
66
- </span>
67
- </HeaderNavLink>
68
- {/each}
69
- </div>
65
+ {#if mobile.current}
66
+ <HeaderMobileSection {label}>
67
+ {#snippet trailing()}
68
+ <span class="flag">{currentLanguage.flag}</span>
70
69
  {/snippet}
71
- </Dropdown>
72
- </div>
70
+ {#each languages as language (language.code)}
71
+ {@render languageLink(language)}
72
+ {/each}
73
+ </HeaderMobileSection>
74
+ {:else}
75
+ <div class="header-language-toggle">
76
+ <Dropdown bind:show {align} {position} contentPadding={8}>
77
+ {#snippet trigger()}
78
+ <HeaderNavLink aria-label={label} aria-expanded={show}>
79
+ <span class="flag">{currentLanguage.flag}</span>
80
+ {#if showName}{currentLanguage.name}{/if}
81
+ </HeaderNavLink>
82
+ {/snippet}
83
+ {#snippet content()}
84
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
85
+ <div class="menu" onclick={closeOnLinkClick}>
86
+ {#each languages as language (language.code)}
87
+ {@render languageLink(language)}
88
+ {/each}
89
+ </div>
90
+ {/snippet}
91
+ </Dropdown>
92
+ </div>
93
+ {/if}
73
94
  {/if}
74
95
 
75
96
  <style>
@@ -0,0 +1,108 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { slide } from 'svelte/transition';
4
+ import IconChevronRight from '@hyvor/icons/IconChevronRight';
5
+
6
+ interface Props {
7
+ /** Section heading, shown on the toggle row. */
8
+ label: string;
9
+ /** Optional content shown on the toggle row, just before the chevron. */
10
+ trailing?: Snippet;
11
+ /** The section content, revealed when expanded. */
12
+ children: Snippet;
13
+ }
14
+
15
+ let { label, trailing, children }: Props = $props();
16
+
17
+ let expanded = $state(false);
18
+ </script>
19
+
20
+ <div class="section">
21
+ <button
22
+ type="button"
23
+ class="toggle"
24
+ class:expanded
25
+ aria-expanded={expanded}
26
+ onclick={() => (expanded = !expanded)}
27
+ >
28
+ <span class="label">{label}</span>
29
+ <span class="right">
30
+ {#if trailing}
31
+ <span class="trailing">{@render trailing()}</span>
32
+ {/if}
33
+ <span class="chevron"><IconChevronRight size={12} /></span>
34
+ </span>
35
+ </button>
36
+ {#if expanded}
37
+ <div class="items" transition:slide={{ duration: 150 }}>
38
+ {@render children()}
39
+ </div>
40
+ {/if}
41
+ </div>
42
+
43
+ <style>
44
+ .section {
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: 2px;
48
+ width: 100%;
49
+ }
50
+
51
+ .toggle {
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: space-between;
55
+ gap: 8px;
56
+ width: 100%;
57
+ padding: 8px 10px;
58
+ border: none;
59
+ background: none;
60
+ border-radius: 12px;
61
+ cursor: pointer;
62
+ font: inherit;
63
+ text-align: left;
64
+ color: var(--text-light);
65
+ transition: background-color 0.15s;
66
+ }
67
+
68
+ .toggle:hover {
69
+ background: var(--hover, var(--accent-lightest));
70
+ }
71
+
72
+ .label {
73
+ font-size: 11px;
74
+ font-weight: 600;
75
+ text-transform: uppercase;
76
+ letter-spacing: 0.04em;
77
+ color: var(--text-light);
78
+ }
79
+
80
+ .right {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: 8px;
84
+ }
85
+
86
+ .trailing {
87
+ display: inline-flex;
88
+ align-items: center;
89
+ }
90
+
91
+ .chevron {
92
+ display: inline-flex;
93
+ color: var(--text-light);
94
+ transition: transform 0.15s ease;
95
+ }
96
+
97
+ .toggle.expanded .chevron {
98
+ transform: rotate(90deg);
99
+ }
100
+
101
+ .items {
102
+ display: flex;
103
+ flex-direction: column;
104
+ gap: 2px;
105
+ /* nudge sub-items in so they read as children of the section label */
106
+ padding-left: 8px;
107
+ }
108
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ /** Section heading, shown on the toggle row. */
4
+ label: string;
5
+ /** Optional content shown on the toggle row, just before the chevron. */
6
+ trailing?: Snippet;
7
+ /** The section content, revealed when expanded. */
8
+ children: Snippet;
9
+ }
10
+ declare const HeaderMobileSection: import("svelte").Component<Props, {}, "">;
11
+ type HeaderMobileSection = ReturnType<typeof HeaderMobileSection>;
12
+ export default HeaderMobileSection;
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { MediaQuery } from 'svelte/reactivity';
4
+ import Dropdown from '../../components/Dropdown/Dropdown.svelte';
5
+ import HeaderNavLink from './HeaderNavLink.svelte';
6
+ import HeaderMobileSection from './HeaderMobileSection.svelte';
7
+ import IconCaretDown from '@hyvor/icons/IconCaretDown';
8
+ import type { DropdownAlign } from '../../components/Dropdown/dropdown.types.js';
9
+ import { HEADER_MOBILE_BREAKPOINT } from './breakpoint.js';
10
+
11
+ interface Props {
12
+ label: string;
13
+ active?: boolean;
14
+ children: Snippet;
15
+ width?: number;
16
+ align?: DropdownAlign;
17
+ }
18
+
19
+ let { label, active = false, children, width = 300, align = 'center' }: Props = $props();
20
+
21
+ // below the breakpoint: render as a collapsible inline section, not a dropdown
22
+ const mobile = new MediaQuery(`(max-width: ${HEADER_MOBILE_BREAKPOINT}px)`);
23
+
24
+ let open = $state(false);
25
+
26
+ // close the dropdown when a link inside it is clicked
27
+ function closeOnLinkClick(e: MouseEvent) {
28
+ const target = e.target as HTMLElement;
29
+ if (target.tagName === 'A' || target.closest('a')) {
30
+ open = false;
31
+ }
32
+ }
33
+ </script>
34
+
35
+ {#if mobile.current}
36
+ <HeaderMobileSection {label}>
37
+ {@render children()}
38
+ </HeaderMobileSection>
39
+ {:else}
40
+ <Dropdown bind:show={open} {width} {align} contentPadding={8}>
41
+ {#snippet trigger()}
42
+ <HeaderNavLink active={active || open}>
43
+ {label}
44
+ {#snippet end()}<IconCaretDown size={11} />{/snippet}
45
+ </HeaderNavLink>
46
+ {/snippet}
47
+ {#snippet content()}
48
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
49
+ <div onclick={closeOnLinkClick}>
50
+ {@render children()}
51
+ </div>
52
+ {/snippet}
53
+ </Dropdown>
54
+ {/if}
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { DropdownAlign } from '../../components/Dropdown/dropdown.types.js';
3
+ interface Props {
4
+ label: string;
5
+ active?: boolean;
6
+ children: Snippet;
7
+ width?: number;
8
+ align?: DropdownAlign;
9
+ }
10
+ declare const HeaderNavMenu: import("svelte").Component<Props, {}, "">;
11
+ type HeaderNavMenu = ReturnType<typeof HeaderNavMenu>;
12
+ export default HeaderNavMenu;
@@ -0,0 +1 @@
1
+ export declare const HEADER_MOBILE_BREAKPOINT = 992;
@@ -0,0 +1,2 @@
1
+ // width (px) at/below which the header collapses into the hamburger menu
2
+ export const HEADER_MOBILE_BREAKPOINT = 992;
@@ -26,8 +26,15 @@
26
26
  speed = 34
27
27
  }: Props = $props();
28
28
 
29
- // render twice for marquee effect
30
- const track = $derived(marquee ? [...logos, ...logos] : logos);
29
+ let containerWidth = $state(0);
30
+ let setWidths = $state<number[]>([]);
31
+ const setWidth = $derived(setWidths[0] ?? 0);
32
+
33
+ const copies = $derived.by(() => {
34
+ if (!setWidth || !containerWidth) return 2;
35
+ const needed = Math.ceil((containerWidth * 2) / setWidth);
36
+ return Math.min(20, Math.max(2, needed % 2 === 0 ? needed : needed + 1));
37
+ });
31
38
  </script>
32
39
 
33
40
  {#snippet logoImg(logo: Logo, hidden = false)}
@@ -65,23 +72,27 @@
65
72
  </div>
66
73
 
67
74
  {#if marquee}
68
- <div class="marquee hds-container-max">
69
- <div class="marquee-track">
70
- {#each track as logo, i}
71
- {@const hidden = i >= logos.length}
72
- {#if logo.href}
73
- <a
74
- href={logo.href}
75
- target="_blank"
76
- rel="noopener"
77
- class="logo-link"
78
- tabindex={hidden ? -1 : undefined}
79
- >
80
- {@render logoImg(logo, hidden)}
81
- </a>
82
- {:else}
83
- {@render logoImg(logo, hidden)}
84
- {/if}
75
+ <div class="marquee hds-container-max" bind:clientWidth={containerWidth}>
76
+ <div class="marquee-track" style:--ls-copies={copies}>
77
+ {#each Array.from({ length: copies }) as _, setIndex (setIndex)}
78
+ {@const hidden = setIndex > 0}
79
+ <div class="marquee-set" bind:clientWidth={setWidths[setIndex]} aria-hidden={hidden}>
80
+ {#each logos as logo}
81
+ {#if logo.href}
82
+ <a
83
+ href={logo.href}
84
+ target="_blank"
85
+ rel="noopener"
86
+ class="logo-link"
87
+ tabindex={hidden ? -1 : undefined}
88
+ >
89
+ {@render logoImg(logo, hidden)}
90
+ </a>
91
+ {:else}
92
+ {@render logoImg(logo, hidden)}
93
+ {/if}
94
+ {/each}
95
+ </div>
85
96
  {/each}
86
97
  </div>
87
98
  </div>
@@ -134,9 +145,18 @@
134
145
  .marquee-track {
135
146
  display: flex;
136
147
  align-items: center;
137
- gap: 52px;
138
148
  width: max-content;
139
- animation: marquee-scroll var(--ls-speed) linear infinite;
149
+ /* keep a constant per-set speed regardless of how many sets we repeat */
150
+ animation: marquee-scroll calc(var(--ls-speed) * var(--ls-copies, 2) / 2) linear infinite;
151
+ }
152
+
153
+ .marquee-set {
154
+ display: flex;
155
+ align-items: center;
156
+ flex: none;
157
+ gap: 52px;
158
+ /* trailing gap so every set is an identical width and -50% lands cleanly */
159
+ padding-right: 52px;
140
160
  }
141
161
 
142
162
  .marquee:hover .marquee-track {
@@ -148,7 +168,6 @@
148
168
  transform: translateX(0);
149
169
  }
150
170
  to {
151
- /* one full set-width, since the track is two identical sets back to back */
152
171
  transform: translateX(-50%);
153
172
  }
154
173
  }
@@ -186,13 +205,18 @@
186
205
 
187
206
  .marquee-track {
188
207
  animation: none;
208
+ width: 100%;
209
+ padding: 0 15px;
210
+ }
211
+
212
+ .marquee-set {
189
213
  flex-wrap: wrap;
190
214
  justify-content: center;
191
215
  width: 100%;
192
- padding: 0 15px;
216
+ padding-right: 0;
193
217
  }
194
218
 
195
- .marquee-track :global(.logo[aria-hidden='true']) {
219
+ .marquee-set:not(:first-child) {
196
220
  display: none;
197
221
  }
198
222
  }
@@ -3,6 +3,10 @@
3
3
  type: 'text';
4
4
  name: string;
5
5
  role: string;
6
+ company?: string;
7
+ companyUrl?: string;
8
+ imageUrl?: string;
9
+ summary?: string;
6
10
  quote: string;
7
11
  }
8
12
 
@@ -10,6 +14,9 @@
10
14
  type: 'video';
11
15
  name: string;
12
16
  role: string;
17
+ company: string;
18
+ companyUrl?: string;
19
+ imageUrl?: string;
13
20
  videoUrl?: string;
14
21
  posterUrl?: string;
15
22
  summary?: string;
@@ -102,6 +109,24 @@
102
109
  {/if}
103
110
  </svelte:head>
104
111
 
112
+ {#snippet meta(review: Review)}
113
+ <div class="role">
114
+ {review.role}, <br />
115
+ {#if review.companyUrl}
116
+ <a
117
+ class="company"
118
+ href={review.companyUrl}
119
+ target="_blank"
120
+ rel="nofollow noopener noreferrer"
121
+ >
122
+ {review.company}
123
+ </a>
124
+ {:else}
125
+ {review.company}
126
+ {/if}
127
+ </div>
128
+ {/snippet}
129
+
105
130
  <section class="hds-testimonials" class:handwritten={handwrittenNames}>
106
131
  <div class="hds-container head">
107
132
  <p class="label">{label}</p>
@@ -124,15 +149,34 @@
124
149
  {#if review.type === 'text'}
125
150
  {@const av = identicon(review.name)}
126
151
  <figure class="card text-card hds-box">
127
- <svg class="avatar" viewBox="0 0 5 5" style="background: {av.bg}" aria-hidden="true">
128
- {#each av.cells as cell}
129
- <rect x={cell.x} y={cell.y} width="1" height="1" fill={av.fg} />
130
- {/each}
152
+ {#if review.imageUrl}
153
+ <img class="avatar photo card-avatar" src={review.imageUrl} alt={review.name} />
154
+ {:else}
155
+ <svg
156
+ class="avatar card-avatar"
157
+ viewBox="0 0 5 5"
158
+ style="background: {av.bg}"
159
+ aria-hidden="true"
160
+ >
161
+ {#each av.cells as cell}
162
+ <rect x={cell.x} y={cell.y} width="1" height="1" fill={av.fg} />
163
+ {/each}
164
+ </svg>
165
+ {/if}
166
+ <svg class="quote-mark" viewBox="0 0 24 24" aria-hidden="true">
167
+ <path
168
+ d="M10 7c-3.3 0-6 2.7-6 6v4h6v-6H7c0-1.7 1.3-3 3-3V7zm10 0c-3.3 0-6 2.7-6 6v4h6v-6h-3c0-1.7 1.3-3 3-3V7z"
169
+ />
131
170
  </svg>
171
+ {#if review.summary}
172
+ <p class="text-summary">{review.summary}</p>
173
+ {/if}
132
174
  <blockquote>&ldquo;{review.quote}&rdquo;</blockquote>
133
175
  <figcaption>
134
- <span class="name">{review.name}</span>
135
- <span class="role">{review.role}</span>
176
+ <span class="caption-text">
177
+ <span class="name">{review.name}</span>
178
+ {@render meta(review)}
179
+ </span>
136
180
  </figcaption>
137
181
  </figure>
138
182
  {:else}
@@ -160,8 +204,8 @@
160
204
  {#if !playing[i]}
161
205
  <div class="poster-scrim"></div>
162
206
 
163
- {#if !started[i] && review.summary}
164
- <p class="video-summary">"{review.summary}"</p>
207
+ {#if review.summary}
208
+ <p class="video-summary">“{review.summary}”</p>
165
209
  {/if}
166
210
 
167
211
  <button
@@ -181,8 +225,13 @@
181
225
  </button>
182
226
 
183
227
  <figcaption>
184
- <span class="name">{review.name}</span>
185
- <span class="role">{review.role}</span>
228
+ {#if review.imageUrl}
229
+ <img class="avatar photo" src={review.imageUrl} alt={review.name} />
230
+ {/if}
231
+ <span class="caption-text">
232
+ <span class="name">{review.name}</span>
233
+ {@render meta(review)}
234
+ </span>
186
235
  </figcaption>
187
236
  {/if}
188
237
  </figure>
@@ -266,6 +315,22 @@
266
315
  flex-direction: column;
267
316
  }
268
317
 
318
+ .quote-mark {
319
+ width: 40px;
320
+ height: 40px;
321
+ margin-bottom: 20px;
322
+ fill: var(--accent);
323
+ opacity: 0.14;
324
+ flex-shrink: 0;
325
+ }
326
+
327
+ .card-avatar {
328
+ position: absolute;
329
+ top: 28px;
330
+ right: 28px;
331
+ z-index: 1;
332
+ }
333
+
269
334
  .avatar {
270
335
  width: 46px;
271
336
  height: 46px;
@@ -275,36 +340,80 @@
275
340
  shape-rendering: crispEdges;
276
341
  }
277
342
 
343
+ .avatar.photo {
344
+ object-fit: cover;
345
+ }
346
+
347
+ .text-summary {
348
+ margin: 0 0 14px;
349
+ font-family: var(--font-serif);
350
+ font-size: 18px;
351
+ font-weight: 800;
352
+ line-height: 1.25;
353
+ letter-spacing: -0.01em;
354
+ color: var(--text);
355
+ }
356
+
278
357
  blockquote {
279
358
  font-size: 16px;
280
359
  line-height: 1.7;
281
360
  color: var(--text);
282
- margin: 20px 0 0;
361
+ margin: 0 0 20px;
283
362
  flex: 1;
363
+ font-family: var(--font-serif);
284
364
  }
285
365
 
286
366
  .text-card figcaption {
367
+ display: flex;
368
+ align-items: center;
369
+ gap: 12px;
370
+ }
371
+
372
+ .caption-text {
287
373
  display: flex;
288
374
  flex-direction: column;
289
- gap: 0;
375
+ justify-content: center;
376
+ gap: 2px;
377
+ min-width: 0;
290
378
  }
291
379
 
292
380
  .name {
293
381
  font-size: 16px;
294
382
  font-weight: 600;
295
383
  color: var(--text);
296
- line-height: 1.2;
384
+ line-height: 1.3;
297
385
  }
298
386
 
299
387
  .handwritten .name {
300
388
  font-family: 'Caveat', cursive;
301
389
  font-size: 28px;
302
390
  font-weight: 600;
391
+ line-height: 1.15;
303
392
  }
304
393
 
305
394
  .role {
306
- font-size: 13px;
395
+ font-size: 14px;
396
+ line-height: 1.35;
307
397
  color: var(--text-light);
398
+ margin-top: 4px;
399
+ font-family: var(--font-serif);
400
+ }
401
+
402
+ .company {
403
+ align-self: flex-start;
404
+ font-size: 13px;
405
+ line-height: 1.35;
406
+ font-weight: 500;
407
+ color: var(--text);
408
+ text-decoration: none;
409
+ }
410
+
411
+ a.company:hover {
412
+ text-decoration: underline;
413
+ }
414
+
415
+ .handwritten .company {
416
+ font-weight: 600;
308
417
  }
309
418
 
310
419
  .video-card {
@@ -344,13 +453,12 @@
344
453
  right: 20px;
345
454
  margin: 0;
346
455
  font-family: var(--font-serif);
347
- font-size: 18px;
456
+ font-size: 16px;
348
457
  font-weight: 800;
349
458
  line-height: 1.25;
350
459
  letter-spacing: -0.01em;
351
460
  color: #fff;
352
461
  text-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
353
- font-style: italic;
354
462
  }
355
463
 
356
464
  .play-btn {
@@ -400,7 +508,12 @@
400
508
  padding: 40px 20px 20px;
401
509
  background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent);
402
510
  display: flex;
403
- flex-direction: column;
511
+ align-items: center;
512
+ gap: 12px;
513
+ }
514
+
515
+ .video-card .avatar.photo {
516
+ border: 2px solid rgba(255, 255, 255, 0.7);
404
517
  }
405
518
 
406
519
  .video-card .name {
@@ -411,6 +524,10 @@
411
524
  color: rgba(255, 255, 255, 0.75);
412
525
  }
413
526
 
527
+ .video-card .company {
528
+ color: rgba(255, 255, 255, 0.9);
529
+ }
530
+
414
531
  @media (max-width: 600px) {
415
532
  .scroll-row {
416
533
  --side-padding: max(20px, calc((100vw - 1000px) / 2));
@@ -2,12 +2,19 @@ interface TextReview {
2
2
  type: 'text';
3
3
  name: string;
4
4
  role: string;
5
+ company?: string;
6
+ companyUrl?: string;
7
+ imageUrl?: string;
8
+ summary?: string;
5
9
  quote: string;
6
10
  }
7
11
  interface VideoReview {
8
12
  type: 'video';
9
13
  name: string;
10
14
  role: string;
15
+ company: string;
16
+ companyUrl?: string;
17
+ imageUrl?: string;
11
18
  videoUrl?: string;
12
19
  posterUrl?: string;
13
20
  summary?: string;
@@ -1,6 +1,7 @@
1
1
  export { default as Accordion } from './DetailsAccordion/DetailsAccordion.svelte';
2
2
  export { default as Header } from './Header/Header.svelte';
3
3
  export { default as HeaderNavLink } from './Header/HeaderNavLink.svelte';
4
+ export { default as HeaderNavMenu } from './Header/HeaderNavMenu.svelte';
4
5
  export { default as HeaderLanguageToggle } from './Header/HeaderLanguageToggle.svelte';
5
6
  export { buildLocalizedUrl } from './Header/language.js';
6
7
  export type { LanguageOption } from './Header/language.js';
@@ -3,6 +3,7 @@ export { default as Accordion } from './DetailsAccordion/DetailsAccordion.svelte
3
3
  // ## Header
4
4
  export { default as Header } from './Header/Header.svelte';
5
5
  export { default as HeaderNavLink } from './Header/HeaderNavLink.svelte';
6
+ export { default as HeaderNavMenu } from './Header/HeaderNavMenu.svelte';
6
7
  export { default as HeaderLanguageToggle } from './Header/HeaderLanguageToggle.svelte';
7
8
  export { buildLocalizedUrl } from './Header/language.js';
8
9
  // ## Footer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyvor/design",
3
- "version": "2.1.13",
3
+ "version": "2.1.15",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "repository": {