@contentrain/astro-kit 0.41.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 +90 -2
- package/components/_shared/Lines.astro +12 -0
- package/components/_shared/bleed.ts +8 -0
- package/components/band/Band.astro +8 -3
- package/components/band/fixtures.json +2 -1
- package/components/band/meta.json +17 -0
- package/components/card-grid/CardGrid.astro +73 -17
- package/components/card-grid/fixtures.json +4 -1
- package/components/card-grid/meta.json +14 -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/Hero.astro +33 -8
- package/components/hero/fixtures.json +8 -1
- package/components/hero/meta.json +27 -0
- package/components/split/Split.astro +7 -6
- package/components/split/fixtures.json +4 -1
- package/components/split/meta.json +6 -2
- 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": {
|
|
@@ -785,6 +802,14 @@
|
|
|
785
802
|
"default": "start",
|
|
786
803
|
"description": "Text alignment inside cards and of the header."
|
|
787
804
|
},
|
|
805
|
+
"layout": {
|
|
806
|
+
"options": [
|
|
807
|
+
"grid",
|
|
808
|
+
"rows"
|
|
809
|
+
],
|
|
810
|
+
"default": "grid",
|
|
811
|
+
"description": "Cards in a grid, or one item per row: title, text and link on one axis, a hairline between rows, no card box (columns and style do not apply)."
|
|
812
|
+
},
|
|
788
813
|
"tone": {
|
|
789
814
|
"options": [
|
|
790
815
|
"default",
|
|
@@ -859,6 +884,12 @@
|
|
|
859
884
|
},
|
|
860
885
|
"description": "The cards. image is an ImageInput ({ src, alt, width?, height? })."
|
|
861
886
|
},
|
|
887
|
+
"index": {
|
|
888
|
+
"type": "boolean",
|
|
889
|
+
"default": false,
|
|
890
|
+
"content": false,
|
|
891
|
+
"description": "With layout rows: a numbered list, each row led by its number (01, 02, …) in the muted role."
|
|
892
|
+
},
|
|
862
893
|
"headingLevel": {
|
|
863
894
|
"type": "integer",
|
|
864
895
|
"min": 2,
|
|
@@ -2945,6 +2976,23 @@
|
|
|
2945
2976
|
],
|
|
2946
2977
|
"default": "stack",
|
|
2947
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."
|
|
2948
2996
|
}
|
|
2949
2997
|
},
|
|
2950
2998
|
"props": {
|
|
@@ -3652,7 +3700,9 @@
|
|
|
3652
3700
|
"shared": [
|
|
3653
3701
|
"Button.astro",
|
|
3654
3702
|
"KitImage.astro",
|
|
3703
|
+
"Lines.astro",
|
|
3655
3704
|
"Section.astro",
|
|
3705
|
+
"bleed.ts",
|
|
3656
3706
|
"instance-id.ts",
|
|
3657
3707
|
"measured.ts",
|
|
3658
3708
|
"muted.ts",
|
|
@@ -3727,6 +3777,31 @@
|
|
|
3727
3777
|
],
|
|
3728
3778
|
"default": "own",
|
|
3729
3779
|
"description": "The level whose site size the heading takes when it is not the level it is written at: h1 on an h2 heading keeps a source's second h1 at the h1 size under a page that keeps one h1. own: the heading's own level under headingScale theme, nothing otherwise. Any other value than own sets the size by itself."
|
|
3780
|
+
},
|
|
3781
|
+
"headingLines": {
|
|
3782
|
+
"options": [
|
|
3783
|
+
"flow",
|
|
3784
|
+
"lines"
|
|
3785
|
+
],
|
|
3786
|
+
"default": "flow",
|
|
3787
|
+
"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."
|
|
3788
|
+
},
|
|
3789
|
+
"stackOrder": {
|
|
3790
|
+
"options": [
|
|
3791
|
+
"text-first",
|
|
3792
|
+
"media-first"
|
|
3793
|
+
],
|
|
3794
|
+
"default": "text-first",
|
|
3795
|
+
"description": "Split and centered: the image before the text, on phones and on the start side (split), or above the heading (centered). A cover ignores it."
|
|
3796
|
+
},
|
|
3797
|
+
"imageBleed": {
|
|
3798
|
+
"options": [
|
|
3799
|
+
"none",
|
|
3800
|
+
"edge",
|
|
3801
|
+
"overlap"
|
|
3802
|
+
],
|
|
3803
|
+
"default": "none",
|
|
3804
|
+
"description": "Split and centered: edge runs the image on to the window's edge on its side (both sides when centered); overlap also lets it cross the section's bottom edge by --hero-overlap (3rem), over the next section's top padding, when the image comes last: with stackOrder media-first it draws as edge, so it never covers the text. A cover ignores it."
|
|
3730
3805
|
}
|
|
3731
3806
|
},
|
|
3732
3807
|
"props": {
|
|
@@ -6256,6 +6331,7 @@
|
|
|
6256
6331
|
"Button.astro",
|
|
6257
6332
|
"KitImage.astro",
|
|
6258
6333
|
"Section.astro",
|
|
6334
|
+
"bleed.ts",
|
|
6259
6335
|
"instance-id.ts",
|
|
6260
6336
|
"measured.ts",
|
|
6261
6337
|
"muted.ts",
|
|
@@ -6292,10 +6368,13 @@
|
|
|
6292
6368
|
"options": [
|
|
6293
6369
|
"none",
|
|
6294
6370
|
"rounded",
|
|
6295
|
-
"circle"
|
|
6371
|
+
"circle",
|
|
6372
|
+
"arch",
|
|
6373
|
+
"chamfer",
|
|
6374
|
+
"blob"
|
|
6296
6375
|
],
|
|
6297
6376
|
"default": "rounded",
|
|
6298
|
-
"description": "The image's
|
|
6377
|
+
"description": "The image's outline: square, the theme's rounding, a circle, or a fixed mask: an arch over the image, a cut corner (chamfer), a soft blob."
|
|
6299
6378
|
},
|
|
6300
6379
|
"imageBleed": {
|
|
6301
6380
|
"options": [
|
|
@@ -7807,6 +7886,7 @@
|
|
|
7807
7886
|
"uses": [],
|
|
7808
7887
|
"shared": [
|
|
7809
7888
|
"KitImage.astro",
|
|
7889
|
+
"Lines.astro",
|
|
7810
7890
|
"Section.astro",
|
|
7811
7891
|
"image.ts",
|
|
7812
7892
|
"instance-id.ts",
|
|
@@ -7879,6 +7959,14 @@
|
|
|
7879
7959
|
],
|
|
7880
7960
|
"default": "marks",
|
|
7881
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."
|
|
7882
7970
|
}
|
|
7883
7971
|
},
|
|
7884
7972
|
"props": {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Text the author broke into lines (`\n`), one block per line, each marked `data-kit-line="<n>"` so a theme or a
|
|
3
|
+
// design layer can give a line its own ink, indent or size. The lines stay one text: a heading made of them is
|
|
4
|
+
// still one heading, read as one sentence (a space between the blocks keeps the words apart when it is read out).
|
|
5
|
+
// Without a line break the text is printed as it is.
|
|
6
|
+
interface Props {
|
|
7
|
+
text: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const lines = Astro.props.text.split('\n').map(line => line.trim()).filter(Boolean)
|
|
11
|
+
---
|
|
12
|
+
{lines.length > 1 ? lines.map((line, i) => <>{i > 0 && ' '}<span class="block" data-kit-line={i + 1}>{line}</span></>) : Astro.props.text}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The viewport's edge, from the content's edge: what the page frame leaves of the window, halved, plus the frame's own
|
|
2
|
+
// gutter (`container-page` caps its width with the gutter inside it). A section whose image runs on to the window's
|
|
3
|
+
// edge (Split, Hero `imageBleed`) pulls it out by this much on its side, on wide screens. Literal class names, so the
|
|
4
|
+
// stylesheet builder sees them.
|
|
5
|
+
export const BLEED = {
|
|
6
|
+
end: 'md:-me-[calc((100vw-min(var(--container-page)+2*var(--container-inset,0px),100vw))/2+var(--spacing-gutter,2rem))]',
|
|
7
|
+
start: 'md:-ms-[calc((100vw-min(var(--container-page)+2*var(--container-inset,0px),100vw))/2+var(--spacing-gutter,2rem))]',
|
|
8
|
+
} as const
|
|
@@ -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": {
|
|
@@ -35,6 +35,29 @@ const grid = tv({
|
|
|
35
35
|
defaultVariants: { columns: '3', style: 'plain', align: 'start' },
|
|
36
36
|
})
|
|
37
37
|
|
|
38
|
+
// `layout: 'rows'`: one item per row, its title, text and link on one axis (an image sits at the row's top, out of the
|
|
39
|
+
// baseline: a head led by an icon too, else the icon's foot would be the baseline and push the text down by its
|
|
40
|
+
// height), a hairline between rows and no card box.
|
|
41
|
+
// With `index`, a numbered list whose numbers (01, 02, …) are drawn in the muted role, tabular; the list itself
|
|
42
|
+
// carries the order for screen readers, so the drawn number is hidden from them.
|
|
43
|
+
const rows = tv({
|
|
44
|
+
slots: {
|
|
45
|
+
list: 'flex flex-col divide-y divide-line border-y border-line',
|
|
46
|
+
row: 'group relative grid gap-x-8 gap-y-2 py-6 md:items-baseline',
|
|
47
|
+
number: `text-sm tabular-nums ${muted}`,
|
|
48
|
+
head: 'flex flex-col gap-2',
|
|
49
|
+
end: 'flex flex-col gap-3 md:items-end',
|
|
50
|
+
media: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))] md:w-48',
|
|
51
|
+
title: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)] text-balance',
|
|
52
|
+
text: muted,
|
|
53
|
+
link: 'font-medium text-accent-text underline-offset-4 hover:underline',
|
|
54
|
+
},
|
|
55
|
+
variants: {
|
|
56
|
+
index: { true: { row: 'md:grid-cols-[3rem_minmax(0,1fr)_minmax(0,2fr)_auto]' }, false: { row: 'md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]' } },
|
|
57
|
+
},
|
|
58
|
+
defaultVariants: { index: false },
|
|
59
|
+
})
|
|
60
|
+
|
|
38
61
|
interface Item {
|
|
39
62
|
title: string
|
|
40
63
|
text?: string | undefined
|
|
@@ -56,6 +79,10 @@ interface Props {
|
|
|
56
79
|
/** Heading level of the section title; card titles are one level below, or at this level when there is no title. */
|
|
57
80
|
headingLevel?: 2 | 3 | undefined
|
|
58
81
|
columns?: '2' | '3' | '4' | undefined
|
|
82
|
+
/** `rows`: one item per row (title, text and link on one axis, a hairline between rows, no card box); `columns`, `style` and the measured columns do not apply. `grid` (absent): cards, as before. */
|
|
83
|
+
layout?: 'grid' | 'rows' | undefined
|
|
84
|
+
/** With `layout: 'rows'`: a numbered list, each row led by its number (01, 02, …) in the muted role. */
|
|
85
|
+
index?: boolean | undefined
|
|
59
86
|
style?: 'plain' | 'bordered' | 'elevated' | undefined
|
|
60
87
|
align?: 'start' | 'center' | undefined
|
|
61
88
|
tone?: Tone | undefined
|
|
@@ -65,7 +92,7 @@ interface Props {
|
|
|
65
92
|
id?: string | undefined
|
|
66
93
|
}
|
|
67
94
|
|
|
68
|
-
const { title, intro, items, headingLevel = 2, columns, style, align, tone, measured, id: givenId } = Astro.props
|
|
95
|
+
const { title, intro, items, headingLevel = 2, columns, style, align, tone, measured, id: givenId, layout, index = false } = Astro.props
|
|
69
96
|
// Every section has an id, so a theme or a design layer can address this one: the page's, or `card-grid`, `card-grid-2`, … in render order.
|
|
70
97
|
const id = instanceId(Astro.locals, givenId, 'card-grid')
|
|
71
98
|
const { list, card, media, title: cardTitle, text } = grid({ columns, style, align })
|
|
@@ -73,6 +100,9 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3'
|
|
|
73
100
|
// One level under the section title, or the title's level when there is none.
|
|
74
101
|
const CardHeading = `h${title ? headingLevel + 1 : headingLevel}` as 'h2' | 'h3' | 'h4'
|
|
75
102
|
const headingId = title ? `${id}-title` : undefined
|
|
103
|
+
const r = rows({ index })
|
|
104
|
+
const List = index ? 'ol' : 'ul'
|
|
105
|
+
const number = (i: number) => String(i + 1).padStart(2, '0')
|
|
76
106
|
---
|
|
77
107
|
<Section kit="card-grid" measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
|
|
78
108
|
{(title || intro) && (
|
|
@@ -81,20 +111,46 @@ const headingId = title ? `${id}-title` : undefined
|
|
|
81
111
|
{intro && <p class:list={['text-lg', muted]}>{intro}</p>}
|
|
82
112
|
</header>
|
|
83
113
|
)}
|
|
84
|
-
|
|
85
|
-
{
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
114
|
+
{layout === 'rows' ? (
|
|
115
|
+
<List class={r.list()} role="list">
|
|
116
|
+
{items.map((item, i) => (
|
|
117
|
+
<li class={r.row()}>
|
|
118
|
+
{index && <span class={r.number()} aria-hidden="true">{number(i)}</span>}
|
|
119
|
+
<div class={r.head({ class: item.icon ? 'md:self-start' : undefined })}>
|
|
120
|
+
{item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-8 w-8 object-contain" />}
|
|
121
|
+
{item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
|
|
122
|
+
<CardHeading class={r.title()}>
|
|
123
|
+
{item.href && !item.linkLabel
|
|
124
|
+
? <a href={safeHref(item.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
|
|
125
|
+
: item.title}
|
|
126
|
+
</CardHeading>
|
|
127
|
+
</div>
|
|
128
|
+
{item.text ? <p class={r.text()} data-kit-text>{item.text}</p> : <span aria-hidden="true" />}
|
|
129
|
+
{(imageOf(item.image, item.imageAlt) || (item.href && item.linkLabel)) && (
|
|
130
|
+
<div class={r.end({ class: imageOf(item.image, item.imageAlt) ? 'md:self-start' : undefined })}>
|
|
131
|
+
{imageOf(item.image, item.imageAlt) && <div class={r.media()}><KitImage image={imageOf(item.image, item.imageAlt)!} sizes="(min-width: 768px) 12rem, 100vw" class="aspect-[3/2] w-full object-cover" /></div>}
|
|
132
|
+
{item.href && item.linkLabel && <a href={safeHref(item.href)} class={r.link()}>{item.linkLabel}</a>}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</li>
|
|
136
|
+
))}
|
|
137
|
+
</List>
|
|
138
|
+
) : (
|
|
139
|
+
<ul class={list({ class: measuredGrid(measured) })}>
|
|
140
|
+
{items.map(item => (
|
|
141
|
+
<li class={card()}>
|
|
142
|
+
{imageOf(item.image, item.imageAlt) && <div class={media()}><KitImage image={imageOf(item.image, item.imageAlt)!} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover transition duration-300 group-hover:scale-[1.02]" /></div>}
|
|
143
|
+
{item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-10 w-10 object-contain" />}
|
|
144
|
+
{item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
|
|
145
|
+
<CardHeading class={cardTitle()}>
|
|
146
|
+
{item.href && !item.linkLabel
|
|
147
|
+
? <a href={safeHref(item.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
|
|
148
|
+
: item.title}
|
|
149
|
+
</CardHeading>
|
|
150
|
+
{item.text && <p class={text()} data-kit-text>{item.text}</p>}
|
|
151
|
+
{item.href && item.linkLabel && <a href={safeHref(item.href)} class="font-medium text-accent-text underline-offset-4 hover:underline">{item.linkLabel}</a>}
|
|
152
|
+
</li>
|
|
153
|
+
))}
|
|
154
|
+
</ul>
|
|
155
|
+
)}
|
|
100
156
|
</Section>
|
|
@@ -5,5 +5,8 @@
|
|
|
5
5
|
{ "name": "accent", "props": { "tone": "accent", "columns": "3", "items": [{ "title": "Fast", "text": "Static HTML, one stylesheet." }, { "title": "Editable", "text": "Content in Contentrain Studio." }, { "title": "Kept", "text": "Every address survives." }] } },
|
|
6
6
|
{"name": "measured-columns", "props": {"title": "Services", "measured": {"containerPx": 1140, "padY": 80, "columns": {"390": 1, "768": 2, "1280": 4}, "gap": 30, "radius": 4, "bg": {"color": "#f4f1ea"}}, "items": [{"title": "Audits", "text": "Where a site loses readers."}, {"title": "Builds", "text": "Static and fast."}, {"title": "Hosting", "text": "Managed for you."}, {"title": "Care", "text": "Kept up to date."}]}},
|
|
7
7
|
{"name": "icon-boxes", "props": {"columns": "3", "align": "center", "items": [{"title": "Design", "text": "Layouts built around your products.", "icon": "/kit-fixtures/icon-1.svg"}, {"title": "Build", "text": "Fast pages that stay fast.", "icon": "/kit-fixtures/icon-1.svg"}, {"title": "Grow", "text": "Monthly reports you can read in five minutes.", "icon": "/kit-fixtures/icon-1.svg"}]}},
|
|
8
|
-
{"name": "link-labels", "props": {"columns": "4", "align": "center", "items": [{"title": "Kasko", "href": "/urunlerimiz/kasko/", "linkLabel": "Teklif Al"}, {"title": "Trafik Sigortası", "href": "/urunlerimiz/trafik-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Konut Sigortası", "href": "/urunlerimiz/konut-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Tamamlayıcı Sağlık"}]}}
|
|
8
|
+
{"name": "link-labels", "props": {"columns": "4", "align": "center", "items": [{"title": "Kasko", "href": "/urunlerimiz/kasko/", "linkLabel": "Teklif Al"}, {"title": "Trafik Sigortası", "href": "/urunlerimiz/trafik-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Konut Sigortası", "href": "/urunlerimiz/konut-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Tamamlayıcı Sağlık"}]}},
|
|
9
|
+
{"name": "rows-index", "props": {"layout": "rows", "index": true, "title": "How it works", "intro": "Three steps, in this order.", "items": [{"title": "Discovery", "text": "We read every page of the site you have, and write down what it holds.", "href": "/how/discovery/"}, {"title": "An offer", "text": "One price for the whole move, with what it includes and what it leaves out.", "href": "/how/offer/"}, {"title": "The move", "text": "Content, addresses and forms carried over, each one checked against the original.", "href": "/how/move/"}]}},
|
|
10
|
+
{"name": "rows-links-muted", "props": {"layout": "rows", "tone": "muted", "title": "What we do", "items": [{"eyebrow": "Studio", "title": "Design systems", "text": "Tokens, components and documentation your team keeps using.", "href": "/services/design/", "linkLabel": "Read more", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "", "width": 1200, "height": 800}}, {"eyebrow": "Studio", "title": "Migrations", "text": "WordPress to Astro without losing an address or a ranking.", "href": "/services/migrations/", "linkLabel": "Read more", "image": {"src": "/kit-fixtures/photo-2.svg", "alt": "", "width": 1200, "height": 800}}, {"eyebrow": "Studio", "title": "Performance", "href": "/services/performance/", "linkLabel": "Read more"}]}},
|
|
11
|
+
{"name": "rows-icons", "props": {"layout": "rows", "title": "What the move keeps", "items": [{"icon": "/kit-fixtures/icon-1.svg", "title": "Addresses", "text": "Every old address answers, or sends the visitor to its new one.", "href": "/keeps/addresses/"}, {"icon": "/kit-fixtures/icon-1.svg", "eyebrow": "Forms", "title": "Contact forms", "text": "Each form posts where it did, with the same fields.", "href": "/keeps/forms/", "linkLabel": "How"}, {"title": "Rankings", "text": "Titles, descriptions and structured data carried over as they were."}]}}
|
|
9
12
|
]
|
|
@@ -41,6 +41,14 @@
|
|
|
41
41
|
"default": "start",
|
|
42
42
|
"description": "Text alignment inside cards and of the header."
|
|
43
43
|
},
|
|
44
|
+
"layout": {
|
|
45
|
+
"options": [
|
|
46
|
+
"grid",
|
|
47
|
+
"rows"
|
|
48
|
+
],
|
|
49
|
+
"default": "grid",
|
|
50
|
+
"description": "Cards in a grid, or one item per row: title, text and link on one axis, a hairline between rows, no card box (columns and style do not apply)."
|
|
51
|
+
},
|
|
44
52
|
"tone": {
|
|
45
53
|
"options": [
|
|
46
54
|
"default",
|
|
@@ -115,6 +123,12 @@
|
|
|
115
123
|
},
|
|
116
124
|
"description": "The cards. image is an ImageInput ({ src, alt, width?, height? })."
|
|
117
125
|
},
|
|
126
|
+
"index": {
|
|
127
|
+
"type": "boolean",
|
|
128
|
+
"default": false,
|
|
129
|
+
"content": false,
|
|
130
|
+
"description": "With layout rows: a numbered list, each row led by its number (01, 02, …) in the muted role."
|
|
131
|
+
},
|
|
118
132
|
"headingLevel": {
|
|
119
133
|
"type": "integer",
|
|
120
134
|
"min": 2,
|
|
@@ -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": {
|
|
@@ -5,11 +5,13 @@ import { tv } from 'tailwind-variants'
|
|
|
5
5
|
import Button from '../_shared/Button.astro'
|
|
6
6
|
import KitImage from '../_shared/KitImage.astro'
|
|
7
7
|
import Section from '../_shared/Section.astro'
|
|
8
|
+
import Lines from '../_shared/Lines.astro'
|
|
8
9
|
import { instanceId } from '../_shared/instance-id'
|
|
9
10
|
import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
|
|
10
11
|
import { measuredFrame } from '../_shared/measured'
|
|
11
12
|
import { coverHeight as heightOf, headingStyle as styleOf } from './hero'
|
|
12
13
|
import { muted, mutedInverse } from '../_shared/muted'
|
|
14
|
+
import { BLEED } from '../_shared/bleed'
|
|
13
15
|
|
|
14
16
|
const hero = tv({
|
|
15
17
|
slots: {
|
|
@@ -47,6 +49,18 @@ const hero = tv({
|
|
|
47
49
|
headingScale: { default: {}, theme: {} },
|
|
48
50
|
// The level whose site size the heading takes when it is not the level it is written at (set inline, see ./hero).
|
|
49
51
|
headingSize: { own: {}, h1: {}, h2: {} },
|
|
52
|
+
// The heading as the author broke it: `lines` prints each `\n`-separated line as its own block (`data-kit-line`),
|
|
53
|
+
// `flow` (the default) as one run of text.
|
|
54
|
+
headingLines: { flow: {}, lines: {} },
|
|
55
|
+
// Split and centered (a cover ignores both): the image before the text, on phones and on the start side (split),
|
|
56
|
+
// or above the heading (centered).
|
|
57
|
+
stackOrder: { 'text-first': {}, 'media-first': { media: 'order-first' } },
|
|
58
|
+
// edge: the image runs on to the window's edge on its side (wide screens), or across the whole width when centered.
|
|
59
|
+
// overlap: it also crosses the section's bottom edge by `--hero-overlap` (3rem), drawn over the next section's top
|
|
60
|
+
// padding: set at the grid's bottom and moved past the hero's own bottom padding (`spacing="lg"`: 4rem, 7rem from md),
|
|
61
|
+
// not laid out there, so the next section stays where it was. Only an image that comes last can cross the bottom: one
|
|
62
|
+
// that comes first (`media-first`) would be moved down over the text, so there overlap draws as edge.
|
|
63
|
+
imageBleed: { none: {}, edge: { media: 'rounded-none' }, overlap: { media: 'relative z-10 self-end translate-y-[calc(4rem+var(--hero-overlap,3rem))] rounded-none md:translate-y-[calc(7rem+var(--hero-overlap,3rem))]' } },
|
|
50
64
|
},
|
|
51
65
|
compoundVariants: [
|
|
52
66
|
// After the layout's own heights, so a boxed cover keeps WordPress's 430px default.
|
|
@@ -56,7 +70,7 @@ const hero = tv({
|
|
|
56
70
|
{ leadAlign: 'start', class: { lead: 'mx-0 max-w-none self-stretch text-start' } },
|
|
57
71
|
{ actionsAlign: 'start', class: { actions: 'justify-start self-stretch' } },
|
|
58
72
|
],
|
|
59
|
-
defaultVariants: { layout: 'split', tone: 'default', align: 'center', leadAlign: 'inherit', actionsAlign: 'inherit', width: 'full', headingScale: 'default', headingSize: 'own' },
|
|
73
|
+
defaultVariants: { layout: 'split', tone: 'default', align: 'center', leadAlign: 'inherit', actionsAlign: 'inherit', width: 'full', headingScale: 'default', headingSize: 'own', headingLines: 'flow', stackOrder: 'text-first', imageBleed: 'none' },
|
|
60
74
|
})
|
|
61
75
|
|
|
62
76
|
interface Props {
|
|
@@ -86,15 +100,23 @@ interface Props {
|
|
|
86
100
|
headingScale?: 'default' | 'theme' | undefined
|
|
87
101
|
/** The level whose site size the heading takes, when it is not the level it is written at (`h1` size on an `h2`: a page keeps one `h1`). `own` (absent): the heading's own level under `theme`, as before. */
|
|
88
102
|
headingSize?: 'own' | 'h1' | 'h2' | undefined
|
|
103
|
+
/** `lines`: each line of the heading (split at `\n`) is a block of its own, `data-kit-line="<n>"`, for a theme to set per line. `flow` (absent): one run of text, as before. */
|
|
104
|
+
headingLines?: 'flow' | 'lines' | undefined
|
|
105
|
+
/** Split and centered: `media-first` puts the image before the text (phones and the start side in split, above the heading in centered). `text-first` (absent): as before. */
|
|
106
|
+
stackOrder?: 'text-first' | 'media-first' | undefined
|
|
107
|
+
/** Split and centered: `edge` runs the image on to the window's edge on its side (both sides when centered); `overlap` also lets it cross the section's bottom edge by `--hero-overlap` (3rem), when the image comes last (with `media-first` it draws as `edge`). `none` (absent): as before. */
|
|
108
|
+
imageBleed?: 'none' | 'edge' | 'overlap' | undefined
|
|
89
109
|
/** The source section's measured style (`MeasuredStyle`): frame, padding, fill, background image, columns per width, gap. Absent, the site's tokens; present, it wins over tone and columns. */
|
|
90
110
|
measured?: MeasuredStyle | undefined
|
|
91
111
|
}
|
|
92
112
|
|
|
93
113
|
|
|
94
|
-
const { heading, level = 1, eyebrow, lead, items = [], actions = [], image, layout = 'split', align, leadAlign, actionsAlign, width = 'full', tone, id: givenId, minHeight, headingScale, headingSize, measured } = Astro.props
|
|
114
|
+
const { heading, level = 1, eyebrow, lead, items = [], actions = [], image, layout = 'split', align, leadAlign, actionsAlign, width = 'full', tone, id: givenId, minHeight, headingScale, headingSize, headingLines, stackOrder, imageBleed, measured } = Astro.props
|
|
95
115
|
// Every section has an id, so a theme or a design layer can address this one: the page's, or `hero`, `hero-2`, … in render order.
|
|
96
116
|
const id = instanceId(Astro.locals, givenId, 'hero')
|
|
97
|
-
|
|
117
|
+
// Overlap moves the image down past the section's bottom, so only an image that comes last may: first, it is edge.
|
|
118
|
+
const bleedAs = imageBleed === 'overlap' && stackOrder === 'media-first' ? 'edge' : imageBleed
|
|
119
|
+
const s = hero({ layout, align, leadAlign, actionsAlign, width, tone, headingScale, headingSize, headingLines, stackOrder, imageBleed: bleedAs })
|
|
98
120
|
const Heading = level === 1 ? 'h1' : 'h2'
|
|
99
121
|
const headingId = `${id}-title`
|
|
100
122
|
const cover = layout === 'cover' && image
|
|
@@ -103,6 +125,9 @@ const headingStyle = styleOf(headingScale, level, headingSize)
|
|
|
103
125
|
// A cover draws its own image: the measured background image is that image, and the measured overlay is its scrim.
|
|
104
126
|
const sectionMeasured = cover && measured ? { ...measured, bg: measured.bg ? { ...measured.bg, image: undefined, overlay: undefined } : undefined } : measured
|
|
105
127
|
const scrim = cover ? measuredFrame(measured).overlay : undefined
|
|
128
|
+
// The window's edge for a bleeding image: its own side in split (the start side when it comes first), both when centered.
|
|
129
|
+
const bleeds = !cover && image && imageBleed !== undefined && imageBleed !== 'none'
|
|
130
|
+
const bleed = bleeds ? (layout === 'centered' ? `max-w-none md:w-auto ${BLEED.start} ${BLEED.end}` : BLEED[stackOrder === 'media-first' ? 'start' : 'end']) : undefined
|
|
106
131
|
---
|
|
107
132
|
{cover && width === 'content'
|
|
108
133
|
? (
|
|
@@ -114,7 +139,7 @@ const scrim = cover ? measuredFrame(measured).overlay : undefined
|
|
|
114
139
|
<div class={s.grid()} style={coverHeight ? `min-height: ${coverHeight}` : undefined}>
|
|
115
140
|
<div class={s.copy()}>
|
|
116
141
|
{eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
|
|
117
|
-
<Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
|
|
142
|
+
<Heading id={headingId} class={s.heading()} style={headingStyle}>{headingLines === 'lines' ? <Lines text={heading} /> : heading}</Heading>
|
|
118
143
|
{lead && <p class={s.lead()} data-kit-text>{lead}</p>}
|
|
119
144
|
{items.length > 0 && <ul class={s.points()}>{items.map(item => <li class={s.point()}>{item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-6 w-6 shrink-0 object-contain" />}<span>{item.title}</span></li>)}</ul>}
|
|
120
145
|
{actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
|
|
@@ -133,7 +158,7 @@ const scrim = cover ? measuredFrame(measured).overlay : undefined
|
|
|
133
158
|
<div class={s.grid()} style={coverHeight ? `min-height: ${coverHeight}` : undefined}>
|
|
134
159
|
<div class={s.copy()}>
|
|
135
160
|
{eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
|
|
136
|
-
<Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
|
|
161
|
+
<Heading id={headingId} class={s.heading()} style={headingStyle}>{headingLines === 'lines' ? <Lines text={heading} /> : heading}</Heading>
|
|
137
162
|
{lead && <p class={s.lead()} data-kit-text>{lead}</p>}
|
|
138
163
|
{items.length > 0 && <ul class={s.points()}>{items.map(item => <li class={s.point()}>{item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-6 w-6 shrink-0 object-contain" />}<span>{item.title}</span></li>)}</ul>}
|
|
139
164
|
{actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
|
|
@@ -143,18 +168,18 @@ const scrim = cover ? measuredFrame(measured).overlay : undefined
|
|
|
143
168
|
</Section>
|
|
144
169
|
)
|
|
145
170
|
: (
|
|
146
|
-
<Section kit="hero" measured={sectionMeasured} tone={tone} spacing="lg" id={id} aria-labelledby={headingId}>
|
|
171
|
+
<Section kit="hero" measured={sectionMeasured} tone={tone} spacing="lg" id={id} aria-labelledby={headingId} class={bleeds ? 'overflow-x-clip' : undefined}>
|
|
147
172
|
{/* Without an image a split has nothing beside the text: the text takes the whole row, not half of it. */}
|
|
148
173
|
<div class={s.grid({ class: image ? undefined : 'md:grid-cols-1' })}>
|
|
149
174
|
<div class={s.copy()}>
|
|
150
175
|
{eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
|
|
151
|
-
<Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
|
|
176
|
+
<Heading id={headingId} class={s.heading()} style={headingStyle}>{headingLines === 'lines' ? <Lines text={heading} /> : heading}</Heading>
|
|
152
177
|
{lead && <p class={s.lead()} data-kit-text>{lead}</p>}
|
|
153
178
|
{items.length > 0 && <ul class={s.points()}>{items.map(item => <li class={s.point()}>{item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-6 w-6 shrink-0 object-contain" />}<span>{item.title}</span></li>)}</ul>}
|
|
154
179
|
{actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
|
|
155
180
|
</div>
|
|
156
181
|
{image && (
|
|
157
|
-
<div class={s.media()}>
|
|
182
|
+
<div class:list={[s.media(), bleed]}>
|
|
158
183
|
<KitImage image={image} priority sizes={layout === 'split' ? '(min-width: 768px) 50vw, 100vw' : '(min-width: 1024px) 56rem, 100vw'} class="h-auto w-full object-cover" />
|
|
159
184
|
</div>
|
|
160
185
|
)}
|
|
@@ -241,5 +241,12 @@
|
|
|
241
241
|
}
|
|
242
242
|
},
|
|
243
243
|
{"name": "cover-measured-overlay", "props": {"heading": "Fresh eggs, every morning", "lead": "A family farm since 1962.", "layout": "cover", "image": {"src": "/kit-fixtures/photo-4.svg", "alt": "", "width": 1600, "height": 900}, "actions": [{"label": "Visit us", "href": "/visit/"}], "measured": {"padY": 140, "minHeight": 560, "tone": "dark", "bg": {"image": "/kit-fixtures/photo-4.svg", "overlay": "rgba(20, 40, 10, 0.45)"}}}},
|
|
244
|
-
{"name": "points", "props": {"heading": "Private Sigorta'ya Hoşgeldiniz!", "items": [{"icon": "/kit-fixtures/logo.svg", "title": "Hızlı, Kolay & Sorunsuz"}, {"title": "En Uygun Fiyatlar"}], "actions": [{"label": "Ürünlerimiz", "href": "/urunlerimiz/"}], "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "", "width": 1200, "height": 800}}}
|
|
244
|
+
{"name": "points", "props": {"heading": "Private Sigorta'ya Hoşgeldiniz!", "items": [{"icon": "/kit-fixtures/logo.svg", "title": "Hızlı, Kolay & Sorunsuz"}, {"title": "En Uygun Fiyatlar"}], "actions": [{"label": "Ürünlerimiz", "href": "/urunlerimiz/"}], "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "", "width": 1200, "height": 800}}},
|
|
245
|
+
{"name": "heading-lines", "props": {"layout": "centered", "tone": "accent", "headingLines": "lines", "eyebrow": "Northwind Journal", "heading": "Slow travel.\nGood coffee.\nTwelve years on the road.", "lead": "Essays, guides and photographs, now in one place.", "actions": [{"label": "Start reading", "href": "/journal/"}]}},
|
|
246
|
+
{"name": "split-media-first", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}, "actions": [{"label": "Start", "href": "/start/"}]}},
|
|
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
|
+
{"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
|
+
{"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}}},
|
|
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."}}
|
|
245
252
|
]
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
"shared": [
|
|
8
8
|
"Button.astro",
|
|
9
9
|
"KitImage.astro",
|
|
10
|
+
"Lines.astro",
|
|
10
11
|
"Section.astro",
|
|
12
|
+
"bleed.ts",
|
|
11
13
|
"instance-id.ts",
|
|
12
14
|
"measured.ts",
|
|
13
15
|
"muted.ts",
|
|
@@ -82,6 +84,31 @@
|
|
|
82
84
|
],
|
|
83
85
|
"default": "own",
|
|
84
86
|
"description": "The level whose site size the heading takes when it is not the level it is written at: h1 on an h2 heading keeps a source's second h1 at the h1 size under a page that keeps one h1. own: the heading's own level under headingScale theme, nothing otherwise. Any other value than own sets the size by itself."
|
|
87
|
+
},
|
|
88
|
+
"headingLines": {
|
|
89
|
+
"options": [
|
|
90
|
+
"flow",
|
|
91
|
+
"lines"
|
|
92
|
+
],
|
|
93
|
+
"default": "flow",
|
|
94
|
+
"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."
|
|
95
|
+
},
|
|
96
|
+
"stackOrder": {
|
|
97
|
+
"options": [
|
|
98
|
+
"text-first",
|
|
99
|
+
"media-first"
|
|
100
|
+
],
|
|
101
|
+
"default": "text-first",
|
|
102
|
+
"description": "Split and centered: the image before the text, on phones and on the start side (split), or above the heading (centered). A cover ignores it."
|
|
103
|
+
},
|
|
104
|
+
"imageBleed": {
|
|
105
|
+
"options": [
|
|
106
|
+
"none",
|
|
107
|
+
"edge",
|
|
108
|
+
"overlap"
|
|
109
|
+
],
|
|
110
|
+
"default": "none",
|
|
111
|
+
"description": "Split and centered: edge runs the image on to the window's edge on its side (both sides when centered); overlap also lets it cross the section's bottom edge by --hero-overlap (3rem), over the next section's top padding, when the image comes last: with stackOrder media-first it draws as edge, so it never covers the text. A cover ignores it."
|
|
85
112
|
}
|
|
86
113
|
},
|
|
87
114
|
"props": {
|
|
@@ -12,6 +12,7 @@ import Section from '../_shared/Section.astro'
|
|
|
12
12
|
import { instanceId } from '../_shared/instance-id'
|
|
13
13
|
import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
|
|
14
14
|
import { muted } from '../_shared/muted'
|
|
15
|
+
import { BLEED } from '../_shared/bleed'
|
|
15
16
|
|
|
16
17
|
const split = tv({
|
|
17
18
|
slots: {
|
|
@@ -35,6 +36,10 @@ const split = tv({
|
|
|
35
36
|
none: { media: 'rounded-none' },
|
|
36
37
|
rounded: { media: 'rounded-[var(--radius-image,var(--radius-card))]' },
|
|
37
38
|
circle: { media: 'mx-auto aspect-square w-full max-w-lg rounded-full', image: 'h-full' },
|
|
39
|
+
// A fixed set of masks (never a value from content): an arch over the image, a cut corner, a soft blob.
|
|
40
|
+
arch: { media: 'rounded-t-full', image: 'h-full' },
|
|
41
|
+
chamfer: { media: '[clip-path:polygon(0_0,82%_0,100%_18%,100%_100%,0_100%)]' },
|
|
42
|
+
blob: { media: 'rounded-[38%_62%_55%_45%/48%_42%_58%_52%]' },
|
|
38
43
|
},
|
|
39
44
|
// edge: the image runs on to the viewport's edge on its side (wide screens).
|
|
40
45
|
imageBleed: { none: {}, edge: { media: 'rounded-none' } },
|
|
@@ -55,11 +60,6 @@ const COLUMNS = {
|
|
|
55
60
|
text: { '1:1': 'md:grid-cols-2', '5:7': 'md:grid-cols-[5fr_7fr]', '7:5': 'md:grid-cols-[7fr_5fr]' },
|
|
56
61
|
image: { '1:1': 'md:grid-cols-2', '5:7': 'md:grid-cols-[7fr_5fr]', '7:5': 'md:grid-cols-[5fr_7fr]' },
|
|
57
62
|
} as const
|
|
58
|
-
// The viewport's edge, from the content's edge: what is left of the page's width, halved.
|
|
59
|
-
const BLEED = {
|
|
60
|
-
end: 'md:-me-[calc((100vw-min(var(--container-page),100vw-2*var(--spacing-gutter,2rem)))/2)]',
|
|
61
|
-
start: 'md:-ms-[calc((100vw-min(var(--container-page),100vw-2*var(--spacing-gutter,2rem)))/2)]',
|
|
62
|
-
} as const
|
|
63
63
|
|
|
64
64
|
interface Feature {
|
|
65
65
|
title: string
|
|
@@ -87,7 +87,8 @@ interface Props {
|
|
|
87
87
|
imageSide?: 'start' | 'end' | undefined
|
|
88
88
|
ratio?: '1:1' | '5:7' | '7:5' | undefined
|
|
89
89
|
imageFit?: 'contain' | 'cover' | undefined
|
|
90
|
-
|
|
90
|
+
/** The image's outline: none, rounded (the default), a circle, or one of a fixed set of masks: an arch, a cut corner (`chamfer`), a soft `blob`. */
|
|
91
|
+
imageShape?: 'none' | 'rounded' | 'circle' | 'arch' | 'chamfer' | 'blob' | undefined
|
|
91
92
|
imageBleed?: 'none' | 'edge' | undefined
|
|
92
93
|
mediaKind?: 'image' | 'video' | undefined
|
|
93
94
|
featureStyle?: 'check' | 'icon' | undefined
|
|
@@ -10,5 +10,8 @@
|
|
|
10
10
|
{ "name": "bleed-edge-start-none", "props": { "heading": "From the other side", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "imageBleed": "edge", "imageSide": "start", "image": { "src": "/kit-fixtures/photo-5.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 }, "tone": "none" } },
|
|
11
11
|
{ "name": "video-sticky-check", "props": { "heading": "See how it works", "headingLevel": 3, "mediaKind": "video", "videoUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "playLabel": "Play video", "sticky": true, "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.\n\nWe have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.\n\nWe have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "features": [{ "title": "Fast", "text": "Static pages, one stylesheet.", "icon": "bolt" }, { "title": "Safe", "text": "Your repository stays yours.", "icon": "shield" }], "image": { "src": "/kit-fixtures/photo-6.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 } } },
|
|
12
12
|
{ "name": "measured-frame", "props": { "eyebrow": "Our studio", "heading": "Small team, careful work", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "actions": [{ "label": "Meet the team", "href": "/team/" }, { "label": "Our process", "href": "/process/", "style": "ghost" }], "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 }, "measured": { "containerPx": 1000, "padY": 80, "bg": { "color": "#eef2ff" }, "tone": "light" } } },
|
|
13
|
-
{ "name": "no-heading", "props": { "body": "Private Sigorta günümüz gelişen sigortacılık sektörünün bir adım önünde olmayı hedeflemiş bir sigorta acentesidir.\n\nÖzel sağlık sigortası alanında, bireysel ve kurumsal müşterilere yönelik çözümler sunuyoruz.", "image": { "src": "/kit-fixtures/photo-3.svg", "alt": "", "width": 1200, "height": 800 } } }
|
|
13
|
+
{ "name": "no-heading", "props": { "body": "Private Sigorta günümüz gelişen sigortacılık sektörünün bir adım önünde olmayı hedeflemiş bir sigorta acentesidir.\n\nÖzel sağlık sigortası alanında, bireysel ve kurumsal müşterilere yönelik çözümler sunuyoruz.", "image": { "src": "/kit-fixtures/photo-3.svg", "alt": "", "width": 1200, "height": 800 } } },
|
|
14
|
+
{"name": "shape-arch", "props": {"heading": "Made to be edited", "body": "Every page lands as content you can change, in the editor you choose.", "imageShape": "arch", "image": {"src": "/kit-fixtures/photo-2.svg", "alt": "A shelf of books", "width": 1200, "height": 800}}},
|
|
15
|
+
{"name": "shape-chamfer", "props": {"heading": "Made to be edited", "body": "Every page lands as content you can change, in the editor you choose.", "imageShape": "chamfer", "image": {"src": "/kit-fixtures/photo-2.svg", "alt": "A shelf of books", "width": 1200, "height": 800}}},
|
|
16
|
+
{"name": "shape-blob", "props": {"heading": "Made to be edited", "body": "Every page lands as content you can change, in the editor you choose.", "imageShape": "blob", "image": {"src": "/kit-fixtures/photo-2.svg", "alt": "A shelf of books", "width": 1200, "height": 800}}}
|
|
14
17
|
]
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"Button.astro",
|
|
12
12
|
"KitImage.astro",
|
|
13
13
|
"Section.astro",
|
|
14
|
+
"bleed.ts",
|
|
14
15
|
"instance-id.ts",
|
|
15
16
|
"measured.ts",
|
|
16
17
|
"muted.ts",
|
|
@@ -47,10 +48,13 @@
|
|
|
47
48
|
"options": [
|
|
48
49
|
"none",
|
|
49
50
|
"rounded",
|
|
50
|
-
"circle"
|
|
51
|
+
"circle",
|
|
52
|
+
"arch",
|
|
53
|
+
"chamfer",
|
|
54
|
+
"blob"
|
|
51
55
|
],
|
|
52
56
|
"default": "rounded",
|
|
53
|
-
"description": "The image's
|
|
57
|
+
"description": "The image's outline: square, the theme's rounding, a circle, or a fixed mask: an arch over the image, a cut corner (chamfer), a soft blob."
|
|
54
58
|
},
|
|
55
59
|
"imageBleed": {
|
|
56
60
|
"options": [
|
|
@@ -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",
|