ferst-core 0.4.3 → 0.4.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.
- package/cms/theme-preview.js +108 -0
- package/components/CookiePolicy.astro +157 -0
- package/components/recipes/FeatureCards.astro +7 -1
- package/components/recipes/Gallery.astro +5 -1
- package/components/recipes/Locations.astro +8 -6
- package/components/recipes/MediaCards.astro +29 -9
- package/components/recipes/Pricing.astro +3 -5
- package/components/recipes/Stats.astro +2 -5
- package/components/recipes/Tiles.astro +5 -1
- package/content/cookies.ts +103 -0
- package/layouts/Base.astro +38 -0
- package/lib/theme-preview.ts +89 -0
- package/package.json +2 -1
- package/styles/theme.css +26 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS design-tokens live preview (PDLC Phase 1 / WS4) — SPIKE artifact.
|
|
3
|
+
*
|
|
4
|
+
* Registers a Sveltia/Decap preview template for the `theme` file collection that
|
|
5
|
+
* embeds an iframe of the live site (?preview=1) and pushes the editor's CURRENT
|
|
6
|
+
* (unsaved) token values to it over postMessage — same-origin, since /admin and
|
|
7
|
+
* the site share the client domain. Re-themes live via ferst-core's site-side
|
|
8
|
+
* listener (lib/theme-preview.ts). Save is the CMS's existing git-proxy write.
|
|
9
|
+
*
|
|
10
|
+
* STATUS: unverified against the deployed Sveltia (pre-1.0; docs warn of Decap
|
|
11
|
+
* preview-template compat gaps). It is fully GUARDED — if the API isn't present or
|
|
12
|
+
* differs, it logs once and no-ops, so it can never break the CMS. The portal
|
|
13
|
+
* Theme Studio is the guaranteed token surface until the dev spike confirms this.
|
|
14
|
+
*
|
|
15
|
+
* Include from a client's public/admin/index.html AFTER the Sveltia bundle:
|
|
16
|
+
* <script src="/ferst/theme-preview.js"></script> (copied from ferst-core/cms)
|
|
17
|
+
*/
|
|
18
|
+
(function () {
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
var MSG = { theme: 'ferst:preview:theme', ready: 'ferst:preview:ready' };
|
|
22
|
+
// Match the CMS theme collection's fields (config.yml `theme` file).
|
|
23
|
+
var FIELDS = ['brand', 'ink', 'surface', 'corners', 'elevation', 'stroke', 'fields', 'darkDepth', 'darkChroma'];
|
|
24
|
+
|
|
25
|
+
function log(msg) {
|
|
26
|
+
try {
|
|
27
|
+
console.info('[ferst theme-preview] ' + msg);
|
|
28
|
+
} catch (_) {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Read the live entry into a themeSettings-shaped draft (Immutable Map API).
|
|
32
|
+
function draftFromEntry(entry) {
|
|
33
|
+
var get = function (k) {
|
|
34
|
+
try {
|
|
35
|
+
return entry && entry.getIn ? entry.getIn(['data', k]) : undefined;
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var fonts = get('fonts') || {};
|
|
41
|
+
var ov = get('darkOverrides') || {};
|
|
42
|
+
var pick = function (o, k) {
|
|
43
|
+
return o && typeof o.get === 'function' ? o.get(k) : o ? o[k] : '';
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
brand: get('brand') || '',
|
|
47
|
+
ink: get('ink') || '',
|
|
48
|
+
surface: get('surface') || '',
|
|
49
|
+
fonts: { heading: pick(fonts, 'heading') || '', body: pick(fonts, 'body') || '', display: pick(fonts, 'display') || '' },
|
|
50
|
+
corners: get('corners') || 'rounded',
|
|
51
|
+
elevation: get('elevation') || 'raised',
|
|
52
|
+
stroke: get('stroke') || 'on',
|
|
53
|
+
fields: get('fields') || 'boxed',
|
|
54
|
+
darkDepth: typeof get('darkDepth') === 'number' ? get('darkDepth') : 0.21,
|
|
55
|
+
darkChroma: typeof get('darkChroma') === 'number' ? get('darkChroma') : 0.05,
|
|
56
|
+
darkOverrides: { ground: pick(ov, 'ground') || '', text: pick(ov, 'text') || '', accent: pick(ov, 'accent') || '' },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function register(CMS, React) {
|
|
61
|
+
var Preview = function (props) {
|
|
62
|
+
// Keep one iframe of the live site in the preview pane; push the draft on
|
|
63
|
+
// every render (Sveltia re-renders as the editor types) and on ready.
|
|
64
|
+
var doc = props.document || document;
|
|
65
|
+
var frame = doc.getElementById('ferst-preview-frame');
|
|
66
|
+
if (!frame) {
|
|
67
|
+
frame = doc.createElement('iframe');
|
|
68
|
+
frame.id = 'ferst-preview-frame';
|
|
69
|
+
frame.src = '/?preview=1';
|
|
70
|
+
frame.style.cssText = 'width:100%;height:100%;min-height:80vh;border:0';
|
|
71
|
+
(doc.body || doc.documentElement).appendChild(frame);
|
|
72
|
+
(props.window || window).addEventListener('message', function (e) {
|
|
73
|
+
if (e && e.data && e.data.type === MSG.ready) postDraft();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function postDraft() {
|
|
77
|
+
try {
|
|
78
|
+
frame.contentWindow.postMessage({ type: MSG.theme, theme: draftFromEntry(props.entry) }, window.location.origin);
|
|
79
|
+
} catch (_) {}
|
|
80
|
+
}
|
|
81
|
+
postDraft();
|
|
82
|
+
// Render nothing of our own into React's tree — we own the iframe directly.
|
|
83
|
+
return React && React.createElement ? React.createElement('div', { style: { display: 'none' } }) : null;
|
|
84
|
+
};
|
|
85
|
+
CMS.registerPreviewTemplate('theme', Preview);
|
|
86
|
+
log('registered live preview for the theme collection');
|
|
87
|
+
void FIELDS; // documents the fields this mirrors
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var tries = 0;
|
|
91
|
+
(function wait() {
|
|
92
|
+
var CMS = window.CMS;
|
|
93
|
+
var React = window.React || window.h || (CMS && CMS.React);
|
|
94
|
+
if (CMS && typeof CMS.registerPreviewTemplate === 'function') {
|
|
95
|
+
try {
|
|
96
|
+
register(CMS, React);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
log('registerPreviewTemplate failed — in-pane preview disabled (use the portal Theme Studio). ' + (err && err.message));
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (tries++ < 40) {
|
|
103
|
+
setTimeout(wait, 250); // CMS loads async as an ES module
|
|
104
|
+
} else {
|
|
105
|
+
log('CMS.registerPreviewTemplate not found — in-pane preview unavailable in this CMS build.');
|
|
106
|
+
}
|
|
107
|
+
})();
|
|
108
|
+
})();
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* CookiePolicy — the auto-generated cookie policy page body. It does NOT decide
|
|
4
|
+
* what to show: the consumer scans its own pages (scanCookieUses in
|
|
5
|
+
* content/cookies) and passes the resulting model, so the categories and items
|
|
6
|
+
* always reflect the recipes/templates the site actually uses (functionality-
|
|
7
|
+
* driven, not client-specific).
|
|
8
|
+
*
|
|
9
|
+
* Necessary items are the site's own functional storage (always on). Additional
|
|
10
|
+
* items are third-party embeds gated behind the shared embed consent; one
|
|
11
|
+
* category toggle turns them on/off, wired to the SAME consent as the cookie
|
|
12
|
+
* banner and footer panel, so all three stay in sync. Token-driven.
|
|
13
|
+
*/
|
|
14
|
+
import type { CookiePolicyModel } from '../content/cookies';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
model: CookiePolicyModel;
|
|
18
|
+
title?: string;
|
|
19
|
+
intro?: string;
|
|
20
|
+
}
|
|
21
|
+
const {
|
|
22
|
+
model,
|
|
23
|
+
title = 'Cookie Policy',
|
|
24
|
+
intro = 'This page explains the cookies and similar storage this website uses. It is generated from the features actually in use here, so it always reflects what is really on the site. You can turn additional (third-party) content on or off at any time — your choice is remembered on this device.',
|
|
25
|
+
} = Astro.props;
|
|
26
|
+
const hasAdditional = model.additional.length > 0;
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<section class="b-cookie-policy">
|
|
30
|
+
<h1 class="b-cookie-policy__title">{title}</h1>
|
|
31
|
+
<p class="b-cookie-policy__intro">{intro}</p>
|
|
32
|
+
|
|
33
|
+
<div class="b-cookie-policy__cat ferst-tile">
|
|
34
|
+
<div class="b-cookie-policy__cat-head">
|
|
35
|
+
<h2 class="b-cookie-policy__cat-title">Necessary</h2>
|
|
36
|
+
<span class="b-cookie-policy__badge">Always on</span>
|
|
37
|
+
</div>
|
|
38
|
+
<p class="b-cookie-policy__cat-note">These keep the site working and remember your choices. They stay on your device only and are never shared, so they can’t be switched off.</p>
|
|
39
|
+
<ul class="b-cookie-policy__list">
|
|
40
|
+
{model.necessary.map((u) => (
|
|
41
|
+
<li class="b-cookie-policy__item">
|
|
42
|
+
<p class="b-cookie-policy__item-name">{u.name}</p>
|
|
43
|
+
<p class="b-cookie-policy__item-purpose">{u.purpose}</p>
|
|
44
|
+
<p class="b-cookie-policy__item-storage">{u.storage}</p>
|
|
45
|
+
</li>
|
|
46
|
+
))}
|
|
47
|
+
</ul>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="b-cookie-policy__cat ferst-tile">
|
|
51
|
+
<div class="b-cookie-policy__cat-head">
|
|
52
|
+
<h2 class="b-cookie-policy__cat-title">Additional</h2>
|
|
53
|
+
{hasAdditional && (
|
|
54
|
+
<label class="b-cookie-policy__switch">
|
|
55
|
+
<span class="b-cookie-policy__switch-state" data-on="On" data-off="Off">Off</span>
|
|
56
|
+
<input type="checkbox" id="cookie-policy-additional" class="b-cookie-policy__switch-input" />
|
|
57
|
+
<span class="b-cookie-policy__switch-track" aria-hidden="true"></span>
|
|
58
|
+
</label>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
{hasAdditional ? (
|
|
62
|
+
<>
|
|
63
|
+
<p class="b-cookie-policy__cat-note">These let third-party content load, which may set that provider’s cookies. Nothing loads until you turn this on.</p>
|
|
64
|
+
<ul class="b-cookie-policy__list">
|
|
65
|
+
{model.additional.map((u) => (
|
|
66
|
+
<li class="b-cookie-policy__item">
|
|
67
|
+
<p class="b-cookie-policy__item-name">{u.name}{u.provider && <span class="b-cookie-policy__item-provider"> · {u.provider}</span>}</p>
|
|
68
|
+
<p class="b-cookie-policy__item-purpose">{u.purpose}</p>
|
|
69
|
+
<p class="b-cookie-policy__item-storage">{u.storage}</p>
|
|
70
|
+
</li>
|
|
71
|
+
))}
|
|
72
|
+
</ul>
|
|
73
|
+
</>
|
|
74
|
+
) : (
|
|
75
|
+
<p class="b-cookie-policy__cat-note">This site currently uses no additional or third-party cookies.</p>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
</section>
|
|
79
|
+
|
|
80
|
+
<script>
|
|
81
|
+
// Wire the Additional toggle to the shared embed consent so it stays in sync
|
|
82
|
+
// with the cookie banner and footer panel (accept there → this shows On, and
|
|
83
|
+
// vice-versa). Turning it on/off here records the same site-wide choice.
|
|
84
|
+
import { hasConsent, setConsent, onConsentChange } from '../lib/consent-client';
|
|
85
|
+
const box = document.getElementById('cookie-policy-additional') as HTMLInputElement | null;
|
|
86
|
+
if (box) {
|
|
87
|
+
const state = box.closest('.b-cookie-policy__switch')?.querySelector<HTMLElement>('.b-cookie-policy__switch-state');
|
|
88
|
+
const sync = (on: boolean) => {
|
|
89
|
+
box.checked = on;
|
|
90
|
+
if (state) state.textContent = on ? state.dataset.on ?? 'On' : state.dataset.off ?? 'Off';
|
|
91
|
+
};
|
|
92
|
+
sync(hasConsent());
|
|
93
|
+
box.addEventListener('change', () => setConsent(box.checked));
|
|
94
|
+
onConsentChange((allowed) => sync(allowed));
|
|
95
|
+
}
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<style>
|
|
99
|
+
.b-cookie-policy {
|
|
100
|
+
max-width: 52rem;
|
|
101
|
+
margin-inline: auto;
|
|
102
|
+
padding-inline: 1rem;
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-direction: column;
|
|
105
|
+
gap: 1.5rem;
|
|
106
|
+
}
|
|
107
|
+
.b-cookie-policy__title { margin: 0; }
|
|
108
|
+
.b-cookie-policy__intro { margin: 0; color: var(--muted); line-height: 1.7; max-width: 46rem; }
|
|
109
|
+
|
|
110
|
+
/* .ferst-tile supplies the surface (bg/border/radius/shadow). */
|
|
111
|
+
.b-cookie-policy__cat { padding: clamp(1.25rem, 3vw, 1.75rem); display: flex; flex-direction: column; gap: 0.9rem; }
|
|
112
|
+
.b-cookie-policy__cat-head {
|
|
113
|
+
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
|
|
114
|
+
}
|
|
115
|
+
.b-cookie-policy__cat-title { margin: 0; font-size: 1.3rem; }
|
|
116
|
+
.b-cookie-policy__cat-note { margin: 0; color: var(--muted); line-height: 1.6; }
|
|
117
|
+
.b-cookie-policy__badge {
|
|
118
|
+
font-family: var(--font-heading); font-size: 0.72rem; font-weight: 700;
|
|
119
|
+
letter-spacing: 0.08em; text-transform: uppercase;
|
|
120
|
+
color: var(--gold);
|
|
121
|
+
border: 1px solid color-mix(in srgb, var(--gold) 45%, transparent);
|
|
122
|
+
border-radius: 999px; padding: 0.25rem 0.7rem; white-space: nowrap;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.b-cookie-policy__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
|
|
126
|
+
.b-cookie-policy__item {
|
|
127
|
+
padding-top: 0.75rem;
|
|
128
|
+
border-top: 1px solid var(--border);
|
|
129
|
+
}
|
|
130
|
+
.b-cookie-policy__item:first-child { padding-top: 0; border-top: none; }
|
|
131
|
+
.b-cookie-policy__item-name { margin: 0; font-family: var(--font-heading); font-weight: 700; }
|
|
132
|
+
.b-cookie-policy__item-provider { color: var(--muted); font-weight: 400; }
|
|
133
|
+
.b-cookie-policy__item-purpose { margin: 0.15rem 0 0; line-height: 1.55; }
|
|
134
|
+
.b-cookie-policy__item-storage { margin: 0.25rem 0 0; font-size: 0.85rem; color: var(--muted); }
|
|
135
|
+
|
|
136
|
+
/* Accessible toggle: a real checkbox drives a styled track. */
|
|
137
|
+
.b-cookie-policy__switch { display: inline-flex; align-items: center; gap: 0.6rem; cursor: pointer; }
|
|
138
|
+
.b-cookie-policy__switch-state {
|
|
139
|
+
font-family: var(--font-heading); font-size: 0.8rem; font-weight: 700; color: var(--muted); min-width: 1.6rem; text-align: right;
|
|
140
|
+
}
|
|
141
|
+
.b-cookie-policy__switch-input {
|
|
142
|
+
position: absolute; opacity: 0; width: 44px; height: 26px; margin: 0; cursor: pointer;
|
|
143
|
+
}
|
|
144
|
+
.b-cookie-policy__switch-track {
|
|
145
|
+
position: relative; width: 44px; height: 26px; border-radius: 999px; flex: 0 0 auto;
|
|
146
|
+
background: color-mix(in srgb, var(--muted) 45%, transparent);
|
|
147
|
+
transition: background 0.15s ease;
|
|
148
|
+
}
|
|
149
|
+
.b-cookie-policy__switch-track::after {
|
|
150
|
+
content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
|
|
151
|
+
border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
152
|
+
transition: transform 0.15s ease;
|
|
153
|
+
}
|
|
154
|
+
.b-cookie-policy__switch-input:checked ~ .b-cookie-policy__switch-track { background: var(--gold); }
|
|
155
|
+
.b-cookie-policy__switch-input:checked ~ .b-cookie-policy__switch-track::after { transform: translateX(18px); }
|
|
156
|
+
.b-cookie-policy__switch-input:focus-visible ~ .b-cookie-policy__switch-track { outline: 2px solid var(--gold); outline-offset: 2px; }
|
|
157
|
+
</style>
|
|
@@ -17,6 +17,9 @@ interface Props {
|
|
|
17
17
|
cards?: { icon?: string; title: string; text?: string; href?: string }[];
|
|
18
18
|
}
|
|
19
19
|
const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
20
|
+
// Never reserve more columns than there are cards (a lone card in a 3-col grid
|
|
21
|
+
// would strand two empty cells); `data-single` centres a solitary card.
|
|
22
|
+
const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3 | 4;
|
|
20
23
|
---
|
|
21
24
|
|
|
22
25
|
<section class="b-feature-cards">
|
|
@@ -26,7 +29,7 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
|
26
29
|
{intro && <p class="b-feature-cards__intro">{intro}</p>}
|
|
27
30
|
</div>
|
|
28
31
|
)}
|
|
29
|
-
<div class="b-feature-cards__grid" data-cols={
|
|
32
|
+
<div class="b-feature-cards__grid" data-cols={cols} data-single={cards.length === 1 ? 'true' : undefined}>
|
|
30
33
|
{cards.map((card) => (
|
|
31
34
|
<Card>
|
|
32
35
|
{card.icon && isIconKey(card.icon) && (
|
|
@@ -77,6 +80,9 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
|
77
80
|
.b-feature-cards__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
|
|
78
81
|
.b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(2, 1fr); }
|
|
79
82
|
}
|
|
83
|
+
/* A solitary card is a centred feature, not a full-width slab. */
|
|
84
|
+
.b-feature-cards__grid[data-single] { max-width: 26rem; margin-inline: auto;
|
|
85
|
+
}
|
|
80
86
|
@media (min-width: 64em) {
|
|
81
87
|
.b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(4, 1fr); }
|
|
82
88
|
}
|
|
@@ -7,9 +7,11 @@ interface Props {
|
|
|
7
7
|
images?: { src: string; alt?: string; caption?: string }[];
|
|
8
8
|
}
|
|
9
9
|
const { columns = 3, images = [] } = Astro.props;
|
|
10
|
+
// Never reserve more columns than there are images; `data-single` centres a lone one.
|
|
11
|
+
const cols = Math.min(columns, Math.max(images.length, 1)) as 1 | 2 | 3 | 4;
|
|
10
12
|
---
|
|
11
13
|
|
|
12
|
-
<div class="b-gallery" data-cols={
|
|
14
|
+
<div class="b-gallery" data-cols={cols} data-single={images.length === 1 ? 'true' : undefined}>
|
|
13
15
|
{images.map((img) => (
|
|
14
16
|
<figure class="b-gallery__item">
|
|
15
17
|
<img src={img.src} alt={img.alt ?? ''} loading="lazy" decoding="async" />
|
|
@@ -27,6 +29,8 @@ const { columns = 3, images = [] } = Astro.props;
|
|
|
27
29
|
margin-inline: auto;
|
|
28
30
|
padding-inline: 1rem;
|
|
29
31
|
}
|
|
32
|
+
/* A solitary image is a centred feature, not a full-bleed slab. */
|
|
33
|
+
.b-gallery[data-single] { max-width: 34rem; }
|
|
30
34
|
@media (min-width: 48em) {
|
|
31
35
|
.b-gallery[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
|
|
32
36
|
.b-gallery[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
|
|
@@ -25,6 +25,9 @@ interface Props {
|
|
|
25
25
|
items?: Item[];
|
|
26
26
|
}
|
|
27
27
|
const { title, intro, columns = 2, provider = 'Google Maps', items = [] } = Astro.props;
|
|
28
|
+
// A single location shouldn't sit in one half of a two-column grid; `data-single`
|
|
29
|
+
// centres a lone tile. (A deliberate columns=1 with several items is untouched.)
|
|
30
|
+
const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2;
|
|
28
31
|
---
|
|
29
32
|
|
|
30
33
|
<section class="b-locations">
|
|
@@ -34,9 +37,9 @@ const { title, intro, columns = 2, provider = 'Google Maps', items = [] } = Astr
|
|
|
34
37
|
{intro && <p class="b-locations__intro">{intro}</p>}
|
|
35
38
|
</div>
|
|
36
39
|
)}
|
|
37
|
-
<div class="b-locations__grid" data-cols={
|
|
40
|
+
<div class="b-locations__grid" data-cols={cols} data-single={items.length === 1 ? 'true' : undefined}>
|
|
38
41
|
{items.map((item) => (
|
|
39
|
-
<article class="b-locations__card">
|
|
42
|
+
<article class="b-locations__card ferst-tile ferst-tile--sm">
|
|
40
43
|
<div class="b-locations__map">
|
|
41
44
|
{isSafeEmbedUrl(item.embedUrl) ? (
|
|
42
45
|
<div class="b-loc-map" data-embed-url={item.embedUrl} data-title={`Map — ${item.name}`}>
|
|
@@ -92,16 +95,15 @@ const { title, intro, columns = 2, provider = 'Google Maps', items = [] } = Astr
|
|
|
92
95
|
.b-locations__head { display: flex; flex-direction: column; gap: 0.6rem; }
|
|
93
96
|
.b-locations__intro { margin: 0; color: var(--muted); max-width: var(--max); }
|
|
94
97
|
.b-locations__grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
|
|
98
|
+
/* A solitary location is a centred feature, not a full-width slab. */
|
|
99
|
+
.b-locations__grid[data-single] { max-width: 32rem; margin-inline: auto; }
|
|
95
100
|
@media (min-width: 44em) {
|
|
96
101
|
.b-locations__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
|
|
97
102
|
}
|
|
98
103
|
.b-locations__card {
|
|
99
104
|
display: flex;
|
|
100
105
|
flex-direction: column;
|
|
101
|
-
|
|
102
|
-
border: var(--card-border);
|
|
103
|
-
border-radius: var(--radius);
|
|
104
|
-
box-shadow: var(--shadow-sm);
|
|
106
|
+
/* surface comes from .ferst-tile .ferst-tile--sm (bg/border/radius/shadow-sm) */
|
|
105
107
|
overflow: hidden;
|
|
106
108
|
}
|
|
107
109
|
/* Map region — uniform ratio; the consent card fills it until the iframe loads. */
|
|
@@ -28,6 +28,8 @@ interface Props {
|
|
|
28
28
|
cards?: Card[];
|
|
29
29
|
}
|
|
30
30
|
const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
31
|
+
// Never reserve more columns than there are cards; `data-single` centres a lone one.
|
|
32
|
+
const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3;
|
|
31
33
|
---
|
|
32
34
|
|
|
33
35
|
<section class="b-media-cards">
|
|
@@ -37,9 +39,14 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
|
37
39
|
{intro && <p class="b-media-cards__intro">{intro}</p>}
|
|
38
40
|
</div>
|
|
39
41
|
)}
|
|
40
|
-
<div class="b-media-cards__grid" data-cols={
|
|
41
|
-
{cards.map((card) =>
|
|
42
|
-
|
|
42
|
+
<div class="b-media-cards__grid" data-cols={cols} data-single={cards.length === 1 ? 'true' : undefined}>
|
|
43
|
+
{cards.map((card) => {
|
|
44
|
+
// Surface a mailto:/tel: as visible, copyable text — a plain mailto does
|
|
45
|
+
// nothing on a device with no mail app, so the address must be readable too.
|
|
46
|
+
const email = card.href?.startsWith('mailto:') ? card.href.slice(7).split('?')[0] : null;
|
|
47
|
+
const phone = card.href?.startsWith('tel:') ? card.href.slice(4) : null;
|
|
48
|
+
return (
|
|
49
|
+
<article class:list={['b-media-cards__card', 'ferst-tile', 'ferst-tile--sm', card.href && 'is-link']}>
|
|
43
50
|
{card.image && (
|
|
44
51
|
<div class="b-media-cards__media">
|
|
45
52
|
<img src={card.image.src} alt={card.image.alt ?? ''} loading="lazy" />
|
|
@@ -54,12 +61,16 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
|
54
61
|
</h3>
|
|
55
62
|
{card.meta && <p class="b-media-cards__meta">{card.meta}</p>}
|
|
56
63
|
{card.text && <p class="b-media-cards__text">{card.text}</p>}
|
|
57
|
-
{
|
|
64
|
+
{(email || phone) && (
|
|
65
|
+
<span class="b-media-cards__contact">{email ?? phone}</span>
|
|
66
|
+
)}
|
|
67
|
+
{card.href && !email && !phone && (
|
|
58
68
|
<span class="b-media-cards__cta" aria-hidden="true">{card.linkLabel ?? 'Learn more'} →</span>
|
|
59
69
|
)}
|
|
60
70
|
</div>
|
|
61
71
|
</article>
|
|
62
|
-
|
|
72
|
+
);
|
|
73
|
+
})}
|
|
63
74
|
</div>
|
|
64
75
|
</section>
|
|
65
76
|
|
|
@@ -89,6 +100,8 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
|
89
100
|
grid-template-columns: 1fr;
|
|
90
101
|
gap: 1.5rem;
|
|
91
102
|
}
|
|
103
|
+
/* A solitary card is a centred feature, not a full-width slab. */
|
|
104
|
+
.b-media-cards__grid[data-single] { max-width: 28rem; margin-inline: auto; }
|
|
92
105
|
@media (min-width: 40em) {
|
|
93
106
|
.b-media-cards__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
|
|
94
107
|
.b-media-cards__grid[data-cols='3'] { grid-template-columns: repeat(2, 1fr); }
|
|
@@ -100,10 +113,7 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
|
100
113
|
position: relative;
|
|
101
114
|
display: flex;
|
|
102
115
|
flex-direction: column;
|
|
103
|
-
|
|
104
|
-
border: var(--card-border);
|
|
105
|
-
border-radius: var(--radius);
|
|
106
|
-
box-shadow: var(--shadow-sm);
|
|
116
|
+
/* surface comes from .ferst-tile .ferst-tile--sm (bg/border/radius/shadow-sm) */
|
|
107
117
|
overflow: hidden;
|
|
108
118
|
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
|
109
119
|
}
|
|
@@ -189,4 +199,14 @@ const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
|
189
199
|
font-weight: 600;
|
|
190
200
|
color: var(--gold);
|
|
191
201
|
}
|
|
202
|
+
/* A mailto/tel shown as visible, copyable text (below any description). */
|
|
203
|
+
.b-media-cards__contact {
|
|
204
|
+
margin-top: auto;
|
|
205
|
+
padding-top: 0.5rem;
|
|
206
|
+
font-family: var(--font-heading);
|
|
207
|
+
font-size: 0.85rem;
|
|
208
|
+
font-weight: 600;
|
|
209
|
+
color: var(--gold);
|
|
210
|
+
overflow-wrap: anywhere;
|
|
211
|
+
}
|
|
192
212
|
</style>
|
|
@@ -32,7 +32,7 @@ const { title, intro, plans = [] } = Astro.props;
|
|
|
32
32
|
)}
|
|
33
33
|
<div class="b-pricing__grid" data-count={plans.length}>
|
|
34
34
|
{plans.map((plan) => (
|
|
35
|
-
<div class="b-pricing__plan" data-featured={plan.featured ? 'true' : 'false'}>
|
|
35
|
+
<div class="b-pricing__plan ferst-tile" data-featured={plan.featured ? 'true' : 'false'}>
|
|
36
36
|
{plan.featured && <span class="b-pricing__badge">Most popular</span>}
|
|
37
37
|
<p class="b-pricing__name">{plan.name}</p>
|
|
38
38
|
<p class="b-pricing__price">
|
|
@@ -94,10 +94,8 @@ const { title, intro, plans = [] } = Astro.props;
|
|
|
94
94
|
flex-direction: column;
|
|
95
95
|
gap: 0.75rem;
|
|
96
96
|
padding: 1.75rem;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
background: var(--bg-surface);
|
|
100
|
-
box-shadow: var(--shadow-md);
|
|
97
|
+
/* surface (bg/border/radius/shadow) comes from .ferst-tile; the featured
|
|
98
|
+
variant below overrides border-colour + shadow at higher specificity. */
|
|
101
99
|
}
|
|
102
100
|
.b-pricing__plan[data-featured='true'] {
|
|
103
101
|
border-color: var(--gold);
|
|
@@ -34,7 +34,7 @@ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
|
|
|
34
34
|
)}
|
|
35
35
|
<div class="b-stats__grid" data-cols={cols}>
|
|
36
36
|
{items.map((item) => (
|
|
37
|
-
<div class="b-stats__item">
|
|
37
|
+
<div class="b-stats__item ferst-tile">
|
|
38
38
|
<span class="b-stats__value">{item.value}</span>
|
|
39
39
|
{item.label && <span class="b-stats__label">{item.label}</span>}
|
|
40
40
|
{item.description && <span class="b-stats__desc">{item.description}</span>}
|
|
@@ -93,10 +93,7 @@ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
|
|
|
93
93
|
text-align: center;
|
|
94
94
|
gap: 0.5rem;
|
|
95
95
|
padding: clamp(1.5rem, 3vw, 2.25rem) clamp(1.25rem, 3vw, 1.75rem);
|
|
96
|
-
|
|
97
|
-
background: var(--bg-surface);
|
|
98
|
-
border: var(--card-border);
|
|
99
|
-
box-shadow: var(--shadow-md);
|
|
96
|
+
/* surface (bg/border/radius/shadow) comes from .ferst-tile */
|
|
100
97
|
}
|
|
101
98
|
.b-stats__value {
|
|
102
99
|
font-family: var(--font-heading);
|
|
@@ -34,6 +34,8 @@ interface Props {
|
|
|
34
34
|
tiles?: Tile[];
|
|
35
35
|
}
|
|
36
36
|
const { title, intro, columns = 3, variant = 'elevated', spotlight = false, tiles = [] } = Astro.props;
|
|
37
|
+
// Never reserve more columns than there are tiles; `data-single` centres a lone one.
|
|
38
|
+
const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
|
|
37
39
|
---
|
|
38
40
|
|
|
39
41
|
<section class="b-tiles">
|
|
@@ -43,7 +45,7 @@ const { title, intro, columns = 3, variant = 'elevated', spotlight = false, tile
|
|
|
43
45
|
{intro && <p class="b-tiles__intro">{intro}</p>}
|
|
44
46
|
</div>
|
|
45
47
|
)}
|
|
46
|
-
<div class="b-tiles__grid" data-cols={
|
|
48
|
+
<div class="b-tiles__grid" data-cols={cols} data-single={tiles.length === 1 ? 'true' : undefined} data-variant={variant} data-spotlight={spotlight ? 'true' : 'false'}>
|
|
47
49
|
{tiles.map((tile) => {
|
|
48
50
|
const Tag = tile.href ? 'a' : 'div';
|
|
49
51
|
const bg =
|
|
@@ -93,6 +95,8 @@ const { title, intro, columns = 3, variant = 'elevated', spotlight = false, tile
|
|
|
93
95
|
grid-template-columns: 1fr;
|
|
94
96
|
gap: 1.25rem;
|
|
95
97
|
}
|
|
98
|
+
/* A solitary tile is a centred feature, not a full-width slab. */
|
|
99
|
+
.b-tiles__grid[data-single] { max-width: 26rem; margin-inline: auto; }
|
|
96
100
|
@media (min-width: 48em) {
|
|
97
101
|
.b-tiles__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
|
|
98
102
|
.b-tiles__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie / third-party-storage registry + a scanner that reports which uses are
|
|
3
|
+
* actually active on a given site. This powers the auto-generated cookie policy
|
|
4
|
+
* page: its categories and items are DERIVED from the recipes/templates the site
|
|
5
|
+
* uses — functionality-driven, never hand-written or client-specific.
|
|
6
|
+
*
|
|
7
|
+
* Two canonical categories:
|
|
8
|
+
* • necessary — the site's OWN functional storage (no third parties). Always
|
|
9
|
+
* on; can't be switched off. Present on every site.
|
|
10
|
+
* • additional — third-party embeds that may set the provider's cookies when
|
|
11
|
+
* they load. Gated behind the site's shared embed consent, and
|
|
12
|
+
* listed only when a recipe/template that uses them is present.
|
|
13
|
+
*
|
|
14
|
+
* Adding a new cookie-using recipe = one entry here (its `blocks` and/or a
|
|
15
|
+
* detection flag); the policy page then lists it automatically wherever it's used.
|
|
16
|
+
* Pure + framework-free so it's unit-testable without a render.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type CookieCategory = 'necessary' | 'additional';
|
|
20
|
+
|
|
21
|
+
export interface CookieUse {
|
|
22
|
+
/** Stable id (also the consent grouping key in future per-item models). */
|
|
23
|
+
id: string;
|
|
24
|
+
category: CookieCategory;
|
|
25
|
+
/** Human name shown on the policy page, e.g. "Google Maps". */
|
|
26
|
+
name: string;
|
|
27
|
+
/** Third-party provider, when applicable. */
|
|
28
|
+
provider?: string;
|
|
29
|
+
/** One-line plain-English purpose. */
|
|
30
|
+
purpose: string;
|
|
31
|
+
/** What is stored and where. */
|
|
32
|
+
storage: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Additional uses carry detection rules; stripped before returning to the view. */
|
|
36
|
+
interface AdditionalUse extends CookieUse {
|
|
37
|
+
/** Present if any of these block `type`s appears anywhere on the site. */
|
|
38
|
+
blocks?: string[];
|
|
39
|
+
/** Present if the site has a Google Calendar configured (a dedicated page). */
|
|
40
|
+
calendar?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The site's own functional storage — always present, always on. */
|
|
44
|
+
const NECESSARY: readonly CookieUse[] = [
|
|
45
|
+
{
|
|
46
|
+
id: 'consent-choice',
|
|
47
|
+
category: 'necessary',
|
|
48
|
+
name: 'Cookie preference',
|
|
49
|
+
purpose: 'Remembers whether you allowed or declined additional content, so you are not asked again.',
|
|
50
|
+
storage: 'Stored on your device only (local storage), never shared.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'theme',
|
|
54
|
+
category: 'necessary',
|
|
55
|
+
name: 'Appearance',
|
|
56
|
+
purpose: 'Remembers your light or dark theme choice.',
|
|
57
|
+
storage: 'Stored on your device only (local storage), never shared.',
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** Third-party embeds — listed only when a site actually uses them. */
|
|
62
|
+
const ADDITIONAL: readonly AdditionalUse[] = [
|
|
63
|
+
{
|
|
64
|
+
id: 'google-maps',
|
|
65
|
+
category: 'additional',
|
|
66
|
+
name: 'Google Maps',
|
|
67
|
+
provider: 'Google',
|
|
68
|
+
purpose: 'Shows embedded maps of where to find us. Google may set cookies when a map loads.',
|
|
69
|
+
storage: 'Third-party cookies set by Google.',
|
|
70
|
+
blocks: ['locations', 'mapEmbed'],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'google-calendar',
|
|
74
|
+
category: 'additional',
|
|
75
|
+
name: 'Google Calendar',
|
|
76
|
+
provider: 'Google',
|
|
77
|
+
purpose: 'Shows the parish calendar. Google may set cookies when the calendar loads.',
|
|
78
|
+
storage: 'Third-party cookies set by Google.',
|
|
79
|
+
blocks: ['calendar'],
|
|
80
|
+
calendar: true,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export interface CookieScanInput {
|
|
85
|
+
/** Every block `type` that appears anywhere on the site. */
|
|
86
|
+
blockTypes: Iterable<string>;
|
|
87
|
+
/** True when the site has a Google Calendar configured (dedicated calendar page). */
|
|
88
|
+
calendarConfigured?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CookiePolicyModel {
|
|
92
|
+
necessary: CookieUse[];
|
|
93
|
+
additional: CookieUse[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Report the cookie uses actually active on a site, grouped by category. */
|
|
97
|
+
export function scanCookieUses(input: CookieScanInput): CookiePolicyModel {
|
|
98
|
+
const types = new Set(input.blockTypes);
|
|
99
|
+
const additional = ADDITIONAL.filter(
|
|
100
|
+
(u) => (u.blocks?.some((b) => types.has(b)) ?? false) || (u.calendar === true && !!input.calendarConfigured),
|
|
101
|
+
).map(({ blocks, calendar, ...use }) => use); // drop detection fields from the view model
|
|
102
|
+
return { necessary: NECESSARY.map((u) => ({ ...u })), additional };
|
|
103
|
+
}
|
package/layouts/Base.astro
CHANGED
|
@@ -142,6 +142,44 @@ const metaDescription = description ?? identity.description;
|
|
|
142
142
|
}
|
|
143
143
|
}) as EventListener);
|
|
144
144
|
</script>
|
|
145
|
+
<script>
|
|
146
|
+
// Open external links in a new tab — site-wide and future-proof. Any <a>
|
|
147
|
+
// pointing to a DIFFERENT origin over http(s) gets target=_blank +
|
|
148
|
+
// rel="noopener noreferrer"; mailto:/tel:/anchors and same-site links are
|
|
149
|
+
// left untouched. Runs on load and after every view-transition navigation,
|
|
150
|
+
// so it covers every component AND markdown-authored links (e.g. FAQ
|
|
151
|
+
// answers, prose) without each one having to opt in. Idempotent, so
|
|
152
|
+
// components that already set target/rel are unaffected.
|
|
153
|
+
function ferstMarkExternalLinks() {
|
|
154
|
+
const host = location.host;
|
|
155
|
+
document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
|
|
156
|
+
let url: URL;
|
|
157
|
+
try {
|
|
158
|
+
url = new URL(a.href, location.href);
|
|
159
|
+
} catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return; // skip mailto/tel/etc.
|
|
163
|
+
if (url.host === host) return; // same-site
|
|
164
|
+
if (!a.target) a.target = '_blank';
|
|
165
|
+
const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
|
|
166
|
+
rel.add('noopener');
|
|
167
|
+
rel.add('noreferrer');
|
|
168
|
+
a.setAttribute('rel', Array.from(rel).join(' '));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
ferstMarkExternalLinks();
|
|
172
|
+
document.addEventListener('astro:page-load', ferstMarkExternalLinks);
|
|
173
|
+
</script>
|
|
174
|
+
<script>
|
|
175
|
+
// Live theme preview: only when this page is FRAMED with ?preview=1 (the
|
|
176
|
+
// portal Theme Studio or the CMS design-tokens pane), listen for draft theme
|
|
177
|
+
// settings over postMessage and re-theme live. Dynamically imported so the
|
|
178
|
+
// code never ships to normal visitors. Inert otherwise.
|
|
179
|
+
if (window.parent !== window && new URLSearchParams(location.search).has('preview')) {
|
|
180
|
+
import('../lib/theme-preview').then((m) => m.initThemePreviewListener());
|
|
181
|
+
}
|
|
182
|
+
</script>
|
|
145
183
|
</head>
|
|
146
184
|
<body>
|
|
147
185
|
<SiteHeader
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live theme preview (client-side). Lets an external editing surface — the portal
|
|
3
|
+
* Theme Studio, or the CMS design-tokens pane — push DRAFT theme settings into a
|
|
4
|
+
* loaded page over `postMessage`, so the whole site re-themes instantly with no
|
|
5
|
+
* rebuild. This is the "instant" preview lane of the Change-Request loop (see
|
|
6
|
+
* myai4-webdev/docs/PDLC-PREVIEW-ARCHITECTURE.md).
|
|
7
|
+
*
|
|
8
|
+
* Robust for ANY site: it re-runs the core's own `buildThemeStyle(draft)` in the
|
|
9
|
+
* browser and swaps a dedicated `<style id="ferst-preview">` — the same output the
|
|
10
|
+
* server emits at build time — instead of only setting inline `--brand/--ink/
|
|
11
|
+
* --surface` (which re-derive nothing on a not-yet-themed site). Injected late in
|
|
12
|
+
* <head>, it out-specifies the server theme block (`:root:root`), so the draft wins.
|
|
13
|
+
*
|
|
14
|
+
* Import ONLY from a component <script> (touches window/document). Gated by the
|
|
15
|
+
* caller to preview mode so it never runs for normal visitors.
|
|
16
|
+
*/
|
|
17
|
+
import { buildThemeStyle } from './themeTokens';
|
|
18
|
+
import type { ThemeSettings } from '../content/schemas';
|
|
19
|
+
|
|
20
|
+
/** postMessage protocol shared with the portal / CMS preview surfaces. */
|
|
21
|
+
export const PREVIEW_MESSAGES = {
|
|
22
|
+
/** surface → page: apply this draft theme */
|
|
23
|
+
theme: 'ferst:preview:theme',
|
|
24
|
+
/** surface → page: switch light/dark */
|
|
25
|
+
mode: 'ferst:preview:mode',
|
|
26
|
+
/** page → surface: listener is live, send the current draft */
|
|
27
|
+
ready: 'ferst:preview:ready',
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
const STYLE_ID = 'ferst-preview';
|
|
31
|
+
|
|
32
|
+
/** Origins allowed to drive the preview (besides same-origin, i.e. the CMS pane). */
|
|
33
|
+
const PORTAL_ORIGINS = [
|
|
34
|
+
'https://portal.ferst.co.uk',
|
|
35
|
+
'https://portal-dev.ferst.co.uk',
|
|
36
|
+
'https://signin.ferst.co.uk',
|
|
37
|
+
'https://signin-dev.ferst.co.uk',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** True for same-origin (CMS design-tokens pane) or a known Ferst portal origin. */
|
|
41
|
+
export function isAllowedPreviewOrigin(origin: string): boolean {
|
|
42
|
+
try {
|
|
43
|
+
if (origin === window.location.origin) return true;
|
|
44
|
+
} catch {
|
|
45
|
+
/* no window (SSR/tests) — fall through to the allowlist */
|
|
46
|
+
}
|
|
47
|
+
return PORTAL_ORIGINS.includes(origin);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Re-theme the current document from a draft, via the core's own derivation. */
|
|
51
|
+
export function applyPreviewTheme(theme: ThemeSettings): void {
|
|
52
|
+
const { css, field } = buildThemeStyle(theme);
|
|
53
|
+
const head = document.head;
|
|
54
|
+
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
|
55
|
+
if (!style) {
|
|
56
|
+
style = document.createElement('style');
|
|
57
|
+
style.id = STYLE_ID;
|
|
58
|
+
head.appendChild(style);
|
|
59
|
+
} else if (style !== head.lastElementChild) {
|
|
60
|
+
head.appendChild(style); // keep it last so it always wins over the server block
|
|
61
|
+
}
|
|
62
|
+
style.textContent = css;
|
|
63
|
+
if (field) document.documentElement.dataset.field = field;
|
|
64
|
+
else delete document.documentElement.dataset.field;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Wire the page to receive drafts from an allowed surface, and announce readiness. */
|
|
68
|
+
export function initThemePreviewListener(): void {
|
|
69
|
+
window.addEventListener('message', (event: MessageEvent) => {
|
|
70
|
+
if (!isAllowedPreviewOrigin(event.origin)) return;
|
|
71
|
+
const data = event.data as { type?: string; theme?: ThemeSettings; mode?: string } | null;
|
|
72
|
+
if (!data || typeof data !== 'object') return;
|
|
73
|
+
if (data.type === PREVIEW_MESSAGES.theme && data.theme) {
|
|
74
|
+
try {
|
|
75
|
+
applyPreviewTheme(data.theme);
|
|
76
|
+
} catch {
|
|
77
|
+
/* a malformed draft must never break the preview page */
|
|
78
|
+
}
|
|
79
|
+
} else if (data.type === PREVIEW_MESSAGES.mode) {
|
|
80
|
+
document.documentElement.dataset.theme = data.mode === 'dark' ? 'dark' : 'light';
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
// Tell whoever framed us that we're listening, so it can push the current draft.
|
|
84
|
+
try {
|
|
85
|
+
window.parent?.postMessage({ type: PREVIEW_MESSAGES.ready }, '*');
|
|
86
|
+
} catch {
|
|
87
|
+
/* no parent / blocked — the surface can still push on its own timer */
|
|
88
|
+
}
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
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",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
],
|
|
33
33
|
"files": [
|
|
34
34
|
"bin",
|
|
35
|
+
"cms",
|
|
35
36
|
"components",
|
|
36
37
|
"layouts",
|
|
37
38
|
"content",
|
package/styles/theme.css
CHANGED
|
@@ -130,3 +130,29 @@
|
|
|
130
130
|
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.45);
|
|
131
131
|
--accent-2: color-mix(in oklch, var(--gold) 82%, #fff);
|
|
132
132
|
}
|
|
133
|
+
|
|
134
|
+
/* ── Global utilities ────────────────────────────────────────────────────────
|
|
135
|
+
Unscoped helpers usable from any component — including from inside an Astro
|
|
136
|
+
scoped style, where a parent can't reach a child element's root. */
|
|
137
|
+
|
|
138
|
+
/* Overlay convention: an element hidden via the `hidden` attribute must never
|
|
139
|
+
linger in the layout intercepting pointer events. An author `display` rule
|
|
140
|
+
(e.g. `display:flex` on a fixed banner) otherwise beats the UA `[hidden]`
|
|
141
|
+
rule, leaving an invisible click-blocker — the cookie-banner / map-tile
|
|
142
|
+
footgun we hit twice. This is the site-wide safety net; components need no
|
|
143
|
+
per-element guard of their own. */
|
|
144
|
+
[hidden] { display: none !important; }
|
|
145
|
+
|
|
146
|
+
/* Card/tile surface — the single home for the "tile" look, driven by the global
|
|
147
|
+
surface knobs (--radius / --card-border / --shadow-*) so every tile restyles
|
|
148
|
+
at once, in both themes. Add `.ferst-tile` to a recipe's card element instead
|
|
149
|
+
of re-inlining these four properties; `.ferst-tile--sm` swaps to the lighter
|
|
150
|
+
shadow for denser grids. Recipes with a genuinely different surface (image /
|
|
151
|
+
gradient tiles, tinted panels) keep their own styling. */
|
|
152
|
+
.ferst-tile {
|
|
153
|
+
background: var(--bg-surface);
|
|
154
|
+
border: var(--card-border);
|
|
155
|
+
border-radius: var(--radius);
|
|
156
|
+
box-shadow: var(--shadow-md);
|
|
157
|
+
}
|
|
158
|
+
.ferst-tile--sm { box-shadow: var(--shadow-sm); }
|