@vectoriox/iox-ui 5.30.0 → 5.31.1
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/composite-core/index.d.ts +270 -0
- package/composite-core/package.json +3 -0
- package/design-core/index.d.ts +870 -41
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +704 -0
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -0
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +1090 -82
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +110 -30
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +134 -31
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +35 -23
- package/index.d.ts +41 -15
- package/package.json +5 -1
- package/src/composite-core/badge.ts +103 -0
- package/src/composite-core/card.ts +530 -0
- package/src/composite-core/composite-node.ts +143 -0
- package/src/composite-core/public-api.ts +11 -0
- package/src/composite-core/structure.ts +58 -0
- package/src/design-core/contrast-audit.ts +38 -5
- package/src/design-core/flavor-fixtures.generated.ts +144 -144
- package/src/design-core/flavor-spec.generated.ts +970 -26
- package/src/design-core/node-classes.ts +16 -4
- package/src/design-core/node-links.ts +29 -0
- package/src/generation-core/compose-site.ts +55 -1
- package/src/generation-core/node-baselines.generated.ts +14 -2
- package/src/generation-core/patterns.ts +29 -2
- package/src/generation-core/serialized-node.ts +2 -0
- package/src/generation-core/site-spec.generated.ts +13 -26
- package/styles/iox-design.css +63 -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 `
|
|
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
|
|
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": "
|
|
432
|
-
"
|
|
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
|
-
"
|
|
436
|
-
"
|
|
437
|
-
"
|
|
438
|
-
"
|
|
439
|
-
|
|
440
|
-
|
|
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
|
}
|
package/styles/iox-design.css
CHANGED
|
@@ -44,3 +44,66 @@ 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
|
+
/* The buttons themselves — not their row — sit above the link: the row's empty width still opens the card. */
|
|
100
|
+
.iox-role-card:has(.iox-card-link:not([data-iox-canvas])) .iox-role-card-actions > * {
|
|
101
|
+
position: relative;
|
|
102
|
+
z-index: 2;
|
|
103
|
+
}
|
|
104
|
+
.iox-role-card .iox-card-link:focus-visible {
|
|
105
|
+
outline: none;
|
|
106
|
+
}
|
|
107
|
+
.iox-role-card:has(.iox-card-link:focus-visible) {
|
|
108
|
+
box-shadow: var(--iox-focus-ring);
|
|
109
|
+
}
|