@contentrain/astro-kit 0.45.7 → 0.46.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 CHANGED
@@ -3065,6 +3065,10 @@
3065
3065
  "type": "string",
3066
3066
  "description": "Heading shown above the links; leave out when the source shows none."
3067
3067
  },
3068
+ "titleHref": {
3069
+ "type": "url",
3070
+ "description": "Where the heading links, when the source prints it as a link; leave out for a plain heading."
3071
+ },
3068
3072
  "label": {
3069
3073
  "type": "string",
3070
3074
  "description": "Accessible name of the column navigation (the menu name); defaults to the title."
@@ -5125,6 +5129,22 @@
5125
5129
  "max": 4,
5126
5130
  "content": false,
5127
5131
  "description": "Clamp the excerpt to this many lines; absent, the layout's own (3 vertical, 2 otherwise)."
5132
+ },
5133
+ "moreLabel": {
5134
+ "type": "string",
5135
+ "description": "The source's own read-more words under the excerpt (READ ARTICLE); absent, none. Shown only on a card with a link; the title link stays the one link."
5136
+ },
5137
+ "dateStyle": {
5138
+ "type": "select",
5139
+ "options": [
5140
+ "full",
5141
+ "long",
5142
+ "medium",
5143
+ "short"
5144
+ ],
5145
+ "default": "long",
5146
+ "content": false,
5147
+ "description": "How the date is written (Intl dateStyle), as the source writes it; absent, long."
5128
5148
  }
5129
5149
  },
5130
5150
  "slots": {},
@@ -1,12 +1,15 @@
1
1
  ---
2
2
  // A link drawn as a button — the kit's call-to-action primitive. Actions in
3
- // migrated content are links, so this is always an <a>.
3
+ // migrated content are links, so this is always an <a>. A label never widens
4
+ // the page: the button is at most as wide as its row, and a label with no
5
+ // break in it (a raw URL) wraps inside it. The label's own box breaks only
6
+ // where it overflows, so a table or a row still sizes the button to its words.
4
7
  import { tv } from 'tailwind-variants'
5
8
  import type { ActionInput } from './types'
6
9
  import { safeHref } from './safe-url'
7
10
 
8
11
  const button = tv({
9
- base: 'inline-flex items-center justify-center gap-2 rounded-[var(--radius-control,var(--radius-md))] font-medium no-underline transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
12
+ base: 'inline-flex max-w-full items-center justify-center gap-2 rounded-[var(--radius-control,var(--radius-md))] font-medium no-underline transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
10
13
  variants: {
11
14
  style: {
12
15
  primary: 'bg-accent text-accent-ink hover:bg-ink hover:text-surface',
@@ -35,4 +38,4 @@ const { action, size, class: className } = Astro.props
35
38
  target={action.newTab ? '_blank' : undefined}
36
39
  rel={action.newTab ? 'noopener' : undefined}
37
40
  class={button({ style: action.style ?? 'primary', size, class: className })}
38
- >{action.label}</a>
41
+ ><span class="min-w-0 text-center [overflow-wrap:break-word]">{action.label}</span></a>
@@ -87,6 +87,12 @@ function control(label: string, onClick: () => void): HTMLButtonElement {
87
87
  return button
88
88
  }
89
89
 
90
+ /** A carousel button on or off; `aria-disabled` is said once the row has been measured, and kept true to its state after. */
91
+ function setEnd(button: HTMLButtonElement, off: boolean, measured: boolean): void {
92
+ button.disabled = off
93
+ if (measured || button.hasAttribute('aria-disabled')) button.setAttribute('aria-disabled', String(off))
94
+ }
95
+
90
96
  function carousel(root: HTMLElement): void {
91
97
  const items = within(root, root.dataset.crItems)
92
98
  const track = items[0]?.parentElement
@@ -98,10 +104,27 @@ function carousel(root: HTMLElement): void {
98
104
  for (const item of items) Object.assign(item.style, { flex: '0 0 100%', scrollSnapAlign: 'start' })
99
105
  track.setAttribute('tabindex', '0')
100
106
  const go = (step: number) => track.scrollBy({ left: step * track.clientWidth })
107
+ const prev = control(root.dataset.crPrev ?? 'Previous', () => go(-1))
108
+ const next = control(root.dataset.crNext ?? 'Next', () => go(1))
109
+ // No loop (owl's own default): Previous is off on the first slide and Next on the last, so neither is a dead button.
110
+ // A row that does not overflow yet (a closed tab or dialog, a frame still sized to its slides) keeps both on: only a
111
+ // track measured wider than its frame has ends. The track or a slide resizing, and the page's load, measure it again.
112
+ const ends = () => {
113
+ const overflows = track.clientWidth > 0 && track.scrollWidth > track.clientWidth + 1
114
+ setEnd(prev, overflows && track.scrollLeft <= 1, overflows)
115
+ setEnd(next, overflows && track.scrollLeft + track.clientWidth >= track.scrollWidth - 1, overflows)
116
+ }
117
+ track.addEventListener('scroll', ends, { passive: true })
118
+ if (typeof ResizeObserver === 'function') {
119
+ const resized = new ResizeObserver(ends)
120
+ for (const box of [track, ...items]) resized.observe(box)
121
+ }
122
+ if (document.readyState !== 'complete') addEventListener('load', ends, { once: true })
101
123
  const bar = document.createElement('div')
102
124
  bar.className = 'cr-behavior-controls'
103
- bar.append(control(root.dataset.crPrev ?? 'Previous', () => go(-1)), control(root.dataset.crNext ?? 'Next', () => go(1)))
125
+ bar.append(prev, next)
104
126
  track.after(bar)
127
+ ends()
105
128
  }
106
129
 
107
130
  function dialog(root: HTMLElement): void {
@@ -17,3 +17,10 @@ export const mutedBrand = '[--kit-muted:var(--color-brand-ink)]'
17
17
 
18
18
  /** A light surface inside a band (a card, a form panel, a drawer): back to the page's muted role. */
19
19
  export const mutedReset = '[--kit-muted:initial]'
20
+
21
+ /**
22
+ * A Typography body (`prose prose-site`) on a band: its text in the band's own colour and its quieter parts (lead,
23
+ * bullets, counters, captions) in the band's muted role. `prose-site` binds the page's ink, and on a dark band that is
24
+ * the band's own fill (1:1); on a filled band, ink under 4.5:1. On the page's own surface both are the ink, as before.
25
+ */
26
+ export const proseOnBand = '[--tw-prose-body:currentColor] [--tw-prose-headings:currentColor] [--tw-prose-bold:currentColor] [--tw-prose-quotes:currentColor] [--tw-prose-code:currentColor] [--tw-prose-lead:var(--kit-muted,var(--color-ink-muted))] [--tw-prose-counters:var(--kit-muted,var(--color-ink-muted))] [--tw-prose-bullets:var(--kit-muted,var(--color-ink-muted))] [--tw-prose-captions:var(--kit-muted,var(--color-ink-muted))]'
@@ -75,6 +75,7 @@ const NOSCRIPT = '<style>.cr-behavior dialog:not([open]){display:block;position:
75
75
  .cr-behavior-controls { display: flex; gap: 0.5rem; justify-content: center; margin-block-start: 1rem; }
76
76
  .cr-behavior-control { cursor: pointer; border: 1px solid currentColor; border-radius: var(--radius-control, 0.375rem); padding: 0.375rem 0.875rem; background: transparent; }
77
77
  .cr-behavior-control:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
78
+ .cr-behavior-control:disabled { cursor: default; opacity: 0.4; }
78
79
  .cr-behavior-dialog { margin: auto; max-width: min(92vw, 40rem); max-height: 85dvh; border: 1px solid var(--color-line); border-radius: var(--radius-card, 0.75rem); padding: 1.5rem; }
79
80
  .cr-behavior-dialog::backdrop { background: rgb(0 0 0 / 0.5); }
80
81
  .cr-behavior-lightbox img { display: block; max-width: 100%; max-height: 75dvh; margin-inline: auto; }
@@ -9,5 +9,6 @@
9
9
  { "name": "eyebrow-note-lg", "props": { "eyebrow": "Free trial", "heading": "Start building today", "body": "Ship a first page in an afternoon.", "note": "No credit card required", "actions": [{ "label": "Subscribe", "href": "/subscribe/" }], "size": "lg", "tone": "muted", "align": "center" } },
10
10
  { "name": "media-end", "props": { "heading": "See it in action", "body": "A short tour of the editor.", "actions": [{ "label": "Subscribe", "href": "/subscribe/" }], "image": { "src": "/kit-fixtures/photo-1.svg", "alt": "The editor", "width": 640, "height": 480 }, "tone": "default", "frame": "card" } },
11
11
  { "name": "media-start-centered", "props": { "heading": "Join the newsletter", "body": "One email a month.", "actions": [{ "label": "Subscribe", "href": "/subscribe/" }], "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "", "width": 640, "height": 480 }, "mediaSide": "start", "layout": "centered", "tone": "inverse" } },
12
- { "name": "background-image", "props": { "heading": "Planning a trip?", "body": "Start with the guides readers use most.", "actions": [{ "label": "Subscribe", "href": "/subscribe/" }], "layout": "centered", "bgImage": "/kit-fixtures/photo-3.svg", "tone": "inverse", "size": "lg" } }
12
+ { "name": "background-image", "props": { "heading": "Planning a trip?", "body": "Start with the guides readers use most.", "actions": [{ "label": "Subscribe", "href": "/subscribe/" }], "layout": "centered", "bgImage": "/kit-fixtures/photo-3.svg", "tone": "inverse", "size": "lg" } },
13
+ { "name": "long-label", "props": { "heading": "Download the full report", "body": "The link as the site printed it.", "actions": [{ "label": "https://example.com/downloads/annual-report-2026-complete-edition.pdf", "href": "/downloads/annual-report/" }] } }
13
14
  ]
@@ -46,8 +46,8 @@ const footer = tv({
46
46
  interface Props {
47
47
  siteName: string
48
48
  tagline?: string | undefined
49
- /** Link groups for the `columns` layout. */
50
- columns?: Array<{ title?: string | undefined, label?: string | undefined, links: LinkInput[] }> | undefined
49
+ /** Link groups for the `columns` layout. `titleHref`: the heading is a link to it (a source heading printed as one). */
50
+ columns?: Array<{ title?: string | undefined, titleHref?: string | undefined, label?: string | undefined, links: LinkInput[] }> | undefined
51
51
  /** A single row of links (the footer menu). */
52
52
  links?: LinkInput[] | undefined
53
53
  social?: LinkInput[] | undefined
@@ -125,7 +125,7 @@ const mark = wordmark === 'fit' || wordmark === 'crop' ? wordmarkBox(siteName, w
125
125
  {layout === 'columns'
126
126
  ? columns.map(column => (
127
127
  <nav aria-label={column.label ?? column.title} data-cr-part="nav-footer">
128
- {column.title && <p class="mb-3 font-semibold">{column.title}</p>}
128
+ {column.title && <p class="mb-3 font-semibold">{column.titleHref ? <a href={safeHref(column.titleHref)} class="text-current no-underline hover:underline">{column.title}</a> : column.title}</p>}
129
129
  <ul class="flex flex-col gap-2">
130
130
  {column.links.map(item => <li><a href={safeHref(item.href)} class={link()}>{item.label}</a></li>)}
131
131
  </ul>
@@ -88,6 +88,10 @@
88
88
  "type": "string",
89
89
  "description": "Heading shown above the links; leave out when the source shows none."
90
90
  },
91
+ "titleHref": {
92
+ "type": "url",
93
+ "description": "Where the heading links, when the source prints it as a link; leave out for a plain heading."
94
+ },
91
95
  "label": {
92
96
  "type": "string",
93
97
  "description": "Accessible name of the column navigation (the menu name); defaults to the title."
@@ -16,7 +16,8 @@ const card = tv({
16
16
  body: 'flex min-w-0 flex-col gap-2',
17
17
  meta: 'flex flex-wrap items-center gap-x-2 text-sm text-ink-muted',
18
18
  title: 'leading-snug [font-weight:var(--font-weight-heading,600)] text-balance text-ink',
19
- excerpt: 'text-ink-muted',
19
+ // At most ~75 characters a line, however wide the card (a one-column list at 1280).
20
+ excerpt: 'max-w-[75ch] text-ink-muted',
20
21
  },
21
22
  variants: {
22
23
  layout: {
@@ -56,6 +57,10 @@ interface Props {
56
57
  byline?: 'inline' | 'below' | undefined
57
58
  /** Clamp the excerpt to this many lines; absent, the layout's own (3 vertical, 2 otherwise). */
58
59
  excerptLines?: 2 | 3 | 4 | undefined
60
+ /** The source's own "read more" words under the excerpt ("READ ARTICLE"); absent, none. The card's title link is the link. */
61
+ moreLabel?: string | undefined
62
+ /** How the date is written (`Intl.DateTimeFormat` `dateStyle`), as the source writes it; absent, long. */
63
+ dateStyle?: 'full' | 'long' | 'medium' | 'short' | undefined
59
64
  /** The title in the site's link colour, as themes that colour every link do (Hello Elementor). */
60
65
  linkTitle?: boolean | undefined
61
66
  /** The image's `sizes` hint; absent, the card's own guess. A list passes the slot its columns give. */
@@ -66,11 +71,11 @@ interface Props {
66
71
  imageAspect?: string | undefined
67
72
  }
68
73
 
69
- const { title, href, excerpt, date: rawDate, locale = 'en', timeZone = 'UTC', image, category, author, headingLevel = 2, layout, surface, byline, linkTitle, excerptLines, sizes, imageHeight, imageAspect } = Astro.props
74
+ const { title, href, excerpt, date: rawDate, locale = 'en', timeZone = 'UTC', image, category, author, headingLevel = 2, layout, surface, byline, linkTitle, excerptLines, moreLabel, dateStyle = 'long', sizes, imageHeight, imageAspect } = Astro.props
70
75
  const date = rawDate === undefined ? undefined : new Date(rawDate)
71
76
  const slots = card({ layout, surface, byline })
72
77
  const below = byline === 'below'
73
- const shown = date?.toLocaleDateString(locale, { dateStyle: 'long', timeZone })
78
+ const shown = date?.toLocaleDateString(locale, { dateStyle, timeZone })
74
79
  const imageStyle = imageHeight ? `height:${imageHeight}` : imageAspect ? `aspect-ratio:${imageAspect}` : undefined
75
80
  const Heading = `h${headingLevel}` as 'h2' | 'h3'
76
81
  ---
@@ -105,5 +110,7 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3'
105
110
  )}
106
111
  {excerpt && <p class={slots.excerpt({ class: excerptLines ? { 2: 'line-clamp-2', 3: 'line-clamp-3', 4: 'line-clamp-4' }[excerptLines] : undefined })}>{excerpt}</p>}
107
112
  {!below && author && <p class="text-sm text-ink-muted">{author}</p>}
113
+ {/* Words, not a second link to the same page: a click on them reaches the title link's overlay. */}
114
+ {href && moreLabel && <p class="text-sm font-semibold text-accent-text" aria-hidden="true">{moreLabel}</p>}
108
115
  </div>
109
116
  </article>
@@ -25,7 +25,9 @@
25
25
  "sizes": { "type": "string", "description": "The image's `sizes` hint; absent, the card's own guess. A list passes the slot its columns give." },
26
26
  "imageHeight": { "type": "string", "description": "The image block's height as the source sets it (a CSS length or clamp()), cropped to fit; wins over imageAspect." },
27
27
  "imageAspect": { "type": "string", "description": "The image block's aspect ratio (16 / 9) where the source sets an aspect ratio; absent, 16 / 9." },
28
- "excerptLines": { "type": "integer", "min": 2, "max": 4, "content": false, "description": "Clamp the excerpt to this many lines; absent, the layout's own (3 vertical, 2 otherwise)." }
28
+ "excerptLines": { "type": "integer", "min": 2, "max": 4, "content": false, "description": "Clamp the excerpt to this many lines; absent, the layout's own (3 vertical, 2 otherwise)." },
29
+ "moreLabel": { "type": "string", "description": "The source's own read-more words under the excerpt (READ ARTICLE); absent, none. Shown only on a card with a link; the title link stays the one link." },
30
+ "dateStyle": { "type": "select", "options": ["full", "long", "medium", "short"], "default": "long", "content": false, "description": "How the date is written (Intl dateStyle), as the source writes it; absent, long." }
29
31
  },
30
32
  "slots": {},
31
33
  "js": "none",
@@ -11,7 +11,7 @@ import KitImage from '../_shared/KitImage.astro'
11
11
  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
- import { muted } from '../_shared/muted'
14
+ import { muted, proseOnBand } from '../_shared/muted'
15
15
  import { BLEED } from '../_shared/bleed'
16
16
 
17
17
  const split = tv({
@@ -20,7 +20,8 @@ const split = tv({
20
20
  copy: 'flex flex-col gap-5',
21
21
  eyebrow: `text-sm font-semibold tracking-wide uppercase ${muted}`,
22
22
  heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
23
- body: 'prose prose-site max-w-none text-pretty',
23
+ // The body in the band's own colours, as the heading beside it (on default and muted that is the page's ink).
24
+ body: `prose prose-site max-w-none text-pretty ${proseOnBand}`,
24
25
  actions: 'flex flex-wrap gap-3 pt-2',
25
26
  media: 'overflow-hidden',
26
27
  image: 'h-auto w-full object-cover',
@@ -49,7 +50,8 @@ const split = tv({
49
50
  sticky: { true: { media: 'md:sticky md:top-8 md:self-start' }, false: {} },
50
51
  align: { start: {}, center: { copy: 'items-center text-center', actions: 'justify-center' } },
51
52
  // accent/accent-ink is only ~5:1 — dimming text on it drops body copy under 4.5:1 (WCAG 1.4.3), so accent keeps it at full strength.
52
- tone: { default: {}, muted: {}, inverse: {}, accent: {}, brand: {}, none: {} },
53
+ // A fill sets no accent text of its own: a link in the body is drawn in the fill's ink (underlined, as prose draws it).
54
+ tone: { default: {}, muted: {}, inverse: {}, accent: { body: '[--tw-prose-links:currentColor]' }, brand: { body: '[--tw-prose-links:currentColor]' }, none: {} },
53
55
  },
54
56
  defaultVariants: { imageSide: 'end', ratio: '1:1', imageFit: 'contain', imageShape: 'rounded', imageBleed: 'none', stackOrder: 'text-first', sticky: false, align: 'start', tone: 'default' },
55
57
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.45.7",
3
+ "version": "0.46.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.69.0"
45
+ "@contentrain/types": "1.70.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.10.0",