ferst-core 0.9.3 → 0.9.4

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.
@@ -242,7 +242,7 @@ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
242
242
  padding: 0.12rem 0.45rem;
243
243
  border-radius: var(--radius-pill);
244
244
  background: var(--gold);
245
- color: var(--text-light);
245
+ color: var(--on-brand);
246
246
  font-family: var(--font-heading);
247
247
  font-size: 0.62rem;
248
248
  font-weight: 700;
@@ -20,7 +20,7 @@ const external = (href: string) => /^https?:\/\//i.test(href);
20
20
  ---
21
21
 
22
22
  <section class="b-linkrow">
23
- {title && <Heading text={title} level={3} />}
23
+ {title && <Heading text={title} level={2} />}
24
24
  {intro && <p class="b-linkrow__intro" set:html={inlineMarkdown(intro)} />}
25
25
  <ul class="b-linkrow__links">
26
26
  {links.map((l) => (
@@ -36,6 +36,8 @@ const external = (href: string) => /^https?:\/\//i.test(href);
36
36
  </section>
37
37
 
38
38
  <style>
39
+ /* Its title is the block's heading (h2, so headings stay in order) at the h3 size it always had. */
40
+ .b-linkrow > :global(h2) { font-size: clamp(1.375rem, 1.2rem + 0.55vw, 1.625rem); }
39
41
  .b-linkrow {
40
42
  display: flex;
41
43
  flex-direction: column;
@@ -126,7 +126,7 @@ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2;
126
126
  .b-loc-map__load {
127
127
  font: inherit; font-family: var(--font-heading); font-weight: 600; font-size: 0.9rem;
128
128
  padding: 0.5rem 1.1rem; border: none; border-radius: var(--radius-sm);
129
- background: var(--gold); color: var(--text-light); cursor: pointer;
129
+ background: var(--gold); color: var(--on-brand); cursor: pointer;
130
130
  transition: filter 0.15s ease;
131
131
  }
132
132
  .b-loc-map__load:hover { filter: brightness(0.94); }
@@ -150,7 +150,7 @@ const safe = isSafeEmbedUrl(embedUrl);
150
150
  border: none;
151
151
  border-radius: var(--radius-sm);
152
152
  background: var(--gold);
153
- color: var(--text-light);
153
+ color: var(--on-brand);
154
154
  cursor: pointer;
155
155
  transition: filter 0.15s ease;
156
156
  }
@@ -111,7 +111,7 @@ const headAlign = headingAlign ?? blockAlign ?? 'center';
111
111
  font-weight: 700;
112
112
  letter-spacing: 0.08em;
113
113
  text-transform: uppercase;
114
- color: var(--text-light);
114
+ color: var(--on-brand);
115
115
  background: var(--gold);
116
116
  padding: 0.2rem 0.6rem;
117
117
  border-radius: var(--radius-pill);
@@ -81,7 +81,7 @@ const headAlign = headingAlign ?? blockAlign ?? 'center';
81
81
  justify-content: center;
82
82
  border-radius: var(--radius-pill);
83
83
  background: var(--gold);
84
- color: var(--text-light);
84
+ color: var(--on-brand);
85
85
  font-family: var(--font-heading);
86
86
  font-weight: 700;
87
87
  font-size: 1.05rem;
@@ -36,6 +36,8 @@ interface Props {
36
36
  tiles?: Tile[];
37
37
  }
38
38
  const { title, intro, columns = 3, variant = 'elevated', spotlight = false, tiles = [] } = Astro.props;
39
+ // Headings stay in order: items sit one level under the block's title, or take its place.
40
+ const ItemHeading = title ? 'h3' : 'h2';
39
41
  // Alignment overrides (BlockLayout): the title + intro default to centred; the block's
40
42
  // own `align`, or `headingAlign` for the title alone, overrides it from the page data.
41
43
  const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | 'center' | 'end'; headingAlign?: 'start' | 'center' | 'end' };
@@ -73,7 +75,7 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
73
75
  <span class="b-tile__icon"><Icon name={tile.icon} size={26} /></span>
74
76
  )}
75
77
  {tile.eyebrow && <span class="b-tile__eyebrow">{tile.eyebrow}</span>}
76
- {tile.title && <h3 class="b-tile__title">{tile.title}</h3>}
78
+ {tile.title && <ItemHeading class="b-tile__title">{tile.title}</ItemHeading>}
77
79
  {/* A linked tile is one big link: its text keeps breaks and emphasis, no inner links. */}
78
80
  {tile.text && <p class="b-tile__text" set:html={tile.href ? inlineMarkdownNoLinks(tile.text) : inlineMarkdown(tile.text)} />}
79
81
  {tile.href && !plain && <span class="b-tile__more" aria-hidden="true">{tile.linkLabel ?? 'Learn more'} &rarr;</span>}
@@ -151,7 +153,7 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
151
153
  }
152
154
  .b-tiles__grid[data-variant='gradient'] .b-tile {
153
155
  background: linear-gradient(135deg, var(--gold), var(--accent-2, var(--gold)));
154
- color: var(--text-light);
156
+ color: var(--on-brand);
155
157
  }
156
158
  .b-tiles__grid[data-variant='image'] .b-tile {
157
159
  color: #fff;
@@ -141,7 +141,7 @@ const safeLink = /^https:\/\//i.test(url) ? url : undefined;
141
141
  border: none;
142
142
  border-radius: var(--radius-sm);
143
143
  background: var(--gold);
144
- color: var(--text-light);
144
+ color: var(--on-brand);
145
145
  cursor: pointer;
146
146
  }
147
147
  .b-video__load:hover {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The accessibility statement (`/accessibility`), generated for every site like the cookie
3
+ * policy: what the site does for people using keyboards, screen readers or magnification,
4
+ * where it falls short, and how to report a problem. Pure, so it's unit-testable; the route
5
+ * (routes/accessibility.astro) renders it with the site's name and contact details.
6
+ */
7
+ export interface StatementSection {
8
+ heading: string;
9
+ paragraphs: string[];
10
+ items?: string[];
11
+ /** On the "reporting a problem" section, when the site has contact details. */
12
+ contact?: { email?: string; phone?: string };
13
+ }
14
+
15
+ export interface AccessibilityStatement {
16
+ title: string;
17
+ intro: string;
18
+ sections: StatementSection[];
19
+ }
20
+
21
+ export function accessibilityStatement({ siteName, email, phone }: { siteName: string; email?: string; phone?: string }): AccessibilityStatement {
22
+ const contact = email || phone ? { ...(email ? { email } : {}), ...(phone ? { phone } : {}) } : undefined;
23
+ return {
24
+ title: 'Accessibility',
25
+ intro: `${siteName} wants everyone to be able to use this website, including people who use a keyboard instead of a mouse, a screen reader, or magnification.`,
26
+ sections: [
27
+ {
28
+ heading: 'What you can do on this site',
29
+ paragraphs: ['The site is built so that you can:'],
30
+ items: [
31
+ 'move around it using only a keyboard, starting with a "Skip to content" link',
32
+ 'use it with a screen reader: pages have headings in order, landmarks and descriptions for meaningful pictures',
33
+ 'zoom in up to 400% without text spilling off the screen, and read it on a phone',
34
+ 'read text that stands out clearly from its background, in light and dark mode',
35
+ 'stop moving backgrounds; they stay still if your device asks for reduced motion',
36
+ ],
37
+ },
38
+ {
39
+ heading: 'The standard we aim for',
40
+ paragraphs: [
41
+ 'The site aims to meet the Web Content Accessibility Guidelines version 2.2 (WCAG 2.2) at level AA. Its layout, navigation and controls are tested for accessibility before every release of the software it runs on.',
42
+ ],
43
+ },
44
+ {
45
+ heading: 'What may not be fully accessible',
46
+ paragraphs: ['We know of some things that may be harder to use:'],
47
+ items: [
48
+ 'some documents, such as PDF newsletters and older uploads, were not written with accessibility in mind',
49
+ 'videos shown from other sites, such as YouTube, rely on that site for their captions',
50
+ 'some older pictures may not have a description yet',
51
+ ],
52
+ },
53
+ {
54
+ heading: 'Reporting a problem',
55
+ paragraphs: contact
56
+ ? ['If something on this site is hard to use, or you need information in another format, please tell us. We will do our best to help.']
57
+ : ['If something on this site is hard to use, or you need information in another format, please get in touch through the contact page. We will do our best to help.'],
58
+ ...(contact ? { contact } : {}),
59
+ },
60
+ ],
61
+ };
62
+ }
package/integration.mjs CHANGED
@@ -22,6 +22,9 @@
22
22
  * @param {boolean} [options.calendar=true] Inject the `/calendar` route.
23
23
  * @param {boolean} [options.blog=true] Inject the blog routes (`/posts`,
24
24
  * `/posts/[...slug]`, `/posts/tag/[...tag]`).
25
+ * @param {boolean} [options.accessibility=true] Inject `/accessibility`, the accessibility statement
26
+ * every footer links to (content/accessibility.ts). Skipped when the site has its own
27
+ * `src/pages/accessibility.astro`.
25
28
  * @param {boolean} [options.cookiePolicy=true] Inject `/cookie-policy` (the page the cookie
26
29
  * banner links to), generated from the blocks the site uses. Skipped automatically when
27
30
  * the site has its own `src/pages/cookie-policy.astro`.
@@ -109,7 +112,7 @@ function readSiteMeta(root) {
109
112
  const hasPublicFile = (root, name) => Boolean(root) && existsSync(new URL(`public/${name}`, root));
110
113
 
111
114
  export default function ferst(options = {}) {
112
- const { calendar = true, blog = true, cookiePolicy = true, notFound = true, icons = true, seo = true } = options;
115
+ const { calendar = true, blog = true, cookiePolicy = true, accessibility = true, notFound = true, icons = true, seo = true } = options;
113
116
  const fonts = ferstFonts().hooks;
114
117
  /** This build's search context, decided at setup and used again when the build is done. */
115
118
  let search = null;
@@ -162,6 +165,11 @@ export default function ferst(options = {}) {
162
165
  entrypoint: 'ferst-core/routes/cookie-policy.astro',
163
166
  });
164
167
  }
168
+ // Every footer links to the accessibility statement. The site's own page wins.
169
+ const ownStatement = config?.root && existsSync(new URL('src/pages/accessibility.astro', config.root));
170
+ if (accessibility && !ownStatement) {
171
+ injectRoute({ pattern: '/accessibility', entrypoint: 'ferst-core/routes/accessibility.astro' });
172
+ }
165
173
  // A missing address must answer 404, not the home page (Cloudflare Pages serves the
166
174
  // home page with a 200 when a build has no 404.html). The site's own page wins.
167
175
  const ownNotFound = config?.root && existsSync(new URL('src/pages/404.astro', config.root));
@@ -175,6 +183,7 @@ export default function ferst(options = {}) {
175
183
  define: {
176
184
  'import.meta.env.FERST_ROUTES_BLOG': JSON.stringify(blog),
177
185
  'import.meta.env.FERST_ROUTES_CALENDAR': JSON.stringify(calendar),
186
+ 'import.meta.env.FERST_ROUTES_ACCESSIBILITY': JSON.stringify(accessibility),
178
187
  },
179
188
  },
180
189
  });
@@ -256,6 +256,8 @@ const hasFeed = Boolean(siteUrl) && import.meta.env.FERST_ROUTES_BLOG !== false;
256
256
  </script>
257
257
  </head>
258
258
  <body>
259
+ <!-- The first thing a keyboard reaches: straight past the header to the page. -->
260
+ <a class="skip-link" href="#main">Skip to content</a>
259
261
  <SiteHeader
260
262
  path={path}
261
263
  headerClass={headerClass}
@@ -267,7 +269,7 @@ const hasFeed = Boolean(siteUrl) && import.meta.env.FERST_ROUTES_BLOG !== false;
267
269
  cta={navigation.cta}
268
270
  />
269
271
 
270
- <main class:list={['site-main', mainClass]} data-content-align={align}>
272
+ <main id="main" tabindex="-1" class:list={['site-main', mainClass]} data-content-align={align}>
271
273
  <slot name="hero" />
272
274
  <slot />
273
275
  {showSecondaryLogo && (secondaryLogoLight || secondaryLogoDark) && (
@@ -301,6 +303,14 @@ const hasFeed = Boolean(siteUrl) && import.meta.env.FERST_ROUTES_BLOG !== false;
301
303
  all flow from the same variables — no per-mode hex overrides here.
302
304
  */
303
305
  * { box-sizing: border-box; }
306
+ /* Skip link: hidden until a keyboard reaches it, then shown at the top left. */
307
+ .skip-link {
308
+ position: absolute; left: 0.75rem; top: -100%; z-index: 1000;
309
+ padding: 0.6rem 1rem; border-radius: 0.375rem;
310
+ background: var(--fg); color: var(--bg); font-weight: 600; text-decoration: none;
311
+ }
312
+ .skip-link:focus { top: 0.75rem; outline: 2px solid var(--bg); outline-offset: 2px; }
313
+ #main:focus { outline: none; }
304
314
  html, body { margin: 0; padding: 0; }
305
315
  html {
306
316
  height: 100%;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Colour contrast for a theme (WCAG 2.2 AA): every text and brand colour the token builder
3
+ * (lib/themeTokens.ts) derives, measured against the ground it sits on, in light and dark
4
+ * mode. Theme Studio warns before saving a theme that fails; the tests hold the neutral
5
+ * defaults to it. Pure, so it's unit-testable.
6
+ *
7
+ * The derivations mirror themeTokens.ts: light mode mixes in sRGB (CSS `color-mix(in srgb)`),
8
+ * dark mode moves the same three colours in OKLCH. A colour that isn't hex (a CSS variable or
9
+ * function in an override) is skipped, never guessed.
10
+ */
11
+ import type { ThemeSettings } from '../content/schemas';
12
+ import { DARK_DEFAULTS, NEUTRAL_SOURCE } from './themeDefaults';
13
+
14
+ type RGB = [number, number, number]; // 0..1, gamma-encoded sRGB
15
+
16
+ function parseHex(hex: string): RGB | null {
17
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex ?? '').trim());
18
+ if (!m) return null;
19
+ const h = m[1].length === 3 ? m[1].split('').map((c) => c + c).join('') : m[1];
20
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as RGB;
21
+ }
22
+
23
+ const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
24
+ const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
25
+ const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
26
+
27
+ function luminance([r, g, b]: RGB): number {
28
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
29
+ }
30
+
31
+ function ratio(a: RGB, b: RGB): number {
32
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
33
+ return (hi + 0.05) / (lo + 0.05);
34
+ }
35
+
36
+ /** WCAG contrast ratio between two hex colours (1 to 21). */
37
+ export function contrastRatio(a: string, b: string): number {
38
+ const ra = parseHex(a);
39
+ const rb = parseHex(b);
40
+ if (!ra || !rb) throw new Error(`not a hex colour: ${!ra ? a : b}`);
41
+ return ratio(ra, rb);
42
+ }
43
+
44
+ const rgbHex = (c: RGB) => '#' + c.map((v) => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
45
+
46
+ /** CSS `color-mix(in srgb, a p, b)`. */
47
+ const mix = (a: RGB, b: RGB, p: number): RGB => a.map((v, i) => v * p + b[i] * (1 - p)) as RGB;
48
+
49
+ // OKLCH ⇄ sRGB (Björn Ottosson's OKLab).
50
+ function toOklch([r, g, b]: RGB): [number, number, number] {
51
+ const [lr, lg, lb] = [r, g, b].map(toLinear);
52
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
53
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
54
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
55
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
56
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
57
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
58
+ return [L, Math.hypot(A, B), Math.atan2(B, A)];
59
+ }
60
+
61
+ function fromOklch(L: number, C: number, h: number): RGB {
62
+ const A = C * Math.cos(h);
63
+ const B = C * Math.sin(h);
64
+ const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
65
+ const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
66
+ const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
67
+ const lin = [
68
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
69
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
70
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
71
+ ];
72
+ return lin.map((c) => clamp01(toGamma(clamp01(c)))) as RGB;
73
+ }
74
+
75
+ export type ContrastPair = 'text' | 'secondary-text' | 'links' | 'button-text';
76
+
77
+ export interface ContrastIssue {
78
+ mode: 'light' | 'dark';
79
+ pair: ContrastPair;
80
+ /** What a person sees fail, in plain words. */
81
+ label: string;
82
+ ratio: number;
83
+ needs: number;
84
+ }
85
+
86
+ const LABELS: Record<ContrastPair, string> = {
87
+ text: 'Text on the page background',
88
+ 'secondary-text': 'Secondary text (dates, captions) on the page background',
89
+ links: 'Links and brand-coloured text on the page background',
90
+ 'button-text': 'Text on brand-coloured buttons',
91
+ };
92
+
93
+ /**
94
+ * Text on brand fills in light mode (`--on-brand`): white, or the theme's ink when that reads
95
+ * better on the brand (a pale brand such as a gold). In dark mode it's the dark ground. Null
96
+ * when either colour isn't hex.
97
+ */
98
+ export function onBrandColour(brand: string, ink: string): string | null {
99
+ const b = parseHex(brand);
100
+ const i = parseHex(ink);
101
+ if (!b || !i) return null;
102
+ return ratio(i, b) > ratio([1, 1, 1], b) ? ink.trim() : '#FFFFFF';
103
+ }
104
+ const AA = 4.5;
105
+
106
+ /**
107
+ * Every pair in `theme` that falls below WCAG AA (4.5:1), light mode then dark. Empty when the
108
+ * theme passes.
109
+ */
110
+ export function contrastIssues(theme: ThemeSettings): ContrastIssue[] {
111
+ const brand = parseHex(theme.brand || NEUTRAL_SOURCE.brand) ?? parseHex(NEUTRAL_SOURCE.brand)!;
112
+ const ink = parseHex(theme.ink || NEUTRAL_SOURCE.ink) ?? parseHex(NEUTRAL_SOURCE.ink)!;
113
+ const surface = parseHex(theme.surface || NEUTRAL_SOURCE.surface) ?? parseHex(NEUTRAL_SOURCE.surface)!;
114
+
115
+ // Dark mode: the same three colours moved in OKLCH (themeTokens.ts DARK_DERIVATION), or the
116
+ // site's own overrides when they're hex.
117
+ const depth = typeof theme.darkDepth === 'number' ? theme.darkDepth : DARK_DEFAULTS.depth;
118
+ const cap = typeof theme.darkChroma === 'number' ? theme.darkChroma : DARK_DEFAULTS.chroma;
119
+ const [, sc, sh] = toOklch(surface);
120
+ const [, ic, ih] = toOklch(ink);
121
+ const [, bc, bh] = toOklch(brand);
122
+ const ov = theme.darkOverrides ?? { ground: '', text: '', accent: '' };
123
+ const override = (v: string | undefined) => (v ? parseHex(v) : undefined);
124
+ const ground = override(ov.ground) ?? (ov.ground ? null : fromOklch(depth, Math.min(Math.max(sc * 3.5, 0), cap), sh));
125
+ const text = override(ov.text) ?? (ov.text ? null : fromOklch(0.94, ic * 0.3, ih));
126
+ const accent = override(ov.accent) ?? (ov.accent ? null : fromOklch(0.78, bc * 0.9, bh));
127
+ const muted = text ? (() => { const [l, c, h] = toOklch(text); return fromOklch(l - 0.2, c, h); })() : null;
128
+
129
+ const checks: Array<[ContrastIssue['mode'], ContrastPair, RGB | null, RGB | null]> = [
130
+ ['light', 'text', ink, surface],
131
+ ['light', 'secondary-text', mix(ink, surface, 0.72), surface],
132
+ ['light', 'links', brand, surface],
133
+ ['light', 'button-text', parseHex(onBrandColour(rgbHex(brand), rgbHex(ink)) ?? '#FFFFFF'), brand],
134
+ ['dark', 'text', text, ground],
135
+ ['dark', 'secondary-text', muted, ground],
136
+ ['dark', 'links', accent, ground],
137
+ ['dark', 'button-text', ground, accent],
138
+ ];
139
+ const issues: ContrastIssue[] = [];
140
+ for (const [mode, pair, fg, bg] of checks) {
141
+ if (!fg || !bg) continue; // a colour we can't read: skipped, never guessed
142
+ const r = ratio(fg, bg);
143
+ if (r < AA) issues.push({ mode, pair, label: LABELS[pair], ratio: Math.round(r * 100) / 100, needs: AA });
144
+ }
145
+ return issues;
146
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The theme's fallbacks, shared by the token builder (themeTokens.ts) and the contrast check
3
+ * (contrast.ts) so both derive the same palette.
4
+ */
5
+
6
+ /** The neutral source triple — used to fill any colour a client leaves blank so
7
+ * the derivation is always self-contained (mirrors `styles/theme.css`). */
8
+ export const NEUTRAL_SOURCE = {
9
+ brand: '#4F46E5',
10
+ ink: '#1F2937',
11
+ surface: '#F1F5F9',
12
+ } as const;
13
+
14
+ /** Defaults for the dark-formula knobs (also the CMS/schema defaults). */
15
+ export const DARK_DEFAULTS = { depth: 0.21, chroma: 0.05 } as const;
@@ -25,14 +25,11 @@
25
25
  * Pure + framework-free so it is unit-testable without a render.
26
26
  */
27
27
  import type { ThemeSettings } from '../content/schemas';
28
+ import { DARK_DEFAULTS, NEUTRAL_SOURCE } from './themeDefaults';
29
+ import { onBrandColour } from './contrast';
30
+
31
+ export { DARK_DEFAULTS, NEUTRAL_SOURCE };
28
32
 
29
- /** The neutral source triple — used to fill any colour a client leaves blank so
30
- * the derivation is always self-contained (mirrors `styles/theme.css`). */
31
- export const NEUTRAL_SOURCE = {
32
- brand: '#4F46E5',
33
- ink: '#1F2937',
34
- surface: '#F1F5F9',
35
- } as const;
36
33
 
37
34
  /** Corner-knob vocabulary → the `--radius` value every surface reads. */
38
35
  export const CORNERS_PX: Record<ThemeSettings['corners'], string> = {
@@ -111,10 +108,11 @@ const DARK_DOWNSTREAM = [
111
108
  '--gold-light:oklch(from var(--dark-accent) calc(l - 0.46) calc(c * 0.6) h)',
112
109
  '--accent-2:oklch(from var(--dark-accent) calc(l - 0.08) c calc(h + 26))',
113
110
  '--text-footer-link:var(--dark-accent)',
111
+ // Text on brand fills: the accent is light in dark mode, so its text is the dark ground
112
+ // (light text on it fell to ~1.9:1; lib/contrast.ts checks it).
113
+ '--on-brand:var(--dark-ground)',
114
114
  ].join(';');
115
115
 
116
- /** Defaults for the dark-formula knobs (also the CMS/schema defaults). */
117
- export const DARK_DEFAULTS = { depth: 0.21, chroma: 0.05 } as const;
118
116
 
119
117
  /** Clamp a numeric knob to a range, falling back to `fallback` when absent/NaN. */
120
118
  function clampNum(v: unknown, min: number, max: number, fallback: number): number {
@@ -188,6 +186,10 @@ export function buildThemeStyle(theme: ThemeSettings): ThemeStyle {
188
186
  rootDecls.push(`--brand:${brand || NEUTRAL_SOURCE.brand}`);
189
187
  rootDecls.push(`--ink:${ink || NEUTRAL_SOURCE.ink}`);
190
188
  rootDecls.push(`--surface:${surface || NEUTRAL_SOURCE.surface}`);
189
+ // Text on brand fills: white or the ink, whichever reads better on the brand (dark mode
190
+ // overrides it with the dark ground). A colour it can't read leaves theme.css's white.
191
+ const onBrand = onBrandColour(brand || NEUTRAL_SOURCE.brand, ink || NEUTRAL_SOURCE.ink);
192
+ if (onBrand) rootDecls.push(`--on-brand:${onBrand}`);
191
193
  }
192
194
  const fh = clean(theme.fonts?.heading);
193
195
  const fb = clean(theme.fonts?.body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
6
6
  "license": "BUSL-1.1",
@@ -0,0 +1,44 @@
1
+ ---
2
+ /**
3
+ * The accessibility statement (`/accessibility`) — INJECTED by the ferst() integration, and
4
+ * linked from every page's footer. Generated from the site's name and contact details
5
+ * (content/accessibility.ts). A site with its own `src/pages/accessibility.astro` keeps it
6
+ * (the integration skips this route then).
7
+ */
8
+ import Base from '../layouts/Base.astro';
9
+ import { loadSiteSettings } from '../lib/siteSettings';
10
+ import { accessibilityStatement } from '../content/accessibility';
11
+
12
+ const { identity, contact } = await loadSiteSettings();
13
+ const statement = accessibilityStatement({
14
+ siteName: identity.siteTitle || identity.shortName,
15
+ email: contact.email || undefined,
16
+ phone: contact.phoneDisplay || undefined,
17
+ });
18
+ const tel = contact.phoneTel || contact.phoneDisplay;
19
+ ---
20
+
21
+ <Base title={statement.title} description={`How ${identity.shortName || identity.siteTitle} works for everyone, and how to report a problem.`}>
22
+ <section class="b-accessibility">
23
+ <h1>{statement.title}</h1>
24
+ <p>{statement.intro}</p>
25
+ {statement.sections.map((s) => (
26
+ <>
27
+ <h2>{s.heading}</h2>
28
+ {s.paragraphs.map((p) => <p>{p}</p>)}
29
+ {s.items && <ul>{s.items.map((i) => <li>{i}</li>)}</ul>}
30
+ {s.contact && (
31
+ <ul>
32
+ {s.contact.email && <li>Email: <a href={`mailto:${s.contact.email}`}>{s.contact.email}</a></li>}
33
+ {s.contact.phone && <li>Phone: <a href={`tel:${tel}`}>{s.contact.phone}</a></li>}
34
+ </ul>
35
+ )}
36
+ </>
37
+ ))}
38
+ </section>
39
+ </Base>
40
+
41
+ <style>
42
+ .b-accessibility { max-width: 52rem; margin-inline: auto; padding: 2rem 1rem 3rem; }
43
+ .b-accessibility ul { padding-inline-start: 1.25rem; }
44
+ </style>
package/styles/theme.css CHANGED
@@ -41,6 +41,7 @@
41
41
  --gold-light: #E0E7FF;
42
42
  --brown: #4B5563;
43
43
  --text-light: #FFFFFF;
44
+ --on-brand: #FFFFFF; /* text on brand-coloured fills (buttons, badges) */
44
45
  --text-footer: #4B5563;
45
46
  --text-footer-link: #4F46E5;
46
47
  --text-copyright: #94A3B8;
@@ -109,6 +110,7 @@
109
110
  --gold-light: #312E81;
110
111
  --brown: #94A3B8;
111
112
  --text-light: #E2E8F0;
113
+ --on-brand: #0F172A; /* the light accent needs dark text */
112
114
  --text-footer: #94A3B8;
113
115
  --text-footer-link: #818CF8;
114
116
  --text-copyright: #64748B;