@contentrain/astro-kit 0.42.0 → 0.43.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/catalog.json +43 -0
- package/components/band/Band.astro +8 -3
- package/components/band/fixtures.json +2 -1
- package/components/band/meta.json +17 -0
- package/components/footer/Footer.astro +13 -2
- package/components/footer/fixtures.json +7 -1
- package/components/footer/footer.ts +37 -0
- package/components/footer/meta.json +17 -0
- package/components/hero/fixtures.json +2 -1
- package/components/testimonial/Testimonial.astro +10 -2
- package/components/testimonial/fixtures.json +2 -1
- package/components/testimonial/meta.json +9 -0
- package/package.json +2 -2
package/catalog.json
CHANGED
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
"shared": [
|
|
104
104
|
"Button.astro",
|
|
105
105
|
"KitImage.astro",
|
|
106
|
+
"Lines.astro",
|
|
106
107
|
"Section.astro",
|
|
107
108
|
"instance-id.ts",
|
|
108
109
|
"measured.ts",
|
|
@@ -146,6 +147,22 @@
|
|
|
146
147
|
],
|
|
147
148
|
"default": "default",
|
|
148
149
|
"description": "Section background when nothing is measured."
|
|
150
|
+
},
|
|
151
|
+
"headingLines": {
|
|
152
|
+
"options": [
|
|
153
|
+
"flow",
|
|
154
|
+
"lines"
|
|
155
|
+
],
|
|
156
|
+
"default": "flow",
|
|
157
|
+
"description": "flow: the heading is one run of text. lines: each line the author broke it into (a line break, \\n, in the heading) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one heading, read as one sentence."
|
|
158
|
+
},
|
|
159
|
+
"leadLines": {
|
|
160
|
+
"options": [
|
|
161
|
+
"flow",
|
|
162
|
+
"lines"
|
|
163
|
+
],
|
|
164
|
+
"default": "flow",
|
|
165
|
+
"description": "flow: the lead is one run of text. lines: each line the author broke it into (a line break, \\n, in the lead) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one paragraph."
|
|
149
166
|
}
|
|
150
167
|
},
|
|
151
168
|
"props": {
|
|
@@ -2959,6 +2976,23 @@
|
|
|
2959
2976
|
],
|
|
2960
2977
|
"default": "stack",
|
|
2961
2978
|
"description": "Packed columns only, below the md breakpoint. stack: one column. row: still side by side from the start edge, under the brand."
|
|
2979
|
+
},
|
|
2980
|
+
"wordmark": {
|
|
2981
|
+
"options": [
|
|
2982
|
+
"none",
|
|
2983
|
+
"fit",
|
|
2984
|
+
"crop"
|
|
2985
|
+
],
|
|
2986
|
+
"default": "none",
|
|
2987
|
+
"description": "none: no wordmark. fit: the site name drawn once more as a row exactly as wide as the footer, in the heading font and the footer's ink. crop: the same row cut by the footer's bottom edge, only the letters' top showing. A name in a script letter spacing breaks (Arabic, Hebrew, Indic, Thai, …) gets none. Decoration, hidden from screen readers: the name is already the footer's brand."
|
|
2988
|
+
},
|
|
2989
|
+
"wordmarkOrder": {
|
|
2990
|
+
"options": [
|
|
2991
|
+
"last",
|
|
2992
|
+
"first"
|
|
2993
|
+
],
|
|
2994
|
+
"default": "last",
|
|
2995
|
+
"description": "Where the wordmark row sits: after everything else in the footer (last) or before it (first). Only with a wordmark."
|
|
2962
2996
|
}
|
|
2963
2997
|
},
|
|
2964
2998
|
"props": {
|
|
@@ -7852,6 +7886,7 @@
|
|
|
7852
7886
|
"uses": [],
|
|
7853
7887
|
"shared": [
|
|
7854
7888
|
"KitImage.astro",
|
|
7889
|
+
"Lines.astro",
|
|
7855
7890
|
"Section.astro",
|
|
7856
7891
|
"image.ts",
|
|
7857
7892
|
"instance-id.ts",
|
|
@@ -7924,6 +7959,14 @@
|
|
|
7924
7959
|
],
|
|
7925
7960
|
"default": "marks",
|
|
7926
7961
|
"description": "marks: quotation marks around the text. glyph: one large decorative mark above. none: no marks."
|
|
7962
|
+
},
|
|
7963
|
+
"quoteLines": {
|
|
7964
|
+
"options": [
|
|
7965
|
+
"flow",
|
|
7966
|
+
"lines"
|
|
7967
|
+
],
|
|
7968
|
+
"default": "flow",
|
|
7969
|
+
"description": "flow: the quote is one run of text. lines: each line the author broke it into (a line break, \\n, in the quote) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one quote in one paragraph; its marks go before the first line and after the last."
|
|
7927
7970
|
}
|
|
7928
7971
|
},
|
|
7929
7972
|
"props": {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// one of the site's sections and never as bare text.
|
|
5
5
|
import { tv } from 'tailwind-variants'
|
|
6
6
|
import Button from '../_shared/Button.astro'
|
|
7
|
+
import Lines from '../_shared/Lines.astro'
|
|
7
8
|
import KitImage from '../_shared/KitImage.astro'
|
|
8
9
|
import Section, { type Tone } from '../_shared/Section.astro'
|
|
9
10
|
import { measuredGrid } from '../_shared/measured'
|
|
@@ -83,9 +84,13 @@ interface Props {
|
|
|
83
84
|
tone?: Tone | undefined
|
|
84
85
|
measured?: MeasuredStyle | undefined
|
|
85
86
|
id?: string | undefined
|
|
87
|
+
/** `lines`: each line the author broke the heading into (`\n`) is a block of its own, `data-kit-line="1"`, `"2"`, …, for a theme to set its ink, indent or size; still one heading. `flow` (absent): one run of text, as before. */
|
|
88
|
+
headingLines?: 'flow' | 'lines' | undefined
|
|
89
|
+
/** The same for the lead: `lines` draws each of its lines as a block, still one paragraph. */
|
|
90
|
+
leadLines?: 'flow' | 'lines' | undefined
|
|
86
91
|
}
|
|
87
92
|
|
|
88
|
-
const { heading, eyebrow, lead, media, items = [], actions = [], headingLevel = 2, layout, columns, align, tone, measured, id } = Astro.props
|
|
93
|
+
const { heading, eyebrow, lead, media, items = [], actions = [], headingLevel = 2, layout, columns, align, tone, measured, id, headingLines, leadLines } = Astro.props
|
|
89
94
|
// The measured alignment when none is given (`end` has no band layout: it reads as `start`).
|
|
90
95
|
const aligned = align ?? (measured?.align === 'center' ? 'center' : undefined)
|
|
91
96
|
const s = band({ layout: media ? layout : 'stack', columns, align: aligned, tone })
|
|
@@ -102,8 +107,8 @@ const hasCopy = Boolean(heading || eyebrow || lead || actions.length)
|
|
|
102
107
|
{hasCopy && (
|
|
103
108
|
<div class={s.copy()}>
|
|
104
109
|
{eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
|
|
105
|
-
{heading && <Heading id={headingId} class={s.heading()}>{heading}</Heading>}
|
|
106
|
-
{lead && <p class={s.lead()} data-kit-text>{lead}</p>}
|
|
110
|
+
{heading && <Heading id={headingId} class={s.heading()}>{headingLines === 'lines' ? <Lines text={heading} /> : heading}</Heading>}
|
|
111
|
+
{lead && <p class={s.lead()} data-kit-text>{leadLines === 'lines' ? <Lines text={lead} /> : lead}</p>}
|
|
107
112
|
{actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
|
|
108
113
|
</div>
|
|
109
114
|
)}
|
|
@@ -4,5 +4,6 @@
|
|
|
4
4
|
{"name": "measured-dark-fill", "props": {"heading": "Our numbers", "align": "center", "measured": {"containerPx": 1140, "padY": 96, "padX": 24, "bg": {"color": "#0f172a"}, "columns": {"390": 1, "768": 2, "1280": 4}, "gap": 32, "radius": 8}, "items": [{"title": "12", "text": "years"}, {"title": "340", "text": "sites"}, {"title": "9", "text": "countries"}, {"title": "1", "text": "team"}], "columns": "4"}},
|
|
5
5
|
{"name": "measured-image-overlay", "props": {"heading": "Visit the farm", "lead": "Open every weekend.", "actions": [{"label": "Plan a visit", "href": "/visit/"}], "measured": {"padY": 120, "minHeight": 480, "tone": "dark", "bg": {"image": "/kit-fixtures/photo-2.svg", "overlay": "rgba(0, 0, 0, 0.55)", "size": "cover", "position": "center"}}, "columns": "1"}},
|
|
6
6
|
{"name": "items-links", "props": {"heading": "Services", "columns": "2", "tone": "muted", "items": [{"title": "Audits", "text": "Where a site loses readers.", "href": "/audits/", "linkLabel": "Read more", "media": "/kit-fixtures/photo-3.svg"}, {"title": "Hosting", "text": "Static hosting, managed.", "href": "/hosting/", "icon": "/kit-fixtures/logo.svg"}]}},
|
|
7
|
-
{"name": "values-points", "props": {"heading": "Plans", "columns": "3", "items": [{"value": "$9", "title": "Starter", "meta": "per month", "points": ["One site", "Email support"]}, {"value": "$29", "title": "Team", "meta": "per month", "points": ["Five sites", "Shared components"], "href": "/pricing/team/", "linkLabel": "Choose Team"}, {"value": "250+", "title": "Sites moved", "text": "Since 2019."}]}}
|
|
7
|
+
{"name": "values-points", "props": {"heading": "Plans", "columns": "3", "items": [{"value": "$9", "title": "Starter", "meta": "per month", "points": ["One site", "Email support"]}, {"value": "$29", "title": "Team", "meta": "per month", "points": ["Five sites", "Shared components"], "href": "/pricing/team/", "linkLabel": "Choose Team"}, {"value": "250+", "title": "Sites moved", "text": "Since 2019."}]}},
|
|
8
|
+
{"name": "heading-lines", "props": {"heading": "Fewer tools.\nMore writing.", "headingLines": "lines", "lead": "Your posts, pages and forms in one place.\nNothing to update, nothing to patch.", "leadLines": "lines", "actions": [{"label": "See how", "href": "/how/"}]}}
|
|
8
9
|
]
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"shared": [
|
|
8
8
|
"Button.astro",
|
|
9
9
|
"KitImage.astro",
|
|
10
|
+
"Lines.astro",
|
|
10
11
|
"Section.astro",
|
|
11
12
|
"instance-id.ts",
|
|
12
13
|
"measured.ts",
|
|
@@ -50,6 +51,22 @@
|
|
|
50
51
|
],
|
|
51
52
|
"default": "default",
|
|
52
53
|
"description": "Section background when nothing is measured."
|
|
54
|
+
},
|
|
55
|
+
"headingLines": {
|
|
56
|
+
"options": [
|
|
57
|
+
"flow",
|
|
58
|
+
"lines"
|
|
59
|
+
],
|
|
60
|
+
"default": "flow",
|
|
61
|
+
"description": "flow: the heading is one run of text. lines: each line the author broke it into (a line break, \\n, in the heading) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one heading, read as one sentence."
|
|
62
|
+
},
|
|
63
|
+
"leadLines": {
|
|
64
|
+
"options": [
|
|
65
|
+
"flow",
|
|
66
|
+
"lines"
|
|
67
|
+
],
|
|
68
|
+
"default": "flow",
|
|
69
|
+
"description": "flow: the lead is one run of text. lines: each line the author broke it into (a line break, \\n, in the lead) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one paragraph."
|
|
53
70
|
}
|
|
54
71
|
},
|
|
55
72
|
"props": {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// links and the copyright line.
|
|
4
4
|
import { tv } from 'tailwind-variants'
|
|
5
5
|
import type { LinkInput } from '../_shared/types'
|
|
6
|
-
import { contactKeys, footerLinkSizeClasses, footerVars, type ContactInput } from './footer'
|
|
6
|
+
import { contactKeys, footerLinkSizeClasses, footerVars, wordmarkBox, type ContactInput } from './footer'
|
|
7
7
|
import { safeHref } from '../_shared/safe-url'
|
|
8
8
|
import { muted as quiet, mutedInverse } from '../_shared/muted'
|
|
9
9
|
|
|
@@ -80,9 +80,13 @@ interface Props {
|
|
|
80
80
|
linkSizeNarrow?: string | undefined
|
|
81
81
|
/** A border on the footer's top side: absent, the default tone has one and the others do not. */
|
|
82
82
|
border?: boolean | undefined
|
|
83
|
+
/** The site name drawn once more as a row as wide as the footer: `fit` whole, `crop` cut by the footer's bottom edge (only its top shows). `none` (absent): no row. Decoration, so hidden from screen readers: the name is already the footer's brand. */
|
|
84
|
+
wordmark?: 'none' | 'fit' | 'crop' | undefined
|
|
85
|
+
/** Where the wordmark row sits: after everything else (`last`, absent) or before it (`first`). */
|
|
86
|
+
wordmarkOrder?: 'last' | 'first' | undefined
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
const { siteName, tagline, columns = [], links = [], social = [], contact, copyright, consentLabel, navLabel = 'Footer', layout = 'simple', tone, align, pack, packGap, narrow, packGapNarrow, linkSize, linkSizeNarrow, border } = Astro.props
|
|
89
|
+
const { siteName, tagline, columns = [], links = [], social = [], contact, copyright, consentLabel, navLabel = 'Footer', layout = 'simple', tone, align, pack, packGap, narrow, packGapNarrow, linkSize, linkSizeNarrow, border, wordmark, wordmarkOrder } = Astro.props
|
|
86
90
|
const { root, inner, muted, link } = footer({ layout, tone, align, pack: layout === 'columns' ? pack : undefined, narrow: layout === 'columns' && pack === 'end' ? narrow : undefined, border: (border ?? (tone ?? 'default') === 'default') ? 'on' : 'off' })
|
|
87
91
|
// Custom properties the packed columns and sized links read; none when none is set.
|
|
88
92
|
const vars = footerVars({ layout, columnCount: columns.length, pack, narrow, packGap, packGapNarrow, linkSize, linkSizeNarrow })
|
|
@@ -93,9 +97,15 @@ const contactOrder = contactKeys(contact?.order)
|
|
|
93
97
|
const line = copyright === false ? undefined : copyright?.replaceAll('{year}', year) ?? `© ${year} ${siteName}`
|
|
94
98
|
// The line and the cookie settings control share one paragraph; neither: no paragraph.
|
|
95
99
|
const hasBottom = Boolean(line || consentLabel)
|
|
100
|
+
// The wordmark: the name set in an SVG whose text is stretched (letter spacing only) to the box's width, so it fills the
|
|
101
|
+
// footer's width at every size whatever its letters are. The box is 1000 wide; the font size guesses the name's width on
|
|
102
|
+
// the wide side, letter by letter, so the stretch only adds space. `crop` ends the box halfway up the letters: they run
|
|
103
|
+
// over its bottom edge and are cut there. A name in a script letter spacing breaks (Arabic, Indic, …) gets none.
|
|
104
|
+
const mark = wordmark === 'fit' || wordmark === 'crop' ? wordmarkBox(siteName, wordmark) : undefined
|
|
96
105
|
---
|
|
97
106
|
<!-- The cookie settings control is a button, not a link: the Consent component's listener reopens its bar on any [data-kit-consent-open]. -->
|
|
98
107
|
<footer class={root()}>
|
|
108
|
+
{mark && wordmarkOrder === 'first' && <div class="container-page pt-10" data-kit-wordmark><svg viewBox={`0 0 1000 ${mark.height}`} class="block h-auto w-full fill-current" aria-hidden="true" focusable="false"><text x="0" y={mark.baseline} textLength="1000" lengthAdjust="spacing" font-size={mark.size} class="[font-family:var(--font-heading,inherit)] [font-weight:var(--font-weight-heading,700)]">{siteName}</text></svg></div>}
|
|
99
109
|
<div class:list={[inner(), ...footerLinkSizeClasses({ linkSize, linkSizeNarrow })]} data-kit-footer style={vars || undefined}>
|
|
100
110
|
<div class="flex flex-col gap-3">
|
|
101
111
|
<p class="text-base font-semibold" data-kit-brand>{siteName}</p>
|
|
@@ -144,4 +154,5 @@ const hasBottom = Boolean(line || consentLabel)
|
|
|
144
154
|
</ul>
|
|
145
155
|
)}
|
|
146
156
|
</div>
|
|
157
|
+
{mark && wordmarkOrder !== 'first' && <div class="container-page" data-kit-wordmark><svg viewBox={`0 0 1000 ${mark.height}`} class="block h-auto w-full fill-current" aria-hidden="true" focusable="false"><text x="0" y={mark.baseline} textLength="1000" lengthAdjust="spacing" font-size={mark.size} class="[font-family:var(--font-heading,inherit)] [font-weight:var(--font-weight-heading,700)]">{siteName}</text></svg></div>}
|
|
147
158
|
</footer>
|
|
@@ -8,5 +8,11 @@
|
|
|
8
8
|
{ "name": "columns-end-row", "props": { "siteName": "Golden Works", "layout": "columns", "pack": "end", "narrow": "row", "packGap": "128px", "packGapNarrow": "48px", "linkSize": "17.88px", "copyright": "© 2026 Golden Works", "columns": [{ "label": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Work", "href": "/work/" }] }, { "label": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }] } },
|
|
9
9
|
{ "name": "columns-end-row-narrow-size", "props": { "siteName": "Golden Works", "layout": "columns", "pack": "end", "narrow": "row", "packGap": "128px", "packGapNarrow": "48px", "linkSize": "17.88px", "linkSizeNarrow": "16.4px", "copyright": "© 2026 Golden Works", "columns": [{ "label": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Work", "href": "/work/" }] }, { "label": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }] } },
|
|
10
10
|
{ "name": "columns-end-row-long-brand", "props": { "siteName": "Golden Works Engineering Consultancy", "tagline": "Websites, systems and signalling for the people who keep cities moving every single day", "layout": "columns", "pack": "end", "narrow": "row", "packGap": "128px", "packGapNarrow": "70px", "linkSize": "17.88px", "linkSizeNarrow": "16.14px", "copyright": "© 2026 Golden Works Engineering Consultancy, all rights reserved", "columns": [{ "label": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Work", "href": "/work/" }] }, { "label": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }] } },
|
|
11
|
-
{ "name": "contact-labels", "props": { "siteName": "Private Sigorta", "tagline": "Private Sigorta, yenilikçi yönetim ve ileri teknolojiyi geçmişin tecrübeleriyle birleştiren bir acentedir. Web Tasarım: Farkol Digital", "contact": { "address": "G.O.P. Mah. Barbaros Cad. No: 2 D: 403 Çerkezköy/Tekirdağ", "phone": "+90 (282) 725 1400", "email": "info@privatesigorta.com.tr", "labels": { "email": "Email:", "phone": "Telefon:" }, "order": ["email", "phone", "address"] }, "links": [{ "label": "Hakkımızda", "href": "/hakkimizda/" }, { "label": "İletişim", "href": "/iletisim/" }] } }
|
|
11
|
+
{ "name": "contact-labels", "props": { "siteName": "Private Sigorta", "tagline": "Private Sigorta, yenilikçi yönetim ve ileri teknolojiyi geçmişin tecrübeleriyle birleştiren bir acentedir. Web Tasarım: Farkol Digital", "contact": { "address": "G.O.P. Mah. Barbaros Cad. No: 2 D: 403 Çerkezköy/Tekirdağ", "phone": "+90 (282) 725 1400", "email": "info@privatesigorta.com.tr", "labels": { "email": "Email:", "phone": "Telefon:" }, "order": ["email", "phone", "address"] }, "links": [{ "label": "Hakkımızda", "href": "/hakkimizda/" }, { "label": "İletişim", "href": "/iletisim/" }] } },
|
|
12
|
+
{ "name": "wordmark-fit", "props": { "siteName": "Northwind Studio", "wordmark": "fit", "copyright": "© 2026 Northwind Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Privacy", "href": "/privacy/" }] } },
|
|
13
|
+
{ "name": "wordmark-crop-inverse", "props": { "siteName": "Northwind", "wordmark": "crop", "tone": "inverse", "tagline": "Calm, fast websites for teams who write.", "layout": "columns", "copyright": "© 2026 Northwind Studio", "columns": [{ "title": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Team", "href": "/about/team/" }] }, { "title": "Help", "links": [{ "label": "Contact", "href": "/contact/" }] }] } },
|
|
14
|
+
{ "name": "wordmark-first-muted", "props": { "siteName": "Lumen & Pine", "wordmark": "fit", "wordmarkOrder": "first", "tone": "muted", "copyright": "© 2026 Lumen & Pine", "links": [{ "label": "Journal", "href": "/journal/" }] } },
|
|
15
|
+
{ "name": "wordmark-upper", "props": { "siteName": "NORTHWIND STUDIO", "wordmark": "fit", "copyright": "© 2026 Northwind Studio", "links": [{ "label": "About", "href": "/about/" }] } },
|
|
16
|
+
{ "name": "wordmark-ja", "props": { "siteName": "北風スタジオ", "wordmark": "fit", "tone": "muted", "copyright": "© 2026 北風スタジオ", "navLabel": "フッター", "links": [{ "label": "会社概要", "href": "/about/" }] } },
|
|
17
|
+
{ "name": "wordmark-ar", "props": { "siteName": "استوديو الشمال", "wordmark": "fit", "copyright": "© 2026 استوديو الشمال", "navLabel": "التذييل", "links": [{ "label": "من نحن", "href": "/about/" }] } }
|
|
12
18
|
]
|
|
@@ -51,3 +51,40 @@ export function footerLinkSizeClasses(i: Pick<FooterVarsInput, 'linkSize' | 'lin
|
|
|
51
51
|
i.linkSizeNarrow ? 'max-md:[&_nav_a]:text-[length:var(--footer-link-size-narrow)]' : '',
|
|
52
52
|
].filter(Boolean)
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Scripts that letter spacing breaks: joined (Arabic, Syriac, Thaana, N'Ko, Mongolian), set without it (Hebrew), or
|
|
57
|
+
* built of clusters it splits (Indic and Southeast Asian), the same set the starter's stylesheet takes tracking from.
|
|
58
|
+
* The wordmark is stretched by letter spacing, so a name in one of them gets none.
|
|
59
|
+
*/
|
|
60
|
+
const UNSPACED = /[\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Mongolian}\p{Script=Hebrew}\p{Script=Devanagari}\p{Script=Bengali}\p{Script=Gurmukhi}\p{Script=Gujarati}\p{Script=Oriya}\p{Script=Tamil}\p{Script=Telugu}\p{Script=Kannada}\p{Script=Malayalam}\p{Script=Sinhala}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}\p{Script=Tibetan}]/u
|
|
61
|
+
/** Full-width letters: CJK ideographs, kana, hangul and the full-width forms. */
|
|
62
|
+
const FULL = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\u3000-\u303F\uFF00-\uFFEF]/u
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A letter's width in em, guessed on the wide side for a bold heading face: the name's natural width then stays at or
|
|
66
|
+
* under the box, so the stretch only ever adds letter spacing and never draws letters over each other.
|
|
67
|
+
*/
|
|
68
|
+
function letterWidth(letter: string): number {
|
|
69
|
+
if (FULL.test(letter)) return 1.05
|
|
70
|
+
if (/\s/u.test(letter)) return 0.3
|
|
71
|
+
if (/[MWmw@&%]/u.test(letter)) return 1
|
|
72
|
+
if (/[\p{Lu}\p{N}]/u.test(letter)) return 0.8
|
|
73
|
+
if (/\p{L}/u.test(letter)) return 0.65
|
|
74
|
+
return 0.45
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The wordmark's SVG box (1000 wide) for a site name, or none for a name letter spacing would break. The font size makes
|
|
79
|
+
* the name's guessed width 1000 (its text is then stretched to exactly 1000 by letter spacing); the baseline sits under
|
|
80
|
+
* the tallest letters (lower for full-width ones, which rise higher), and `fit` keeps room for descenders under it while
|
|
81
|
+
* `crop` ends the box halfway up the letters, so the footer cuts them.
|
|
82
|
+
*/
|
|
83
|
+
export function wordmarkBox(name: string, mode: 'fit' | 'crop'): { size: number, baseline: number, height: number } | undefined {
|
|
84
|
+
const letters = [...name.trim()]
|
|
85
|
+
if (letters.length === 0 || UNSPACED.test(name)) return undefined
|
|
86
|
+
const size = Math.floor(1000 / letters.reduce((sum, letter) => sum + letterWidth(letter), 0))
|
|
87
|
+
const rise = letters.some(letter => FULL.test(letter)) ? 0.88 : 0.78
|
|
88
|
+
const baseline = Math.round(size * rise)
|
|
89
|
+
return { size, baseline, height: mode === 'fit' ? Math.round(size * (rise + 0.22)) : Math.round(size * (rise - 0.36)) }
|
|
90
|
+
}
|
|
@@ -50,6 +50,23 @@
|
|
|
50
50
|
],
|
|
51
51
|
"default": "stack",
|
|
52
52
|
"description": "Packed columns only, below the md breakpoint. stack: one column. row: still side by side from the start edge, under the brand."
|
|
53
|
+
},
|
|
54
|
+
"wordmark": {
|
|
55
|
+
"options": [
|
|
56
|
+
"none",
|
|
57
|
+
"fit",
|
|
58
|
+
"crop"
|
|
59
|
+
],
|
|
60
|
+
"default": "none",
|
|
61
|
+
"description": "none: no wordmark. fit: the site name drawn once more as a row exactly as wide as the footer, in the heading font and the footer's ink. crop: the same row cut by the footer's bottom edge, only the letters' top showing. A name in a script letter spacing breaks (Arabic, Hebrew, Indic, Thai, …) gets none. Decoration, hidden from screen readers: the name is already the footer's brand."
|
|
62
|
+
},
|
|
63
|
+
"wordmarkOrder": {
|
|
64
|
+
"options": [
|
|
65
|
+
"last",
|
|
66
|
+
"first"
|
|
67
|
+
],
|
|
68
|
+
"default": "last",
|
|
69
|
+
"description": "Where the wordmark row sits: after everything else in the footer (last) or before it (first). Only with a wordmark."
|
|
53
70
|
}
|
|
54
71
|
},
|
|
55
72
|
"props": {
|
|
@@ -247,5 +247,6 @@
|
|
|
247
247
|
{"name": "centered-media-first-edge", "props": {"layout": "centered", "heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "edge", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
|
|
248
248
|
{"name": "split-overlap", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
|
|
249
249
|
{"name": "centered-media-first-overlap", "props": {"layout": "centered", "heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
|
|
250
|
-
{"name": "split-media-first-overlap", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}}
|
|
250
|
+
{"name": "split-media-first-overlap", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
|
|
251
|
+
{"name": "heading-hooks", "theme": {"--font-heading": "var(--font-serif)", "--tracking-heading": "0.04em"}, "props": {"layout": "centered", "eyebrow": "A design direction", "heading": "Headings in a face of their own", "lead": "The heading face and track come from the theme; the lead keeps the body face."}}
|
|
251
252
|
]
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// slider's job; this one shows them all.
|
|
5
5
|
import { tv } from 'tailwind-variants'
|
|
6
6
|
import KitImage from '../_shared/KitImage.astro'
|
|
7
|
+
import Lines from '../_shared/Lines.astro'
|
|
7
8
|
import Section from '../_shared/Section.astro'
|
|
8
9
|
import { instanceId } from '../_shared/instance-id'
|
|
9
10
|
import { imageOf, type ImageSource } from '../_shared/image'
|
|
@@ -89,9 +90,11 @@ interface Props {
|
|
|
89
90
|
measured?: MeasuredStyle | undefined
|
|
90
91
|
tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
|
|
91
92
|
id?: string | undefined
|
|
93
|
+
/** `lines`: each line the author broke a quote into (`\n`) is a block of its own, `data-kit-line="1"`, `"2"`, …, for a theme to set its ink, indent or size; still one quote in one paragraph, its marks before the first line and after the last. `flow` (absent): as before. */
|
|
94
|
+
quoteLines?: 'flow' | 'lines' | undefined
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
const { items, heading, layout, quoteSize, frame, avatarSize, avatarPlace, quoteMark = 'marks', ratingLabel = '{rating} out of 5 stars', label = 'Testimonials', tone, measured, id: givenId } = Astro.props
|
|
97
|
+
const { items, heading, layout, quoteSize, frame, avatarSize, avatarPlace, quoteMark = 'marks', ratingLabel = '{rating} out of 5 stars', label = 'Testimonials', tone, measured, id: givenId, quoteLines } = Astro.props
|
|
95
98
|
// Every section has an id, so a theme or a design layer can address this one: the page's, or `testimonial`, `testimonial-2`, … in render order.
|
|
96
99
|
const id = instanceId(Astro.locals, givenId, 'testimonial')
|
|
97
100
|
const s = testimonial({ layout, quoteSize, frame, avatarSize, avatarPlace, quoteMark, tone })
|
|
@@ -99,6 +102,8 @@ const headingId = heading ? `${id}-title` : undefined
|
|
|
99
102
|
// Two quotes share the row: a third column would stand empty beside them and narrow both (IP-4 elementor launch G9).
|
|
100
103
|
const measuredList = (layout ?? 'grid') === 'grid' ? measuredGrid(measured) : undefined
|
|
101
104
|
const list = s.list({ class: measuredList ?? (items.length === 2 && (layout ?? 'grid') === 'grid' ? 'lg:grid-cols-2' : undefined) })
|
|
105
|
+
// A quote drawn as lines: asked for, and broken into more than one (one line prints as it is).
|
|
106
|
+
const lined = (text: string) => quoteLines === 'lines' && text.split('\n').filter(line => line.trim()).length > 1
|
|
102
107
|
// The quotation marks are drawn, not written: the quote's text is the source's words, read once. A quote the source
|
|
103
108
|
// already wrote inside marks keeps its own.
|
|
104
109
|
const quoted = (text: string) => /^\s*["'“‘„«‹「『]/.test(text)
|
|
@@ -127,7 +132,10 @@ const scrolls = layout === 'row'
|
|
|
127
132
|
</span>
|
|
128
133
|
)}
|
|
129
134
|
{quoteMark === 'glyph' && <span class:list={[s.glyph(), "before:content-['“']"]} aria-hidden="true"></span>}
|
|
130
|
-
<blockquote class={s.quote()}
|
|
135
|
+
<blockquote class={s.quote()}>{lined(item.quote)
|
|
136
|
+
// Lines are blocks: the marks go on the first and the last, or the opening one would stand on a line of its own.
|
|
137
|
+
? <p class:list={[quoteMark === 'marks' && !quoted(item.quote) && "[&>[data-kit-line]:first-of-type]:before:content-[open-quote] [&>[data-kit-line]:last-of-type]:after:content-[close-quote]"]}><Lines text={item.quote} /></p>
|
|
138
|
+
: <p class:list={[quoteMark === 'marks' && !quoted(item.quote) && "before:content-[open-quote] after:content-[close-quote]"]}>{item.quote}</p>}</blockquote>
|
|
131
139
|
<figcaption class={s.person()}>
|
|
132
140
|
{avatar && avatarPlace !== 'top' && <KitImage image={avatar} sizes="64px" class={s.avatar()} />}
|
|
133
141
|
<span class="flex flex-col text-start">
|
|
@@ -119,5 +119,6 @@
|
|
|
119
119
|
"tone": "light"
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
-
}
|
|
122
|
+
},
|
|
123
|
+
{ "name": "single-quote-lines", "props": { "layout": "single", "quoteLines": "lines", "items": [{ "quote": "We moved on a Friday.\nOn Monday nobody noticed —\nexcept that the site was fast.", "name": "Ada Lindqvist", "role": "Editor, Northwind Journal" }] } }
|
|
123
124
|
]
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"uses": [],
|
|
7
7
|
"shared": [
|
|
8
8
|
"KitImage.astro",
|
|
9
|
+
"Lines.astro",
|
|
9
10
|
"Section.astro",
|
|
10
11
|
"image.ts",
|
|
11
12
|
"instance-id.ts",
|
|
@@ -78,6 +79,14 @@
|
|
|
78
79
|
],
|
|
79
80
|
"default": "marks",
|
|
80
81
|
"description": "marks: quotation marks around the text. glyph: one large decorative mark above. none: no marks."
|
|
82
|
+
},
|
|
83
|
+
"quoteLines": {
|
|
84
|
+
"options": [
|
|
85
|
+
"flow",
|
|
86
|
+
"lines"
|
|
87
|
+
],
|
|
88
|
+
"default": "flow",
|
|
89
|
+
"description": "flow: the quote is one run of text. lines: each line the author broke it into (a line break, \\n, in the quote) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one quote in one paragraph; its marks go before the first line and after the last."
|
|
81
90
|
}
|
|
82
91
|
},
|
|
83
92
|
"props": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contentrain/astro-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Copy-in Astro components for Contentrain sites (shadcn model): typed props, tailwind-variants, zero JavaScript by default — with a machine-readable catalog",
|
|
6
6
|
"type": "module",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"mapping"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@contentrain/types": "1.
|
|
45
|
+
"@contentrain/types": "1.65.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^22.10.0",
|