@vit-foundation/ui 0.7.0 → 0.10.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 (39) hide show
  1. package/dist/admin.d.ts +11 -0
  2. package/dist/admin.js +11 -0
  3. package/dist/components/admin/AdminShell.svelte +42 -0
  4. package/dist/components/admin/AdminShell.svelte.d.ts +18 -0
  5. package/dist/components/admin/DecorMosaic.svelte +129 -0
  6. package/dist/components/admin/DecorMosaic.svelte.d.ts +10 -0
  7. package/dist/components/admin/PageHeading.svelte +41 -0
  8. package/dist/components/admin/PageHeading.svelte.d.ts +14 -0
  9. package/dist/components/admin/Sidebar.svelte +149 -0
  10. package/dist/components/admin/Sidebar.svelte.d.ts +23 -0
  11. package/dist/components/contact/ContactForm.svelte +4 -0
  12. package/dist/components/projects/ProjectCard.svelte +11 -3
  13. package/dist/components/timeline/TimelineMilestone.svelte +21 -6
  14. package/dist/components/ui/Button.svelte +118 -23
  15. package/dist/components/ui/Button.svelte.d.ts +30 -4
  16. package/dist/components/ui/Field.svelte +23 -2
  17. package/dist/components/ui/Field.svelte.d.ts +8 -0
  18. package/dist/components/ui/FilterChips.svelte +21 -8
  19. package/dist/components/ui/FilterChips.svelte.d.ts +8 -0
  20. package/dist/components/ui/Icon.svelte +46 -0
  21. package/dist/components/ui/Icon.svelte.d.ts +24 -0
  22. package/dist/components/ui/IconButton.svelte +41 -0
  23. package/dist/components/ui/IconButton.svelte.d.ts +11 -0
  24. package/dist/components/ui/Logo.svelte +63 -0
  25. package/dist/components/ui/Logo.svelte.d.ts +9 -0
  26. package/dist/components/ui/Modal.svelte +79 -0
  27. package/dist/components/ui/Modal.svelte.d.ts +14 -0
  28. package/dist/components/ui/TextField.svelte +116 -0
  29. package/dist/components/ui/TextField.svelte.d.ts +36 -0
  30. package/dist/components/weeklies/SortSelect.svelte +12 -7
  31. package/dist/edit/types.d.ts +1 -1
  32. package/dist/forms/index.d.ts +1 -0
  33. package/dist/forms/index.js +1 -0
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.js +2 -1
  36. package/dist/primitives.d.ts +4 -0
  37. package/dist/primitives.js +4 -0
  38. package/dist/styles/tokens.css +7 -0
  39. package/package.json +6 -1
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shell-level composition for the foundation's ADMIN apps (Brain and any
3
+ * future internal tool). Primitives stay in /primitives; only what shapes an
4
+ * admin page lives here. The host owns routes, permissions, and auth — these
5
+ * components receive already-filtered data through props, the Nav/Footer
6
+ * genericization pattern.
7
+ */
8
+ export { default as AdminShell } from './components/admin/AdminShell.svelte';
9
+ export { default as DecorMosaic } from './components/admin/DecorMosaic.svelte';
10
+ export { default as PageHeading } from './components/admin/PageHeading.svelte';
11
+ export { default as Sidebar, type SidebarItem } from './components/admin/Sidebar.svelte';
package/dist/admin.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shell-level composition for the foundation's ADMIN apps (Brain and any
3
+ * future internal tool). Primitives stay in /primitives; only what shapes an
4
+ * admin page lives here. The host owns routes, permissions, and auth — these
5
+ * components receive already-filtered data through props, the Nav/Footer
6
+ * genericization pattern.
7
+ */
8
+ export { default as AdminShell } from './components/admin/AdminShell.svelte';
9
+ export { default as DecorMosaic } from './components/admin/DecorMosaic.svelte';
10
+ export { default as PageHeading } from './components/admin/PageHeading.svelte';
11
+ export { default as Sidebar } from './components/admin/Sidebar.svelte';
@@ -0,0 +1,42 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import Sidebar, { type SidebarItem } from './Sidebar.svelte';
4
+
5
+ interface Props {
6
+ /** Rail entries, already permission-filtered by the host. */
7
+ items: SidebarItem[];
8
+ /** Accessible name of the sidebar nav landmark. */
9
+ navLabel?: string;
10
+ logo?: Snippet;
11
+ /** Bottom of the rail — the host's logout form. */
12
+ footer?: Snippet;
13
+ /** The page content. */
14
+ children: Snippet;
15
+ /** Highlighting override for tests and stories. */
16
+ url?: URL;
17
+ }
18
+
19
+ let { items, navLabel = 'Principal', logo, footer, children, url = undefined }: Props = $props();
20
+ </script>
21
+
22
+ <div class="shell">
23
+ <Sidebar {items} label={navLabel} {logo} {footer} {url} />
24
+ <main class="content">
25
+ {@render children()}
26
+ </main>
27
+ </div>
28
+
29
+ <style>
30
+ /* The sidebar is fixed at 4.5rem; the content pays for it once, here,
31
+ instead of every page knowing the rail's width. */
32
+ .content {
33
+ margin-left: 4.5rem;
34
+ min-height: 100vh;
35
+ }
36
+
37
+ @media print {
38
+ .content {
39
+ margin-left: 0;
40
+ }
41
+ }
42
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type SidebarItem } from './Sidebar.svelte';
3
+ interface Props {
4
+ /** Rail entries, already permission-filtered by the host. */
5
+ items: SidebarItem[];
6
+ /** Accessible name of the sidebar nav landmark. */
7
+ navLabel?: string;
8
+ logo?: Snippet;
9
+ /** Bottom of the rail — the host's logout form. */
10
+ footer?: Snippet;
11
+ /** The page content. */
12
+ children: Snippet;
13
+ /** Highlighting override for tests and stories. */
14
+ url?: URL;
15
+ }
16
+ declare const AdminShell: import("svelte").Component<Props, {}, "">;
17
+ type AdminShell = ReturnType<typeof AdminShell>;
18
+ export default AdminShell;
@@ -0,0 +1,129 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ /**
4
+ * 'login' — full-page geometric mosaic behind an auth card;
5
+ * 'app' — lighter scatter on app pages (top-right + bottom strip).
6
+ */
7
+ variant?: 'login' | 'app';
8
+ }
9
+
10
+ let { variant = 'app' }: Props = $props();
11
+ </script>
12
+
13
+ <!-- Decorative geometric mosaic (brand shapes: squares, circles, quarter
14
+ circles, triangles). Purely visual; pointer events pass through. Named
15
+ apart from DecorShapes: that one is the website's corner composition,
16
+ this is the admin apps' full mosaic. -->
17
+ <div class="decor" aria-hidden="true">
18
+ {#if variant === 'login'}
19
+ <svg class="cluster tl" viewBox="0 0 320 400" width="320" height="400">
20
+ <circle cx="120" cy="40" r="40" fill="var(--color-navy)" />
21
+ <rect x="240" y="0" width="80" height="80" fill="var(--color-navy)" />
22
+ <circle cx="40" cy="160" r="40" fill="var(--color-navy)" />
23
+ <path d="M80 120h80v80l-80-80z" fill="var(--color-ink)" />
24
+ <path d="M80 200 L160 120 L160 200 Z" fill="var(--color-navy)" />
25
+ <rect x="160" y="120" width="80" height="80" fill="var(--color-navy)" />
26
+ <rect x="0" y="80" width="60" height="60" fill="var(--color-cream)" />
27
+ <path d="M80 200 A80 80 0 0 1 160 280 L80 280 Z" fill="var(--color-navy)" />
28
+ <rect x="160" y="200" width="80" height="80" fill="var(--color-cream)" />
29
+ <path d="M160 200 L240 200 L160 280 Z" fill="var(--color-brand)" />
30
+ <path d="M0 280 L80 280 L80 360 Z" fill="var(--color-ink)" />
31
+ <path d="M0 280 L80 360 L0 360 Z" fill="var(--color-brand)" />
32
+ <rect x="80" y="280" width="80" height="80" fill="var(--color-navy)" />
33
+ <circle cx="120" cy="320" r="40" fill="var(--color-cream)" />
34
+ </svg>
35
+ <svg class="cluster tr" viewBox="0 0 280 280" width="280" height="280">
36
+ <path d="M0 0h80v80H0z" fill="var(--color-navy)" />
37
+ <path d="M0 0 L80 0 L0 80 Z" fill="var(--color-ink)" />
38
+ <rect x="80" y="0" width="80" height="80" fill="var(--color-brand)" />
39
+ <path d="M80 0 L160 0 L80 80 Z" fill="var(--color-ink)" />
40
+ <circle cx="220" cy="60" r="45" fill="var(--color-cream)" />
41
+ <path d="M0 80 L80 160 L0 160 Z" fill="var(--color-ink)" />
42
+ <path d="M0 80 L80 80 L80 160 Z" fill="var(--color-navy)" />
43
+ <path d="M80 160 L160 80 L160 160 Z" fill="var(--color-brand)" />
44
+ </svg>
45
+ <svg class="cluster br" viewBox="0 0 400 160" width="400" height="160">
46
+ <path
47
+ d="M0 0 A80 80 0 0 1 80 80 L0 80 Z"
48
+ fill="color-mix(in srgb, var(--color-brand) 70%, var(--color-ink))"
49
+ />
50
+ <rect x="80" y="0" width="80" height="80" fill="var(--color-ink)" />
51
+ <path d="M80 0 L160 80 L80 80 Z" fill="var(--color-navy)" />
52
+ <circle cx="240" cy="60" r="42" fill="var(--color-brand)" />
53
+ <circle cx="330" cy="60" r="42" fill="var(--color-brand)" />
54
+ <rect x="180" y="100" width="60" height="60" fill="var(--color-navy)" opacity="0.15" />
55
+ </svg>
56
+ <svg class="cluster bl" viewBox="0 0 300 120" width="300" height="120">
57
+ <circle cx="60" cy="60" r="50" fill="var(--color-cream)" />
58
+ <rect x="150" y="10" width="90" height="90" fill="var(--color-brand)" />
59
+ </svg>
60
+ {:else}
61
+ <svg class="cluster tr" viewBox="0 0 560 180" width="560" height="180">
62
+ <rect x="0" y="0" width="90" height="90" fill="var(--color-navy)" />
63
+ <path d="M0 0 L90 0 L0 90 Z" fill="var(--color-ink)" />
64
+ <path d="M90 0 A90 90 0 0 1 180 90 L90 90 Z" fill="var(--color-cream)" />
65
+ <circle cx="300" cy="45" r="55" fill="var(--color-brand)" />
66
+ <circle cx="420" cy="45" r="55" fill="var(--color-brand)" />
67
+ <circle cx="120" cy="160" r="60" fill="var(--color-brand)" />
68
+ <rect x="470" y="120" width="90" height="90" fill="var(--color-navy)" />
69
+ </svg>
70
+ <svg class="cluster bottom" viewBox="0 0 900 120" width="900" height="120">
71
+ <circle cx="80" cy="90" r="70" fill="var(--color-cream)" />
72
+ <rect x="380" y="30" width="110" height="110" fill="var(--color-brand)" />
73
+ <circle cx="800" cy="100" r="70" fill="var(--color-navy)" />
74
+ </svg>
75
+ {/if}
76
+ </div>
77
+
78
+ <style>
79
+ .decor {
80
+ position: absolute;
81
+ inset: 0;
82
+ overflow: hidden;
83
+ pointer-events: none;
84
+ z-index: 0;
85
+ }
86
+
87
+ .cluster {
88
+ position: absolute;
89
+ }
90
+
91
+ .tl {
92
+ top: 4%;
93
+ left: 3%;
94
+ }
95
+
96
+ .tr {
97
+ top: 0;
98
+ right: 0;
99
+ }
100
+
101
+ .br {
102
+ bottom: 10%;
103
+ right: 0;
104
+ }
105
+
106
+ .bl {
107
+ bottom: -2%;
108
+ left: 12%;
109
+ }
110
+
111
+ .bottom {
112
+ bottom: -3rem;
113
+ left: 8%;
114
+ }
115
+
116
+ @media (max-width: 900px) {
117
+ .tl,
118
+ .bl,
119
+ .br {
120
+ display: none;
121
+ }
122
+ }
123
+
124
+ @media print {
125
+ .decor {
126
+ display: none;
127
+ }
128
+ }
129
+ </style>
@@ -0,0 +1,10 @@
1
+ interface Props {
2
+ /**
3
+ * 'login' — full-page geometric mosaic behind an auth card;
4
+ * 'app' — lighter scatter on app pages (top-right + bottom strip).
5
+ */
6
+ variant?: 'login' | 'app';
7
+ }
8
+ declare const DecorMosaic: import("svelte").Component<Props, {}, "">;
9
+ type DecorMosaic = ReturnType<typeof DecorMosaic>;
10
+ export default DecorMosaic;
@@ -0,0 +1,41 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ /**
4
+ * Italic brand-colored word ("My", "Hi", "Workers"). Optional: a plain
5
+ * title is a heading too, and forcing an accent made call sites
6
+ * hand-roll their own h1.
7
+ */
8
+ accent?: string;
9
+ /** The serif rest of the heading ("Time Control", "Users"). */
10
+ rest: string;
11
+ level?: 1 | 2;
12
+ }
13
+
14
+ let { accent, rest, level = 1 }: Props = $props();
15
+ </script>
16
+
17
+ <svelte:element this={`h${level}`} class="heading">
18
+ {#if accent}<em>{accent}</em>{/if}
19
+ {rest}
20
+ </svelte:element>
21
+
22
+ <style>
23
+ .heading {
24
+ font-family: var(--font-serif);
25
+ font-weight: 600;
26
+ font-size: 3rem;
27
+ line-height: var(--leading-tight);
28
+ margin: 0;
29
+ color: var(--color-ink);
30
+ }
31
+
32
+ h2.heading {
33
+ font-size: 2.1rem;
34
+ }
35
+
36
+ em {
37
+ color: var(--color-brand);
38
+ font-style: italic;
39
+ font-weight: 600;
40
+ }
41
+ </style>
@@ -0,0 +1,14 @@
1
+ interface Props {
2
+ /**
3
+ * Italic brand-colored word ("My", "Hi", "Workers"). Optional: a plain
4
+ * title is a heading too, and forcing an accent made call sites
5
+ * hand-roll their own h1.
6
+ */
7
+ accent?: string;
8
+ /** The serif rest of the heading ("Time Control", "Users"). */
9
+ rest: string;
10
+ level?: 1 | 2;
11
+ }
12
+ declare const PageHeading: import("svelte").Component<Props, {}, "">;
13
+ type PageHeading = ReturnType<typeof PageHeading>;
14
+ export default PageHeading;
@@ -0,0 +1,149 @@
1
+ <script lang="ts" module>
2
+ import type { IconName } from '../ui/Icon.svelte';
3
+
4
+ /** One rail entry. The host owns routes, labels, and who sees what — a
5
+ * permission-filtered list arrives here already filtered. */
6
+ export interface SidebarItem {
7
+ href: string;
8
+ icon: IconName;
9
+ label: string;
10
+ }
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ import type { Snippet } from 'svelte';
15
+ import { getUiConfig } from '../../config/context.js';
16
+ import { isPathUnder } from '../../utils/paths.js';
17
+ import Icon from '../ui/Icon.svelte';
18
+
19
+ interface Props {
20
+ items: SidebarItem[];
21
+ /** Accessible name of the nav landmark. */
22
+ label?: string;
23
+ /** Brand mark above the rail; the host owns its logo and home link. */
24
+ logo?: Snippet;
25
+ /** Bottom slot — the host's logout form lives here, never in the package. */
26
+ footer?: Snippet;
27
+ /** The URL highlighting reads from. Only tests and stories pass one. */
28
+ url?: URL;
29
+ }
30
+
31
+ let { items, label = 'Principal', logo, footer, url = undefined }: Props = $props();
32
+
33
+ const config = getUiConfig();
34
+
35
+ // The localhost fallback keeps a provider-less render (a story, a bare
36
+ // test) standing: nothing highlights, nothing crashes — the Nav pattern.
37
+ const currentUrl = $derived(url ?? config.url() ?? new URL('http://localhost/'));
38
+ const currentPath = $derived(config.canonicalPathname(currentUrl));
39
+
40
+ // '/' matches only itself; a section stays current on its subpages.
41
+ const isCurrent = (href: string): boolean =>
42
+ href === '/' ? currentPath === '/' : isPathUnder(currentPath, href);
43
+ </script>
44
+
45
+ <nav class="sidebar" aria-label={label}>
46
+ {#if logo}
47
+ {@render logo()}
48
+ {/if}
49
+
50
+ <div class="rail">
51
+ {#each items as item (item.href)}
52
+ <a
53
+ href={item.href}
54
+ class="item"
55
+ class:active={isCurrent(item.href)}
56
+ aria-label={item.label}
57
+ aria-current={isCurrent(item.href) ? 'page' : undefined}
58
+ title={item.label}
59
+ >
60
+ <Icon name={item.icon} />
61
+ </a>
62
+ {/each}
63
+ </div>
64
+
65
+ {#if footer}
66
+ <div class="footer">
67
+ {@render footer()}
68
+ </div>
69
+ {/if}
70
+ </nav>
71
+
72
+ <style>
73
+ .sidebar {
74
+ position: fixed;
75
+ top: 0;
76
+ left: 0;
77
+ bottom: 0;
78
+ width: 4.5rem;
79
+ display: flex;
80
+ flex-direction: column;
81
+ align-items: center;
82
+ padding: var(--space-4) 0;
83
+ gap: var(--space-4);
84
+ z-index: var(--z-raised);
85
+ }
86
+
87
+ .rail {
88
+ display: flex;
89
+ flex-direction: column;
90
+ align-items: center;
91
+ gap: var(--space-1);
92
+ background: var(--color-surface);
93
+ border-radius: 999px;
94
+ padding: var(--space-2);
95
+ box-shadow: var(--shadow-1);
96
+ }
97
+
98
+ .item {
99
+ display: inline-flex;
100
+ align-items: center;
101
+ justify-content: center;
102
+ width: 2.4rem;
103
+ height: 2.4rem;
104
+ border-radius: 999px;
105
+ color: var(--color-navy);
106
+ border: none;
107
+ background: transparent;
108
+ cursor: pointer;
109
+ transition: background var(--transition-fast);
110
+ }
111
+
112
+ .item:hover {
113
+ background: color-mix(in srgb, var(--color-navy) 10%, transparent);
114
+ }
115
+
116
+ .item.active {
117
+ background: var(--color-navy);
118
+ color: var(--color-surface);
119
+ }
120
+
121
+ .footer {
122
+ margin-top: auto;
123
+ }
124
+
125
+ /* The host's footer control (a logout submit) should sit like an item. */
126
+ .footer :global(button) {
127
+ display: inline-flex;
128
+ align-items: center;
129
+ justify-content: center;
130
+ width: 2.4rem;
131
+ height: 2.4rem;
132
+ border-radius: 999px;
133
+ color: var(--color-navy);
134
+ border: none;
135
+ background: transparent;
136
+ cursor: pointer;
137
+ transition: background var(--transition-fast);
138
+ }
139
+
140
+ .footer :global(button:hover) {
141
+ background: color-mix(in srgb, var(--color-navy) 10%, transparent);
142
+ }
143
+
144
+ @media print {
145
+ .sidebar {
146
+ display: none;
147
+ }
148
+ }
149
+ </style>
@@ -0,0 +1,23 @@
1
+ import type { IconName } from '../ui/Icon.svelte';
2
+ /** One rail entry. The host owns routes, labels, and who sees what — a
3
+ * permission-filtered list arrives here already filtered. */
4
+ export interface SidebarItem {
5
+ href: string;
6
+ icon: IconName;
7
+ label: string;
8
+ }
9
+ import type { Snippet } from 'svelte';
10
+ interface Props {
11
+ items: SidebarItem[];
12
+ /** Accessible name of the nav landmark. */
13
+ label?: string;
14
+ /** Brand mark above the rail; the host owns its logo and home link. */
15
+ logo?: Snippet;
16
+ /** Bottom slot — the host's logout form lives here, never in the package. */
17
+ footer?: Snippet;
18
+ /** The URL highlighting reads from. Only tests and stories pass one. */
19
+ url?: URL;
20
+ }
21
+ declare const Sidebar: import("svelte").Component<Props, {}, "">;
22
+ type Sidebar = ReturnType<typeof Sidebar>;
23
+ export default Sidebar;
@@ -58,6 +58,7 @@
58
58
  <Field
59
59
  id="contact-category"
60
60
  label={msg.contact_categoryLabel()}
61
+ labelEdit={config.messageEdit?.('contact_categoryLabel')}
61
62
  field={f.fields.category}
62
63
  constraint={null}
63
64
  >
@@ -73,6 +74,7 @@
73
74
  <Field
74
75
  id="contact-name"
75
76
  label={msg.contact_nameLabel()}
77
+ labelEdit={config.messageEdit?.('contact_nameLabel')}
76
78
  field={f.fields.name}
77
79
  constraint={CONTACT_NAME}
78
80
  >
@@ -90,6 +92,7 @@
90
92
  <Field
91
93
  id="contact-email"
92
94
  label={msg.contact_emailLabel()}
95
+ labelEdit={config.messageEdit?.('contact_emailLabel')}
93
96
  field={f.fields.email}
94
97
  constraint={EMAIL}
95
98
  >
@@ -107,6 +110,7 @@
107
110
  <Field
108
111
  id="contact-message"
109
112
  label={msg.contact_messageLabel()}
113
+ labelEdit={config.messageEdit?.('contact_messageLabel')}
110
114
  field={f.fields.message}
111
115
  constraint={CONTACT_MESSAGE}
112
116
  >
@@ -10,6 +10,7 @@
10
10
 
11
11
  <script lang="ts">
12
12
  import { getUiConfig } from '../../config/context.js';
13
+ import ActionLabel from '../../edit/ActionLabel.svelte';
13
14
  import type { ProjectCardData } from '../../content/types.js';
14
15
  import { getEditAdapter } from '../../edit/context.js';
15
16
  import Editable from '../../edit/Editable.svelte';
@@ -57,9 +58,16 @@
57
58
  {#snippet children(text, attrs)}<p {...attrs}>{text}</p>{/snippet}
58
59
  </Editable>
59
60
  {#if project.hasStory}
60
- <Link href={`/what-we-do/${project.slug}`} class="more">
61
- {config.messages.common_readMore()}
62
- </Link>
61
+ <ActionLabel
62
+ edit={config.messageEdit?.('common_readMore')}
63
+ value={config.messages.common_readMore()}
64
+ >
65
+ {#snippet control()}
66
+ <Link href={`/what-we-do/${project.slug}`} class="more">
67
+ {config.messages.common_readMore()}
68
+ </Link>
69
+ {/snippet}
70
+ </ActionLabel>
63
71
  {/if}
64
72
  </div>
65
73
  </article>
@@ -10,6 +10,7 @@
10
10
 
11
11
  <script lang="ts">
12
12
  import { getUiConfig } from '../../config/context.js';
13
+ import ActionLabel from '../../edit/ActionLabel.svelte';
13
14
  import type { MilestoneData } from '../../content/types.js';
14
15
  import Editable from '../../edit/Editable.svelte';
15
16
  import { MILESTONE_CATEGORY_COLOR, milestoneCategoryLabel } from '../../utils/milestones.js';
@@ -47,7 +48,12 @@
47
48
 
48
49
  <article style={`--milestone-color: ${color}`}>
49
50
  <span class="dot" aria-hidden="true"></span>
50
- <p class="category">{milestoneCategoryLabel(milestone.category, config.messages)}</p>
51
+ <Editable
52
+ edit={config.messageEdit?.(`category_${milestone.category}`)}
53
+ value={milestoneCategoryLabel(milestone.category, config.messages)}
54
+ >
55
+ {#snippet children(text, attrs)}<p class="category" {...attrs}>{text}</p>{/snippet}
56
+ </Editable>
51
57
  <DateText value={milestone.occurredOn} />
52
58
  <Editable edit={edit?.title} value={milestone.title}>
53
59
  {#snippet children(text, attrs)}<h3 {...attrs}>{text}</h3>{/snippet}
@@ -62,11 +68,20 @@
62
68
  {/if}
63
69
  {#if milestone.linkUrl}
64
70
  <p class="more">
65
- {#if isExternal}
66
- <a href={milestone.linkUrl} rel="external noopener">{config.messages.common_readMore()}</a>
67
- {:else}
68
- <Link href={milestone.linkUrl}>{config.messages.common_readMore()}</Link>
69
- {/if}
71
+ <ActionLabel
72
+ edit={config.messageEdit?.('common_readMore')}
73
+ value={config.messages.common_readMore()}
74
+ >
75
+ {#snippet control()}
76
+ {#if isExternal}
77
+ <a href={milestone.linkUrl} rel="external noopener"
78
+ >{config.messages.common_readMore()}</a
79
+ >
80
+ {:else if milestone.linkUrl}
81
+ <Link href={milestone.linkUrl}>{config.messages.common_readMore()}</Link>
82
+ {/if}
83
+ {/snippet}
84
+ </ActionLabel>
70
85
  </p>
71
86
  {/if}
72
87
  </article>