ferst-core 0.7.6 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,90 +1,90 @@
1
- ---
2
- /**
3
- * Notice — a Level-2 recipe: an inline highlighted info box (optional icon +
4
- * title + body + optional link) that sets a passage apart from the surrounding
5
- * prose — a "reporting a concern", "please note", "how to book" callout. Two
6
- * tones (accent / neutral) in keeping with badge/announcement; token-driven, so
7
- * it follows the brand and both themes. The page-level sibling of the corner
8
- * StickyCallout (this one sits in the content flow).
9
- */
10
- import Icon from '../Icon.astro';
11
- import { isIconKey } from '../../lib/icons';
12
-
13
- import { inlineMarkdown } from '../../lib/inline-markdown';
14
- interface Props {
15
- tone?: 'accent' | 'neutral';
16
- icon?: string;
17
- title?: string;
18
- text: string;
19
- link?: { label: string; href: string };
20
- }
21
- const { tone = 'accent', icon, title, text, link } = Astro.props;
22
- const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
23
- ---
24
-
25
- <aside class="b-notice" data-tone={tone}>
26
- {icon && isIconKey(icon) && (
27
- <span class="b-notice__icon" aria-hidden="true"><Icon name={icon} size={22} /></span>
28
- )}
29
- <div class="b-notice__body">
30
- {title && <p class="b-notice__title">{title}</p>}
31
- {paras.map((p) => <p class="b-notice__text" set:html={inlineMarkdown(p)} />)}
32
- {link && <a class="b-notice__link" href={link.href}>{link.label}</a>}
33
- </div>
34
- </aside>
35
-
36
- <style>
37
- .b-notice {
38
- display: flex;
39
- gap: 0.85rem;
40
- align-items: flex-start;
41
- max-width: var(--max);
42
- margin-inline: auto;
43
- padding: 1.1rem 1.25rem;
44
- border-radius: var(--radius);
45
- border-inline-start: 4px solid var(--gold);
46
- }
47
- .b-notice[data-tone='accent'] {
48
- background: var(--gold-light);
49
- border-inline-start-color: var(--gold);
50
- }
51
- .b-notice[data-tone='neutral'] {
52
- background: var(--bg-section);
53
- border-inline-start-color: var(--accent);
54
- }
55
- /* On a centred page, keep the box a readable width but centre it as a block;
56
- text inside stays left (a centred info box reads badly). */
57
- :global([data-content-align='center']) .b-notice {
58
- text-align: start;
59
- }
60
- .b-notice__icon {
61
- display: inline-flex;
62
- color: var(--gold);
63
- margin-top: 0.1rem;
64
- flex-shrink: 0;
65
- }
66
- .b-notice__body {
67
- display: flex;
68
- flex-direction: column;
69
- gap: 0.4rem;
70
- min-width: 0;
71
- }
72
- .b-notice__title {
73
- margin: 0;
74
- font-family: var(--font-heading);
75
- font-weight: 600;
76
- font-size: 1.05rem;
77
- color: var(--fg);
78
- }
79
- .b-notice__text {
80
- margin: 0;
81
- color: var(--muted);
82
- line-height: 1.6;
83
- }
84
- .b-notice__link {
85
- margin-top: 0.1rem;
86
- align-self: flex-start;
87
- color: var(--gold);
88
- font-weight: 600;
89
- }
90
- </style>
1
+ ---
2
+ /**
3
+ * Notice — a Level-2 recipe: an inline highlighted info box (optional icon +
4
+ * title + body + optional link) that sets a passage apart from the surrounding
5
+ * prose — a "reporting a concern", "please note", "how to book" callout. Two
6
+ * tones (accent / neutral) in keeping with badge/announcement; token-driven, so
7
+ * it follows the brand and both themes. The page-level sibling of the corner
8
+ * StickyCallout (this one sits in the content flow).
9
+ */
10
+ import Icon from '../Icon.astro';
11
+ import { isIconKey } from '../../lib/icons';
12
+
13
+ import { inlineMarkdown } from '../../lib/inline-markdown';
14
+ interface Props {
15
+ tone?: 'accent' | 'neutral';
16
+ icon?: string;
17
+ title?: string;
18
+ text: string;
19
+ link?: { label: string; href: string };
20
+ }
21
+ const { tone = 'accent', icon, title, text, link } = Astro.props;
22
+ const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
23
+ ---
24
+
25
+ <aside class="b-notice" data-tone={tone}>
26
+ {icon && isIconKey(icon) && (
27
+ <span class="b-notice__icon" aria-hidden="true"><Icon name={icon} size={22} /></span>
28
+ )}
29
+ <div class="b-notice__body">
30
+ {title && <p class="b-notice__title">{title}</p>}
31
+ {paras.map((p) => <p class="b-notice__text" set:html={inlineMarkdown(p)} />)}
32
+ {link && <a class="b-notice__link" href={link.href}>{link.label}</a>}
33
+ </div>
34
+ </aside>
35
+
36
+ <style>
37
+ .b-notice {
38
+ display: flex;
39
+ gap: 0.85rem;
40
+ align-items: flex-start;
41
+ max-width: var(--max);
42
+ margin-inline: auto;
43
+ padding: 1.1rem 1.25rem;
44
+ border-radius: var(--radius);
45
+ border-inline-start: 4px solid var(--gold);
46
+ }
47
+ .b-notice[data-tone='accent'] {
48
+ background: var(--gold-light);
49
+ border-inline-start-color: var(--gold);
50
+ }
51
+ .b-notice[data-tone='neutral'] {
52
+ background: var(--bg-section);
53
+ border-inline-start-color: var(--accent);
54
+ }
55
+ /* On a centred page, keep the box a readable width but centre it as a block;
56
+ text inside stays left (a centred info box reads badly). */
57
+ :global([data-content-align='center']) .b-notice {
58
+ text-align: start;
59
+ }
60
+ .b-notice__icon {
61
+ display: inline-flex;
62
+ color: var(--gold);
63
+ margin-top: 0.1rem;
64
+ flex-shrink: 0;
65
+ }
66
+ .b-notice__body {
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: 0.4rem;
70
+ min-width: 0;
71
+ }
72
+ .b-notice__title {
73
+ margin: 0;
74
+ font-family: var(--font-heading);
75
+ font-weight: 600;
76
+ font-size: 1.05rem;
77
+ color: var(--fg);
78
+ }
79
+ .b-notice__text {
80
+ margin: 0;
81
+ color: var(--muted);
82
+ line-height: 1.6;
83
+ }
84
+ .b-notice__link {
85
+ margin-top: 0.1rem;
86
+ align-self: var(--content-self, flex-start);
87
+ color: var(--gold);
88
+ font-weight: 600;
89
+ }
90
+ </style>
@@ -21,12 +21,16 @@ interface Props {
21
21
  plans?: Plan[];
22
22
  }
23
23
  const { title, intro, plans = [] } = Astro.props;
24
+ // Alignment overrides (BlockLayout): the title + intro default to centred; the block's
25
+ // own `align`, or `headingAlign` for the title alone, overrides it from the page data.
26
+ const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | 'center' | 'end'; headingAlign?: 'start' | 'center' | 'end' };
27
+ const headAlign = headingAlign ?? blockAlign ?? 'center';
24
28
  ---
25
29
 
26
30
  <section class="b-pricing">
27
31
  {(title || intro) && (
28
- <div class="b-pricing__head">
29
- {title && <Heading text={title} level={2} align="center" />}
32
+ <div class="b-pricing__head" data-recipe-head data-content-align={headAlign}>
33
+ {title && <Heading text={title} level={2} />}
30
34
  {intro && <p class="b-pricing__intro">{intro}</p>}
31
35
  </div>
32
36
  )}
@@ -69,7 +73,6 @@ const { title, intro, plans = [] } = Astro.props;
69
73
  .b-pricing__head {
70
74
  max-width: var(--max);
71
75
  margin-inline: auto;
72
- text-align: center;
73
76
  display: flex;
74
77
  flex-direction: column;
75
78
  gap: 0.75rem;
@@ -103,7 +106,7 @@ const { title, intro, plans = [] } = Astro.props;
103
106
  position: relative;
104
107
  }
105
108
  .b-pricing__badge {
106
- align-self: flex-start;
109
+ align-self: var(--content-self, flex-start);
107
110
  font-size: 0.7rem;
108
111
  font-weight: 700;
109
112
  letter-spacing: 0.08em;
@@ -1,152 +1,155 @@
1
- ---
2
- /** Stats — a Level-2 recipe: a band of headline numbers over an optional
3
- * heading/intro. Composes the Heading primitive; numbers use the brand accent.
4
- *
5
- * Self-composing: the grid never reserves more columns than there are stats, so
6
- * a single item centres as a feature instead of stranding an empty half-band
7
- * (the "£10 pinned to one side" bug); each stat is a token-driven tile (radius/
8
- * surface/stroke/shadow knobs, like every Card), so it reads as a designed unit
9
- * at any count and in either theme. Labels are width-capped so a long caption
10
- * wraps to a tidy block rather than a full-width line. */
11
- import Heading from '../blocks/Heading.astro';
12
-
13
- interface Item { value: string; label: string; description?: string }
14
- interface Props {
15
- title?: string;
16
- intro?: string;
17
- background?: 'none' | 'muted' | 'accent';
18
- columns?: 2 | 3 | 4;
19
- items?: Item[];
20
- }
21
- const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.props;
22
- // Cap the column count at the item count — one stat should never sit in a
23
- // two-column grid (half empty). Falls back to 1 for an empty list.
24
- const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
25
- ---
26
-
27
- <section class="b-stats" data-bg={background}>
28
- <div class="b-stats__inner">
29
- {(title || intro) && (
30
- <div class="b-stats__head">
31
- {title && <Heading text={title} level={2} align="center" />}
32
- {intro && <p class="b-stats__intro">{intro}</p>}
33
- </div>
34
- )}
35
- <div class="b-stats__grid" data-cols={cols}>
36
- {items.map((item) => (
37
- <div class="b-stats__item ferst-tile">
38
- <span class="b-stats__value">{item.value}</span>
39
- {item.label && <span class="b-stats__label">{item.label}</span>}
40
- {item.description && <span class="b-stats__desc">{item.description}</span>}
41
- </div>
42
- ))}
43
- </div>
44
- </div>
45
- </section>
46
-
47
- <style>
48
- .b-stats { padding-block: clamp(2.5rem, 6vw, 4rem); }
49
- .b-stats[data-bg='muted'] { background: var(--bg-section); }
50
- .b-stats[data-bg='accent'] { background: var(--gold-light); }
51
- .b-stats__inner {
52
- max-width: 72rem;
53
- margin-inline: auto;
54
- padding-inline: 1rem;
55
- display: flex;
56
- flex-direction: column;
57
- gap: 2rem;
58
- }
59
- .b-stats__head {
60
- max-width: var(--max);
61
- margin-inline: auto;
62
- text-align: center;
63
- display: flex;
64
- flex-direction: column;
65
- gap: 0.75rem;
66
- }
67
- .b-stats__intro { margin: 0; color: var(--muted); line-height: 1.6; }
68
- .b-stats__grid {
69
- display: grid;
70
- grid-template-columns: 1fr;
71
- gap: 1.25rem;
72
- width: 100%;
73
- margin-inline: auto;
74
- }
75
- @media (min-width: 40em) {
76
- .b-stats__grid[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
77
- .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
78
- .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
79
- }
80
- @media (min-width: 60em) {
81
- .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
82
- .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
83
- }
84
- /* A lone stat is a centred feature, not a full-width slab. */
85
- .b-stats__grid[data-cols='1'] { max-width: 30rem; }
86
- /* Token-driven tile: the same radius / surface / stroke / shadow knobs as every
87
- Card, so it stays consistent and theme-correct. On the muted band the tile's
88
- --bg-surface sits a step above --bg-section, giving depth in both themes. */
89
- .b-stats__item {
90
- display: flex;
91
- flex-direction: column;
92
- align-items: center;
93
- text-align: center;
94
- gap: 0.5rem;
95
- padding: clamp(1.5rem, 3vw, 2.25rem) clamp(1.25rem, 3vw, 1.75rem);
96
- /* surface (bg/border/radius/shadow) comes from .ferst-tile */
97
- }
98
- .b-stats__value {
99
- font-family: var(--font-heading);
100
- font-weight: 700;
101
- font-size: clamp(2.25rem, 1.6rem + 2.6vw, 3.25rem);
102
- line-height: 1;
103
- color: var(--gold);
104
- }
105
- .b-stats__label { font-weight: 600; color: var(--fg); line-height: 1.4; max-width: 30ch; }
106
- .b-stats__desc { font-size: 0.875rem; color: var(--muted); line-height: 1.5; max-width: 32ch; }
107
- /* One key figure: centred, not stranded at the start of a wide row. */
108
- .b-stats__grid[data-cols='1'] {
109
- max-width: 28rem;
110
- margin-inline: auto;
111
- text-align: center;
112
- justify-items: center;
113
- }
114
- </style>
115
-
116
- <script>
117
- // Count-up: numbers tick from 0 to their value when they scroll into view.
118
- // Progressive — the final value is server-rendered, so with no JS (or under
119
- // reduced-motion) it just shows the number. Non-numeric values (One, Any…) are
120
- // left untouched; a leading sign / trailing suffix (+, %, " day") is preserved.
121
- const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
122
- const values = document.querySelectorAll<HTMLElement>('.b-stats__value');
123
- if (!reduce && 'IntersectionObserver' in window && values.length) {
124
- const parse = (t: string) => {
125
- const m = t.match(/^(\D*)(\d[\d,]*(?:\.\d+)?)(.*)$/);
126
- if (!m) return null;
127
- const decimals = (m[2].split('.')[1] || '').length;
128
- return { pre: m[1], num: parseFloat(m[2].replace(/,/g, '')), suf: m[3], decimals };
129
- };
130
- const fmt = (n: number, d: number) => (d > 0 ? n.toFixed(d) : Math.round(n).toLocaleString());
131
- const run = (el: HTMLElement) => {
132
- const final = (el.textContent || '').trim();
133
- const p = parse(final);
134
- if (!p) return;
135
- const dur = 1100;
136
- const t0 = performance.now();
137
- const tick = (now: number) => {
138
- const t = Math.min(1, (now - t0) / dur);
139
- const eased = 1 - Math.pow(1 - t, 3);
140
- el.textContent = p.pre + fmt(p.num * eased, p.decimals) + p.suf;
141
- if (t < 1) requestAnimationFrame(tick);
142
- else el.textContent = final;
143
- };
144
- requestAnimationFrame(tick);
145
- };
146
- const io = new IntersectionObserver(
147
- (entries) => entries.forEach((e) => { if (e.isIntersecting) { run(e.target as HTMLElement); io.unobserve(e.target); } }),
148
- { threshold: 0.4 }
149
- );
150
- values.forEach((v) => io.observe(v));
151
- }
152
- </script>
1
+ ---
2
+ /** Stats — a Level-2 recipe: a band of headline numbers over an optional
3
+ * heading/intro. Composes the Heading primitive; numbers use the brand accent.
4
+ *
5
+ * Self-composing: the grid never reserves more columns than there are stats, so
6
+ * a single item centres as a feature instead of stranding an empty half-band
7
+ * (the "£10 pinned to one side" bug); each stat is a token-driven tile (radius/
8
+ * surface/stroke/shadow knobs, like every Card), so it reads as a designed unit
9
+ * at any count and in either theme. Labels are width-capped so a long caption
10
+ * wraps to a tidy block rather than a full-width line. */
11
+ import Heading from '../blocks/Heading.astro';
12
+
13
+ interface Item { value: string; label: string; description?: string }
14
+ interface Props {
15
+ title?: string;
16
+ intro?: string;
17
+ background?: 'none' | 'muted' | 'accent';
18
+ columns?: 2 | 3 | 4;
19
+ items?: Item[];
20
+ }
21
+ const { title, intro, background = 'muted', columns = 3, items = [] } = Astro.props;
22
+ // Alignment overrides (BlockLayout): the title + intro default to centred; the block's
23
+ // own `align`, or `headingAlign` for the title alone, overrides it from the page data.
24
+ const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | 'center' | 'end'; headingAlign?: 'start' | 'center' | 'end' };
25
+ const headAlign = headingAlign ?? blockAlign ?? 'center';
26
+ // Cap the column count at the item count — one stat should never sit in a
27
+ // two-column grid (half empty). Falls back to 1 for an empty list.
28
+ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
29
+ ---
30
+
31
+ <section class="b-stats" data-bg={background}>
32
+ <div class="b-stats__inner">
33
+ {(title || intro) && (
34
+ <div class="b-stats__head" data-recipe-head data-content-align={headAlign}>
35
+ {title && <Heading text={title} level={2} />}
36
+ {intro && <p class="b-stats__intro">{intro}</p>}
37
+ </div>
38
+ )}
39
+ <div class="b-stats__grid" data-cols={cols}>
40
+ {items.map((item) => (
41
+ <div class="b-stats__item ferst-tile">
42
+ <span class="b-stats__value">{item.value}</span>
43
+ {item.label && <span class="b-stats__label">{item.label}</span>}
44
+ {item.description && <span class="b-stats__desc">{item.description}</span>}
45
+ </div>
46
+ ))}
47
+ </div>
48
+ </div>
49
+ </section>
50
+
51
+ <style>
52
+ .b-stats { padding-block: clamp(2.5rem, 6vw, 4rem); }
53
+ .b-stats[data-bg='muted'] { background: var(--bg-section); }
54
+ .b-stats[data-bg='accent'] { background: var(--gold-light); }
55
+ .b-stats__inner {
56
+ max-width: 72rem;
57
+ margin-inline: auto;
58
+ padding-inline: 1rem;
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: 2rem;
62
+ }
63
+ .b-stats__head {
64
+ max-width: var(--max);
65
+ margin-inline: auto;
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 0.75rem;
69
+ }
70
+ .b-stats__intro { margin: 0; color: var(--muted); line-height: 1.6; }
71
+ .b-stats__grid {
72
+ display: grid;
73
+ grid-template-columns: 1fr;
74
+ gap: 1.25rem;
75
+ width: 100%;
76
+ margin-inline: auto;
77
+ }
78
+ @media (min-width: 40em) {
79
+ .b-stats__grid[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
80
+ .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
81
+ .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
82
+ }
83
+ @media (min-width: 60em) {
84
+ .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
85
+ .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
86
+ }
87
+ /* A lone stat is a centred feature, not a full-width slab. */
88
+ .b-stats__grid[data-cols='1'] { max-width: 30rem; }
89
+ /* Token-driven tile: the same radius / surface / stroke / shadow knobs as every
90
+ Card, so it stays consistent and theme-correct. On the muted band the tile's
91
+ --bg-surface sits a step above --bg-section, giving depth in both themes. */
92
+ .b-stats__item {
93
+ display: flex;
94
+ flex-direction: column;
95
+ align-items: center;
96
+ text-align: center;
97
+ gap: 0.5rem;
98
+ padding: clamp(1.5rem, 3vw, 2.25rem) clamp(1.25rem, 3vw, 1.75rem);
99
+ /* surface (bg/border/radius/shadow) comes from .ferst-tile */
100
+ }
101
+ .b-stats__value {
102
+ font-family: var(--font-heading);
103
+ font-weight: 700;
104
+ font-size: clamp(2.25rem, 1.6rem + 2.6vw, 3.25rem);
105
+ line-height: 1;
106
+ color: var(--gold);
107
+ }
108
+ .b-stats__label { font-weight: 600; color: var(--fg); line-height: 1.4; max-width: 30ch; }
109
+ .b-stats__desc { font-size: 0.875rem; color: var(--muted); line-height: 1.5; max-width: 32ch; }
110
+ /* One key figure: centred, not stranded at the start of a wide row. */
111
+ .b-stats__grid[data-cols='1'] {
112
+ max-width: 28rem;
113
+ margin-inline: auto;
114
+ text-align: center;
115
+ justify-items: center;
116
+ }
117
+ </style>
118
+
119
+ <script>
120
+ // Count-up: numbers tick from 0 to their value when they scroll into view.
121
+ // Progressive — the final value is server-rendered, so with no JS (or under
122
+ // reduced-motion) it just shows the number. Non-numeric values (One, Any…) are
123
+ // left untouched; a leading sign / trailing suffix (+, %, " day") is preserved.
124
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
125
+ const values = document.querySelectorAll<HTMLElement>('.b-stats__value');
126
+ if (!reduce && 'IntersectionObserver' in window && values.length) {
127
+ const parse = (t: string) => {
128
+ const m = t.match(/^(\D*)(\d[\d,]*(?:\.\d+)?)(.*)$/);
129
+ if (!m) return null;
130
+ const decimals = (m[2].split('.')[1] || '').length;
131
+ return { pre: m[1], num: parseFloat(m[2].replace(/,/g, '')), suf: m[3], decimals };
132
+ };
133
+ const fmt = (n: number, d: number) => (d > 0 ? n.toFixed(d) : Math.round(n).toLocaleString());
134
+ const run = (el: HTMLElement) => {
135
+ const final = (el.textContent || '').trim();
136
+ const p = parse(final);
137
+ if (!p) return;
138
+ const dur = 1100;
139
+ const t0 = performance.now();
140
+ const tick = (now: number) => {
141
+ const t = Math.min(1, (now - t0) / dur);
142
+ const eased = 1 - Math.pow(1 - t, 3);
143
+ el.textContent = p.pre + fmt(p.num * eased, p.decimals) + p.suf;
144
+ if (t < 1) requestAnimationFrame(tick);
145
+ else el.textContent = final;
146
+ };
147
+ requestAnimationFrame(tick);
148
+ };
149
+ const io = new IntersectionObserver(
150
+ (entries) => entries.forEach((e) => { if (e.isIntersecting) { run(e.target as HTMLElement); io.unobserve(e.target); } }),
151
+ { threshold: 0.4 }
152
+ );
153
+ values.forEach((v) => io.observe(v));
154
+ }
155
+ </script>