@sarimarcus/content-sites-ui 0.3.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 +5 -0
- package/README.md +21 -0
- package/package.json +2 -2
- package/src/primitives/Badge.astro +110 -0
- package/src/primitives/Chip.astro +248 -0
- package/src/primitives/badgeText.ts +37 -0
- package/src/primitives/index.ts +4 -0
- package/src/primitives/pillRecipe.ts +18 -0
- package/src/styles/theme.css +15 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
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
|
+
|
|
5
10
|
## 0.3.0 (2026-09-29)
|
|
6
11
|
|
|
7
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).
|
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
|
+
"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.
|
|
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
|
+
}
|
package/src/primitives/index.ts
CHANGED
|
@@ -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';
|
package/src/styles/theme.css
CHANGED
|
@@ -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`.
|
|
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
|
}
|