@vectoriox/iox-ui 5.30.0 → 5.31.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.
Files changed (30) hide show
  1. package/composite-core/index.d.ts +270 -0
  2. package/composite-core/package.json +3 -0
  3. package/design-core/index.d.ts +870 -41
  4. package/fesm2022/vectoriox-iox-ui-composite-core.mjs +698 -0
  5. package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -0
  6. package/fesm2022/vectoriox-iox-ui-design-core.mjs +1090 -82
  7. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +110 -30
  9. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  10. package/fesm2022/vectoriox-iox-ui.mjs +134 -31
  11. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  12. package/generation-core/index.d.ts +35 -23
  13. package/index.d.ts +41 -15
  14. package/package.json +5 -1
  15. package/src/composite-core/badge.ts +103 -0
  16. package/src/composite-core/card.ts +524 -0
  17. package/src/composite-core/composite-node.ts +143 -0
  18. package/src/composite-core/public-api.ts +11 -0
  19. package/src/composite-core/structure.ts +58 -0
  20. package/src/design-core/contrast-audit.ts +38 -5
  21. package/src/design-core/flavor-fixtures.generated.ts +144 -144
  22. package/src/design-core/flavor-spec.generated.ts +970 -26
  23. package/src/design-core/node-classes.ts +16 -4
  24. package/src/design-core/node-links.ts +29 -0
  25. package/src/generation-core/compose-site.ts +55 -1
  26. package/src/generation-core/node-baselines.generated.ts +14 -2
  27. package/src/generation-core/patterns.ts +29 -2
  28. package/src/generation-core/serialized-node.ts +2 -0
  29. package/src/generation-core/site-spec.generated.ts +13 -26
  30. package/styles/iox-design.css +62 -0
@@ -9,9 +9,12 @@ import { SPEC_SCHEMES } from './flavor-spec.generated';
9
9
  * - the element-style MARKER `iox-es iox-es-<id>` (design-system §3b-bis) — component CSS carrying
10
10
  * the system look is scoped to it, so a node saved before DS2 keeps its pre-DS2 look;
11
11
  * - the section colour scheme `iox-scheme-<id>` (§3c) for a non-default `colorScheme` — the class
12
- * `compileSchemeCss` re-declares tokens on, so everything inside re-resolves.
12
+ * `compileSchemeCss` re-declares tokens on, so everything inside re-resolves;
13
+ * - the composite ROLE `iox-role-<role>` (`card.media` → `iox-role-card-media`; composite-components
14
+ * plan §3, Card v2) — what the shared CSS keys a part's structural rules on (the badge's corner, the
15
+ * card's stretched link), so a role is never a renderer's private knowledge.
13
16
  *
14
- * Renderers read `elementStyle` and `colorScheme` for exactly this and nothing else.
17
+ * Renderers read `elementStyle`, `colorScheme` and `role` for exactly this and nothing else.
15
18
  */
16
19
 
17
20
  /** The section colour schemes — the contract's `schemes` keys. */
@@ -34,6 +37,9 @@ export const COLOR_SCHEMES: ReadonlyArray<{ readonly id: ColorScheme; readonly l
34
37
 
35
38
  const SCHEME_IDS = new Set<string>(Object.keys(SPEC_SCHEMES));
36
39
  const SCHEME_PREFIX = 'iox-scheme-';
40
+ const ROLE_PREFIX = 'iox-role-';
41
+ /** A role is dot-separated lowercase words (`card`, `card.title`) — anything else never reaches a class. */
42
+ const ROLE_SHAPE = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
37
43
  const MARKER_PREFIX = `${ELEMENT_STYLE_MARKER_CLASS}-`;
38
44
 
39
45
  /** `iox-scheme-<id>` for a known NON-default scheme, else null — an unknown id never reaches a class attribute. */
@@ -41,9 +47,15 @@ export function colorSchemeClass(id: unknown): string | null {
41
47
  return typeof id === 'string' && id !== ColorScheme.Default && SCHEME_IDS.has(id) ? `${SCHEME_PREFIX}${id}` : null;
42
48
  }
43
49
 
50
+ /** `iox-role-<role>` for a well-formed composite role, else null. */
51
+ export function roleClass(role: unknown): string | null {
52
+ return typeof role === 'string' && ROLE_SHAPE.test(role) ? `${ROLE_PREFIX}${role.replace(/\./g, '-')}` : null;
53
+ }
54
+
44
55
  /** The node shapes this module reads: a serialized node (`props`) or a builder node (`traits[]`). */
45
56
  export interface DesignClassSource {
46
57
  elementStyle?: string | null;
58
+ role?: string | null;
47
59
  props?: Readonly<Record<string, unknown>> | null;
48
60
  traits?: ReadonlyArray<{ name?: string; default?: unknown }> | null;
49
61
  }
@@ -65,12 +77,12 @@ export function hasElementStyleMarker(node: { elementStyle?: string | null } | n
65
77
  /** The classes a node renders: `'iox-es iox-es-card iox-scheme-inverse'`, or `''` for none. */
66
78
  export function nodeDesignClasses(node: DesignClassSource | null | undefined): string {
67
79
  const marker = elementStyleClass(node?.elementStyle);
68
- return [marker ? `${ELEMENT_STYLE_MARKER_CLASS} ${marker}` : '', colorSchemeClass(colorSchemeOf(node)) ?? '']
80
+ return [marker ? `${ELEMENT_STYLE_MARKER_CLASS} ${marker}` : '', colorSchemeClass(colorSchemeOf(node)) ?? '', roleClass(node?.role) ?? '']
69
81
  .filter(Boolean).join(' ');
70
82
  }
71
83
 
72
84
  const isDesignClass = (c: string) =>
73
- c === ELEMENT_STYLE_MARKER_CLASS || c.startsWith(MARKER_PREFIX) || c.startsWith(SCHEME_PREFIX);
85
+ c === ELEMENT_STYLE_MARKER_CLASS || c.startsWith(MARKER_PREFIX) || c.startsWith(SCHEME_PREFIX) || c.startsWith(ROLE_PREFIX);
74
86
 
75
87
  /** Make an element's design classes match the node — the live switch on the canvas and the
76
88
  * component path's one-time stamp. Removes stale ones; touches no other class. */
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  export enum LinkType {
9
+ /** Links nowhere — a Heading's default; "Link the whole card" (Card v2) turns its title's link on. */
10
+ None = 'none',
9
11
  External = 'external',
10
12
  Page = 'page',
11
13
  }
@@ -30,3 +32,30 @@ export function buttonBlockHref(props: ButtonBlockLinkProps, pageHref: (route: s
30
32
  const href = props.linkType === LinkType.Page ? pageHref(String(props.pageRoute ?? '')) : String(props.href ?? '');
31
33
  return href || null;
32
34
  }
35
+
36
+ export interface NodeLinkProps {
37
+ linkType?: unknown;
38
+ url?: unknown;
39
+ pageRoute?: unknown;
40
+ }
41
+
42
+ /** The class of the `<a>` a linked Heading renders — the Card v2 stretched link keys on it (iox-design.css:
43
+ * the card is the click target, its buttons sit above, the focus ring is drawn on the card). */
44
+ export const CARD_LINK_CLASS = 'iox-card-link';
45
+ /** On the editing canvas the anchor carries this, so the stretched `::after` is off and every part of the
46
+ * card stays clickable (board 8). */
47
+ export const CANVAS_LINK_ATTRIBUTE = 'data-iox-canvas';
48
+
49
+ /**
50
+ * The href a linked Heading renders (Card v2: the card's title IS its link), or `null` when it links
51
+ * nowhere — `none`, or a link with no target yet (the title then renders as plain words). A page link goes
52
+ * through `pageHref` exactly as a ButtonBlock's does.
53
+ */
54
+ export function headingHref(props: NodeLinkProps, pageHref: (route: string) => string = route => route): string | null {
55
+ if (props.linkType === LinkType.Page) {
56
+ const route = String(props.pageRoute ?? '');
57
+ return route ? pageHref(route) || null : null;
58
+ }
59
+ if (props.linkType === LinkType.External) return String(props.url ?? '').trim() || null;
60
+ return null;
61
+ }
@@ -1,8 +1,9 @@
1
1
  import { DesignChoices, LinkType, resolveDesignChoices } from '@vectoriox/iox-ui/design-core';
2
2
  import { FormPresetDef, FormPresetField, formPresetById } from '@vectoriox/iox-ui/forms-core';
3
+ import { buildCardRecipe, CardLayout, CardPart, CardRole, CARD_LAYOUTS, CARD_PARTS, CompositeNode, CompositeNodeSpec, findByRole } from '@vectoriox/iox-ui/composite-core';
3
4
 
4
5
  import { NODE_BASELINES } from './node-baselines.generated';
5
- import { BindScope, PatternDirective, PatternNode, PatternPlaceholder, SitePattern, patternById } from './patterns';
6
+ import { BindScope, CardBindTarget, PatternDirective, PatternNode, PatternPlaceholder, PatternRecipe, SitePattern, patternById } from './patterns';
6
7
  import { assertStyleSlots, ComposeError, createSerializedNode, SerializedBinding, SerializedNode, setStyleProps } from './serialized-node';
7
8
  import {
8
9
  ContentTypePlan, DataSourcePlan, FactKey, FACT_KEYS, GlobalSlot, PagePlan, PageSectionPlan, PlanDataSourceMode, PlanDataSourceType,
@@ -159,6 +160,8 @@ const LINK_SLOTS: Readonly<Record<string, LinkSlots>> = {
159
160
  Link: { url: 'url' },
160
161
  LinkedContainer: { url: 'url' },
161
162
  ButtonBlock: { url: 'href', fixed: { tag: 'a' } },
163
+ // A Card v2 links as a whole through its TITLE (the stretched link): the Heading carries the link.
164
+ Heading: { url: 'url' },
162
165
  };
163
166
  /** What a linked box becomes when its link has nowhere to go: the same box, unlinked. */
164
167
  const UNLINKED_TYPE: Readonly<Record<string, string>> = { LinkedContainer: 'Container' };
@@ -433,6 +436,7 @@ function composeChildren(pn: PatternNode, id: string, repeater: RepeaterScope |
433
436
  * dangling link is removed. */
434
437
  function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null, ctx: Ctx): SerializedNode | null {
435
438
  if (pn.optional && ctx.section?.without?.includes(pn.optional)) return null;
439
+ if (pn.recipe) return composeRecipe(pn, id, repeater, ctx);
436
440
 
437
441
  const words = chooseText(pn, ctx);
438
442
  const fate = danglingFate(pn, ctx);
@@ -537,6 +541,56 @@ function composeNode(pn: PatternNode, id: string, repeater: RepeaterScope | null
537
541
  return node;
538
542
  }
539
543
 
544
+ /** A Card v2 `binds` key → the part it binds and that part's bound prop. */
545
+ const CARD_BINDS: Readonly<Record<CardBindTarget, { role: CardRole; prop: string }>> = {
546
+ [CardBindTarget.Image]: { role: CardRole.Media, prop: 'src' },
547
+ [CardBindTarget.Eyebrow]: { role: CardRole.Eyebrow, prop: 'content' },
548
+ [CardBindTarget.Title]: { role: CardRole.Title, prop: 'content' },
549
+ [CardBindTarget.Text]: { role: CardRole.Text, prop: 'content' },
550
+ };
551
+
552
+ /**
553
+ * A `recipe` node: the composite-core recipe, stamped whole with THIS composer's node factory — the same
554
+ * recipe the builder's palette stamps with its own — then bound part by part, and linked as a whole card
555
+ * through its title (`href` + `param`, the stretched link). Ids derive from the recipe's keys.
556
+ */
557
+ function composeRecipe(pn: PatternNode, id: string, repeater: RepeaterScope | null, ctx: Ctx): SerializedNode {
558
+ if (pn.recipe !== PatternRecipe.Card) throw new ComposeError(`${id}: no recipe "${pn.recipe}" in composite-core`);
559
+ const layout = (pn.layout ?? CardLayout.Top) as CardLayout;
560
+ if (!CARD_LAYOUTS.includes(layout)) throw new ComposeError(`${id}: a card has no layout "${pn.layout}"`);
561
+ for (const part of Object.keys(pn.parts ?? {})) {
562
+ if (!CARD_PARTS.includes(part as CardPart)) throw new ComposeError(`${id}: a card has no part "${part}"`);
563
+ }
564
+ const factory = (spec: CompositeNodeSpec): CompositeNode =>
565
+ create(spec.key === CardRole.Card ? id : `${id}-${spec.key}`, spec.type, { ...(spec.props ?? {}) }, spec.elementStyle ?? null) as CompositeNode;
566
+ const card = buildCardRecipe(factory, { layout, parts: pn.parts as Partial<Record<CardPart, boolean>> }) as SerializedNode;
567
+
568
+ for (const [key, path] of Object.entries(pn.binds ?? {})) {
569
+ const target = CARD_BINDS[key as CardBindTarget];
570
+ if (!target) throw new ComposeError(`${id}: a card cannot bind "${key}"`);
571
+ const part = findByRole(card as CompositeNode, target.role) as SerializedNode | undefined;
572
+ if (!part) throw new ComposeError(`${id}: "${key}" binds a part the card leaves out`);
573
+ const node = target.role === CardRole.Media ? part.children?.find(c => c.type === IMAGE) : part;
574
+ if (!node) throw new ComposeError(`${id}: the card's media holds no image`);
575
+ addBinding(node, { prop: target.prop, ...resolveScoped(path, repeater, ctx, id) });
576
+ if (node.type === IMAGE) addBinding(node, { prop: 'alt', ...altBinding({ ...pn, t: IMAGE, bind: path, alt: undefined }, repeater, ctx, id) });
577
+ }
578
+
579
+ const fate = danglingFate({ ...pn, t: 'Heading' }, ctx);
580
+ const title = findByRole(card as CompositeNode, CardRole.Title) as SerializedNode;
581
+ if (pn.href && !fate) writePageLink(title, pn.href);
582
+ else if (fate) {
583
+ if (fate === DanglingFate.Email) writeExternalLink(title, `${MAILTO}${ctx.email}`);
584
+ reportDangling(fate === DanglingFate.Remove ? DanglingFate.Unlink : fate, pn, id, undefined, ctx);
585
+ }
586
+ if (pn.param && !fate) {
587
+ const names = pn.href ? routeParams(pn.href) : [];
588
+ if (names.length !== 1) throw new ComposeError(`${id}: "param" needs an href with one :param`);
589
+ addBinding(title, { prop: `routeParam:${names[0]}`, ...resolveScoped(pn.param, repeater, ctx, id) });
590
+ }
591
+ return card;
592
+ }
593
+
540
594
  function composePattern(pattern: SitePattern, id: string, ctx: Ctx): SerializedNode {
541
595
  if (pattern.node.each) throw new ComposeError(`${id}: a pattern's root cannot be stamped per page`);
542
596
  const node = composeNode(pattern.node, id, null, ctx);
@@ -764,7 +764,11 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
764
764
  "Heading": {
765
765
  "props": {
766
766
  "content": "Heading",
767
- "level": "h2"
767
+ "level": "h2",
768
+ "linkType": "none",
769
+ "url": "",
770
+ "pageRoute": "",
771
+ "target": "_self"
768
772
  },
769
773
  "styleProps": {
770
774
  "position": "static",
@@ -813,6 +817,7 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
813
817
  "textDecoration": "none",
814
818
  "WebkitTextStrokeWidth": "0px",
815
819
  "WebkitTextStrokeColor": "transparent",
820
+ "maxLines": "none",
816
821
  "margin": "0px 0px 0px 0px",
817
822
  "padding": "0px 0px 0px 0px",
818
823
  "borderWidth": "0px",
@@ -852,7 +857,11 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
852
857
  },
853
858
  "traits": [
854
859
  "content",
855
- "level"
860
+ "level",
861
+ "linkType",
862
+ "url",
863
+ "pageRoute",
864
+ "target"
856
865
  ],
857
866
  "styleTraits": [
858
867
  "position",
@@ -902,6 +911,7 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
902
911
  "_sep_stroke",
903
912
  "WebkitTextStrokeWidth",
904
913
  "WebkitTextStrokeColor",
914
+ "maxLines",
905
915
  "margin",
906
916
  "padding",
907
917
  "borderWidth",
@@ -999,6 +1009,7 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
999
1009
  "textDecoration": "none",
1000
1010
  "WebkitTextStrokeWidth": "0px",
1001
1011
  "WebkitTextStrokeColor": "transparent",
1012
+ "maxLines": "none",
1002
1013
  "margin": "0px 0px 0px 0px",
1003
1014
  "padding": "10px",
1004
1015
  "borderWidth": "0px",
@@ -1088,6 +1099,7 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
1088
1099
  "_sep_stroke",
1089
1100
  "WebkitTextStrokeWidth",
1090
1101
  "WebkitTextStrokeColor",
1102
+ "maxLines",
1091
1103
  "margin",
1092
1104
  "padding",
1093
1105
  "borderWidth",
@@ -16,7 +16,9 @@ import { SPEC_SITE_PATTERNS } from './site-spec.generated';
16
16
  * per page in the navigation (`$navPages`) · `href` a page route (`$page.route` = the stamped page's) ·
17
17
  * `param` the value of the route's `:param` · `optional` a part a page may leave out (`without`) ·
18
18
  * `preset` a forms-core form preset (its fields, submit and Success/Error words) · `control` / `label` /
19
- * `required` a Field's · `c` children.
19
+ * `required` a Field's · `c` children · `recipe` a composite-core recipe stamped whole (`card`: Card v2 —
20
+ * its `layout`, the optional `parts` it leaves out, and `binds`, a binding path per part: `image`,
21
+ * `eyebrow`, `title`, `text`; `href` + `param` link the whole card through its title).
20
22
  */
21
23
  export interface PatternNode {
22
24
  readonly t: string;
@@ -43,6 +45,28 @@ export interface PatternNode {
43
45
  readonly label?: string;
44
46
  readonly required?: boolean;
45
47
  readonly c?: ReadonlyArray<PatternNode>;
48
+ readonly recipe?: string;
49
+ readonly layout?: string;
50
+ readonly parts?: Readonly<Record<string, boolean>>;
51
+ readonly binds?: Readonly<Record<string, string>>;
52
+ }
53
+
54
+ /** The composite-core recipes a pattern may stamp (`recipe`). */
55
+ export enum PatternRecipe {
56
+ Card = 'card',
57
+ }
58
+
59
+ /** A recipe node's `binds` keys → the part each binds (Card v2). */
60
+ export enum CardBindTarget {
61
+ Image = 'image',
62
+ Eyebrow = 'eyebrow',
63
+ Title = 'title',
64
+ Text = 'text',
65
+ }
66
+
67
+ /** Every binding path a node reads: its own `bind`, and a recipe's `binds`. */
68
+ export function nodeBindPaths(node: PatternNode): string[] {
69
+ return [node.bind, ...Object.values(node.binds ?? {})].filter((p): p is string => !!p);
46
70
  }
47
71
 
48
72
  export enum PatternGlobalPosition {
@@ -129,6 +153,9 @@ export function altSourceScopes(pattern: SitePattern): Set<string> {
129
153
  const scopes = new Set<string>();
130
154
  const visit = (n: PatternNode) => {
131
155
  if (n.t === IMAGE_TYPE && n.bind && n.alt === undefined) scopes.add(n.bind.split('.')[0]);
156
+ // A Card v2's bound image takes its alt from the same item.
157
+ const cardImage = n.recipe === PatternRecipe.Card ? n.binds?.[CardBindTarget.Image] : undefined;
158
+ if (cardImage) scopes.add(cardImage.split('.')[0]);
132
159
  for (const child of n.c ?? []) visit(child);
133
160
  };
134
161
  visit(pattern.node);
@@ -139,7 +166,7 @@ export function altSourceScopes(pattern: SitePattern): Set<string> {
139
166
  export function patternScopes(pattern: SitePattern): Set<string> {
140
167
  const scopes = new Set<string>();
141
168
  for (const n of patternNodes(pattern)) {
142
- for (const path of [n.bind, n.alt, n.param, n.src]) {
169
+ for (const path of [...nodeBindPaths(n), n.alt, n.param, n.src]) {
143
170
  if (path && !path.startsWith('$')) scopes.add(path.split('.')[0]);
144
171
  }
145
172
  }
@@ -27,6 +27,8 @@ export interface SerializedNode {
27
27
  stateStyles?: Partial<Record<string, Record<string, unknown>>>;
28
28
  responsive?: SerializedResponsive;
29
29
  elementStyle?: string;
30
+ /** The part this node plays in a composite (composite-core: `card.title`). */
31
+ role?: string;
30
32
  bindings: SerializedBinding[];
31
33
  interactions: unknown[];
32
34
  isGlobal?: boolean;
@@ -428,34 +428,21 @@ export const SPEC_SITE_PATTERNS = {
428
428
  },
429
429
  "c": [
430
430
  {
431
- "t": "LinkedContainer",
432
- "es": "card",
431
+ "t": "Container",
432
+ "recipe": "card",
433
+ "layout": "top",
434
+ "parts": {
435
+ "badge": false,
436
+ "actions": false
437
+ },
433
438
  "href": "/work/:slug",
434
439
  "param": "item.slug",
435
- "lay": {
436
- "display": "flex",
437
- "flexDirection": "column",
438
- "gap": "12px"
439
- },
440
- "c": [
441
- {
442
- "t": "Image",
443
- "es": "media",
444
- "bind": "item.cover",
445
- "aspect": "4 / 3"
446
- },
447
- {
448
- "t": "Heading",
449
- "es": "h3",
450
- "level": "h3",
451
- "bind": "item.title"
452
- },
453
- {
454
- "t": "Text",
455
- "es": "small",
456
- "bind": "item.summary"
457
- }
458
- ]
440
+ "binds": {
441
+ "image": "item.cover",
442
+ "eyebrow": "item.year",
443
+ "title": "item.title",
444
+ "text": "item.summary"
445
+ }
459
446
  }
460
447
  ]
461
448
  }
@@ -44,3 +44,65 @@ iox-gallery.iox-es-media[data-iox-empty] > .iox-gallery {
44
44
  --iox-link-hover-color: currentColor;
45
45
  --iox-text-muted: currentColor;
46
46
  }
47
+
48
+ /* A Badge's or a Chip's Icon reads the pill's own ink (Card v2 / T25 — the contract's `inkScopes`). */
49
+ .iox-es-badge-neutral,
50
+ .iox-es-badge-success,
51
+ .iox-es-badge-warning,
52
+ .iox-es-badge-accent,
53
+ .iox-es-chip {
54
+ --iox-icon-color: currentColor;
55
+ }
56
+
57
+ /* Text over an image (Card v2 "Image behind text"): everything the scrim block holds reads the scrim's
58
+ fixed ink. Declared on the CHILDREN (the contract's `children: true`): the block also wears the Inverse
59
+ scheme, which re-declares these tokens on the block itself — a scope there would tie with it. */
60
+ .iox-es-media-scrim > * {
61
+ --iox-heading-color: currentColor;
62
+ --iox-eyebrow-color: currentColor;
63
+ --iox-text-muted: currentColor;
64
+ --iox-link-color: currentColor;
65
+ --iox-link-hover-color: currentColor;
66
+ --iox-icon-color: currentColor;
67
+ --iox-button-ghost-ink: currentColor;
68
+ }
69
+
70
+ /* ── Card v2 (composite T23; complex-components board 8) ──────────────────────────────────────────
71
+ The parts are ordinary nodes; `nodeDesignClasses` writes `iox-role-<role>` on each, so these rules are
72
+ the same in every renderer. Zero-specificity where an author's own value must win. */
73
+
74
+ /* A Badge on the Media sits in the Media's top inline-start corner (mirrored in RTL), over the image. */
75
+ :where(.iox-role-card-media) > :where(.iox-role-card-badge) {
76
+ position: absolute;
77
+ inset-block-start: 10px;
78
+ inset-inline-start: 10px;
79
+ z-index: 1;
80
+ }
81
+
82
+ /* Link the whole card — the stretched link. The title's <a> is the card's ONE link (its words are the
83
+ link's name); its ::after covers the card, so a click anywhere on it navigates. The Buttons sit above
84
+ it and keep their own actions; the focus ring is drawn on the card. Never a link inside a link. On the
85
+ editing canvas the anchor carries data-iox-canvas and the ::after is off: every part stays clickable. */
86
+ .iox-card-link {
87
+ color: inherit;
88
+ text-decoration: none;
89
+ }
90
+ :where(.iox-role-card):has(.iox-card-link) {
91
+ position: relative;
92
+ }
93
+ .iox-role-card .iox-card-link:not([data-iox-canvas])::after {
94
+ content: '';
95
+ position: absolute;
96
+ inset: 0;
97
+ z-index: 1;
98
+ }
99
+ .iox-role-card:has(.iox-card-link) .iox-role-card-actions {
100
+ position: relative;
101
+ z-index: 2;
102
+ }
103
+ .iox-role-card .iox-card-link:focus-visible {
104
+ outline: none;
105
+ }
106
+ .iox-role-card:has(.iox-card-link:focus-visible) {
107
+ box-shadow: var(--iox-focus-ring);
108
+ }