ferst-core 0.7.4 → 0.7.6

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.
@@ -12,6 +12,7 @@
12
12
  import Button from './Button.astro';
13
13
  import type { NavLink, NavGroup } from '../content/schemas';
14
14
 
15
+ import { navWithoutCta } from '../lib/nav';
15
16
  interface Props {
16
17
  path: string;
17
18
  headerClass?: string;
@@ -21,9 +22,14 @@ interface Props {
21
22
  primaryNav: NavLink[];
22
23
  /** Optional dropdown / mega-menu group. Client-configured — the core ships none. */
23
24
  dropdown?: NavGroup;
25
+ /** The call-to-action button (navbarSettings.cta); null hides it. */
26
+ cta?: { label: string; href: string } | null;
24
27
  }
25
28
 
26
- const { path, headerClass, primaryLogoLight, primaryLogoDark, brandAlt, primaryNav, dropdown } = Astro.props;
29
+ const { path, headerClass, primaryLogoLight, primaryLogoDark, brandAlt, primaryNav, dropdown, cta = { label: 'Contact', href: '/contact/' } } = Astro.props;
30
+ // A menu link to the same place as the button would show it twice: the button wins.
31
+ const navLinks = navWithoutCta(primaryNav, cta);
32
+ const ctaActive = !!cta && cta.href.startsWith('/') && path.startsWith(cta.href.replace(/\/$/, '') || '/#');
27
33
 
28
34
  /**
29
35
  * Longest-prefix-wins active-nav matching. A plain independent `startsWith`
@@ -34,7 +40,7 @@ const { path, headerClass, primaryLogoLight, primaryLogoDark, brandAlt, primaryN
34
40
  * longest means only the most specific matching link is ever active,
35
41
  * however nav hrefs happen to nest.
36
42
  */
37
- const primaryNavMatchLengths = primaryNav.map((link) => {
43
+ const primaryNavMatchLengths = navLinks.map((link) => {
38
44
  const trimmed = link.href.replace(/\/$/, '');
39
45
  return trimmed.length > 0 && path.startsWith(trimmed) ? trimmed.length : -1;
40
46
  });
@@ -121,7 +127,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
121
127
  the longest/most-specific match wins — see primaryNavLongestMatch
122
128
  above — so a link whose href is a subpath of another's (Newsletters
123
129
  under Posts) doesn't light up both at once. */}
124
- {primaryNav.map((link, i) => {
130
+ {navLinks.map((link, i) => {
125
131
  const isActive =
126
132
  primaryNavMatchLengths[i] >= 0 && primaryNavMatchLengths[i] === primaryNavLongestMatch;
127
133
  return (
@@ -131,13 +137,15 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
131
137
  );
132
138
  })}
133
139
  <div class="nav-mobile-theme">
134
- <Button
135
- href="/contact/"
136
- variant="cta"
137
- class:list={['nav-cta', 'nav-cta--drawer', { active: path.startsWith('/contact') }]}
138
- >
139
- Contact
140
- </Button>
140
+ {cta && (
141
+ <Button
142
+ href={cta.href}
143
+ variant="cta"
144
+ class:list={['nav-cta', 'nav-cta--drawer', { active: ctaActive }]}
145
+ >
146
+ {cta.label}
147
+ </Button>
148
+ )}
141
149
  <button type="button" class="theme-toggle-mobile" id="theme-toggle-mobile" role="switch" aria-checked="false" aria-label="Toggle dark mode">
142
150
  <span class="theme-toggle__highlight" aria-hidden="true"></span>
143
151
  <svg class="theme-icon theme-icon--sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
@@ -152,15 +160,17 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
152
160
  <svg class="theme-icon theme-icon--sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
153
161
  <svg class="theme-icon theme-icon--moon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
154
162
  </button>
155
- <span class="nav-cta-header-wrap">
156
- <Button
157
- href="/contact/"
158
- variant="cta"
159
- class:list={['nav-cta', 'nav-cta--header', { active: path.startsWith('/contact') }]}
160
- >
161
- Contact
162
- </Button>
163
- </span>
163
+ {cta && (
164
+ <span class="nav-cta-header-wrap">
165
+ <Button
166
+ href={cta.href}
167
+ variant="cta"
168
+ class:list={['nav-cta', 'nav-cta--header', { active: ctaActive }]}
169
+ >
170
+ {cta.label}
171
+ </Button>
172
+ </span>
173
+ )}
164
174
  <button class="nav-toggle" aria-label="Toggle navigation" aria-expanded="false" aria-controls="site-nav">
165
175
  <span></span><span></span><span></span>
166
176
  </button>
@@ -357,6 +367,32 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
357
367
  z-index: 101; /* always above the mobile full-screen nav (z-index: 99) */
358
368
  }
359
369
 
370
+ /* Crowded (#11, set by measuring): the centred menu would collide with the site name or
371
+ the buttons, so it takes its own row under them and wraps if it must. Desktop only;
372
+ below 64em the drawer applies as usual. */
373
+ @media (min-width: 64em) {
374
+ .site-header.is-crowded,
375
+ .site-header.is-crowded.is-compact {
376
+ height: auto;
377
+ flex-wrap: wrap;
378
+ row-gap: 0;
379
+ padding-block: 6px;
380
+ }
381
+ .site-header.is-crowded .site-nav {
382
+ position: static;
383
+ transform: none;
384
+ order: 3;
385
+ flex-basis: 100%;
386
+ justify-content: center;
387
+ flex-wrap: wrap;
388
+ column-gap: 24px;
389
+ row-gap: 0;
390
+ }
391
+ .site-header.is-crowded .site-nav .nav-link {
392
+ padding-block: 8px;
393
+ }
394
+ }
395
+
360
396
  /* ── Contact CTA ── */
361
397
  .nav-cta {
362
398
  white-space: nowrap;
@@ -990,6 +1026,33 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
990
1026
  window.addEventListener('scroll', onScrollCompact, { passive: true });
991
1027
  }
992
1028
 
1029
+ // Crowding (#11): on desktop the menu is centred on the header. When the site name (or
1030
+ // the right-hand buttons) would run into it, give the menu its own row instead. Decided
1031
+ // by measuring the real layout, so it adapts to any name and any number of links.
1032
+ if (siteHeader) {
1033
+ const desktop = window.matchMedia('(min-width: 64em)');
1034
+ const brand = siteHeader.querySelector<HTMLElement>('.brand');
1035
+ const nav = siteHeader.querySelector<HTMLElement>('.site-nav');
1036
+ const right = siteHeader.querySelector<HTMLElement>('.nav-right');
1037
+ const GAP = 16;
1038
+ const fitCheck = () => {
1039
+ if (!brand || !nav || !right) return;
1040
+ siteHeader.classList.remove('is-crowded');
1041
+ if (!desktop.matches) return;
1042
+ const b = brand.getBoundingClientRect();
1043
+ const n = nav.getBoundingClientRect();
1044
+ const r = right.getBoundingClientRect();
1045
+ if (b.right + GAP > n.left || n.right + GAP > r.left) siteHeader.classList.add('is-crowded');
1046
+ };
1047
+ let queued = false;
1048
+ const queue = () => { if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; fitCheck(); }); } };
1049
+ if (brand) new ResizeObserver(queue).observe(brand);
1050
+ new ResizeObserver(queue).observe(siteHeader);
1051
+ desktop.addEventListener('change', queue);
1052
+ document.fonts?.ready.then(queue);
1053
+ fitCheck();
1054
+ }
1055
+
993
1056
  function openMega() {
994
1057
  if (!megaNav || !aboutTrigger) return;
995
1058
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { pageSlugs, uniqueSlug } from '../../lib/slug';
2
3
  interface Props {
3
4
  text: string;
4
5
  level?: number;
@@ -7,9 +8,11 @@ interface Props {
7
8
  }
8
9
  const { text, level = 2, align, gradient = false } = Astro.props;
9
10
  const Tag = `h${level}` as unknown as keyof HTMLElementTagNameMap;
11
+ // A readable, page-unique anchor so any heading can be linked to (#mass-times).
12
+ const id = uniqueSlug(text, pageSlugs(Astro.locals));
10
13
  ---
11
14
 
12
- <Tag class="b-heading" data-align={align} data-gradient={gradient ? 'true' : 'false'}>{text}</Tag>
15
+ <Tag class="b-heading" id={id} data-align={align} data-gradient={gradient ? 'true' : 'false'}>{text}</Tag>
13
16
 
14
17
  <style>
15
18
  /* By default a heading INHERITS the page/section content alignment (the
@@ -22,6 +25,7 @@ const Tag = `h${level}` as unknown as keyof HTMLElementTagNameMap;
22
25
  margin: 0;
23
26
  text-wrap: balance;
24
27
  white-space: pre-line; /* a title typed over several lines keeps its breaks */
28
+ scroll-margin-top: 6rem; /* a linked heading stops clear of the sticky header */
25
29
  line-height: 1.2;
26
30
  text-align: inherit;
27
31
  }
@@ -0,0 +1,139 @@
1
+ ---
2
+ /**
3
+ * Contacts — a compact directory of people or offices. Each card: the role as its title,
4
+ * the name, phone and email as tap targets (email with an optional subject), address and
5
+ * hours. A group can carry one shared note (e.g. office hours) instead of repeating it on
6
+ * every card. For one contact as a branded band use `contactCard`; for one contact's
7
+ * links in a row, `linkRow`.
8
+ */
9
+ import Heading from '../blocks/Heading.astro';
10
+ import Icon from '../Icon.astro';
11
+ import { inlineMarkdown, textWithBreaks } from '../../lib/inline-markdown';
12
+ import { mailtoHref, telHref } from '../../lib/links';
13
+
14
+ interface Contact { role?: string; name?: string; phone?: string; email?: string; emailSubject?: string; address?: string; hours?: string }
15
+ interface Props {
16
+ title?: string;
17
+ intro?: string;
18
+ groups: Array<{ heading?: string; note?: string; contacts: Contact[] }>;
19
+ }
20
+ const { title, intro, groups = [] } = Astro.props;
21
+ ---
22
+
23
+ <section class="b-contacts">
24
+ {(title || intro) && (
25
+ <div class="b-contacts__head">
26
+ {title && <Heading text={title} level={2} />}
27
+ {intro && <p class="b-contacts__intro" set:html={inlineMarkdown(intro)} />}
28
+ </div>
29
+ )}
30
+ {groups.map((g) => (
31
+ <div class="b-contacts__group">
32
+ {(g.heading || g.note) && (
33
+ <div class="b-contacts__group-head">
34
+ {g.heading && <h3 class="b-contacts__group-title">{g.heading}</h3>}
35
+ {g.note && <p class="b-contacts__note" set:html={inlineMarkdown(g.note)} />}
36
+ </div>
37
+ )}
38
+ <ul class="b-contacts__grid">
39
+ {g.contacts.map((c) => (
40
+ <li class="b-contacts__card">
41
+ <h3 class="b-contacts__role">{c.role ?? c.name}</h3>
42
+ {c.role && c.name && <p class="b-contacts__name">{c.name}</p>}
43
+ <div class="b-contacts__lines">
44
+ {c.phone && <a class="b-contacts__line" href={telHref(c.phone)}><Icon name="phone" size={16} /><span>{c.phone}</span></a>}
45
+ {c.email && <a class="b-contacts__line" href={mailtoHref(c.email, c.emailSubject)}><Icon name="email" size={16} /><span>{c.email}</span></a>}
46
+ {c.address && <p class="b-contacts__line"><Icon name="map-pin" size={16} /><span set:html={textWithBreaks(c.address)} /></p>}
47
+ {c.hours && <p class="b-contacts__line"><Icon name="calendar" size={16} /><span set:html={textWithBreaks(c.hours)} /></p>}
48
+ </div>
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ </div>
53
+ ))}
54
+ </section>
55
+
56
+ <style>
57
+ .b-contacts {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 2rem;
61
+ width: 100%;
62
+ max-width: 72rem;
63
+ }
64
+ .b-contacts__head,
65
+ .b-contacts__group-head {
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 0.4rem;
69
+ max-width: var(--max);
70
+ }
71
+ .b-contacts__intro,
72
+ .b-contacts__note {
73
+ margin: 0;
74
+ color: var(--muted);
75
+ line-height: 1.6;
76
+ }
77
+ .b-contacts__group {
78
+ display: flex;
79
+ flex-direction: column;
80
+ gap: 1rem;
81
+ }
82
+ .b-contacts__group-title {
83
+ margin: 0;
84
+ font-size: 1.15rem;
85
+ }
86
+ .b-contacts__grid {
87
+ list-style: none;
88
+ margin: 0;
89
+ padding: 0;
90
+ display: grid;
91
+ gap: 1rem;
92
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
93
+ }
94
+ .b-contacts__card {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: 0.4rem;
98
+ padding: 1.1rem 1.25rem;
99
+ border: var(--card-border);
100
+ border-radius: var(--radius);
101
+ background: var(--bg-surface);
102
+ }
103
+ .b-contacts__role {
104
+ margin: 0;
105
+ font-size: 1.05rem;
106
+ }
107
+ .b-contacts__name {
108
+ margin: 0;
109
+ font-size: 0.9375rem;
110
+ color: var(--muted);
111
+ }
112
+ .b-contacts__lines {
113
+ display: grid;
114
+ gap: 0.35rem;
115
+ margin-top: 0.35rem;
116
+ }
117
+ .b-contacts__line {
118
+ display: flex;
119
+ align-items: flex-start;
120
+ gap: 0.5rem;
121
+ margin: 0;
122
+ color: var(--fg);
123
+ text-decoration: none;
124
+ font-size: 0.9375rem;
125
+ overflow-wrap: anywhere;
126
+ }
127
+ .b-contacts__line :global(svg) {
128
+ flex: none;
129
+ margin-top: 0.2em;
130
+ color: var(--gold);
131
+ }
132
+ a.b-contacts__line:hover span {
133
+ text-decoration: underline;
134
+ }
135
+ a.b-contacts__line:focus-visible {
136
+ outline: 2px solid var(--gold);
137
+ outline-offset: 2px;
138
+ }
139
+ </style>
@@ -1,152 +1,157 @@
1
- ---
2
- /**
3
- * Document — a policy or legal text as ONE block. Sections are numbered automatically,
4
- * a contents list links to each (beside the text, sticky, on wide screens), and an
5
- * optional "last updated" line sits at the top. Section bodies take prose formatting
6
- * (links, bold, lists); `items` add a list. Print-friendly: the contents list is
7
- * dropped on paper.
8
- */
9
- import { proseParts } from '../../lib/prose';
10
- import { inlineMarkdown } from '../../lib/inline-markdown';
11
- import { longDate } from '../../lib/documents';
12
-
13
- interface Section { title: string; body?: string; items?: string[] }
14
- interface Props {
15
- intro?: string;
16
- updated?: string;
17
- numbered?: boolean;
18
- toc?: boolean;
19
- sections: Section[];
20
- }
21
- const { intro, updated, numbered = true, toc = true, sections = [] } = Astro.props;
22
- const updatedLabel = longDate(updated);
23
- const showToc = toc && sections.length > 1;
24
- ---
25
-
26
- <section class="b-doc" data-toc={showToc ? 'true' : undefined}>
27
- {showToc && (
28
- <nav class="b-doc__toc" aria-label="Contents">
29
- <p class="b-doc__toc-title">Contents</p>
30
- <ol>
31
- {sections.map((s, i) => (
32
- <li><a href={`#clause-${i + 1}`}>{numbered && <span class="b-doc__toc-num">{i + 1}.</span>} {s.title}</a></li>
33
- ))}
34
- </ol>
35
- </nav>
36
- )}
37
- <div class="b-doc__body">
38
- {updatedLabel && <p class="b-doc__updated">Last updated {updatedLabel}</p>}
39
- {intro && <p class="b-doc__intro" set:html={inlineMarkdown(intro)} />}
40
- {sections.map((s, i) => (
41
- <section class="b-doc__clause" id={`clause-${i + 1}`}>
42
- <h2>{numbered && <span class="b-doc__num">{i + 1}.</span>} {s.title}</h2>
43
- {proseParts(s.body).map((part) =>
44
- part.kind === 'p' ? <p set:html={part.html} />
45
- : part.kind === 'ul' ? <ul>{part.items.map((x) => <li set:html={x} />)}</ul>
46
- : <ol>{part.items.map((x) => <li set:html={x} />)}</ol>
47
- )}
48
- {s.items && s.items.length > 0 && <ul>{s.items.map((x) => <li set:html={inlineMarkdown(x)} />)}</ul>}
49
- </section>
50
- ))}
51
- </div>
52
- </section>
53
-
54
- <style>
55
- .b-doc {
56
- display: grid;
57
- gap: 2rem;
58
- width: 100%;
59
- max-width: 64rem;
60
- }
61
- @media (min-width: 64em) {
62
- .b-doc[data-toc] {
63
- grid-template-columns: 15rem minmax(0, 1fr);
64
- align-items: start;
65
- }
66
- .b-doc__toc {
67
- position: sticky;
68
- top: 6rem;
69
- }
70
- }
71
- .b-doc__toc {
72
- border: var(--card-border);
73
- border-radius: var(--radius);
74
- background: var(--bg-surface);
75
- padding: 1rem 1.1rem;
76
- }
77
- .b-doc__toc-title {
78
- margin: 0 0 0.5rem;
79
- font-size: 0.75rem;
80
- font-weight: 600;
81
- letter-spacing: 0.08em;
82
- text-transform: uppercase;
83
- color: var(--muted);
84
- }
85
- .b-doc__toc ol {
86
- list-style: none;
87
- margin: 0;
88
- padding: 0;
89
- display: grid;
90
- gap: 0.35rem;
91
- font-size: 0.9375rem;
92
- }
93
- .b-doc__toc a {
94
- color: var(--fg);
95
- text-decoration: none;
96
- }
97
- .b-doc__toc a:hover {
98
- color: var(--gold);
99
- text-decoration: underline;
100
- }
101
- .b-doc__toc-num {
102
- color: var(--muted);
103
- font-variant-numeric: tabular-nums;
104
- }
105
- .b-doc__body {
106
- max-width: var(--max);
107
- display: flex;
108
- flex-direction: column;
109
- gap: 1.75rem;
110
- }
111
- .b-doc__updated {
112
- margin: 0;
113
- font-size: 0.875rem;
114
- color: var(--muted);
115
- }
116
- .b-doc__intro {
117
- margin: 0;
118
- font-size: 1.125rem;
119
- line-height: 1.6;
120
- color: var(--muted);
121
- }
122
- .b-doc__clause {
123
- scroll-margin-top: 6rem;
124
- }
125
- .b-doc__clause h2 {
126
- margin: 0 0 0.6rem;
127
- font-size: 1.25rem;
128
- }
129
- .b-doc__num {
130
- color: var(--gold);
131
- font-variant-numeric: tabular-nums;
132
- }
133
- .b-doc__clause p,
134
- .b-doc__clause ul,
135
- .b-doc__clause ol {
136
- margin: 0 0 0.75rem;
137
- line-height: 1.7;
138
- }
139
- .b-doc__clause ul,
140
- .b-doc__clause ol {
141
- padding-inline-start: 1.4em;
142
- }
143
- .b-doc__clause :global(a) {
144
- color: var(--accent);
145
- text-decoration: underline;
146
- text-underline-offset: 0.15em;
147
- }
148
- @media print {
149
- .b-doc__toc { display: none; }
150
- .b-doc[data-toc] { grid-template-columns: 1fr; }
151
- }
152
- </style>
1
+ ---
2
+ /**
3
+ * Document — a policy or legal text as ONE block. Sections are numbered automatically,
4
+ * a contents list links to each (beside the text, sticky, on wide screens), and an
5
+ * optional "last updated" line sits at the top. Section bodies take prose formatting
6
+ * (links, bold, lists); `items` add a list. Print-friendly: the contents list is
7
+ * dropped on paper.
8
+ */
9
+ import { proseParts } from '../../lib/prose';
10
+ import { inlineMarkdown } from '../../lib/inline-markdown';
11
+ import { longDate } from '../../lib/documents';
12
+ import { pageSlugs, uniqueSlug } from '../../lib/slug';
13
+
14
+ interface Section { title: string; body?: string; items?: string[] }
15
+ interface Props {
16
+ intro?: string;
17
+ updated?: string;
18
+ numbered?: boolean;
19
+ toc?: boolean;
20
+ sections: Section[];
21
+ }
22
+ const { intro, updated, numbered = true, toc = true, sections = [] } = Astro.props;
23
+ const updatedLabel = longDate(updated);
24
+ const showToc = toc && sections.length > 1;
25
+ // Readable anchors that survive renumbering (#payment); #clause-N stays on the heading.
26
+ const used = pageSlugs(Astro.locals);
27
+ const ids = sections.map((s) => uniqueSlug(s.title, used));
28
+ ---
29
+
30
+ <section class="b-doc" data-toc={showToc ? 'true' : undefined}>
31
+ {showToc && (
32
+ <nav class="b-doc__toc" aria-label="Contents">
33
+ <p class="b-doc__toc-title">Contents</p>
34
+ <ol>
35
+ {sections.map((s, i) => (
36
+ <li><a href={`#${ids[i]}`}>{numbered && <span class="b-doc__toc-num">{i + 1}.</span>} {s.title}</a></li>
37
+ ))}
38
+ </ol>
39
+ </nav>
40
+ )}
41
+ <div class="b-doc__body">
42
+ {updatedLabel && <p class="b-doc__updated">Last updated {updatedLabel}</p>}
43
+ {intro && <p class="b-doc__intro" set:html={inlineMarkdown(intro)} />}
44
+ {sections.map((s, i) => (
45
+ <section class="b-doc__clause" id={ids[i]}>
46
+ <h2 id={`clause-${i + 1}`}>{numbered && <span class="b-doc__num">{i + 1}.</span>} {s.title}</h2>
47
+ {proseParts(s.body).map((part) =>
48
+ part.kind === 'p' ? <p set:html={part.html} />
49
+ : part.kind === 'ul' ? <ul>{part.items.map((x) => <li set:html={x} />)}</ul>
50
+ : <ol>{part.items.map((x) => <li set:html={x} />)}</ol>
51
+ )}
52
+ {s.items && s.items.length > 0 && <ul>{s.items.map((x) => <li set:html={inlineMarkdown(x)} />)}</ul>}
53
+ </section>
54
+ ))}
55
+ </div>
56
+ </section>
57
+
58
+ <style>
59
+ .b-doc {
60
+ display: grid;
61
+ gap: 2rem;
62
+ width: 100%;
63
+ max-width: 64rem;
64
+ }
65
+ @media (min-width: 64em) {
66
+ .b-doc[data-toc] {
67
+ grid-template-columns: 15rem minmax(0, 1fr);
68
+ align-items: start;
69
+ }
70
+ .b-doc__toc {
71
+ position: sticky;
72
+ top: 6rem;
73
+ }
74
+ }
75
+ .b-doc__toc {
76
+ border: var(--card-border);
77
+ border-radius: var(--radius);
78
+ background: var(--bg-surface);
79
+ padding: 1rem 1.1rem;
80
+ }
81
+ .b-doc__toc-title {
82
+ margin: 0 0 0.5rem;
83
+ font-size: 0.75rem;
84
+ font-weight: 600;
85
+ letter-spacing: 0.08em;
86
+ text-transform: uppercase;
87
+ color: var(--muted);
88
+ }
89
+ .b-doc__toc ol {
90
+ list-style: none;
91
+ margin: 0;
92
+ padding: 0;
93
+ display: grid;
94
+ gap: 0.35rem;
95
+ font-size: 0.9375rem;
96
+ }
97
+ .b-doc__toc a {
98
+ color: var(--fg);
99
+ text-decoration: none;
100
+ }
101
+ .b-doc__toc a:hover {
102
+ color: var(--gold);
103
+ text-decoration: underline;
104
+ }
105
+ .b-doc__toc-num {
106
+ color: var(--muted);
107
+ font-variant-numeric: tabular-nums;
108
+ }
109
+ .b-doc__body {
110
+ max-width: var(--max);
111
+ display: flex;
112
+ flex-direction: column;
113
+ gap: 1.75rem;
114
+ }
115
+ .b-doc__updated {
116
+ margin: 0;
117
+ font-size: 0.875rem;
118
+ color: var(--muted);
119
+ }
120
+ .b-doc__intro {
121
+ margin: 0;
122
+ font-size: 1.125rem;
123
+ line-height: 1.6;
124
+ color: var(--muted);
125
+ }
126
+ .b-doc__clause {
127
+ scroll-margin-top: 6rem;
128
+ }
129
+ .b-doc__clause h2 {
130
+ scroll-margin-top: 6rem;
131
+ margin: 0 0 0.6rem;
132
+ font-size: 1.25rem;
133
+ }
134
+ .b-doc__num {
135
+ color: var(--gold);
136
+ font-variant-numeric: tabular-nums;
137
+ }
138
+ .b-doc__clause p,
139
+ .b-doc__clause ul,
140
+ .b-doc__clause ol {
141
+ margin: 0 0 0.75rem;
142
+ line-height: 1.7;
143
+ }
144
+ .b-doc__clause ul,
145
+ .b-doc__clause ol {
146
+ padding-inline-start: 1.4em;
147
+ }
148
+ .b-doc__clause :global(a) {
149
+ color: var(--accent);
150
+ text-decoration: underline;
151
+ text-underline-offset: 0.15em;
152
+ }
153
+ @media print {
154
+ .b-doc__toc { display: none; }
155
+ .b-doc[data-toc] { grid-template-columns: 1fr; }
156
+ }
157
+ </style>
@@ -8,7 +8,7 @@
8
8
  import Heading from '../blocks/Heading.astro';
9
9
  import Icon from '../Icon.astro';
10
10
  import { documentGroups, type DocumentItem } from '../../lib/documents';
11
-
11
+ import { inlineMarkdown } from '../../lib/inline-markdown';
12
12
  interface Props {
13
13
  title?: string;
14
14
  intro?: string;
@@ -23,7 +23,7 @@ const groups = documentGroups(items, groupByYear);
23
23
  {(title || intro) && (
24
24
  <div class="b-docs__head">
25
25
  {title && <Heading text={title} level={2} />}
26
- {intro && <p class="b-docs__intro">{intro}</p>}
26
+ {intro && <p class="b-docs__intro" set:html={inlineMarkdown(intro)} />}
27
27
  </div>
28
28
  )}
29
29
  {items.length === 0 ? (