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.
- package/LICENSE +102 -201
- package/LICENSE-Apache-2.0 +201 -0
- package/NOTICE +24 -5
- package/README.md +89 -73
- package/bin/check-thin.mjs +74 -0
- package/components/BlockRenderer.astro +89 -0
- package/components/Button.astro +85 -33
- package/components/CalendarEmbed.astro +188 -0
- package/components/Card.astro +7 -17
- package/components/Icon.astro +35 -0
- package/components/PostArticle.astro +133 -0
- package/components/PostCard.astro +85 -225
- package/components/SiteFooter.astro +2 -2
- package/components/SiteHeader.astro +104 -51
- package/components/blocks/Badge.astro +29 -0
- package/components/blocks/ButtonBlock.astro +13 -0
- package/components/blocks/Divider.astro +12 -0
- package/components/blocks/Grid.astro +48 -0
- package/components/blocks/Heading.astro +42 -0
- package/components/blocks/ImageBlock.astro +80 -0
- package/components/blocks/List.astro +78 -0
- package/components/blocks/Prose.astro +29 -0
- package/components/blocks/Quote.astro +32 -0
- package/components/blocks/Section.astro +52 -0
- package/components/blocks/Spacer.astro +20 -0
- package/components/blocks/Stack.astro +30 -0
- package/components/blocks/Stat.astro +30 -0
- package/components/recipes/Accordion.astro +84 -0
- package/components/recipes/Announcement.astro +74 -0
- package/components/recipes/Banner.astro +134 -0
- package/components/recipes/Bento.astro +138 -0
- package/components/recipes/ContactForm.astro +172 -0
- package/components/recipes/Cta.astro +83 -0
- package/components/recipes/Faq.astro +80 -0
- package/components/recipes/FeatureCards.astro +105 -0
- package/components/recipes/Gallery.astro +55 -0
- package/components/recipes/Hero.astro +191 -0
- package/components/recipes/Logos.astro +77 -0
- package/components/recipes/Marquee.astro +75 -0
- package/components/recipes/Pricing.astro +149 -0
- package/components/recipes/Stats.astro +124 -0
- package/components/recipes/Steps.astro +120 -0
- package/components/recipes/Tabs.astro +133 -0
- package/components/recipes/Testimonial.astro +66 -0
- package/components/recipes/Tiles.astro +248 -0
- package/content/blocks.ts +534 -0
- package/content/calendar.ts +40 -0
- package/content/collections.ts +25 -14
- package/content/posts.ts +89 -0
- package/content/schemas.ts +58 -123
- package/layouts/Base.astro +30 -56
- package/lib/calendar.ts +12 -0
- package/lib/icons.ts +64 -0
- package/lib/pages.ts +42 -0
- package/lib/posts.ts +93 -0
- package/lib/siteSettings.ts +18 -36
- package/lib/themeTokens.ts +163 -0
- package/package.json +61 -48
- package/styles/theme.css +42 -0
- package/components/DocLayout.astro +0 -292
- package/components/GroupCard.astro +0 -164
- package/components/LatestPostList.astro +0 -64
- package/components/PaginationNav.astro +0 -81
- package/components/PostsToolbar.astro +0 -83
- package/content/filters.ts +0 -107
- package/lib/mailLinks.ts +0 -13
- package/lib/mapsLink.ts +0 -7
- package/utils/excerpt.ts +0 -56
- package/utils/formatDate.ts +0 -22
- 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
|
+
}
|
package/lib/siteSettings.ts
CHANGED
|
@@ -1,45 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Single accessor merging
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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": "
|
|
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
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
}
|