@vit-foundation/ui 0.1.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 (109) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +129 -0
  3. package/dist/components/jobs/JobList.svelte +88 -0
  4. package/dist/components/jobs/JobList.svelte.d.ts +18 -0
  5. package/dist/components/jobs/index.d.ts +2 -0
  6. package/dist/components/jobs/index.js +1 -0
  7. package/dist/components/layout/Footer.svelte +79 -0
  8. package/dist/components/layout/Footer.svelte.d.ts +12 -0
  9. package/dist/components/layout/Nav.svelte +229 -0
  10. package/dist/components/layout/Nav.svelte.d.ts +18 -0
  11. package/dist/components/layout/PageShell.svelte +107 -0
  12. package/dist/components/layout/PageShell.svelte.d.ts +42 -0
  13. package/dist/components/layout/index.d.ts +3 -0
  14. package/dist/components/layout/index.js +3 -0
  15. package/dist/components/projects/ProjectCard.svelte +107 -0
  16. package/dist/components/projects/ProjectCard.svelte.d.ts +17 -0
  17. package/dist/components/projects/index.d.ts +2 -0
  18. package/dist/components/projects/index.js +1 -0
  19. package/dist/components/team/CollaboratorList.svelte +37 -0
  20. package/dist/components/team/CollaboratorList.svelte.d.ts +7 -0
  21. package/dist/components/team/TeamMemberCard.svelte +78 -0
  22. package/dist/components/team/TeamMemberCard.svelte.d.ts +21 -0
  23. package/dist/components/team/index.d.ts +3 -0
  24. package/dist/components/team/index.js +2 -0
  25. package/dist/components/timeline/Timeline.svelte +116 -0
  26. package/dist/components/timeline/Timeline.svelte.d.ts +12 -0
  27. package/dist/components/timeline/TimelineMilestone.svelte +127 -0
  28. package/dist/components/timeline/TimelineMilestone.svelte.d.ts +15 -0
  29. package/dist/components/timeline/index.d.ts +3 -0
  30. package/dist/components/timeline/index.js +2 -0
  31. package/dist/components/ui/Button.svelte +53 -0
  32. package/dist/components/ui/Button.svelte.d.ts +21 -0
  33. package/dist/components/ui/CardMedia.svelte +23 -0
  34. package/dist/components/ui/CardMedia.svelte.d.ts +11 -0
  35. package/dist/components/ui/CardTitle.svelte +28 -0
  36. package/dist/components/ui/CardTitle.svelte.d.ts +8 -0
  37. package/dist/components/ui/CopyIntro.svelte +40 -0
  38. package/dist/components/ui/CopyIntro.svelte.d.ts +24 -0
  39. package/dist/components/ui/DateText.svelte +43 -0
  40. package/dist/components/ui/DateText.svelte.d.ts +28 -0
  41. package/dist/components/ui/DecorShapes.svelte +36 -0
  42. package/dist/components/ui/DecorShapes.svelte.d.ts +7 -0
  43. package/dist/components/ui/Field.svelte +89 -0
  44. package/dist/components/ui/Field.svelte.d.ts +55 -0
  45. package/dist/components/ui/FilterChips.svelte +43 -0
  46. package/dist/components/ui/FilterChips.svelte.d.ts +15 -0
  47. package/dist/components/ui/FormErrorFeedback.svelte +55 -0
  48. package/dist/components/ui/FormErrorFeedback.svelte.d.ts +33 -0
  49. package/dist/components/ui/FormFeedback.svelte +65 -0
  50. package/dist/components/ui/FormFeedback.svelte.d.ts +30 -0
  51. package/dist/components/ui/FormResultSlot.svelte +33 -0
  52. package/dist/components/ui/FormResultSlot.svelte.d.ts +26 -0
  53. package/dist/components/ui/GhostButton.svelte +46 -0
  54. package/dist/components/ui/GhostButton.svelte.d.ts +10 -0
  55. package/dist/components/ui/Link.svelte +30 -0
  56. package/dist/components/ui/Link.svelte.d.ts +21 -0
  57. package/dist/components/ui/Pagination.svelte +77 -0
  58. package/dist/components/ui/Pagination.svelte.d.ts +18 -0
  59. package/dist/components/ui/RichText.svelte +160 -0
  60. package/dist/components/ui/RichText.svelte.d.ts +18 -0
  61. package/dist/components/ui/SearchInput.svelte +90 -0
  62. package/dist/components/ui/SearchInput.svelte.d.ts +13 -0
  63. package/dist/components/ui/ShareRow.svelte +95 -0
  64. package/dist/components/ui/ShareRow.svelte.d.ts +19 -0
  65. package/dist/components/ui/index.d.ts +17 -0
  66. package/dist/components/ui/index.js +17 -0
  67. package/dist/components/weeklies/SortSelect.svelte +42 -0
  68. package/dist/components/weeklies/SortSelect.svelte.d.ts +8 -0
  69. package/dist/components/weeklies/WeeklieCard.svelte +82 -0
  70. package/dist/components/weeklies/WeeklieCard.svelte.d.ts +15 -0
  71. package/dist/components/weeklies/index.d.ts +3 -0
  72. package/dist/components/weeklies/index.js +2 -0
  73. package/dist/config/UiProvider.svelte +21 -0
  74. package/dist/config/UiProvider.svelte.d.ts +14 -0
  75. package/dist/config/context.d.ts +17 -0
  76. package/dist/config/context.js +47 -0
  77. package/dist/config/index.d.ts +5 -0
  78. package/dist/config/index.js +4 -0
  79. package/dist/config/messages.d.ts +7 -0
  80. package/dist/config/messages.js +38 -0
  81. package/dist/config/types.d.ts +99 -0
  82. package/dist/config/types.js +9 -0
  83. package/dist/content/index.d.ts +3 -0
  84. package/dist/content/index.js +1 -0
  85. package/dist/content/richtext.d.ts +10 -0
  86. package/dist/content/richtext.js +14 -0
  87. package/dist/content/types.d.ts +71 -0
  88. package/dist/content/types.js +6 -0
  89. package/dist/edit/Editable.svelte +197 -0
  90. package/dist/edit/Editable.svelte.d.ts +52 -0
  91. package/dist/edit/context.d.ts +10 -0
  92. package/dist/edit/context.js +15 -0
  93. package/dist/edit/helpers.d.ts +18 -0
  94. package/dist/edit/helpers.js +14 -0
  95. package/dist/edit/index.d.ts +5 -0
  96. package/dist/edit/index.js +4 -0
  97. package/dist/edit/types.d.ts +64 -0
  98. package/dist/edit/types.js +4 -0
  99. package/dist/index.d.ts +13 -0
  100. package/dist/index.js +17 -0
  101. package/dist/styles/base.css +67 -0
  102. package/dist/styles/tokens.css +57 -0
  103. package/dist/utils/dates.d.ts +14 -0
  104. package/dist/utils/dates.js +34 -0
  105. package/dist/utils/milestones.d.ts +11 -0
  106. package/dist/utils/milestones.js +28 -0
  107. package/dist/utils/paths.d.ts +2 -0
  108. package/dist/utils/paths.js +4 -0
  109. package/package.json +139 -0
@@ -0,0 +1,229 @@
1
+ <script lang="ts">
2
+ import { getUiConfig } from '../../config/context.js';
3
+ import type { SiteLink } from '../../config/types.js';
4
+ import { isPathUnder } from '../../utils/paths.js';
5
+ import GhostButton from '../ui/GhostButton.svelte';
6
+ import Link from '../ui/Link.svelte';
7
+
8
+ interface Props {
9
+ /**
10
+ * Primary navigation entries. A prop rather than a package constant:
11
+ * the host app owns its routes and re-derives the labels when its
12
+ * locale changes (the foundation site wraps `siteLinks()` in $derived).
13
+ */
14
+ links: SiteLink[];
15
+ /** Logged-in account shown as a menu item linking to /account. */
16
+ account?: { displayName: string } | null;
17
+ /** The URL highlighting reads from. Only tests and stories pass one. */
18
+ url?: URL;
19
+ }
20
+
21
+ let { links, account = null, url = undefined }: Props = $props();
22
+
23
+ const config = getUiConfig();
24
+ const msg = $derived(config.messages);
25
+
26
+ let isMenuOpen = $state(false);
27
+
28
+ // The localhost fallback keeps a provider-less render (a story, a bare
29
+ // test) standing: nothing highlights, nothing crashes.
30
+ const currentUrl = $derived(url ?? config.url() ?? new URL('http://localhost/'));
31
+ const currentPath = $derived(config.canonicalPathname(currentUrl));
32
+
33
+ // /what-we-do stays current on /what-we-do/<slug>.
34
+ const isCurrent = (href: string): boolean => isPathUnder(currentPath, href);
35
+
36
+ const MENU_TOGGLE_ID = 'site-menu-toggle';
37
+
38
+ // Nav renders in the root layout, so it is never remounted and `isMenuOpen`
39
+ // survives every client-side navigation. Reading the path here is what
40
+ // subscribes this effect to it: without it, the menu a reader opened stays
41
+ // expanded over the page they navigated to, still asserting aria-expanded,
42
+ // with the whole menu to tab back through to reach the new content.
43
+ $effect(() => {
44
+ void currentPath;
45
+ isMenuOpen = false;
46
+ });
47
+
48
+ function closeOnEscape(event: KeyboardEvent): void {
49
+ if (event.key !== 'Escape' || !isMenuOpen) return;
50
+ isMenuOpen = false;
51
+ // Focus is inside the menu that just disappeared; put it back on the
52
+ // control that opened it. GhostButton spreads its rest props onto the
53
+ // button, so the id reaches the real element.
54
+ document.getElementById(MENU_TOGGLE_ID)?.focus();
55
+ }
56
+ </script>
57
+
58
+ <svelte:window onkeydown={closeOnEscape} />
59
+
60
+ <header class="header">
61
+ <nav class="band-bar" aria-label={msg.nav_mainLabel()}>
62
+ <span class="logo">
63
+ <Link href="/" aria-label={msg.nav_home()}>{config.siteName}</Link>
64
+ </span>
65
+
66
+ <GhostButton
67
+ id={MENU_TOGGLE_ID}
68
+ class="menu-toggle"
69
+ aria-expanded={isMenuOpen}
70
+ aria-controls="site-menu"
71
+ onclick={() => (isMenuOpen = !isMenuOpen)}
72
+ >
73
+ {msg.nav_menuLabel()}
74
+ </GhostButton>
75
+
76
+ <div class="menu" id="site-menu" class:open={isMenuOpen}>
77
+ <ul class="links">
78
+ {#each links as link (link.href)}
79
+ <li>
80
+ <Link href={link.href} aria-current={isCurrent(link.href) ? 'page' : undefined}>
81
+ {link.label}
82
+ </Link>
83
+ </li>
84
+ {/each}
85
+ </ul>
86
+
87
+ {#if account}
88
+ <ul class="account" aria-label={msg.account_navLabel()}>
89
+ <li>
90
+ <Link href="/account" aria-current={isCurrent('/account') ? 'page' : undefined}>
91
+ {account.displayName}
92
+ </Link>
93
+ </li>
94
+ </ul>
95
+ {/if}
96
+
97
+ <ul class="locales" aria-label={msg.lang_switcherLabel()}>
98
+ {#each config.locales as locale (locale)}
99
+ <li>
100
+ <Link
101
+ href={currentPath + currentUrl.search}
102
+ {locale}
103
+ data-sveltekit-reload
104
+ aria-current={config.locale() === locale ? 'true' : undefined}
105
+ >
106
+ {locale}
107
+ </Link>
108
+ </li>
109
+ {/each}
110
+ </ul>
111
+ </div>
112
+ </nav>
113
+ </header>
114
+
115
+ <style>
116
+ .header {
117
+ border-bottom: 1px solid var(--color-hairline);
118
+ background: var(--color-surface);
119
+ }
120
+
121
+ nav {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: var(--space-4);
125
+ flex-wrap: wrap;
126
+ }
127
+
128
+ .logo :global(a) {
129
+ font-size: var(--text-xl);
130
+ font-weight: 700;
131
+ letter-spacing: 0.04em;
132
+ text-decoration: none;
133
+ color: var(--color-ink);
134
+ }
135
+
136
+ .menu {
137
+ display: flex;
138
+ flex: 1;
139
+ align-items: center;
140
+ justify-content: space-between;
141
+ gap: var(--space-4);
142
+ }
143
+
144
+ ul {
145
+ list-style: none;
146
+ display: flex;
147
+ gap: var(--space-3);
148
+ margin: 0;
149
+ padding: 0;
150
+ }
151
+
152
+ .links :global(a) {
153
+ text-decoration: none;
154
+ color: var(--color-ink-secondary);
155
+ padding: var(--space-1) 0;
156
+ border-bottom: 2px solid transparent;
157
+ transition: color var(--transition-fast);
158
+ }
159
+
160
+ .links :global(a:hover) {
161
+ color: var(--color-ink);
162
+ }
163
+
164
+ .links :global(a[aria-current='page']) {
165
+ color: var(--color-ink);
166
+ border-bottom-color: var(--color-brand);
167
+ }
168
+
169
+ .account {
170
+ margin-left: auto;
171
+ }
172
+
173
+ .account :global(a) {
174
+ text-decoration: none;
175
+ color: var(--color-ink-secondary);
176
+ font-weight: 600;
177
+ padding: var(--space-1) 0;
178
+ border-bottom: 2px solid transparent;
179
+ transition: color var(--transition-fast);
180
+ }
181
+
182
+ .account :global(a:hover) {
183
+ color: var(--color-ink);
184
+ }
185
+
186
+ .account :global(a[aria-current='page']) {
187
+ color: var(--color-ink);
188
+ border-bottom-color: var(--color-brand);
189
+ }
190
+
191
+ .locales :global(a) {
192
+ text-decoration: none;
193
+ color: var(--color-ink-muted);
194
+ text-transform: uppercase;
195
+ font-size: var(--text-sm);
196
+ padding: var(--space-1);
197
+ }
198
+
199
+ .locales :global(a[aria-current='true']) {
200
+ color: var(--color-brand);
201
+ font-weight: 700;
202
+ }
203
+
204
+ nav :global(.menu-toggle) {
205
+ display: none;
206
+ }
207
+
208
+ @media (max-width: 720px) {
209
+ nav :global(.menu-toggle) {
210
+ display: block;
211
+ margin-left: auto;
212
+ }
213
+
214
+ .menu {
215
+ display: none;
216
+ flex-basis: 100%;
217
+ flex-direction: column;
218
+ align-items: flex-start;
219
+ }
220
+
221
+ .menu.open {
222
+ display: flex;
223
+ }
224
+
225
+ .links {
226
+ flex-direction: column;
227
+ }
228
+ }
229
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { SiteLink } from '../../config/types.js';
2
+ interface Props {
3
+ /**
4
+ * Primary navigation entries. A prop rather than a package constant:
5
+ * the host app owns its routes and re-derives the labels when its
6
+ * locale changes (the foundation site wraps `siteLinks()` in $derived).
7
+ */
8
+ links: SiteLink[];
9
+ /** Logged-in account shown as a menu item linking to /account. */
10
+ account?: {
11
+ displayName: string;
12
+ } | null;
13
+ /** The URL highlighting reads from. Only tests and stories pass one. */
14
+ url?: URL;
15
+ }
16
+ declare const Nav: import("svelte").Component<Props, {}, "">;
17
+ type Nav = ReturnType<typeof Nav>;
18
+ export default Nav;
@@ -0,0 +1,107 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { getUiConfig } from '../../config/context.js';
4
+
5
+ /**
6
+ * The chrome every page shares: the content column, the vertical rhythm,
7
+ * and the browser title, composed as `title — siteName` from the config's
8
+ * site name. (The foundation site's error page spells the same format on
9
+ * its own; the two are one decision, noted in both places.)
10
+ *
11
+ * The shape is one named variant rather than four presentation flags.
12
+ * Width, spacing, stacking and the wrapper element were independent props,
13
+ * so 24 combinations were expressible and 6 were used — `width="wide"
14
+ * element="article" stack` typechecked and meant nothing. Naming the shapes
15
+ * makes the ones that exist the only ones a page can ask for.
16
+ */
17
+ type PageVariant =
18
+ /** The standard column. */
19
+ | 'content'
20
+ /** Full width, for content that is deliberately wider (the timeline). */
21
+ | 'wide'
22
+ /** A single editorial piece at the standard column width (a project). */
23
+ | 'article'
24
+ /** A single editorial piece at a long-form reading measure (a weekly). */
25
+ | 'reading'
26
+ /** Sits lower on the page: account, admin, legal. */
27
+ | 'chrome'
28
+ /** Chrome spacing with stacked children: the auth and unsubscribe pages. */
29
+ | 'form';
30
+
31
+ interface Props {
32
+ /**
33
+ * Browser title; the site name is appended. Pages title themselves from
34
+ * page copy, and content.getPage returns '' for a row that is missing,
35
+ * so an empty title is a real state — it renders as the site name alone
36
+ * rather than as a dangling separator.
37
+ */
38
+ title: string;
39
+ /** Meta description, for the pages that have real copy for one. */
40
+ description?: string;
41
+ variant?: PageVariant;
42
+ children: Snippet;
43
+ }
44
+
45
+ let { title, description, variant = 'content', children }: Props = $props();
46
+
47
+ const config = getUiConfig();
48
+ const pageTitle = $derived(title ? `${title} — ${config.siteName}` : config.siteName);
49
+ /**
50
+ * The editorial variants render an <article>, and say so to crawlers too.
51
+ * og:type was hardcoded to 'website' while the element already switched,
52
+ * so every weekly and every project story shipped the wrong social type.
53
+ */
54
+ const isEditorial = $derived(variant === 'article' || variant === 'reading');
55
+ const element = $derived(isEditorial ? 'article' : 'div');
56
+ </script>
57
+
58
+ <svelte:head>
59
+ <title>{pageTitle}</title>
60
+ <meta property="og:title" content={pageTitle} />
61
+ <meta property="og:type" content={isEditorial ? 'article' : 'website'} />
62
+ {#if description}
63
+ <meta name="description" content={description} />
64
+ <meta property="og:description" content={description} />
65
+ {/if}
66
+ </svelte:head>
67
+
68
+ <!-- class: directives rather than an interpolated string, so Svelte's CSS
69
+ scoping can see every selector and does not prune them as unused. -->
70
+ <svelte:element
71
+ this={element}
72
+ class="page"
73
+ class:reading={variant === 'reading'}
74
+ class:wide={variant === 'wide'}
75
+ class:chrome={variant === 'chrome' || variant === 'form'}
76
+ class:stack={variant === 'form'}
77
+ >
78
+ {@render children()}
79
+ </svelte:element>
80
+
81
+ <style>
82
+ .page {
83
+ max-width: var(--content-max);
84
+ margin: 0 auto;
85
+ padding: var(--space-4) var(--space-3);
86
+ }
87
+
88
+ .reading {
89
+ max-width: 48rem;
90
+ }
91
+
92
+ /* The timeline is intentionally wider than the content column. */
93
+ .wide {
94
+ max-width: none;
95
+ margin: 0;
96
+ }
97
+
98
+ .chrome {
99
+ padding: var(--space-5) var(--space-3) var(--space-6);
100
+ }
101
+
102
+ .stack {
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: var(--space-3);
106
+ }
107
+ </style>
@@ -0,0 +1,42 @@
1
+ import type { Snippet } from 'svelte';
2
+ /**
3
+ * The chrome every page shares: the content column, the vertical rhythm,
4
+ * and the browser title, composed as `title — siteName` from the config's
5
+ * site name. (The foundation site's error page spells the same format on
6
+ * its own; the two are one decision, noted in both places.)
7
+ *
8
+ * The shape is one named variant rather than four presentation flags.
9
+ * Width, spacing, stacking and the wrapper element were independent props,
10
+ * so 24 combinations were expressible and 6 were used — `width="wide"
11
+ * element="article" stack` typechecked and meant nothing. Naming the shapes
12
+ * makes the ones that exist the only ones a page can ask for.
13
+ */
14
+ type PageVariant =
15
+ /** The standard column. */
16
+ 'content'
17
+ /** Full width, for content that is deliberately wider (the timeline). */
18
+ | 'wide'
19
+ /** A single editorial piece at the standard column width (a project). */
20
+ | 'article'
21
+ /** A single editorial piece at a long-form reading measure (a weekly). */
22
+ | 'reading'
23
+ /** Sits lower on the page: account, admin, legal. */
24
+ | 'chrome'
25
+ /** Chrome spacing with stacked children: the auth and unsubscribe pages. */
26
+ | 'form';
27
+ interface Props {
28
+ /**
29
+ * Browser title; the site name is appended. Pages title themselves from
30
+ * page copy, and content.getPage returns '' for a row that is missing,
31
+ * so an empty title is a real state — it renders as the site name alone
32
+ * rather than as a dangling separator.
33
+ */
34
+ title: string;
35
+ /** Meta description, for the pages that have real copy for one. */
36
+ description?: string;
37
+ variant?: PageVariant;
38
+ children: Snippet;
39
+ }
40
+ declare const PageShell: import("svelte").Component<Props, {}, "">;
41
+ type PageShell = ReturnType<typeof PageShell>;
42
+ export default PageShell;
@@ -0,0 +1,3 @@
1
+ export { default as Footer } from './Footer.svelte';
2
+ export { default as Nav } from './Nav.svelte';
3
+ export { default as PageShell } from './PageShell.svelte';
@@ -0,0 +1,3 @@
1
+ export { default as Footer } from './Footer.svelte';
2
+ export { default as Nav } from './Nav.svelte';
3
+ export { default as PageShell } from './PageShell.svelte';
@@ -0,0 +1,107 @@
1
+ <script module lang="ts">
2
+ import type { EditDescriptor } from '../../edit/types.js';
3
+
4
+ /** Which of the card's localized fields are editable at this render site. */
5
+ export interface ProjectEditMap {
6
+ title?: EditDescriptor;
7
+ excerpt?: EditDescriptor;
8
+ }
9
+ </script>
10
+
11
+ <script lang="ts">
12
+ import { getUiConfig } from '../../config/context.js';
13
+ import type { ProjectCardData } from '../../content/types.js';
14
+ import { getEditAdapter } from '../../edit/context.js';
15
+ import Editable from '../../edit/Editable.svelte';
16
+ import CardMedia from '../ui/CardMedia.svelte';
17
+ import CardTitle from '../ui/CardTitle.svelte';
18
+ import DateText from '../ui/DateText.svelte';
19
+ import Link from '../ui/Link.svelte';
20
+
21
+ interface Props {
22
+ project: ProjectCardData;
23
+ /** 'wide' for collaboration rows, 'grid' for passion-project tiles. */
24
+ variant?: 'wide' | 'grid';
25
+ /** Marks fields editable where an edit adapter is active. */
26
+ edit?: ProjectEditMap;
27
+ }
28
+
29
+ let { project, variant = 'grid', edit }: Props = $props();
30
+
31
+ const config = getUiConfig();
32
+ const adapter = getEditAdapter();
33
+
34
+ // See WeeklieCard: an editing title renders as plain text, never inside
35
+ // the story link or the external anchor.
36
+ const titleEditing = $derived(edit?.title !== undefined && (adapter?.isEditing ?? false));
37
+ </script>
38
+
39
+ <article class={variant}>
40
+ <CardMedia src={project.imageUrl} alt="" ratio="16 / 9" width="1200" height="675" />
41
+ <div class="text">
42
+ <DateText value={project.publishedOn} />
43
+ <CardTitle>
44
+ {#if titleEditing}
45
+ <Editable edit={edit?.title} value={project.title}>
46
+ {#snippet children(text, attrs)}<span {...attrs}>{text}</span>{/snippet}
47
+ </Editable>
48
+ {:else if project.hasStory}
49
+ <Link href={`/what-we-do/${project.slug}`}>{project.title}</Link>
50
+ {:else if project.externalUrl}
51
+ <a href={project.externalUrl} rel="external noopener">{project.title}</a>
52
+ {:else}
53
+ {project.title}
54
+ {/if}
55
+ </CardTitle>
56
+ <Editable edit={edit?.excerpt} value={project.excerpt}>
57
+ {#snippet children(text, attrs)}<p {...attrs}>{text}</p>{/snippet}
58
+ </Editable>
59
+ {#if project.hasStory}
60
+ <Link href={`/what-we-do/${project.slug}`} class="more">
61
+ {config.messages.common_readMore()}
62
+ </Link>
63
+ {/if}
64
+ </div>
65
+ </article>
66
+
67
+ <style>
68
+ article {
69
+ display: flex;
70
+ flex-direction: column;
71
+ gap: var(--space-3);
72
+ }
73
+
74
+ article.wide {
75
+ flex-direction: row;
76
+ align-items: flex-start;
77
+ }
78
+
79
+ article.wide > :global(img) {
80
+ flex: 1 1 55%;
81
+ min-width: 0;
82
+ }
83
+
84
+ article.wide .text {
85
+ flex: 1 1 45%;
86
+ }
87
+
88
+ .text :global(h3) {
89
+ margin: var(--space-1) 0 var(--space-2);
90
+ }
91
+
92
+ p {
93
+ margin: 0 0 var(--space-2);
94
+ color: var(--color-ink-secondary);
95
+ }
96
+
97
+ .text :global(a.more) {
98
+ font-weight: 600;
99
+ color: var(--color-brand);
100
+ }
101
+
102
+ @media (max-width: 720px) {
103
+ article.wide {
104
+ flex-direction: column;
105
+ }
106
+ }
107
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { EditDescriptor } from '../../edit/types.js';
2
+ /** Which of the card's localized fields are editable at this render site. */
3
+ export interface ProjectEditMap {
4
+ title?: EditDescriptor;
5
+ excerpt?: EditDescriptor;
6
+ }
7
+ import type { ProjectCardData } from '../../content/types.js';
8
+ interface Props {
9
+ project: ProjectCardData;
10
+ /** 'wide' for collaboration rows, 'grid' for passion-project tiles. */
11
+ variant?: 'wide' | 'grid';
12
+ /** Marks fields editable where an edit adapter is active. */
13
+ edit?: ProjectEditMap;
14
+ }
15
+ declare const ProjectCard: import("svelte").Component<Props, {}, "">;
16
+ type ProjectCard = ReturnType<typeof ProjectCard>;
17
+ export default ProjectCard;
@@ -0,0 +1,2 @@
1
+ export { default as ProjectCard } from './ProjectCard.svelte';
2
+ export type { ProjectEditMap } from './ProjectCard.svelte';
@@ -0,0 +1 @@
1
+ export { default as ProjectCard } from './ProjectCard.svelte';
@@ -0,0 +1,37 @@
1
+ <script lang="ts">
2
+ import type { CollaboratorData } from '../../content/types.js';
3
+
4
+ interface Props {
5
+ collaborators: CollaboratorData[];
6
+ }
7
+
8
+ let { collaborators }: Props = $props();
9
+ </script>
10
+
11
+ <ul>
12
+ {#each collaborators as collaborator (collaborator.personName + collaborator.affiliation)}
13
+ <li>
14
+ <strong>{collaborator.personName}</strong>
15
+ ·
16
+ {#if collaborator.url}
17
+ <a href={collaborator.url} rel="external noopener">{collaborator.affiliation}</a>
18
+ {:else}
19
+ {collaborator.affiliation}
20
+ {/if}
21
+ </li>
22
+ {/each}
23
+ </ul>
24
+
25
+ <style>
26
+ ul {
27
+ margin: 0;
28
+ padding-left: var(--space-4);
29
+ display: flex;
30
+ flex-direction: column;
31
+ gap: var(--space-2);
32
+ }
33
+
34
+ a {
35
+ color: var(--color-brand);
36
+ }
37
+ </style>
@@ -0,0 +1,7 @@
1
+ import type { CollaboratorData } from '../../content/types.js';
2
+ interface Props {
3
+ collaborators: CollaboratorData[];
4
+ }
5
+ declare const CollaboratorList: import("svelte").Component<Props, {}, "">;
6
+ type CollaboratorList = ReturnType<typeof CollaboratorList>;
7
+ export default CollaboratorList;
@@ -0,0 +1,78 @@
1
+ <script module lang="ts">
2
+ import type { EditDescriptor } from '../../edit/types.js';
3
+
4
+ /**
5
+ * Which of the card's localized fields are editable at this render site.
6
+ * `name` is deliberately absent: it is a plain-text column, not a
7
+ * localized one, so the per-locale save contract does not apply to it.
8
+ */
9
+ export interface TeamMemberEditMap {
10
+ role?: EditDescriptor;
11
+ bio?: EditDescriptor;
12
+ }
13
+ </script>
14
+
15
+ <script lang="ts">
16
+ import type { TeamMemberData } from '../../content/types.js';
17
+ import Editable from '../../edit/Editable.svelte';
18
+ import CardMedia from '../ui/CardMedia.svelte';
19
+
20
+ interface Props {
21
+ member: TeamMemberData;
22
+ /** 'featured' = big cut-out figures; 'board' = compact grid cell. */
23
+ variant?: 'featured' | 'board';
24
+ /** Marks fields editable where an edit adapter is active. */
25
+ edit?: TeamMemberEditMap;
26
+ }
27
+
28
+ let { member, variant = 'board', edit }: Props = $props();
29
+ </script>
30
+
31
+ <article class={variant}>
32
+ <CardMedia
33
+ src={member.photoUrl}
34
+ alt={member.name}
35
+ ratio={variant === 'featured' ? '4 / 5' : '1 / 1'}
36
+ width="400"
37
+ height="500"
38
+ />
39
+ <h3>{member.name}</h3>
40
+ <Editable edit={edit?.role} value={member.role}>
41
+ {#snippet children(text, attrs)}<p class="role" {...attrs}>{text}</p>{/snippet}
42
+ </Editable>
43
+ {#if member.bio}
44
+ <Editable edit={edit?.bio} value={member.bio}>
45
+ {#snippet children(text, attrs)}<p class="bio" {...attrs}>{text}</p>{/snippet}
46
+ </Editable>
47
+ {/if}
48
+ </article>
49
+
50
+ <style>
51
+ article {
52
+ display: flex;
53
+ flex-direction: column;
54
+ gap: var(--space-1);
55
+ }
56
+
57
+ h3 {
58
+ margin: var(--space-2) 0 0;
59
+ font-size: var(--text-base);
60
+ }
61
+
62
+ .featured h3 {
63
+ font-size: var(--text-lg);
64
+ }
65
+
66
+ .role {
67
+ margin: 0;
68
+ font-weight: 600;
69
+ color: var(--color-brand);
70
+ font-size: var(--text-sm);
71
+ }
72
+
73
+ .bio {
74
+ margin: 0;
75
+ color: var(--color-ink-secondary);
76
+ font-size: var(--text-sm);
77
+ }
78
+ </style>
@@ -0,0 +1,21 @@
1
+ import type { EditDescriptor } from '../../edit/types.js';
2
+ /**
3
+ * Which of the card's localized fields are editable at this render site.
4
+ * `name` is deliberately absent: it is a plain-text column, not a
5
+ * localized one, so the per-locale save contract does not apply to it.
6
+ */
7
+ export interface TeamMemberEditMap {
8
+ role?: EditDescriptor;
9
+ bio?: EditDescriptor;
10
+ }
11
+ import type { TeamMemberData } from '../../content/types.js';
12
+ interface Props {
13
+ member: TeamMemberData;
14
+ /** 'featured' = big cut-out figures; 'board' = compact grid cell. */
15
+ variant?: 'featured' | 'board';
16
+ /** Marks fields editable where an edit adapter is active. */
17
+ edit?: TeamMemberEditMap;
18
+ }
19
+ declare const TeamMemberCard: import("svelte").Component<Props, {}, "">;
20
+ type TeamMemberCard = ReturnType<typeof TeamMemberCard>;
21
+ export default TeamMemberCard;
@@ -0,0 +1,3 @@
1
+ export { default as CollaboratorList } from './CollaboratorList.svelte';
2
+ export { default as TeamMemberCard } from './TeamMemberCard.svelte';
3
+ export type { TeamMemberEditMap } from './TeamMemberCard.svelte';