ferst-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +102 -201
  2. package/LICENSE-Apache-2.0 +201 -0
  3. package/NOTICE +24 -5
  4. package/README.md +89 -73
  5. package/bin/check-thin.mjs +74 -0
  6. package/components/BlockRenderer.astro +89 -0
  7. package/components/Button.astro +85 -33
  8. package/components/CalendarEmbed.astro +188 -0
  9. package/components/Card.astro +7 -17
  10. package/components/Icon.astro +35 -0
  11. package/components/PostArticle.astro +133 -0
  12. package/components/PostCard.astro +85 -225
  13. package/components/SiteFooter.astro +2 -2
  14. package/components/SiteHeader.astro +104 -51
  15. package/components/blocks/Badge.astro +29 -0
  16. package/components/blocks/ButtonBlock.astro +13 -0
  17. package/components/blocks/Divider.astro +12 -0
  18. package/components/blocks/Grid.astro +48 -0
  19. package/components/blocks/Heading.astro +42 -0
  20. package/components/blocks/ImageBlock.astro +80 -0
  21. package/components/blocks/List.astro +78 -0
  22. package/components/blocks/Prose.astro +29 -0
  23. package/components/blocks/Quote.astro +32 -0
  24. package/components/blocks/Section.astro +52 -0
  25. package/components/blocks/Spacer.astro +20 -0
  26. package/components/blocks/Stack.astro +30 -0
  27. package/components/blocks/Stat.astro +30 -0
  28. package/components/recipes/Accordion.astro +84 -0
  29. package/components/recipes/Announcement.astro +74 -0
  30. package/components/recipes/Banner.astro +134 -0
  31. package/components/recipes/Bento.astro +138 -0
  32. package/components/recipes/ContactForm.astro +172 -0
  33. package/components/recipes/Cta.astro +83 -0
  34. package/components/recipes/Faq.astro +80 -0
  35. package/components/recipes/FeatureCards.astro +105 -0
  36. package/components/recipes/Gallery.astro +55 -0
  37. package/components/recipes/Hero.astro +191 -0
  38. package/components/recipes/Logos.astro +77 -0
  39. package/components/recipes/Marquee.astro +75 -0
  40. package/components/recipes/Pricing.astro +149 -0
  41. package/components/recipes/Stats.astro +124 -0
  42. package/components/recipes/Steps.astro +120 -0
  43. package/components/recipes/Tabs.astro +133 -0
  44. package/components/recipes/Testimonial.astro +66 -0
  45. package/components/recipes/Tiles.astro +248 -0
  46. package/content/blocks.ts +534 -0
  47. package/content/calendar.ts +40 -0
  48. package/content/collections.ts +25 -14
  49. package/content/posts.ts +89 -0
  50. package/content/schemas.ts +58 -123
  51. package/layouts/Base.astro +30 -56
  52. package/lib/calendar.ts +12 -0
  53. package/lib/icons.ts +64 -0
  54. package/lib/pages.ts +42 -0
  55. package/lib/posts.ts +93 -0
  56. package/lib/siteSettings.ts +18 -36
  57. package/lib/themeTokens.ts +163 -0
  58. package/package.json +61 -48
  59. package/styles/theme.css +42 -0
  60. package/components/DocLayout.astro +0 -292
  61. package/components/GroupCard.astro +0 -164
  62. package/components/LatestPostList.astro +0 -64
  63. package/components/PaginationNav.astro +0 -81
  64. package/components/PostsToolbar.astro +0 -83
  65. package/content/filters.ts +0 -107
  66. package/lib/mailLinks.ts +0 -13
  67. package/lib/mapsLink.ts +0 -7
  68. package/utils/excerpt.ts +0 -56
  69. package/utils/formatDate.ts +0 -22
  70. package/utils/freshness.ts +0 -5
package/lib/posts.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The posts/blog capability's data side — `astro:content` wrappers around the pure
3
+ * selectors in content/posts.ts, plus `getStaticPaths` helpers so a client's post
4
+ * routes stay thin (like lib/pages.ts for pages). A client repo carries the post
5
+ * markdown + tag data + a couple of two-line routes; the logic lives here, once.
6
+ *
7
+ * Route sketch a client (or the showcase) uses:
8
+ * // src/pages/posts/[...slug].astro — an individual post
9
+ * import { getPostRoutes } from 'ferst-core/lib/posts';
10
+ * export const getStaticPaths = getPostRoutes;
11
+ * const { entry } = Astro.props;
12
+ * const { Content } = await entry.render();
13
+ * // <PostArticle ...><Content /></PostArticle>
14
+ */
15
+ import { getCollection, getEntry, type CollectionEntry } from 'astro:content';
16
+ import {
17
+ postsSettingsSchema,
18
+ tagSchema,
19
+ sortByDateDesc,
20
+ dropDrafts,
21
+ excludeTags,
22
+ withTag,
23
+ tagCounts,
24
+ type PostsSettings,
25
+ } from '../content/posts';
26
+
27
+ export type PostEntry = CollectionEntry<'posts'>;
28
+
29
+ /** A tag resolved for display: its slug + label/description + how many posts use it. */
30
+ export interface UsedTag {
31
+ slug: string;
32
+ label: string;
33
+ description: string;
34
+ count: number;
35
+ }
36
+
37
+ /** All non-draft posts, newest first. */
38
+ export async function getPublishedPosts(): Promise<PostEntry[]> {
39
+ return sortByDateDesc(dropDrafts(await getCollection('posts')));
40
+ }
41
+
42
+ /** Blog-wide settings (defaults applied). */
43
+ export async function getPostsSettings(): Promise<PostsSettings> {
44
+ const entry = await getEntry('postsSettings', 'index');
45
+ return postsSettingsSchema.parse(entry?.data ?? {});
46
+ }
47
+
48
+ /** Posts for the MAIN index — published, minus any `excludeFromIndex` tags. */
49
+ export async function getIndexPosts(): Promise<PostEntry[]> {
50
+ const [posts, settings] = await Promise.all([getPublishedPosts(), getPostsSettings()]);
51
+ return excludeTags(posts, settings.excludeFromIndex);
52
+ }
53
+
54
+ /** Published posts carrying a given tag slug, newest first. */
55
+ export async function getPostsByTag(tag: string): Promise<PostEntry[]> {
56
+ return withTag(await getPublishedPosts(), tag);
57
+ }
58
+
59
+ /** Visible tags that actually have published posts, with counts (for tag routes/listings). */
60
+ export async function getUsedTags(): Promise<UsedTag[]> {
61
+ const [posts, tagEntries] = await Promise.all([getPublishedPosts(), getCollection('tags')]);
62
+ const counts = tagCounts(posts);
63
+ return tagEntries
64
+ .map((t) => {
65
+ const data = tagSchema.parse(t.data);
66
+ return { slug: t.id, label: data.label, description: data.description, hidden: data.hidden, count: counts[t.id] ?? 0 };
67
+ })
68
+ .filter((t) => !t.hidden && t.count > 0)
69
+ .map(({ hidden: _hidden, ...t }) => t)
70
+ .sort((a, b) => a.label.localeCompare(b.label));
71
+ }
72
+
73
+ /** Resolve tag slugs on a post to `{ slug, label }` for display (unknown slugs fall back to the slug). */
74
+ export async function resolveTagLabels(slugs: string[]): Promise<Array<{ slug: string; label: string }>> {
75
+ return Promise.all(
76
+ slugs.map(async (slug) => {
77
+ const entry = await getEntry('tags', slug);
78
+ return { slug, label: entry ? tagSchema.parse(entry.data).label : slug };
79
+ }),
80
+ );
81
+ }
82
+
83
+ /** `getStaticPaths` for a post route (`posts/[...slug].astro`). */
84
+ export async function getPostRoutes() {
85
+ const posts = await getPublishedPosts();
86
+ return posts.map((entry) => ({ params: { slug: entry.slug }, props: { entry } }));
87
+ }
88
+
89
+ /** `getStaticPaths` for a tag route (`tags/[...tag].astro`). */
90
+ export async function getTagRoutes() {
91
+ const tags = await getUsedTags();
92
+ return tags.map((tag) => ({ params: { tag: tag.slug }, props: { tag } }));
93
+ }
@@ -1,45 +1,32 @@
1
1
  /**
2
- * Single accessor merging all the site-settings content entries into one
3
- * combined object.
2
+ * Single accessor merging the site-settings content entries into one combined
3
+ * object. Storage is split across small collections (identity+contact, logos,
4
+ * navbar, footer, theme) so each is its own focused Sveltia CMS pane — see
5
+ * `content/schemas.ts`. Consumers read one combined shape though, so this loader
6
+ * re-assembles it (including the `navigation.primary` / `navigation.footer`
7
+ * grouping) at read time — the only place that needs to know storage is split.
4
8
  *
5
- * Storage is split across several small collections (identity+contact,
6
- * logos, posts, calendar, navbar, footer) so each is also its own focused
7
- * Sveltia CMS pane instead of one long mixed-purpose form see
8
- * `src/content/schemas.ts`. Every existing consumer (`Base.astro`,
9
- * `/calendar/`, `/contact/`, `/`) reads a single combined shape though, so
10
- * this loader re-assembles that shape (including the `navigation.primary`
11
- * / `navigation.footer` grouping, even though navbar and footer links now
12
- * live in two separate collections) at read time — the only place that
13
- * needs to know the storage is split.
14
- *
15
- * `getEntry(...)` yields `data` typed per-collection when the entry exists,
16
- * but the content runtime does not always apply nested Zod defaults the same
17
- * way a plain `schema.parse()` does. We always re-parse each entry's data
18
- * through its own schema so nested defaults (`identity`, `contact`,
19
- * `secondaryLogoLink`) are fully populated, including when a file is missing.
9
+ * `getEntry(...)` yields `data` typed per-collection when the entry exists, but
10
+ * the content runtime doesn't always apply nested Zod defaults the way a plain
11
+ * `schema.parse()` does. We re-parse each entry through its own schema so nested
12
+ * defaults are fully populated, including when a file is missing.
20
13
  */
21
14
  import { getEntry } from 'astro:content';
22
15
  import {
23
- calendarSettingsSchema,
24
16
  footerSettingsSchema,
25
17
  logoSettingsSchema,
26
18
  navbarSettingsSchema,
27
- postsSettingsSchema,
28
19
  siteSettingsSchema,
29
20
  themeSettingsSchema,
30
- type CalendarSettings,
31
21
  type LogoSettings,
32
22
  type NavLink,
33
23
  type NavGroup,
34
- type PostsSettings,
35
24
  type SiteSettings,
36
25
  type ThemeSettings,
37
26
  } from '../content/schemas';
38
27
 
39
28
  export type LoadedSiteSettings = SiteSettings &
40
- LogoSettings &
41
- PostsSettings &
42
- CalendarSettings & {
29
+ LogoSettings & {
43
30
  navigation: { primary: NavLink[]; footer: NavLink[]; dropdown?: NavGroup };
44
31
  /** Client-provided design-token overrides (empty objects if none). */
45
32
  theme: ThemeSettings;
@@ -50,22 +37,17 @@ let cached: LoadedSiteSettings | null = null;
50
37
  export async function loadSiteSettings(): Promise<LoadedSiteSettings> {
51
38
  if (cached) return cached;
52
39
 
53
- const [siteSettingsEntry, logoEntry, postsEntry, calendarEntry, navbarEntry, footerEntry, themeEntry] =
54
- await Promise.all([
55
- getEntry('siteSettings', 'index'),
56
- getEntry('logoSettings', 'index'),
57
- getEntry('postsSettings', 'index'),
58
- getEntry('calendarSettings', 'index'),
59
- getEntry('navbarSettings', 'index'),
60
- getEntry('footerSettings', 'index'),
61
- getEntry('themeSettings', 'index'),
62
- ]);
40
+ const [siteSettingsEntry, logoEntry, navbarEntry, footerEntry, themeEntry] = await Promise.all([
41
+ getEntry('siteSettings', 'index'),
42
+ getEntry('logoSettings', 'index'),
43
+ getEntry('navbarSettings', 'index'),
44
+ getEntry('footerSettings', 'index'),
45
+ getEntry('themeSettings', 'index'),
46
+ ]);
63
47
 
64
48
  cached = {
65
49
  ...siteSettingsSchema.parse(siteSettingsEntry?.data ?? {}),
66
50
  ...logoSettingsSchema.parse(logoEntry?.data ?? {}),
67
- ...postsSettingsSchema.parse(postsEntry?.data ?? {}),
68
- ...calendarSettingsSchema.parse(calendarEntry?.data ?? {}),
69
51
  navigation: {
70
52
  primary: navbarSettingsSchema.parse(navbarEntry?.data ?? {}).primary,
71
53
  footer: footerSettingsSchema.parse(footerEntry?.data ?? {}).footer,
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The theming engine: turn the structured `themeSettings` (three brand colours +
3
+ * a font pairing + four surface knobs) into the CSS that drives the whole site.
4
+ *
5
+ * The design premise is that a brand needs only THREE source colours — `--brand`
6
+ * (interactive accent), `--ink` (text) and `--surface` (background). Every other
7
+ * working token (`--muted`, `--border`, `--bg-surface`, `--gold`, …) is DERIVED:
8
+ * • Light — `color-mix()` tints between ink/brand and the surface.
9
+ * • Dark — a scientific OKLCH translation of the SAME three colours: lightness
10
+ * is flipped to dark-surface / light-text targets while HUE is
11
+ * preserved and chroma is DAMPED, because a naïve hex inversion
12
+ * yields harsh neon tones that vibrate and fail contrast on dark
13
+ * grounds. Surfaces are deep, desaturated and faintly brand-tinted;
14
+ * text lands at OKLCH L≈0.74–0.94 for comfortable legibility.
15
+ * (Refs: Material dark theme; OKLCH accessible-palette guidance.)
16
+ *
17
+ * This lives in the CORE (not the showcase) so a real client site, the CMS, and
18
+ * the showcase's live playground all derive a palette the SAME way. `Base.astro`
19
+ * emits the result at doubled `:root:root` specificity so it beats the neutral
20
+ * literal defaults in `styles/theme.css` regardless of stylesheet order; the
21
+ * showcase playground then overrides `--brand/--ink/--surface` inline on <html>
22
+ * for per-visitor re-theming, and because the derivations read `var(--brand)`
23
+ * etc. the whole palette re-resolves live.
24
+ *
25
+ * Pure + framework-free so it is unit-testable without a render.
26
+ */
27
+ import type { ThemeSettings } from '../content/schemas';
28
+
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
+
37
+ /** Corner-knob vocabulary → the `--radius` value every surface reads. */
38
+ export const CORNERS_PX: Record<ThemeSettings['corners'], string> = {
39
+ sharp: '0px',
40
+ rounded: '12px',
41
+ soft: '22px',
42
+ };
43
+
44
+ /**
45
+ * Light-mode derivations — read the three source vars, tint via color-mix.
46
+ * Secondary-text mixes sit at ~72% ink so body copy (which uses `--muted`) keeps
47
+ * a comfortable AA contrast on the surface, rather than the trendy-but-faint
48
+ * light grey that fails it.
49
+ */
50
+ const LIGHT_DERIVATION = [
51
+ '--gold:var(--brand)',
52
+ '--gold-light:color-mix(in srgb, var(--brand) 14%, var(--surface))',
53
+ '--accent-2:oklch(from var(--brand) 0.58 c calc(h + 26))',
54
+ '--fg:var(--ink)',
55
+ '--accent:color-mix(in srgb, var(--ink) 84%, var(--surface))',
56
+ '--muted:color-mix(in srgb, var(--ink) 72%, var(--surface))',
57
+ '--brown:color-mix(in srgb, var(--ink) 72%, var(--surface))',
58
+ '--bg:var(--surface)',
59
+ '--bg-surface:color-mix(in srgb, #fff 62%, var(--surface))',
60
+ '--bg-section:color-mix(in srgb, var(--ink) 7%, var(--surface))',
61
+ '--bg-tag:color-mix(in srgb, var(--ink) 6%, var(--surface))',
62
+ '--border:color-mix(in srgb, var(--ink) 16%, var(--surface))',
63
+ '--text-footer:color-mix(in srgb, var(--ink) 72%, var(--surface))',
64
+ '--text-footer-link:var(--brand)',
65
+ '--news-card-body:var(--ink)',
66
+ '--news-card-meta:color-mix(in srgb, var(--ink) 72%, var(--surface))',
67
+ ].join(';');
68
+
69
+ /** Dark-mode derivations — the OKLCH translation of the same three source vars. */
70
+ const DARK_DERIVATION = [
71
+ '--fg:oklch(from var(--ink) 0.94 calc(c * 0.3) h)',
72
+ '--muted:oklch(from var(--ink) 0.74 calc(c * 0.3) h)',
73
+ '--accent:oklch(from var(--ink) 0.86 calc(c * 0.3) h)',
74
+ '--brown:oklch(from var(--ink) 0.74 calc(c * 0.3) h)',
75
+ '--bg:oklch(from var(--brand) 0.16 0.015 h)',
76
+ '--bg-surface:oklch(from var(--brand) 0.21 0.02 h)',
77
+ '--bg-section:oklch(from var(--brand) 0.19 0.018 h)',
78
+ '--bg-tag:oklch(from var(--brand) 0.14 0.015 h)',
79
+ '--dark-bg:oklch(from var(--brand) 0.12 0.015 h)',
80
+ '--border:oklch(from var(--brand) 0.32 0.02 h)',
81
+ '--gold:oklch(from var(--brand) 0.78 calc(c * 0.9) h)',
82
+ '--gold-light:oklch(from var(--brand) 0.30 calc(c * 0.55) h)',
83
+ '--accent-2:oklch(from var(--brand) 0.70 calc(c * 0.9) calc(h + 26))',
84
+ '--text-footer:oklch(from var(--ink) 0.74 calc(c * 0.3) h)',
85
+ '--text-footer-link:oklch(from var(--brand) 0.78 calc(c * 0.9) h)',
86
+ '--text-copyright:oklch(from var(--ink) 0.55 calc(c * 0.3) h)',
87
+ '--text-subtle:oklch(from var(--ink) 0.55 calc(c * 0.3) h)',
88
+ '--divider-dark:oklch(from var(--brand) 0.32 0.02 h)',
89
+ '--news-card-meta:oklch(from var(--ink) 0.74 calc(c * 0.3) h)',
90
+ ].join(';');
91
+
92
+ /** Drop a value that could break out of a CSS declaration. The source is trusted
93
+ * repo JSON, but this keeps a stray value honest (defence in depth). */
94
+ function clean(v: unknown): string {
95
+ return typeof v === 'string' && v.trim() !== '' && !/[<>{};]/.test(v) ? v.trim() : '';
96
+ }
97
+
98
+ /** A raw `{ '--token': 'value' }` record → one CSS rule (the advanced escape hatch). */
99
+ function rawBlock(selector: string, tokens: Record<string, string> | undefined): string {
100
+ const decls = Object.entries(tokens ?? {})
101
+ .filter(([k, v]) => /^--?[a-zA-Z0-9-]+$/.test(k) && clean(v) !== '')
102
+ .map(([k, v]) => `${k.startsWith('--') ? k : `--${k}`}:${clean(v)}`)
103
+ .join(';');
104
+ return decls ? `${selector}{${decls}}` : '';
105
+ }
106
+
107
+ export interface ThemeStyle {
108
+ /** CSS to inline in <head> (may be an empty string when nothing is overridden). */
109
+ css: string;
110
+ /** Value for `<html data-field="…">`, or undefined for the `boxed` default. */
111
+ field: string | undefined;
112
+ }
113
+
114
+ /**
115
+ * Build the inline <style> body + the `data-field` attribute for a theme.
116
+ *
117
+ * When a client sets any of brand/ink/surface the full derivation is emitted
118
+ * (source colours — blanks filled from NEUTRAL_SOURCE — plus the light and dark
119
+ * derivations). When they set none, no colour CSS is emitted at all and the
120
+ * core's neutral literals in `styles/theme.css` stand. Fonts and knobs are
121
+ * emitted independently of colour; `advanced.*` raw tokens are layered last.
122
+ */
123
+ export function buildThemeStyle(theme: ThemeSettings): ThemeStyle {
124
+ const brand = clean(theme.brand);
125
+ const ink = clean(theme.ink);
126
+ const surface = clean(theme.surface);
127
+ const themed = !!(brand || ink || surface);
128
+
129
+ // Block A — source colours + fonts + knobs (both modes). `:root:root` beats the
130
+ // theme.css `:root` / `[data-theme=dark]` literals; inline <html> beats this.
131
+ const rootDecls: string[] = [];
132
+ if (themed) {
133
+ rootDecls.push(`--brand:${brand || NEUTRAL_SOURCE.brand}`);
134
+ rootDecls.push(`--ink:${ink || NEUTRAL_SOURCE.ink}`);
135
+ rootDecls.push(`--surface:${surface || NEUTRAL_SOURCE.surface}`);
136
+ }
137
+ const fh = clean(theme.fonts?.heading);
138
+ const fb = clean(theme.fonts?.body);
139
+ const fd = clean(theme.fonts?.display);
140
+ if (fh) rootDecls.push(`--font-heading:${fh}`);
141
+ if (fb) rootDecls.push(`--font-body:${fb}`);
142
+ if (fd) rootDecls.push(`--font-display:${fd}`);
143
+ // Corners always resolves to a radius; only emit when it differs from the
144
+ // core default (`rounded` = 12px) to keep the payload minimal.
145
+ if (theme.corners && theme.corners !== 'rounded') {
146
+ rootDecls.push(`--radius:${CORNERS_PX[theme.corners]}`);
147
+ }
148
+ if (theme.elevation === 'flat') rootDecls.push('--shadow-md:none');
149
+ if (theme.stroke === 'off') rootDecls.push('--card-border:none');
150
+
151
+ const css = [
152
+ rootDecls.length ? `:root:root{${rootDecls.join(';')}}` : '',
153
+ themed ? `:root:root:not([data-theme="dark"]){${LIGHT_DERIVATION}}` : '',
154
+ themed ? `:root:root[data-theme="dark"]{${DARK_DERIVATION}}` : '',
155
+ rawBlock(':root:root', theme.advanced?.light),
156
+ rawBlock(':root:root[data-theme="dark"]', theme.advanced?.dark),
157
+ ]
158
+ .filter(Boolean)
159
+ .join('');
160
+
161
+ const field = theme.fields && theme.fields !== 'boxed' ? theme.fields : undefined;
162
+ return { css, field };
163
+ }
package/package.json CHANGED
@@ -1,48 +1,61 @@
1
- {
2
- "name": "ferst-core",
3
- "version": "0.1.0",
4
- "type": "module",
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
- "license": "Apache-2.0",
7
- "author": "MyAI4 Ltd (https://ferst.co.uk)",
8
- "homepage": "https://ferst.co.uk",
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/MyAI4WebDev/myai4-ferst-core.git",
12
- "directory": "packages/core"
13
- },
14
- "bugs": {
15
- "url": "https://github.com/MyAI4WebDev/myai4-ferst-core/issues"
16
- },
17
- "keywords": [
18
- "astro",
19
- "astro-component",
20
- "cms",
21
- "sveltia",
22
- "website",
23
- "design-tokens",
24
- "ferst"
25
- ],
26
- "sideEffects": [
27
- "**/*.css"
28
- ],
29
- "files": [
30
- "components",
31
- "layouts",
32
- "content",
33
- "lib",
34
- "utils",
35
- "styles",
36
- "README.md",
37
- "NOTICE"
38
- ],
39
- "dependencies": {
40
- "zod": "^3.25.0"
41
- },
42
- "peerDependencies": {
43
- "astro": "^5.0.0"
44
- },
45
- "publishConfig": {
46
- "access": "public"
47
- }
48
- }
1
+ {
2
+ "name": "ferst-core",
3
+ "version": "0.2.0",
4
+ "type": "module",
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
+ "license": "BUSL-1.1",
7
+ "author": "MyAI4 Ltd (https://ferst.co.uk)",
8
+ "homepage": "https://ferst.co.uk",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MyAI4WebDev/myai4-ferst-core.git",
12
+ "directory": "packages/core"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/MyAI4WebDev/myai4-ferst-core/issues"
16
+ },
17
+ "keywords": [
18
+ "astro",
19
+ "astro-component",
20
+ "cms",
21
+ "sveltia",
22
+ "website",
23
+ "design-tokens",
24
+ "ferst"
25
+ ],
26
+ "scripts": {
27
+ "test": "vitest run",
28
+ "test:watch": "vitest"
29
+ },
30
+ "sideEffects": [
31
+ "**/*.css"
32
+ ],
33
+ "files": [
34
+ "bin",
35
+ "components",
36
+ "layouts",
37
+ "content",
38
+ "lib",
39
+ "utils",
40
+ "styles",
41
+ "README.md",
42
+ "NOTICE",
43
+ "LICENSE",
44
+ "LICENSE-Apache-2.0"
45
+ ],
46
+ "bin": {
47
+ "ferst-check-thin": "bin/check-thin.mjs"
48
+ },
49
+ "dependencies": {
50
+ "zod": "^3.25.0"
51
+ },
52
+ "devDependencies": {
53
+ "vitest": "^3.0.0"
54
+ },
55
+ "peerDependencies": {
56
+ "astro": "^5.0.0"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ }
61
+ }
package/styles/theme.css CHANGED
@@ -56,6 +56,42 @@
56
56
  --news-card-meta: #4B5563;
57
57
  /* All `h3` / `.t-h3`: ~22px (1.375rem) floor on mobile → max 26px (1.625rem) */
58
58
  --type-h3-size: clamp(1.375rem, 1.2rem + 0.55vw, 1.625rem);
59
+
60
+ /* ── Surface treatment — the design "knobs" every card / tile / input reads,
61
+ so a single change restyles the whole site consistently (a client sets
62
+ these once in themeSettings; the showcase /theme playground sets them live).
63
+ --radius corner radius for cards, tiles, inputs, buttons-as-tiles
64
+ --card-border the stroke around a surface (set to `none` for borderless)
65
+ --shadow-* elevation ramp (sm/md/lg); set --shadow-md:none for flat. */
66
+ --radius: 12px;
67
+ /* Derived from --radius so the one "corners" control also rounds the smaller
68
+ surfaces (fields, accordion rows) proportionally — sharp brand → sharp
69
+ fields, soft brand → soft fields. */
70
+ --radius-sm: calc(var(--radius) * 0.66);
71
+ --radius-pill: 999px;
72
+ --card-border: 1px solid var(--border);
73
+ --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.08);
74
+ --shadow-md: 0 6px 22px rgba(15, 23, 42, 0.06), 0 2px 8px rgba(15, 23, 42, 0.04);
75
+ --shadow-lg: 0 12px 32px rgba(15, 23, 42, 0.12), 0 4px 12px rgba(15, 23, 42, 0.08);
76
+
77
+ /* Derived secondary accent for gradients / two-tone tiles — a tonal shade of
78
+ the single brand accent, so a one-colour brand still yields depth. Clients
79
+ (or the showcase's live playground) can override with a distinct hue. */
80
+ --accent-2: color-mix(in oklch, var(--gold) 82%, #000);
81
+
82
+ /* ── Typography roles — the site-wide font config. Every heading / body /
83
+ accent face reads one of these, so a client sets fonts by name (in
84
+ themeSettings, same as colours) and it propagates everywhere. A recipe that
85
+ needs a special face uses the DISPLAY role by design — we don't allow a
86
+ free-form per-block font (that's the inconsistency the system prevents).
87
+ --font-heading h1/h3, nav, buttons, card titles (the workhorse UI face)
88
+ --font-display h2/h4 and hero display type (the characterful face)
89
+ --font-body paragraphs, lists, form fields, meta
90
+ --font-mono code, block-type labels, tabular data */
91
+ --font-heading: 'Poppins', system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
92
+ --font-display: 'Playfair Display', Georgia, 'Times New Roman', serif;
93
+ --font-body: 'Inter', system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
94
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
59
95
  }
60
96
 
61
97
  /* ── Dark mode ── */
@@ -87,4 +123,10 @@
87
123
  --overlay-white-12: rgba(255,255,255,0.12);
88
124
  --news-card-body: var(--fg);
89
125
  --news-card-meta: rgba(226, 232, 240, 0.78);
126
+ /* On dark, depth comes from the elevated surface + border, not soft shadows —
127
+ keep shadows deep and quiet so they don't glow. */
128
+ --shadow-sm: none;
129
+ --shadow-md: 0 1px 0 rgba(255, 255, 255, 0.03);
130
+ --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.45);
131
+ --accent-2: color-mix(in oklch, var(--gold) 82%, #fff);
90
132
  }