@sarimarcus/content-sites-ui 0.3.0-rc.0 → 0.4.0-rc.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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All packages in the platform release in lockstep; entries are per release version.
4
4
 
5
+ ## 0.4.0-rc.0 (2026-09-29)
6
+
7
+ - `./primitives`: `Badge` and `Chip` (tones and accents are theme roles: `primary`, `secondary`, `accent`, `neutral`, `onImage`, `custom`), `PILL_BASE`, and the badge text limits `BADGE_MAX_CHARS`, `PRICE_LABEL_MAX`, `isBadgeLength`.
8
+ - `./theme.css`: `primary`/`secondary`/`accent` `-tint` and `-emphasis` roles, `badge-on-image(-foreground)`, `chip-on-image(-hover/-foreground)`, so each site keeps its exact pill colours (SLA-2821).
9
+
10
+ ## 0.3.0 (2026-09-29)
11
+
12
+ - Stable release of 0.3.0-rc.0, unchanged: adopted on all three sites with HTML identical on every page and the contract gate passing (SLA-2821).
13
+
5
14
  ## 0.3.0-rc.0 (2026-09-29)
6
15
 
7
16
  - `./theme.css`: the semantic theme roles as `--color-X: var(--X)` (18 existing roles moved from the sites, plus `primary`/`secondary`/`accent` `-strong`/`-soft`, `overlay`, `overlay-foreground`) and the radius scale. Sites set the values in `:root` (SLA-2821).
package/README.md CHANGED
@@ -8,6 +8,27 @@ and `src/styles` holds the Tailwind entry.
8
8
  Depends on `@sarimarcus/content-sites-core` (exact lockstep peer) and `@lucide/astro` (the `Icon` primitive). It carries no reader-visible copy:
9
9
  text arrives through props and slots.
10
10
 
11
+ ## Primitives
12
+
13
+ ```ts
14
+ import { Icon, Badge, Chip, PILL_BASE, isBadgeLength, PRICE_LABEL_MAX } from '@sarimarcus/content-sites-ui/primitives';
15
+ ```
16
+
17
+ - `Icon`: any Lucide icon by kebab-case name.
18
+ - `Badge`: a content label (`<span data-badge>`), clamped to `lines` (default 2). Tones: `primary` (default),
19
+ `secondary`, `accent`, `neutral`, `onImage`, or `custom` with a site-owned `toneClass`.
20
+ - `Chip`: an interactive pill (`<a>`/`<button>`/`<span>`, `data-chip` first). `tone` sets the idle surface
21
+ (`outline`, `outlineMuted`, `outlineQuiet`, `muted`, `tint`, `onImage`, `custom`), `accent` the hover, active and
22
+ focus colour (`primary` (default), `secondary`, `accent`, `neutral`). `active` emits `data-active="true"|"false"`
23
+ only when set; scripts must write `dataset.active = 'true'|'false'`, never add or remove the attribute. Other
24
+ attributes pass through last, so markers such as `data-section-link` reach the element.
25
+ - `PILL_BASE`: the shared pill geometry. `isBadgeLength`, `BADGE_MAX_CHARS`, `PRICE_LABEL_MAX`: the label-length
26
+ limits a call site branches on (pill or prose); a price-range schema `.max()` must equal `PRICE_LABEL_MAX`.
27
+
28
+ `data-badge` and `data-chip` are what `validate-badge-overflow.mjs` selects on, so they are part of the API. Badge and
29
+ Chip colour only through theme roles, including `-tint`, `-emphasis` and the `badge-on-image` / `chip-on-image`
30
+ roles, which each site sets to its own values.
31
+
11
32
  ## Tailwind
12
33
 
13
34
  Tailwind v4 does not scan `node_modules`, so a site must import the package's source entry next to Tailwind:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sarimarcus/content-sites-ui",
3
- "version": "0.3.0-rc.0",
3
+ "version": "0.4.0-rc.0",
4
4
  "description": "Universal Astro UI components, layout shell and design tokens shared by the content sites.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,6 @@
42
42
  "@lucide/astro": "^1.23.0",
43
43
  "astro": "^7.0.6",
44
44
  "tailwindcss": "^4.1.18",
45
- "@sarimarcus/content-sites-core": "0.3.0-rc.0"
45
+ "@sarimarcus/content-sites-core": "0.4.0-rc.0"
46
46
  }
47
47
  }
@@ -0,0 +1,110 @@
1
+ ---
2
+ // ------------------------------------------------------------------
3
+ // Badge — the single pill primitive for content-driven labels (audience tags, event periods,
4
+ // price pills, categories, markers).
5
+ //
6
+ // A pill written as hand-rolled markup has no width or line constraint, so a long content value
7
+ // overflows its card. This component makes the safety net structural.
8
+ //
9
+ // The real guarantee lives in the schema (the `.max()` caps in the site's content config); this
10
+ // clamp is defense in depth and should never visibly fire. That ordering is deliberate: an ellipsis
11
+ // silently destroys meaning and a `title` tooltip is unreachable on touch, so the cap comes first
12
+ // and the clamp only catches regressions. For fields that are legitimately sometimes-prose (a
13
+ // free-text average price), don't reach for a bigger clamp: branch at the call site with
14
+ // `isBadgeLength` from this package and render prose (or no pill) instead.
15
+ //
16
+ // On-image visibility rule: the translucent `primary` tint is for solid page/card backgrounds
17
+ // ONLY. A pill overlaid on a photo must use `onImage` (an opaque scrim with backdrop blur) or a
18
+ // fully opaque tone (`secondary`, or an opaque `custom` toneClass). Never a translucent tint over
19
+ // an image.
20
+ //
21
+ // Sizes, all taking their line-height from the font-size utility:
22
+ // sm — dense label, extra-small text (tags in card bodies)
23
+ // md — roomier label, extra-small text (price pills over card photos)
24
+ // lg — roomiest label, small text (overlays, hero badges)
25
+ // ------------------------------------------------------------------
26
+ import type { HTMLAttributes } from 'astro/types';
27
+ import Icon from './Icon.astro';
28
+ import { PILL_BASE } from './pillRecipe';
29
+
30
+ // Extends the span attributes so call sites can pass id / aria-* / data-* / slot through.
31
+ interface Props extends Omit<HTMLAttributes<'span'>, 'class'> {
32
+ /** Preset color. Use `custom` + `toneClass` for runtime category/type color maps. */
33
+ tone?: 'primary' | 'secondary' | 'accent' | 'neutral' | 'onImage' | 'custom';
34
+ /** Color classes for tone="custom" (e.g. typeColors[type]). */
35
+ toneClass?: string;
36
+ size?: 'sm' | 'md' | 'lg';
37
+ /** Optional leading Lucide icon name — never shrinks. */
38
+ icon?: string;
39
+ /**
40
+ * Max wrapped lines before ellipsis. 1 = a single line. 0 = no clamp wrapper: the slot renders
41
+ * directly as children of the pill row (icons/dots stay in the row), relying on the schema caps
42
+ * and the badge-overflow gate alone. Use 0 for pills migrated from hand-rolled pill markup.
43
+ * A slotted <svg> REQUIRES 0 (or its own non-block display): preflight changes an svg's display,
44
+ * so inside the clamp wrapper it stacks ABOVE the label instead of beside it.
45
+ */
46
+ lines?: 0 | 1 | 2 | 3;
47
+ /** Style / tooltip passthrough (HTMLAttributes types them too loosely for astro check). */
48
+ style?: string;
49
+ title?: string;
50
+ /** Passthrough — call sites own the width budget (e.g. a percentage max width). */
51
+ class?: string;
52
+ }
53
+
54
+ const {
55
+ tone = 'primary',
56
+ toneClass,
57
+ size = 'sm',
58
+ icon,
59
+ lines = 2,
60
+ class: className,
61
+ ...rest
62
+ } = Astro.props;
63
+
64
+ // Astro has already consumed `slot` for slot assignment by the time props arrive; leaving it
65
+ // in the spread renders a stray slot="" attribute on every pill.
66
+ const passthrough = { ...rest };
67
+ delete passthrough.slot;
68
+
69
+ const tones = {
70
+ primary: 'bg-primary/10 text-primary-emphasis',
71
+ secondary: 'bg-secondary text-secondary-foreground',
72
+ accent: 'bg-accent-tint text-accent-emphasis',
73
+ neutral: 'bg-muted text-muted-foreground',
74
+ onImage: 'bg-badge-on-image text-badge-on-image-foreground backdrop-blur-sm',
75
+ custom: '',
76
+ } as const;
77
+
78
+ const sizes = {
79
+ sm: 'px-2 py-0.5 text-xs',
80
+ md: 'px-3 py-1 text-xs',
81
+ lg: 'px-3 py-1.5 text-sm',
82
+ } as const;
83
+
84
+ // `data-badge` is the marker scripts/validate-badge-overflow.mjs selects on. It must stay: the
85
+ // gate used to key on the class string, which silently stopped matching the moment lines={0}
86
+ // dropped the width guard, and a looser class selector then judged hand-rolled <a> chips the
87
+ // 48-char cap was never calibrated for.
88
+ // The width guard is what lets the pill narrow below its intrinsic content width so the clamp can
89
+ // fire. With lines=0 there is no clamp, so it would only let an unwrapped label paint outside its
90
+ // own pill background — the hand-rolled originals carried none.
91
+ const widthGuard = lines === 0 ? undefined : 'min-w-0 max-w-full';
92
+
93
+ const clamps = { 1: 'truncate', 2: 'line-clamp-2', 3: 'line-clamp-3' } as const;
94
+ const iconSize = size === 'lg' ? 'h-4 w-4' : 'h-3.5 w-3.5';
95
+ ---
96
+
97
+ <span
98
+ data-badge
99
+ {...passthrough}
100
+ class:list={[
101
+ PILL_BASE,
102
+ widthGuard,
103
+ sizes[size],
104
+ tone === 'custom' ? toneClass : tones[tone],
105
+ className,
106
+ ]}
107
+ >
108
+ {icon && <Icon name={icon} class={`${iconSize} shrink-0`} />}
109
+ {lines === 0 ? <slot /> : <span class:list={['min-w-0', clamps[lines]]}><slot /></span>}
110
+ </span>
@@ -0,0 +1,248 @@
1
+ ---
2
+ // ------------------------------------------------------------------
3
+ // Chip — the pill primitive for INTERACTIVE controls.
4
+ //
5
+ // Badge renders a <span>, so a pill that is a link or a button is excluded from it by
6
+ // construction. Chip stamps `data-chip`, which scripts/validate-badge-overflow.mjs caps at 80
7
+ // chars — a separate, deliberately looser ceiling than Badge's 48, because a control label is a
8
+ // nav label, not a clamped content value.
9
+ //
10
+ // Badge vs Chip, in one line each:
11
+ // Badge — content labels. A <span>. Clamped. 48 chars. Text comes from a schema field.
12
+ // Chip — controls. An <a>/<button>. Free-wrapping, tappable. 80 chars. Text is authored UI copy.
13
+ //
14
+ // THE ORDERING TRAP, and why this component's API looks the way it does.
15
+ //
16
+ // Tailwind v4 emits same-property utilities in ascending NUMERIC order for numeric scales
17
+ // (padding) but in ALPHABETICAL suffix order for everything else. For font size that puts
18
+ // extra-small after small, so extra-small wins; for font weight it puts medium after bold, so a
19
+ // base medium weight beats a passthrough bold one.
20
+ //
21
+ // A `class` passthrough therefore cannot reliably override a preset: padding only goes up, and
22
+ // font size/weight are decided by spelling. So Chip never emits a utility a call site is meant to
23
+ // override. `size` owns padding, `text` owns font size, `weight` owns font weight, and each emits
24
+ // exactly ONE utility, chosen here in TypeScript. That is also why the escape hatches are
25
+ // `text="inherit"` / `weight="inherit"` rather than "pass your own class": they emit arbitrary
26
+ // properties with !important, which win regardless of emission order. Use the `!` PREFIX form: the
27
+ // v4 suffix form emits nothing, and `!` combined with a variant emits nothing at all.
28
+ //
29
+ // Active state is `data-active="true"|"false"`, styled through the data-active=true variant,
30
+ // NEVER a build-time class branch: a scroll-spy mutates `dataset.active` at runtime, and a branch
31
+ // resolved at build time would freeze it. That variant also carries attribute-selector
32
+ // specificity ((0,2,0) vs a plain class's (0,1,0)), so it beats the IDLE colours no matter how
33
+ // emission order falls. Against hover it is only a tie that emission order happens to break the
34
+ // right way today — so a chip whose active state is toggled at RUNTIME and must survive a Tailwind
35
+ // reorder should pass `hover={false}` and spell its inactive hover out.
36
+ //
37
+ // `active` is undefined by default and then NO attribute is emitted at all. That is load-bearing,
38
+ // not tidiness: a chip that has no active state must not advertise one, or a presence-keyed
39
+ // `[data-active]` selector elsewhere on the page matches every chip on first paint. Scripts that
40
+ // drive a chip's active state must write `dataset.active = 'true'|'false'` (value-keyed) — never
41
+ // presence-keyed (`dataset.active = ''` / `delete`), which a `[data-active]` selector also matches.
42
+ // ------------------------------------------------------------------
43
+ import type { HTMLAttributes } from 'astro/types';
44
+ import Icon from './Icon.astro';
45
+ import { PILL_BASE } from './pillRecipe';
46
+
47
+ type Accent = 'primary' | 'secondary' | 'accent' | 'neutral';
48
+ type Tone = 'outline' | 'outlineMuted' | 'outlineQuiet' | 'muted' | 'tint' | 'onImage' | 'custom';
49
+
50
+ // `type`/`href`/`rel`/`target` are Omitted rather than just redeclared, because HTMLAttributes<'a'>
51
+ // types them more loosely and narrowing an inherited key is an incompatible override.
52
+ //
53
+ // `Astro.props` is annotated `: Props` explicitly below rather than left to Astro's implicit
54
+ // binding. Without it props degrade to `any`, every map lookup becomes an implicit-any error,
55
+ // and the BUILD STAYS GREEN. Only `astro check` reports it.
56
+ interface Props extends Omit<HTMLAttributes<'a'>, 'class' | 'type' | 'href' | 'rel' | 'target'> {
57
+ /** Element. Defaults to `a` when `href` is set, else `button`. `span` is for a pill that is only
58
+ * sometimes a link (rendered unlinked when there is nowhere to go). A `span` gets no focus ring
59
+ * and no hover. */
60
+ as?: 'a' | 'button' | 'span';
61
+ href?: string | null;
62
+ type?: 'button' | 'submit' | 'reset';
63
+ rel?: string;
64
+ target?: string;
65
+ /** Padding + tap target. `md`/`lg` carry a 44px minimum height; `xs`/`sm` do NOT, so a call
66
+ * site that needs the 44px tap target on those adds its own. */
67
+ size?: 'xs' | 'sm' | 'md' | 'lg';
68
+ /** Font size. Defaults per `size`; `inherit` emits an !important inherited font size. */
69
+ text?: 'xs' | 'sm' | 'base' | 'inherit';
70
+ /** `inherit` emits an !important inherited font weight — for chips whose weight lives on their
71
+ * children. */
72
+ weight?: 'medium' | 'inherit';
73
+ /** Idle surface. */
74
+ tone?: Tone;
75
+ /** Hover / active / focus-ring colour. */
76
+ accent?: Accent;
77
+ /** Colour classes for tone="custom" (runtime colour maps, three-state pills). */
78
+ toneClass?: string;
79
+ /** Active-state classes for tone="custom". Spell them with the data-active=true variant. */
80
+ activeClass?: string;
81
+ /** Set false for a chip that deliberately has no hover — scroll-spy rails, filter pills that
82
+ * express state through the active variant alone. */
83
+ hover?: boolean;
84
+ /** Emits `data-active`. Leave undefined to emit no attribute (see the header note). */
85
+ active?: boolean;
86
+ /** Optional leading Lucide icon — never shrinks. */
87
+ icon?: string;
88
+ class?: string;
89
+ }
90
+
91
+ const {
92
+ as,
93
+ href,
94
+ type,
95
+ rel,
96
+ target,
97
+ size = 'md',
98
+ text,
99
+ weight = 'medium',
100
+ tone = 'outline',
101
+ accent = 'primary',
102
+ toneClass,
103
+ activeClass,
104
+ hover = true,
105
+ active,
106
+ icon,
107
+ class: className,
108
+ ...rest
109
+ }: Props = Astro.props;
110
+
111
+ const Tag = as ?? (href ? 'a' : 'button');
112
+
113
+ // Astro has already consumed `slot` for slot assignment by the time props arrive; leaving it in
114
+ // the spread renders a stray slot="" attribute on every chip.
115
+ const passthrough: Record<string, unknown> = { ...rest };
116
+ delete passthrough.slot;
117
+
118
+ const sizes = {
119
+ xs: 'px-2.5 py-1',
120
+ sm: 'px-3 py-1.5',
121
+ md: 'px-4 py-2 min-h-[44px]',
122
+ lg: 'px-5 py-2.5 min-h-[44px]',
123
+ } as const;
124
+
125
+ const defaultText = { xs: 'xs', sm: 'xs', md: 'sm', lg: 'inherit' } as const;
126
+
127
+ const texts = {
128
+ xs: 'text-xs',
129
+ sm: 'text-sm',
130
+ base: 'text-base',
131
+ inherit: '![font-size:inherit] ![line-height:inherit]',
132
+ } as const;
133
+
134
+ // Idle surfaces. `outlineQuiet` is `outline` with muted label text — for a chip that is a
135
+ // wayfinding aid rather than a primary action.
136
+ const tones = {
137
+ outline: 'border border-border bg-card text-foreground',
138
+ outlineMuted: 'border border-border bg-muted/40 text-foreground',
139
+ outlineQuiet: 'border border-border bg-card text-muted-foreground',
140
+ muted: 'bg-muted text-muted-foreground',
141
+ tint: '',
142
+ onImage: 'bg-chip-on-image text-chip-on-image-foreground backdrop-blur-sm',
143
+ custom: '',
144
+ } as const;
145
+
146
+ // The emphasis roles carry the contrast decision per theme: text in the accent hue that reads on
147
+ // its own tint, a card or a muted surface.
148
+ const tints: Record<Accent, string> = {
149
+ primary: 'bg-primary-tint text-primary-emphasis',
150
+ secondary: 'bg-secondary-tint text-secondary-emphasis',
151
+ accent: 'bg-accent-tint text-accent-emphasis',
152
+ neutral: 'bg-muted text-muted-foreground',
153
+ };
154
+
155
+ // One hover per tone x accent cell. `outline` hovers by tinting its fill, `outlineQuiet` by
156
+ // recolouring its label.
157
+ const hovers: Record<Exclude<Tone, 'custom'>, Record<Accent, string>> = {
158
+ outline: {
159
+ primary: 'hover:border-primary hover:bg-primary/10',
160
+ secondary: 'hover:border-secondary hover:bg-secondary/10',
161
+ accent: 'hover:border-accent hover:bg-accent/10',
162
+ neutral: 'hover:border-foreground/30',
163
+ },
164
+ outlineMuted: {
165
+ primary: 'hover:border-primary hover:bg-primary/10',
166
+ secondary: 'hover:border-secondary hover:bg-secondary/10',
167
+ accent: 'hover:border-accent hover:bg-accent/10',
168
+ neutral: 'hover:border-foreground/30',
169
+ },
170
+ outlineQuiet: {
171
+ primary: 'hover:border-primary hover:text-primary-emphasis',
172
+ secondary: 'hover:border-secondary hover:text-secondary-emphasis',
173
+ accent: 'hover:border-accent hover:text-accent-emphasis',
174
+ neutral: 'hover:border-foreground/30 hover:text-foreground',
175
+ },
176
+ muted: {
177
+ primary: 'hover:bg-primary/10 hover:text-primary-emphasis',
178
+ secondary: 'hover:bg-secondary/10 hover:text-secondary-emphasis',
179
+ accent: 'hover:bg-accent/10 hover:text-accent-emphasis',
180
+ neutral: 'hover:bg-muted/80 hover:text-foreground',
181
+ },
182
+ tint: {
183
+ primary: 'hover:bg-primary/20',
184
+ secondary: 'hover:bg-secondary/20',
185
+ accent: 'hover:bg-accent/20',
186
+ neutral: 'hover:bg-muted/80',
187
+ },
188
+ onImage: {
189
+ primary: 'hover:bg-chip-on-image-hover',
190
+ secondary: 'hover:bg-chip-on-image-hover',
191
+ accent: 'hover:bg-chip-on-image-hover',
192
+ neutral: 'hover:bg-chip-on-image-hover',
193
+ },
194
+ };
195
+
196
+ // Deliberately carries no elevation: a call site that wants one on its active state passes it
197
+ // back through `class`, rather than making it the default for every chip.
198
+ const actives: Record<Accent, string> = {
199
+ primary:
200
+ 'data-[active=true]:border-primary data-[active=true]:bg-primary data-[active=true]:text-primary-foreground',
201
+ secondary:
202
+ 'data-[active=true]:border-secondary data-[active=true]:bg-secondary data-[active=true]:text-secondary-foreground',
203
+ accent:
204
+ 'data-[active=true]:border-accent data-[active=true]:bg-accent data-[active=true]:text-accent-foreground',
205
+ neutral:
206
+ 'data-[active=true]:border-foreground/40 data-[active=true]:bg-foreground/5 data-[active=true]:text-foreground',
207
+ };
208
+
209
+ const rings: Record<Accent, string> = {
210
+ primary: 'focus-visible:ring-primary',
211
+ secondary: 'focus-visible:ring-secondary',
212
+ accent: 'focus-visible:ring-accent',
213
+ neutral: 'focus-visible:ring-foreground/40',
214
+ };
215
+
216
+ const interactive = Tag !== 'span';
217
+ // A non-interactive chip must not advertise a hover any more than a focus ring: inheriting the
218
+ // linked variant's hover makes an unclickable pill look clickable.
219
+ const showHover = hover && interactive;
220
+ const iconSize = size === 'lg' ? 'h-4 w-4' : 'h-3.5 w-3.5';
221
+ const resolvedText = texts[text ?? defaultText[size]];
222
+ ---
223
+
224
+ <Tag
225
+ data-chip
226
+ href={Tag === 'a' ? href : undefined}
227
+ type={Tag === 'button' ? (type ?? 'button') : undefined}
228
+ rel={Tag === 'a' ? (rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)) : undefined}
229
+ target={Tag === 'a' ? target : undefined}
230
+ data-active={active === undefined ? undefined : active ? 'true' : 'false'}
231
+ class:list={[
232
+ PILL_BASE,
233
+ Tag === 'button' && 'cursor-pointer',
234
+ sizes[size],
235
+ resolvedText,
236
+ weight === 'inherit' && '![font-weight:inherit]',
237
+ tone === 'custom' ? toneClass : tone === 'tint' ? tints[accent] : tones[tone],
238
+ tone !== 'custom' && 'transition-[background-color,border-color,color] duration-200',
239
+ showHover && tone !== 'custom' && hovers[tone][accent],
240
+ tone === 'custom' ? activeClass : active !== undefined && actives[accent],
241
+ interactive && `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${rings[accent]}`,
242
+ className,
243
+ ]}
244
+ {...passthrough}
245
+ >
246
+ {icon && <Icon name={icon} class={`${iconSize} shrink-0`} />}
247
+ <slot />
248
+ </Tag>
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Badge label-length helpers.
3
+ *
4
+ * Several content fields are author-written free text that is sometimes a short label ("€15–25")
5
+ * and sometimes a full sentence carrying real information ("€80 for six courses to €100 for nine;
6
+ * wine pairing available"). The long form is long because the fact is complicated, and there is
7
+ * no shorter true version.
8
+ *
9
+ * So a pill is the wrong home for it. Truncating destroys the information, and a `title`
10
+ * tooltip is unreachable on touch. The call site branches instead: short values render as a Badge,
11
+ * long ones as prose (or the card pill is omitted and the detail page's prose row carries it).
12
+ *
13
+ * Two different contracts with a site's content schema:
14
+ *
15
+ * - A price range that is ALWAYS a pill label: its schema `.max()` MUST equal PRICE_LABEL_MAX.
16
+ * Change both or neither. When they drifted (schema 40, display gate 24), a quarter of the
17
+ * entries had a schema-valid price that rendered no pill at all.
18
+ * - A price field that MAY be prose: the schema allows more, and pills render it only when
19
+ * `isBadgeLength(v, PRICE_LABEL_MAX)`.
20
+ */
21
+
22
+ /** Longest string that still reads as a label rather than a sentence. */
23
+ export const BADGE_MAX_CHARS = 24;
24
+
25
+ /** Longest price label that still belongs in a pill. Equals the price-range schema `.max()`. */
26
+ export const PRICE_LABEL_MAX = 40;
27
+
28
+ /**
29
+ * True when `value` is short enough to render inside a pill.
30
+ *
31
+ * @param value Raw content field; `undefined`/blank returns false so callers can
32
+ * use this as a single presence-and-length guard.
33
+ * @param max Override for surfaces with more or less room than the default.
34
+ */
35
+ export function isBadgeLength(value?: string | null, max: number = BADGE_MAX_CHARS): boolean {
36
+ return typeof value === 'string' && value.trim().length > 0 && value.trim().length <= max;
37
+ }
@@ -1,2 +1,6 @@
1
1
  // Public surface of @sarimarcus/content-sites-ui/primitives.
2
2
  export { default as Icon } from './Icon.astro';
3
+ export { default as Badge } from './Badge.astro';
4
+ export { default as Chip } from './Chip.astro';
5
+ export { PILL_BASE } from './pillRecipe';
6
+ export { BADGE_MAX_CHARS, PRICE_LABEL_MAX, isBadgeLength } from './badgeText';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The pill shape, in one place. Badge and Chip both build on it.
3
+ *
4
+ * Geometry only: the pill shape, the row layout, the gap, the weight. Nothing about size, colour
5
+ * or state.
6
+ *
7
+ * Size scales are deliberately NOT shared, and should not be unified later:
8
+ *
9
+ * - Badge's sm/md/lg is a label scale. It sits inside a card without stealing room, and its
10
+ * ceiling is the 48-char content cap.
11
+ * - Chip's xs/sm/md/lg is a touch-target scale (md and lg carry a 44px minimum height). It has to
12
+ * be tappable, and its ceiling is the 80-char control cap.
13
+ *
14
+ * Merging them would make one of those two jobs lose. scripts/validate-badge-overflow.mjs enforces
15
+ * the two ceilings separately for the same reason, and pre-filters pages on this recipe's
16
+ * full-radius class, so that class must stay here.
17
+ */
18
+ export const PILL_BASE = 'inline-flex items-center gap-1.5 rounded-full font-medium';
@@ -1,7 +1,9 @@
1
1
  /* Semantic theme roles. A site imports this right after its own `@theme inline` block and sets a value for every
2
- role below in its `:root` (`--X` for each `--color-X: var(--X)`), plus `--radius`. Runtime vars, so a site's
2
+ role below in its `:root` (`--X` for each `--color-X: var(--X)`), plus `--radius`. A `-tint` value carries its own
3
+ alpha (a colour-mix), so a site keeps its tint strength; `-emphasis` is the accent-coloured text that reads on a
4
+ tint, a card or a muted surface. Runtime vars, so a site's
3
5
  `.dark` block and its non-Tailwind sheets (consent, search, map) read the same values. No brand names here:
4
- scripts/platform/theme-contract.mjs checks both rules. */
6
+ scripts/platform/validate-theme-contract.mjs checks both rules. */
5
7
  @theme inline {
6
8
  --radius-sm: calc(var(--radius) - 4px);
7
9
  --radius-md: calc(var(--radius) - 2px);
@@ -21,20 +23,31 @@
21
23
  --color-primary-foreground: var(--primary-foreground);
22
24
  --color-primary-strong: var(--primary-strong);
23
25
  --color-primary-soft: var(--primary-soft);
26
+ --color-primary-tint: var(--primary-tint);
27
+ --color-primary-emphasis: var(--primary-emphasis);
24
28
  --color-secondary: var(--secondary);
25
29
  --color-secondary-foreground: var(--secondary-foreground);
26
30
  --color-secondary-strong: var(--secondary-strong);
27
31
  --color-secondary-soft: var(--secondary-soft);
32
+ --color-secondary-tint: var(--secondary-tint);
33
+ --color-secondary-emphasis: var(--secondary-emphasis);
28
34
  --color-muted: var(--muted);
29
35
  --color-muted-foreground: var(--muted-foreground);
30
36
  --color-accent: var(--accent);
31
37
  --color-accent-foreground: var(--accent-foreground);
32
38
  --color-accent-strong: var(--accent-strong);
33
39
  --color-accent-soft: var(--accent-soft);
40
+ --color-accent-tint: var(--accent-tint);
41
+ --color-accent-emphasis: var(--accent-emphasis);
34
42
  --color-destructive: var(--destructive);
35
43
  --color-border: var(--border);
36
44
  --color-input: var(--input);
37
45
  --color-ring: var(--ring);
38
46
  --color-overlay: var(--overlay);
39
47
  --color-overlay-foreground: var(--overlay-foreground);
48
+ --color-badge-on-image: var(--badge-on-image);
49
+ --color-badge-on-image-foreground: var(--badge-on-image-foreground);
50
+ --color-chip-on-image: var(--chip-on-image);
51
+ --color-chip-on-image-hover: var(--chip-on-image-hover);
52
+ --color-chip-on-image-foreground: var(--chip-on-image-foreground);
40
53
  }