ferst-core 0.9.7 → 0.9.9

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.
@@ -5,6 +5,7 @@
5
5
  * link. Composes the Card, Icon, Heading and Button primitives.
6
6
  */
7
7
  import Heading from '../blocks/Heading.astro';
8
+ import { gridColumns } from '../../lib/gridColumns';
8
9
  import Card from '../Card.astro';
9
10
  import Icon from '../Icon.astro';
10
11
  import Button from '../Button.astro';
@@ -24,7 +25,7 @@ const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | '
24
25
  const headAlign = headingAlign ?? blockAlign ?? 'center';
25
26
  // Never reserve more columns than there are cards (a lone card in a 3-col grid
26
27
  // would strand two empty cells); `data-single` centres a solitary card.
27
- const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3 | 4;
28
+ const { tablet, desktop: cols } = gridColumns(cards.length, columns); // never a short last row
28
29
  ---
29
30
 
30
31
  <section class="b-feature-cards">
@@ -34,7 +35,7 @@ const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3 | 4;
34
35
  {intro && <p class="b-feature-cards__intro" set:html={inlineMarkdown(intro)} />}
35
36
  </div>
36
37
  )}
37
- <div class="b-feature-cards__grid" data-cols={cols} data-single={cards.length === 1 ? 'true' : undefined}>
38
+ <div class="b-feature-cards__grid" data-cols={cols} data-tablet={tablet} data-single={cards.length === 1 ? 'true' : undefined}>
38
39
  {cards.map((card) => (
39
40
  <Card>
40
41
  {card.icon && isIconKey(card.icon) && (
@@ -79,17 +80,19 @@ const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3 | 4;
79
80
  grid-template-columns: 1fr; /* mobile-first */
80
81
  gap: 1.5rem;
81
82
  }
82
- @media (min-width: 48em) {
83
- .b-feature-cards__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
84
- .b-feature-cards__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
85
- .b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(2, 1fr); }
83
+ /* Never a short last row (lib/gridColumns): twos on tablet for an even count; on desktop
84
+ everything in one row, or even rows. Phones stay one column. */
85
+ @media (min-width: 40em) {
86
+ .b-feature-cards__grid[data-tablet='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
87
+ }
88
+ @media (min-width: 60em) {
89
+ .b-feature-cards__grid[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
90
+ .b-feature-cards__grid[data-cols='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
91
+ .b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
86
92
  }
87
93
  /* A solitary card is a centred feature, not a full-width slab. */
88
94
  .b-feature-cards__grid[data-single] { max-width: 26rem; margin-inline: auto;
89
95
  }
90
- @media (min-width: 64em) {
91
- .b-feature-cards__grid[data-cols='4'] { grid-template-columns: repeat(4, 1fr); }
92
- }
93
96
  .b-feature-cards__icon {
94
97
  color: var(--gold);
95
98
  margin-bottom: 0.5rem;
@@ -9,6 +9,7 @@
9
9
  * when `href` is set. Token-driven, equal-height.
10
10
  */
11
11
  import Icon from '../Icon.astro';
12
+ import { gridColumns } from '../../lib/gridColumns';
12
13
  import { isIconKey } from '../../lib/icons';
13
14
  import Heading from '../blocks/Heading.astro';
14
15
 
@@ -31,7 +32,7 @@ interface Props {
31
32
  }
32
33
  const { title, intro, columns = 3, ratio = '16/9', cards = [] } = Astro.props;
33
34
  // Never reserve more columns than there are cards; `data-single` centres a lone one.
34
- const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3;
35
+ const { tablet, desktop: cols } = gridColumns(cards.length, columns); // never a short last row
35
36
  ---
36
37
 
37
38
  <section class="b-media-cards">
@@ -41,7 +42,7 @@ const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3;
41
42
  {intro && <p class="b-media-cards__intro" set:html={inlineMarkdown(intro)} />}
42
43
  </div>
43
44
  )}
44
- <div class="b-media-cards__grid" data-cols={cols} data-single={cards.length === 1 ? 'true' : undefined}>
45
+ <div class="b-media-cards__grid" data-cols={cols} data-tablet={tablet} data-single={cards.length === 1 ? 'true' : undefined}>
45
46
  {cards.map((card) => {
46
47
  // Surface a mailto:/tel: as visible, copyable text — a plain mailto does
47
48
  // nothing on a device with no mail app, so the address must be readable too.
@@ -104,12 +105,15 @@ const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3;
104
105
  }
105
106
  /* A solitary card is a centred feature, not a full-width slab. */
106
107
  .b-media-cards__grid[data-single] { max-width: 28rem; margin-inline: auto; }
108
+ /* Never a short last row (lib/gridColumns): twos on tablet for an even count; on desktop
109
+ everything in one row, or even rows. Phones stay one column. */
107
110
  @media (min-width: 40em) {
108
- .b-media-cards__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
109
- .b-media-cards__grid[data-cols='3'] { grid-template-columns: repeat(2, 1fr); }
111
+ .b-media-cards__grid[data-tablet='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
110
112
  }
111
113
  @media (min-width: 60em) {
112
- .b-media-cards__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
114
+ .b-media-cards__grid[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
115
+ .b-media-cards__grid[data-cols='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
116
+ .b-media-cards__grid[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
113
117
  }
114
118
  .b-media-cards__card {
115
119
  position: relative;
@@ -9,6 +9,7 @@
9
9
  * at any count and in either theme. Labels are width-capped so a long caption
10
10
  * wraps to a tidy block rather than a full-width line. */
11
11
  import Heading from '../blocks/Heading.astro';
12
+ import { gridColumns } from '../../lib/gridColumns';
12
13
 
13
14
  interface Item { value: string; label: string; description?: string }
14
15
  interface Props {
@@ -25,7 +26,7 @@ const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | '
25
26
  const headAlign = headingAlign ?? blockAlign ?? 'center';
26
27
  // Cap the column count at the item count — one stat should never sit in a
27
28
  // two-column grid (half empty). Falls back to 1 for an empty list.
28
- const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
29
+ const { tablet, desktop: cols } = gridColumns(items.length, columns); // never a short last row
29
30
  ---
30
31
 
31
32
  <section class="b-stats" data-bg={background}>
@@ -36,7 +37,7 @@ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
36
37
  {intro && <p class="b-stats__intro">{intro}</p>}
37
38
  </div>
38
39
  )}
39
- <div class="b-stats__grid" data-cols={cols}>
40
+ <div class="b-stats__grid" data-cols={cols} data-tablet={tablet}>
40
41
  {items.map((item) => (
41
42
  <div class="b-stats__item ferst-tile">
42
43
  <span class="b-stats__value">{item.value}</span>
@@ -75,12 +76,13 @@ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2 | 3 | 4;
75
76
  width: 100%;
76
77
  margin-inline: auto;
77
78
  }
79
+ /* Never a short last row (lib/gridColumns): twos on tablet for an even count; on desktop
80
+ everything in one row, or even rows. Phones stay one column. */
78
81
  @media (min-width: 40em) {
79
- .b-stats__grid[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
80
- .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
81
- .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
82
+ .b-stats__grid[data-tablet='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
82
83
  }
83
84
  @media (min-width: 60em) {
85
+ .b-stats__grid[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
84
86
  .b-stats__grid[data-cols='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
85
87
  .b-stats__grid[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
86
88
  }
@@ -14,6 +14,7 @@
14
14
  * Composes the Icon primitive.
15
15
  */
16
16
  import Heading from '../blocks/Heading.astro';
17
+ import { gridColumns } from '../../lib/gridColumns';
17
18
  import Icon from '../Icon.astro';
18
19
  import { isIconKey } from '../../lib/icons';
19
20
 
@@ -43,7 +44,7 @@ const ItemHeading = title ? 'h3' : 'h2';
43
44
  const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | 'center' | 'end'; headingAlign?: 'start' | 'center' | 'end' };
44
45
  const headAlign = headingAlign ?? blockAlign ?? 'center';
45
46
  // Never reserve more columns than there are tiles; `data-single` centres a lone one.
46
- const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
47
+ const { tablet, desktop: cols } = gridColumns(tiles.length, columns); // never a short last row
47
48
  ---
48
49
 
49
50
  <section class="b-tiles">
@@ -53,7 +54,7 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
53
54
  {intro && <p class="b-tiles__intro" set:html={inlineMarkdown(intro)} />}
54
55
  </div>
55
56
  )}
56
- <div class="b-tiles__grid" data-cols={cols} data-single={tiles.length === 1 ? 'true' : undefined} data-variant={variant} data-spotlight={spotlight ? 'true' : 'false'}>
57
+ <div class="b-tiles__grid" data-cols={cols} data-tablet={tablet} data-single={tiles.length === 1 ? 'true' : undefined} data-variant={variant} data-spotlight={spotlight ? 'true' : 'false'}>
57
58
  {tiles.map((tile) => {
58
59
  const Tag = tile.href ? 'a' : 'div';
59
60
  // A picture tile: an image with no words, shown WHOLE as a real <img> (posters,
@@ -114,13 +115,15 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
114
115
  }
115
116
  /* A solitary tile is a centred feature, not a full-width slab. */
116
117
  .b-tiles__grid[data-single] { max-width: 26rem; margin-inline: auto; }
117
- @media (min-width: 48em) {
118
- .b-tiles__grid[data-cols='2'] { grid-template-columns: repeat(2, 1fr); }
119
- .b-tiles__grid[data-cols='3'] { grid-template-columns: repeat(3, 1fr); }
120
- .b-tiles__grid[data-cols='4'] { grid-template-columns: repeat(2, 1fr); }
121
- }
122
- @media (min-width: 64em) {
123
- .b-tiles__grid[data-cols='4'] { grid-template-columns: repeat(4, 1fr); }
118
+ /* Never a short last row (lib/gridColumns): twos on tablet for an even count; on desktop
119
+ everything in one row, or even rows. Phones stay one column. */
120
+ @media (min-width: 40em) {
121
+ .b-tiles__grid[data-tablet='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
122
+ }
123
+ @media (min-width: 60em) {
124
+ .b-tiles__grid[data-cols='2'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
125
+ .b-tiles__grid[data-cols='3'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
126
+ .b-tiles__grid[data-cols='4'] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
124
127
  }
125
128
 
126
129
  /* Base tile — radius/stroke/shadow all from the global surface tokens. */
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Setup questions, kept with the recipes. When a client sets up a page from a design, each
3
+ * section of the design asks one free-flowing question; the answer is a paragraph in the client's
4
+ * own words, from which the section is filled. Deliberately not a field-by-field form: the
5
+ * recipe decides how the words are laid out, so the client only has to say what they mean.
6
+ *
7
+ * - `ask`: the question, a hint, and a plain name for the section.
8
+ * - `note`: the section fills itself (from the platform or the site), so nothing is asked.
9
+ * - `null`: spacing and layout pieces, which have no content of their own (containers are
10
+ * walked into by the caller).
11
+ *
12
+ * Generic wording only. How questions are ordered and combined into a request lives with the
13
+ * platform.
14
+ */
15
+ export type RecipeQuestion =
16
+ | { kind: 'ask'; label: string; question: string; hint: string }
17
+ | { kind: 'note'; label: string; note: string };
18
+
19
+ const ask = (label: string, question: string, hint: string): RecipeQuestion => ({ kind: 'ask', label, question, hint });
20
+ const note = (label: string, text: string): RecipeQuestion => ({ kind: 'note', label, note: text });
21
+
22
+ const QUESTIONS: Record<string, RecipeQuestion | null> = {
23
+ // Recipes
24
+ hero: ask('Opening banner', 'What should the first thing visitors see say?', 'Your main message in your own words, a supporting line, and where the main button should take people.'),
25
+ featureCards: ask('Highlights', 'What are the main things you want to highlight here?', 'Describe each one in a sentence or two, and say where each should lead if people want more.'),
26
+ cta: ask('Call to action', 'What do you most want visitors to do next?', 'For example: book, call, visit, sign up. Say it the way you would to a customer.'),
27
+ faq: ask('Questions and answers', 'What do people often ask you, and how do you answer?', 'Write each question with your usual answer. As many as you like.'),
28
+ gallery: ask('Photo gallery', 'What should these photos show?', 'Describe the pictures you have or want: your work, your place, your people. You can upload them in the content editor.'),
29
+ testimonial: ask('What people say', 'What have customers said about you?', 'Paste a kind word or review, with the person’s name or initials if they’re happy to be named.'),
30
+ contactForm: ask('Contact form', 'What should people be able to ask you through this form?', 'Who answers, what you need to know from them, and how soon they can expect a reply.'),
31
+ form: note('Form', 'This shows one of your organisation’s forms. Choose or create it in the portal under Forms.'),
32
+ accordion: ask('Expandable sections', 'What information should people be able to open up and read here?', 'Give each topic a short heading and the detail that goes under it.'),
33
+ tiles: ask('Quick links', 'Where do visitors most often want to go from here?', 'List the places or tasks, a few words each, for example: opening times, book now, prices.'),
34
+ banner: ask('Banner', 'What message deserves a bold band across the page?', 'A short statement or invitation, and where it should lead if anywhere.'),
35
+ stats: ask('Key numbers', 'Which numbers say something good about you?', 'Years running, people helped, places served: the number and what it means.'),
36
+ steps: ask('How it works', 'What are the steps someone goes through with you?', 'From first contact to finished, in your own words, in order.'),
37
+ pricing: ask('Prices', 'What do you charge, and what does each option include?', 'List each option with its price and what someone gets. Say if prices start “from”.'),
38
+ logos: ask('Logos', 'Whose logos should appear here?', 'Partners, accreditations or clients you are proud to show. You can upload the images in the content editor.'),
39
+ tabs: ask('Tabbed information', 'What information would people like to switch between here?', 'Name each topic and what it should say.'),
40
+ announcement: ask('Announcement', 'Is there a short announcement visitors should see?', 'One or two sentences: news, a change to times, an offer. Leave it blank if not.'),
41
+ bento: ask('Feature panels', 'What would you show off in a set of feature panels?', 'Your strongest points, products or moments, a sentence or two each.'),
42
+ marquee: ask('Scrolling line', 'What short words or phrases should scroll across here?', 'Things you offer, places you serve, or values, a few words each.'),
43
+ latestPosts: note('Latest posts', 'This shows your newest posts automatically. Write posts in the content editor.'),
44
+ mapEmbed: ask('Map', 'Where should the map show?', 'The address or place name, and anything about finding you or parking.'),
45
+ table: ask('Table', 'What information belongs in a table here?', 'Describe the rows and columns, or paste the details as you have them.'),
46
+ notice: ask('Notice', 'What notice should stand out on this page?', 'An important point people must not miss, in a sentence or two.'),
47
+ mediaCards: ask('Picture cards', 'What should each picture card show and say?', 'For each one: what the picture is, a short title and a line of text.'),
48
+ contactCard: ask('Contact details', 'How would you like people to reach you?', 'Phone, email, address and opening hours, as you want them shown.'),
49
+ locations: ask('Locations', 'Which places should be listed?', 'Each place with its address and anything visitors should know.'),
50
+ video: ask('Video', 'Which video should play here, and what is it about?', 'A link to the video (for example YouTube) and a line about it.'),
51
+ documents: ask('Documents', 'Which documents should people be able to open or download?', 'List each with a short description. You can upload the files in the content editor.'),
52
+ linkRow: ask('Links', 'Which links should sit together here?', 'Each link’s words and where it goes.'),
53
+ document: ask('Document', 'Which document should be shown here?', 'What it is and who it is for. You can upload the file in the content editor.'),
54
+ timetable: ask('Timetable', 'What happens when?', 'The days and times, and what is on at each. Say if it changes through the year.'),
55
+ contacts: ask('People to contact', 'Who should people contact, and about what?', 'Each person or role, what they help with, and how to reach them.'),
56
+ mediaText: ask('Picture with text', 'What should this picture and its words tell people?', 'Describe the picture and write the words that go beside it.'),
57
+ // Leaves and primitives
58
+ heading: ask('Heading', 'What should this heading say?', 'A few words that introduce what comes next.'),
59
+ prose: ask('Text', 'What should this text say?', 'Write it as you would explain it to a visitor. The more you give, the better the result.'),
60
+ image: ask('Picture', 'What should this picture show?', 'Describe it, or say which of your photos to use. You can upload it in the content editor.'),
61
+ button: ask('Button', 'What should this button say, and where should it take people?', 'A few words for the button and the page or address it opens.'),
62
+ quote: ask('Quote', 'What quote belongs here, and who said it?', 'The words and the person, if they are happy to be named.'),
63
+ list: ask('List', 'What should this list contain?', 'One item per line, in the order you want them.'),
64
+ stat: ask('Number', 'Which number should stand out here, and what does it mean?', 'The figure and a few words about it.'),
65
+ badge: ask('Label', 'What short label belongs here?', 'One to three words, for example: New, Open now, Since 1998.'),
66
+ divider: null,
67
+ spacer: null,
68
+ section: null,
69
+ grid: null,
70
+ stack: null,
71
+ };
72
+
73
+ const GENERAL = ask('Section', 'What should this section say?', 'Describe what it is for and the words you want in it.');
74
+
75
+ /** The setup question for a block type; `null` for spacing and layout pieces. */
76
+ export function recipeQuestion(type: string): RecipeQuestion | null {
77
+ return type in QUESTIONS ? QUESTIONS[type] : GENERAL;
78
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * How many columns a grid of cards or numbers uses, so the last row is never short.
3
+ * - **Desktop:** everything in one row when it fits within `max`; otherwise the widest column
4
+ * count up to `max` that divides the items evenly; failing that, `max`.
5
+ * - **Tablet:** twos for an even count, one column for an odd one (an odd count sits in one row
6
+ * on desktop and one column below it, never 2 + 1).
7
+ * - **Phones:** always one column (the recipes' base style).
8
+ */
9
+ export interface GridColumns { tablet: 1 | 2; desktop: number }
10
+
11
+ export function gridColumns(count: number, max: number): GridColumns {
12
+ if (count <= 1) return { tablet: 1, desktop: 1 };
13
+ let desktop = max;
14
+ if (count <= max) desktop = count;
15
+ else {
16
+ for (let c = max; c >= 2; c--) {
17
+ if (count % c === 0) { desktop = c; break; }
18
+ }
19
+ }
20
+ const tablet = desktop > 1 && count % 2 === 0 ? 2 : 1;
21
+ return { tablet, desktop };
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
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",