@oneli8/react 1.0.0-beta.10

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 (61) hide show
  1. package/AGENTS.md +16 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +8 -0
  4. package/PRINCIPLES.md +48 -0
  5. package/README.md +136 -0
  6. package/ai-context.json +264 -0
  7. package/package.json +66 -0
  8. package/skills/oneli8-figma-to-code/SKILL.md +25 -0
  9. package/skills/oneli8-icons/SKILL.md +36 -0
  10. package/skills/oneli8-ui/SKILL.md +52 -0
  11. package/src/atoms/Icon.js +34 -0
  12. package/src/atoms/NavigationBadge.js +19 -0
  13. package/src/atoms/SelectionIndicator.js +43 -0
  14. package/src/foundations/geometry.js +143 -0
  15. package/src/foundations/icons.generated.d.ts +48 -0
  16. package/src/foundations/icons.generated.js +562 -0
  17. package/src/index.d.ts +381 -0
  18. package/src/index.js +23 -0
  19. package/src/molecules/Button.js +50 -0
  20. package/src/molecules/ChoiceChip.js +101 -0
  21. package/src/molecules/ChoiceItem.js +55 -0
  22. package/src/molecules/FormMessage.js +33 -0
  23. package/src/molecules/IconButton.js +33 -0
  24. package/src/molecules/Link.js +31 -0
  25. package/src/molecules/NavigationItem.js +55 -0
  26. package/src/molecules/Select.js +109 -0
  27. package/src/molecules/SelectionOption.js +44 -0
  28. package/src/molecules/TextField.js +174 -0
  29. package/src/molecules/Token.js +49 -0
  30. package/src/organisms/ChoiceGroup.js +78 -0
  31. package/src/organisms/ChoicePicker.js +114 -0
  32. package/src/organisms/Combobox.js +122 -0
  33. package/src/organisms/MultiSelectField.js +138 -0
  34. package/src/organisms/SegmentedControl.js +114 -0
  35. package/src/organisms/SelectionPopup.js +73 -0
  36. package/src/organisms/TabBar.js +68 -0
  37. package/src/organisms/Tabs.js +123 -0
  38. package/src/styles/button.css +195 -0
  39. package/src/styles/choice-chip.css +216 -0
  40. package/src/styles/choice-group.css +95 -0
  41. package/src/styles/choice-item.css +267 -0
  42. package/src/styles/choice-picker.css +100 -0
  43. package/src/styles/combobox.css +42 -0
  44. package/src/styles/form-message.css +48 -0
  45. package/src/styles/gem.css +376 -0
  46. package/src/styles/icon-button.css +224 -0
  47. package/src/styles/icon.css +56 -0
  48. package/src/styles/link.css +116 -0
  49. package/src/styles/multi-select-field.css +78 -0
  50. package/src/styles/navigation-badge.css +37 -0
  51. package/src/styles/navigation-item.css +155 -0
  52. package/src/styles/segmented-control.css +115 -0
  53. package/src/styles/select.css +33 -0
  54. package/src/styles/selection-indicator.css +162 -0
  55. package/src/styles/selection-option.css +123 -0
  56. package/src/styles/selection-popup.css +92 -0
  57. package/src/styles/tab-bar.css +47 -0
  58. package/src/styles/tabs.css +55 -0
  59. package/src/styles/text-field.css +268 -0
  60. package/src/styles/token.css +65 -0
  61. package/styles.css +29 -0
@@ -0,0 +1,123 @@
1
+ import { createElement, forwardRef, useCallback, useId, useRef, useState } from 'react';
2
+ import { OL8_TABS_HIERARCHIES, OL8_TABS_ORIENTATIONS, OL8_TABS_ACTIVATIONS, OL8_NAVIGATION_SIZES } from '../foundations/geometry.js';
3
+ import { NavigationItem } from '../molecules/NavigationItem.js';
4
+
5
+ /**
6
+ * ORGANISM. Figma 693:171.
7
+ *
8
+ * "Production emits tablist, tab and tabpanel, requires a non empty accessible
9
+ * region name, retains one enabled Tab stop, mirrors horizontal Arrow keys in
10
+ * RTL, and supports horizontal/vertical orientation plus automatic/manual
11
+ * activation." Every clause is enforced here rather than described.
12
+ *
13
+ * Tabs owns the group. Consumers pass items and panels; they never assemble a
14
+ * tablist by hand, which is what keeps selection and focus in one place.
15
+ */
16
+ export const Tabs = forwardRef(function Tabs({
17
+ label, items = [], value, defaultValue, onChange,
18
+ hierarchy = 'primary', size = 'standard', material = 'regular',
19
+ orientation = 'horizontal', activation = 'automatic',
20
+ renderPanel, className = '', ...rest
21
+ }, ref) {
22
+ if (typeof label !== 'string' || label === '') {
23
+ throw new Error('[ol8] a tab list requires an accessible name. A region nobody can name is a region nobody can reach.');
24
+ }
25
+ if (!Array.isArray(items) || items.length === 0) throw new Error('[ol8] a tab list needs at least one tab.');
26
+ if (!OL8_TABS_HIERARCHIES.includes(hierarchy)) throw new Error(`[ol8] unknown tabs hierarchy "${hierarchy}"`);
27
+ if (!OL8_NAVIGATION_SIZES.includes(size)) throw new Error(`[ol8] unknown tabs size "${size}"`);
28
+ if (!OL8_TABS_ORIENTATIONS.includes(orientation)) throw new Error(`[ol8] unknown tabs orientation "${orientation}"`);
29
+ if (!OL8_TABS_ACTIVATIONS.includes(activation)) throw new Error(`[ol8] unknown tabs activation "${activation}"`);
30
+ if (items.every(i => i.disabled)) throw new Error('[ol8] a tab list must keep one enabled tab stop.');
31
+ const seen = new Set();
32
+ for (const item of items) {
33
+ if (!item.id) throw new Error('[ol8] every tab needs an id, because exactly one panel is paired with it.');
34
+ if (seen.has(item.id)) throw new Error(`[ol8] two tabs share the id "${item.id}"`);
35
+ seen.add(item.id);
36
+ }
37
+
38
+ const prefix = useId();
39
+ const firstEnabled = items.find(i => !i.disabled);
40
+ const [internal, setInternal] = useState(defaultValue ?? firstEnabled?.id);
41
+ const selected = value ?? internal;
42
+ const listRef = useRef(null);
43
+
44
+ const select = useCallback((id) => {
45
+ if (value === undefined) setInternal(id);
46
+ onChange?.(id);
47
+ }, [value, onChange]);
48
+
49
+ const onKeyDown = useCallback((event) => {
50
+ const buttons = [...(listRef.current?.querySelectorAll('.ol8-nav-item--tab') ?? [])];
51
+ const at = buttons.indexOf(document.activeElement);
52
+ if (at === -1) return;
53
+
54
+ if (event.key === 'Enter' || event.key === ' ') {
55
+ if (activation === 'manual') { event.preventDefault(); select(items[at].id); }
56
+ return;
57
+ }
58
+
59
+ // "Mirrors horizontal Arrow keys in RTL": the browser's own direction
60
+ // decides which physical key means forward.
61
+ const rtl = getComputedStyle(listRef.current).direction === 'rtl';
62
+ const forward = orientation === 'vertical' ? 'ArrowDown' : (rtl ? 'ArrowLeft' : 'ArrowRight');
63
+ const back = orientation === 'vertical' ? 'ArrowUp' : (rtl ? 'ArrowRight' : 'ArrowLeft');
64
+
65
+ let next = -1;
66
+ if (event.key === 'Home') next = items.findIndex(i => !i.disabled);
67
+ else if (event.key === 'End') next = items.map(i => !i.disabled).lastIndexOf(true);
68
+ else if (event.key === forward || event.key === back) {
69
+ const step = event.key === forward ? 1 : -1;
70
+ // "Disabled choices are skipped by roving focus."
71
+ for (let hop = 1; hop <= items.length; hop += 1) {
72
+ const i = (at + step * hop + items.length * hop) % items.length;
73
+ if (!items[i].disabled) { next = i; break; }
74
+ }
75
+ }
76
+ if (next === -1 || next === at) return;
77
+ event.preventDefault();
78
+ buttons[next].focus();
79
+ if (activation !== 'manual') select(items[next].id);
80
+ }, [items, orientation, activation, select]);
81
+
82
+ const list = createElement('div', {
83
+ ...rest, ref: (node) => {
84
+ listRef.current = node;
85
+ if (typeof ref === 'function') ref(node); else if (ref) ref.current = node;
86
+ },
87
+ className: `ol8-tabs${className ? ` ${className}` : ''}`,
88
+ role: 'tablist',
89
+ 'aria-label': label,
90
+ 'aria-orientation': orientation,
91
+ 'data-ol8-hierarchy': hierarchy,
92
+ 'data-ol8-size': size,
93
+ 'data-ol8-orientation': orientation,
94
+ ...(material === 'gem' ? { 'data-ol8-material': 'gem' } : {}),
95
+ onKeyDown,
96
+ }, items.map(item => createElement(NavigationItem, {
97
+ key: item.id, kind: 'tab', size,
98
+ label: item.label, icon: item.icon, badge: item.badge,
99
+ ariaLabel: item.ariaLabel, disabled: item.disabled,
100
+ selected: item.id === selected,
101
+ id: `${prefix}-tab-${item.id}`,
102
+ 'aria-controls': `${prefix}-panel-${item.id}`,
103
+ // One enabled tab stop; arrows move focus inside the list.
104
+ tabIndex: item.id === selected ? 0 : -1,
105
+ onClick: () => select(item.id),
106
+ })));
107
+
108
+ // "Every consuming composition must pair each item with exactly one owned panel."
109
+ if (!renderPanel) return list;
110
+
111
+ return createElement('div', { className: 'ol8-tabs__group' },
112
+ list,
113
+ items.map(item => createElement('div', {
114
+ key: item.id,
115
+ className: 'ol8-tabs__panel',
116
+ role: 'tabpanel',
117
+ id: `${prefix}-panel-${item.id}`,
118
+ 'aria-labelledby': `${prefix}-tab-${item.id}`,
119
+ tabIndex: 0,
120
+ hidden: item.id !== selected,
121
+ }, renderPanel(item))),
122
+ );
123
+ });
@@ -0,0 +1,195 @@
1
+ /* GENERATED from packages/core/src/molecules/button/button.css by packages/sync/src/build-styles.mjs — do not edit.
2
+ Edit the source in packages/core/src and re-run `npm run sync:styles`. */
3
+ /**
4
+ * Oneli8 · Button
5
+ * Figma: section "Button" (1034:9968) — Primary 15:823, Secondary 15:824,
6
+ * Quiet 15:825, Destructive 15:826. 4 families × 4 sizes × 6 states.
7
+ *
8
+ * The governing rule comes from the Figma component description itself:
9
+ *
10
+ * "State → deterministic visual evidence … Runtime-only native button
11
+ * semantics, disabled, loading, pressed, fullWidth and iconMotion remain
12
+ * implemented and tested in code; they must not become fake visual axes."
13
+ *
14
+ * So the six Figma States are NOT six markup variants:
15
+ * Default → the base rule
16
+ * Hover → :hover
17
+ * Pressed → :active (and [aria-pressed="true"] for toggles)
18
+ * Focus → :focus-visible
19
+ * Disabled → [disabled] (native, not a class)
20
+ * Loading → [data-ol8-loading] (which also implies disabled + aria-busy)
21
+ *
22
+ * There is no .ol8-btn--hover. Emitting one is a bug.
23
+ */
24
+
25
+ .ol8-btn {
26
+ /* Per-variant channels. Families set these; the base rule never hardcodes. */
27
+ --_bg: transparent;
28
+ --_fg: currentColor;
29
+ --_bd: transparent;
30
+ --_bw: 0px;
31
+
32
+ /* Size channels, defaulted to Standard. */
33
+ --_h: var(--ol8-component-button-height-standard);
34
+ --_px: var(--ol8-component-button-paddinginline-standard);
35
+ --_r: var(--ol8-component-button-radius-standard);
36
+ --_gap: var(--ol8-component-button-gap-compact);
37
+ --_icon: var(--ol8-size-icon-small);
38
+
39
+ box-sizing: border-box; /* Figma strokes are inside the frame */
40
+ display: inline-flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ gap: var(--_gap);
44
+ block-size: var(--_h);
45
+ padding-inline: var(--_px);
46
+ padding-block: 0;
47
+ border: var(--_bw) solid var(--_bd);
48
+ border-radius: var(--_r);
49
+ background: var(--_bg);
50
+ color: var(--_fg);
51
+
52
+ font-family: var(--ol8-font-family-functional);
53
+ font-size: var(--ol8-font-size-016);
54
+ line-height: var(--ol8-font-line-024);
55
+ font-weight: var(--ol8-font-weight-regular);
56
+ letter-spacing: 0;
57
+ /* AR One Sans variable axis. Figma sets this on every functional text node;
58
+ omitting it shifts glyph advances and the button measures narrow. */
59
+ font-variation-settings: "ARRR" var(--ol8-font-axis-arrr-baseline);
60
+
61
+ cursor: pointer;
62
+ user-select: none;
63
+ white-space: nowrap;
64
+ text-decoration: none; /* when rendered as <a class="ol8-btn"> */
65
+ -webkit-appearance: none;
66
+ appearance: none;
67
+ }
68
+
69
+ .ol8-btn__label { white-space: nowrap; }
70
+
71
+ /* Button size governs icon size — the Figma Icon Frame is driven by the
72
+ button, never set on the glyph. Higher specificity than .ol8-icon's own
73
+ [data-ol8-icon-size] rule, deliberately. */
74
+ .ol8-btn .ol8-icon { --_ol8-icon-size: var(--_icon); }
75
+
76
+ /* ---- Size (Figma: Size variant axis) --------------------------------- */
77
+ .ol8-btn[data-ol8-size="compact"] {
78
+ --_h: var(--ol8-component-button-height-compact);
79
+ --_px: var(--ol8-component-button-paddinginline-compact);
80
+ --_r: var(--ol8-component-button-radius-compact);
81
+ --_gap: var(--ol8-component-button-gap-compact);
82
+ --_icon: var(--ol8-size-icon-small);
83
+ }
84
+ .ol8-btn[data-ol8-size="standard"] {
85
+ --_h: var(--ol8-component-button-height-standard);
86
+ --_px: var(--ol8-component-button-paddinginline-standard);
87
+ --_r: var(--ol8-component-button-radius-standard);
88
+ --_gap: var(--ol8-component-button-gap-compact); /* shared, by design */
89
+ --_icon: var(--ol8-size-icon-small);
90
+ }
91
+ .ol8-btn[data-ol8-size="comfortable"] {
92
+ --_h: var(--ol8-component-button-height-comfortable);
93
+ --_px: var(--ol8-component-button-paddinginline-comfortable);
94
+ --_r: var(--ol8-component-button-radius-comfortable);
95
+ --_gap: var(--ol8-component-button-gap-comfortable);
96
+ --_icon: var(--ol8-size-icon-standard);
97
+ }
98
+ .ol8-btn[data-ol8-size="large"] {
99
+ --_h: var(--ol8-component-button-height-large);
100
+ --_px: var(--ol8-component-button-paddinginline-large);
101
+ --_r: var(--ol8-component-button-radius-large);
102
+ --_gap: var(--ol8-component-button-gap-comfortable); /* shared, by design */
103
+ --_icon: var(--ol8-size-icon-standard);
104
+ }
105
+
106
+ /* ---- Family: Primary ------------------------------------------------- */
107
+ .ol8-btn--primary { --_bg: var(--ol8-color-actionprimary-default); --_fg: var(--ol8-color-actionprimary-content); }
108
+ .ol8-btn--primary:hover:not(:disabled):not([data-ol8-loading]) { --_bg: var(--ol8-color-actionprimary-hover); }
109
+ .ol8-btn--primary:active:not(:disabled):not([data-ol8-loading]),
110
+ .ol8-btn--primary[aria-pressed="true"]:not(:disabled) { --_bg: var(--ol8-color-actionprimary-pressed); }
111
+ .ol8-btn--primary:disabled, .ol8-btn--primary[data-ol8-loading] {
112
+ --_bg: var(--ol8-color-actionprimary-disabled);
113
+ --_fg: var(--ol8-color-actionprimary-disabledcontent);
114
+ }
115
+
116
+ /* ---- Family: Destructive (same shape as Primary) --------------------- */
117
+ .ol8-btn--destructive { --_bg: var(--ol8-color-actiondestructive-default); --_fg: var(--ol8-color-actiondestructive-content); }
118
+ .ol8-btn--destructive:hover:not(:disabled):not([data-ol8-loading]) { --_bg: var(--ol8-color-actiondestructive-hover); }
119
+ .ol8-btn--destructive:active:not(:disabled):not([data-ol8-loading]),
120
+ .ol8-btn--destructive[aria-pressed="true"]:not(:disabled) { --_bg: var(--ol8-color-actiondestructive-pressed); }
121
+ .ol8-btn--destructive:disabled, .ol8-btn--destructive[data-ol8-loading] {
122
+ --_bg: var(--ol8-color-actiondestructive-disabled);
123
+ --_fg: var(--ol8-color-actiondestructive-disabledcontent);
124
+ }
125
+
126
+ /* ---- Family: Secondary ----------------------------------------------
127
+ Secondary and Quiet carry byte-identical color roles in Figma. The ONLY
128
+ differentiator is this boundary: Secondary has a 2px stroke whose color
129
+ tracks the content color; Quiet has none. Verified — Quiet's node binds no
130
+ --ol8-component-button-secondaryboundary at all. */
131
+ .ol8-btn--secondary {
132
+ --_bw: var(--ol8-component-button-secondaryboundary);
133
+ --_bg: var(--ol8-color-actionsecondary-surfacedefault);
134
+ --_fg: var(--ol8-color-actionsecondary-contentdefault);
135
+ --_bd: var(--ol8-color-actionsecondary-contentdefault);
136
+ }
137
+ .ol8-btn--secondary:hover:not(:disabled):not([data-ol8-loading]) {
138
+ --_bg: var(--ol8-color-actionsecondary-surfacehover);
139
+ --_fg: var(--ol8-color-actionsecondary-contenthover);
140
+ --_bd: var(--ol8-color-actionsecondary-contenthover);
141
+ }
142
+ .ol8-btn--secondary:active:not(:disabled):not([data-ol8-loading]),
143
+ .ol8-btn--secondary[aria-pressed="true"]:not(:disabled) {
144
+ --_bg: var(--ol8-color-actionsecondary-surfacepressed);
145
+ --_fg: var(--ol8-color-actionsecondary-contentpressed);
146
+ --_bd: var(--ol8-color-actionsecondary-contentpressed);
147
+ }
148
+ .ol8-btn--secondary:disabled, .ol8-btn--secondary[data-ol8-loading] {
149
+ --_bg: var(--ol8-color-actionsecondary-surfacedisabled);
150
+ --_fg: var(--ol8-color-actionsecondary-contentdisabled);
151
+ --_bd: var(--ol8-color-actionsecondary-contentdisabled);
152
+ }
153
+
154
+ /* ---- Family: Quiet (identical colors, no boundary) ------------------- */
155
+ .ol8-btn--quiet {
156
+ --_bg: var(--ol8-color-actionquiet-surfacedefault);
157
+ --_fg: var(--ol8-color-actionquiet-contentdefault);
158
+ }
159
+ .ol8-btn--quiet:hover:not(:disabled):not([data-ol8-loading]) {
160
+ --_bg: var(--ol8-color-actionquiet-surfacehover);
161
+ --_fg: var(--ol8-color-actionquiet-contenthover);
162
+ }
163
+ .ol8-btn--quiet:active:not(:disabled):not([data-ol8-loading]),
164
+ .ol8-btn--quiet[aria-pressed="true"]:not(:disabled) {
165
+ --_bg: var(--ol8-color-actionquiet-surfacepressed);
166
+ --_fg: var(--ol8-color-actionquiet-contentpressed);
167
+ }
168
+ .ol8-btn--quiet:disabled, .ol8-btn--quiet[data-ol8-loading] {
169
+ --_bg: var(--ol8-color-actionquiet-surfacedisabled);
170
+ --_fg: var(--ol8-color-actionquiet-contentdisabled);
171
+ }
172
+
173
+ /* ---- Focus ----------------------------------------------------------
174
+ Figma draws the focus ring as an INSIDE stroke, so the focused variant is
175
+ exactly the same size as the unfocused one (83×42 either way, Standard).
176
+ An inset box-shadow reproduces that without disturbing layout or fighting
177
+ Secondary's own 2px boundary — `outline` would sit outside and grow it. */
178
+ .ol8-btn:focus-visible {
179
+ outline: none;
180
+ box-shadow: inset 0 0 0 var(--ol8-focus-ring-innerwidth) var(--ol8-color-focus-outer);
181
+ }
182
+
183
+ /* ---- Disabled / Loading --------------------------------------------- */
184
+ .ol8-btn:disabled, .ol8-btn[data-ol8-loading] { cursor: not-allowed; }
185
+ .ol8-btn[data-ol8-loading] { cursor: progress; }
186
+
187
+ /* "Platform motion adapters may rotate it; Reduced Motion keeps the static
188
+ busy mark." — Figma 379:7. Rotation is opt-in and motion-guarded. */
189
+ @media (prefers-reduced-motion: no-preference) {
190
+ .ol8-btn[data-ol8-loading] .ol8-btn__spinner > svg { animation: ol8-spin 900ms linear infinite; }
191
+ }
192
+ @keyframes ol8-spin { to { transform: rotate(360deg); } }
193
+
194
+ /* ---- fullWidth (runtime-only, per the Figma description) ------------- */
195
+ .ol8-btn[data-ol8-fullwidth] { display: flex; inline-size: 100%; }
@@ -0,0 +1,216 @@
1
+ /* GENERATED from packages/core/src/molecules/choice-chip/choice-chip.css by packages/sync/src/build-styles.mjs — do not edit.
2
+ Edit the source in packages/core/src and re-run `npm run sync:styles`. */
3
+ /**
4
+ * Oneli8 · Choice Chip (MOLECULE) — the Soft Hexagon
5
+ * Figma: Select and Combobox / Choice Chip / Soft Hexagon 636:847 (8 variants)
6
+ *
7
+ * GEOMETRY, read from all four sizes in Figma:
8
+ * size target visible rail inset gap label
9
+ * compact 48 30 9 9 12 / 18
10
+ * standard 48 36 12 9 12 / 18
11
+ * comfortable 48 42 15 9 16 / 24
12
+ * large 60 42 15 9 16 / 24
13
+ * The terminal is a fixed 12 at every size and never scales with the label,
14
+ * so peer chips of different lengths keep one shoulder depth and side angle.
15
+ *
16
+ * The visible silhouette sits inside a taller protected target, which is why
17
+ * `min-block-size` is the target and the rail carries the visible height.
18
+ */
19
+
20
+ .ol8-chip {
21
+ --_height: var(--ol8-component-choice-chip-height-standard);
22
+ --_target: var(--ol8-component-choice-chip-target-standard);
23
+ --_inset: var(--ol8-component-choice-chip-rail-inset-standard);
24
+ --_label-size: var(--ol8-font-size-012);
25
+ --_label-line: var(--ol8-font-line-018);
26
+
27
+ /* Inactive is the quiet Neutral treatment; Selected is Primary Soft. */
28
+ --_surface: var(--ol8-color-surface-sunken);
29
+ --_edge: var(--ol8-color-border-subtle);
30
+ --_ink: var(--ol8-color-text-primary);
31
+
32
+ /* The ring is painted at every state and is simply invisible until focus,
33
+ so focus never changes the geometry, only the colour. */
34
+ --_focus-outer: transparent;
35
+ --_focus-inner: transparent;
36
+
37
+ box-sizing: border-box;
38
+ position: relative;
39
+ display: inline-flex;
40
+ align-items: center;
41
+ min-block-size: var(--_target);
42
+ font-family: var(--ol8-font-family-functional);
43
+ font-variation-settings: "ARRR" var(--ol8-font-axis-arrr-baseline);
44
+ color: var(--_ink);
45
+ cursor: pointer;
46
+ }
47
+ .ol8-chip[data-ol8-availability="disabled"] { cursor: not-allowed; }
48
+
49
+ /* ---- Size ------------------------------------------------------------ */
50
+ .ol8-chip[data-ol8-size="compact"] {
51
+ --_height: var(--ol8-component-choice-chip-height-compact);
52
+ --_inset: var(--ol8-component-choice-chip-rail-inset-compact);
53
+ }
54
+ .ol8-chip[data-ol8-size="standard"] {
55
+ --_height: var(--ol8-component-choice-chip-height-standard);
56
+ --_inset: var(--ol8-component-choice-chip-rail-inset-standard);
57
+ }
58
+ .ol8-chip[data-ol8-size="comfortable"] {
59
+ --_height: var(--ol8-component-choice-chip-height-comfortable);
60
+ --_inset: var(--ol8-component-choice-chip-rail-inset-comfortable);
61
+ --_label-size: var(--ol8-font-size-016);
62
+ --_label-line: var(--ol8-font-line-024);
63
+ }
64
+ .ol8-chip[data-ol8-size="large"] {
65
+ --_height: var(--ol8-component-choice-chip-height-large);
66
+ --_target: var(--ol8-component-choice-chip-target-large);
67
+ --_inset: var(--ol8-component-choice-chip-rail-inset-large);
68
+ --_label-size: var(--ol8-font-size-016);
69
+ --_label-line: var(--ol8-font-line-024);
70
+ }
71
+
72
+ /* ---- Selection ------------------------------------------------------- */
73
+ /* Never colour alone: the native checked state carries the same fact, and the
74
+ selected edge is a stronger contrast than the inactive one. */
75
+ .ol8-chip[data-ol8-selection="selected"] {
76
+ --_surface: var(--ol8-color-selection-surface);
77
+ --_edge: var(--ol8-color-primary-360);
78
+ --_ink: var(--ol8-color-selection-content);
79
+ }
80
+ .ol8-chip[data-ol8-availability="disabled"] {
81
+ --_surface: var(--ol8-color-surface-disabled);
82
+ --_edge: var(--ol8-color-border-disabled);
83
+ --_ink: var(--ol8-color-text-disabled);
84
+ }
85
+
86
+ .ol8-chip__input {
87
+ position: absolute;
88
+ inline-size: 1px;
89
+ block-size: 1px;
90
+ opacity: 0;
91
+ pointer-events: none;
92
+ }
93
+
94
+ /* ---- Terminals ------------------------------------------------------- */
95
+ /* One viewBox serves every height: Figma's terminal paths share identical X
96
+ values at 30, 36 and 42, and Y values that are the same fractions of the
97
+ height, so stretching Y alone is exact rather than approximate. */
98
+ .ol8-chip__terminal {
99
+ flex: none;
100
+ inline-size: var(--ol8-component-choice-chip-terminal);
101
+ block-size: var(--_height);
102
+ /* The point reaches 0.15 beyond the box, as it does in Figma, and the focus
103
+ ring is painted outside the silhouette. Neither may be clipped. */
104
+ overflow: visible;
105
+ }
106
+ /* The trailing terminal is the leading one mirrored, so they cannot drift. */
107
+ .ol8-chip__terminal--trailing { transform: scaleX(-1); }
108
+
109
+ .ol8-chip__fill { fill: var(--_surface); }
110
+
111
+ /* Double width, clipped to the fill: the outer half is cut away and what
112
+ remains is an inside edge of exactly one border width, matching the rail's
113
+ own inside border. A centred stroke would stand 1px proud of the rail and
114
+ show a step at the shoulder. */
115
+ .ol8-chip__edge {
116
+ fill: none;
117
+ stroke: var(--_edge);
118
+ stroke-width: calc(var(--ol8-border-width-standard) * 2);
119
+ vector-effect: non-scaling-stroke;
120
+ }
121
+
122
+ /* Both rings are painted before the fill and at double width, so the inner
123
+ half of each lands under the fill and only the outer half shows. This is how
124
+ the ring follows the hexagon instead of boxing it. */
125
+ .ol8-chip__focus-outer,
126
+ .ol8-chip__focus-inner { fill: none; vector-effect: non-scaling-stroke; }
127
+ .ol8-chip__focus-outer {
128
+ stroke: var(--_focus-outer);
129
+ stroke-width: calc((var(--ol8-focus-ring-outerwidth) + var(--ol8-focus-ring-innerwidth)) * 2);
130
+ }
131
+ .ol8-chip__focus-inner {
132
+ stroke: var(--_focus-inner);
133
+ stroke-width: calc(var(--ol8-focus-ring-innerwidth) * 2);
134
+ }
135
+
136
+ .ol8-chip:has(> .ol8-chip__input:focus-visible) {
137
+ --_focus-outer: var(--ol8-color-focus-outer);
138
+ --_focus-inner: var(--ol8-color-focus-inner);
139
+ }
140
+
141
+ /* ---- Centre rail ----------------------------------------------------- */
142
+ /* Only the rail grows with the label. It owns the top and bottom of the
143
+ perimeter; the terminals own the angled ends. */
144
+ .ol8-chip__rail {
145
+ box-sizing: border-box;
146
+ position: relative;
147
+ display: flex;
148
+ align-items: center;
149
+ gap: var(--ol8-component-choice-gap-standard);
150
+ block-size: var(--_height);
151
+ padding-inline: var(--_inset);
152
+ background: var(--_surface);
153
+ border-block: var(--ol8-border-width-standard) solid var(--_edge);
154
+ }
155
+
156
+ /* The ring's flat runs, continuing the terminals' strokes across the rail.
157
+ Two colours in one band, ordered outward from the silhouette. */
158
+ .ol8-chip__rail::before,
159
+ .ol8-chip__rail::after {
160
+ content: "";
161
+ position: absolute;
162
+ inset-inline: 0;
163
+ block-size: calc(var(--ol8-focus-ring-outerwidth) + var(--ol8-focus-ring-innerwidth));
164
+ }
165
+ .ol8-chip__rail::before {
166
+ inset-block-end: 100%;
167
+ background: linear-gradient(
168
+ var(--_focus-outer) 0 var(--ol8-focus-ring-outerwidth),
169
+ var(--_focus-inner) var(--ol8-focus-ring-outerwidth) 100%);
170
+ }
171
+ .ol8-chip__rail::after {
172
+ inset-block-start: 100%;
173
+ background: linear-gradient(
174
+ var(--_focus-inner) 0 var(--ol8-focus-ring-innerwidth),
175
+ var(--_focus-outer) var(--ol8-focus-ring-innerwidth) 100%);
176
+ }
177
+
178
+ .ol8-chip__label {
179
+ font-size: var(--_label-size);
180
+ line-height: var(--_label-line);
181
+ font-weight: var(--ol8-font-weight-regular);
182
+ color: var(--_ink);
183
+ }
184
+
185
+ /* ---- Mark ------------------------------------------------------------ */
186
+ /* Figma places the mark BEFORE the label in every variant, Remove included. */
187
+ .ol8-chip__mark {
188
+ flex: none;
189
+ display: inline-flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ inline-size: var(--ol8-component-choicechip-mark);
193
+ block-size: var(--ol8-component-choicechip-mark);
194
+ color: var(--_ink);
195
+ }
196
+
197
+ /* The Check does not scale with its slot. Figma's `Icon / Selection / Check`
198
+ is a governed small master, not an Icon Frame instance: Size=Small and
199
+ Size=Standard both carry the same 10.2 x 6.9 artwork at the same 1.8 stroke,
200
+ centred in an 18 or 24 box. So the source box stays 24 units and overflows
201
+ the smaller slot, exactly as Figma draws it. */
202
+ .ol8-chip__mark > svg {
203
+ display: block;
204
+ flex: none;
205
+ inline-size: var(--ol8-size-icon-standard);
206
+ block-size: var(--ol8-size-icon-standard);
207
+ overflow: visible;
208
+ }
209
+
210
+ /* Both marks read at the one governed optical stroke, which is what Figma
211
+ draws and what the iconography contract calls contextual stroke
212
+ normalization: peer marks in one family match apparent weight. */
213
+ .ol8-chip__mark > svg > * {
214
+ vector-effect: non-scaling-stroke;
215
+ stroke-width: var(--ol8-component-choice-optical-stroke);
216
+ }
@@ -0,0 +1,95 @@
1
+ /* GENERATED from packages/core/src/organisms/choice-group/choice-group.css by packages/sync/src/build-styles.mjs — do not edit.
2
+ Edit the source in packages/core/src and re-run `npm run sync:styles`. */
3
+ /**
4
+ * Oneli8 · Choice Group (ORGANISM)
5
+ * Figma: Documentation / Organism / Choice Group
6
+ * Checkbox Hierarchy 273:603 Aggregate = None | Some | All
7
+ * Radio Group 859:5180 Behavior = Safe Default | No Default |
8
+ * Horizontal | Invalid
9
+ *
10
+ * First organism in the system, and the first tier allowed to hold logic:
11
+ * the checkbox group computes its parent from its children, and the radio
12
+ * group owns group-level validation.
13
+ *
14
+ * From Figma (273:603): "Aggregate None/Some/All is computed from nested
15
+ * Checkbox molecules; MIXED IS NEVER A THIRD PREFERENCE. Runtime recursion,
16
+ * parent-child synchronization, group naming, required behavior, reset, and
17
+ * announcements live in code."
18
+ *
19
+ * So Aggregate is an OUTPUT, never an input. Nothing may author it.
20
+ *
21
+ * The two shells differ, deliberately:
22
+ * checkbox bordered, radius-container (24px), legend Title/Small
23
+ * radio unbordered, radius-standard (12px), legend Body/Medium
24
+ */
25
+
26
+ .ol8-choice-group {
27
+ box-sizing: border-box;
28
+ display: flex;
29
+ flex-direction: column;
30
+ gap: var(--ol8-spacing-stack-related);
31
+ padding: var(--ol8-spacing-inset-standard);
32
+ background: var(--ol8-color-surface-raised);
33
+ border: 0;
34
+ border-radius: var(--ol8-shape-radius-standard);
35
+ font-family: var(--ol8-font-family-functional);
36
+ font-variation-settings: "ARRR" var(--ol8-font-axis-arrr-baseline);
37
+ min-inline-size: 0; /* fieldset defaults to min-content; this lets it shrink */
38
+ }
39
+
40
+ .ol8-choice-group--checkbox {
41
+ border: 1px solid var(--ol8-color-border-subtle);
42
+ border-radius: var(--ol8-shape-radius-container);
43
+ }
44
+
45
+ /* <legend> is taken out of flow by default, so it is laid out as a normal
46
+ flex child instead — the group label is a real legend for assistive tech. */
47
+ .ol8-choice-group__legend {
48
+ display: block;
49
+ float: none;
50
+ padding: 0;
51
+ inline-size: 100%;
52
+ color: var(--ol8-color-text-primary);
53
+ }
54
+ .ol8-choice-group--checkbox > .ol8-choice-group__legend {
55
+ font-size: var(--ol8-font-size-021);
56
+ line-height: var(--ol8-font-line-030);
57
+ font-weight: var(--ol8-font-weight-semibold);
58
+ }
59
+ .ol8-choice-group--radio > .ol8-choice-group__legend {
60
+ font-size: var(--ol8-font-size-016);
61
+ line-height: var(--ol8-font-line-024);
62
+ font-weight: var(--ol8-font-weight-regular);
63
+ }
64
+
65
+ .ol8-choice-group__instruction {
66
+ font-size: var(--ol8-font-size-012);
67
+ line-height: var(--ol8-font-line-018);
68
+ letter-spacing: 0.12px;
69
+ color: var(--ol8-color-text-secondary);
70
+ }
71
+
72
+ .ol8-choice-group__options { display: flex; flex-direction: column; }
73
+ .ol8-choice-group[data-ol8-orientation="horizontal"] > .ol8-choice-group__options {
74
+ flex-direction: row;
75
+ gap: var(--ol8-spacing-stack-related);
76
+ flex-wrap: wrap;
77
+ }
78
+
79
+ /* Children of a hierarchy sit under their parent, indented one 24px step. */
80
+ .ol8-choice-group__children {
81
+ display: flex;
82
+ flex-direction: column;
83
+ padding-inline-start: var(--ol8-dimension-scale-024);
84
+ }
85
+
86
+ /* Group-level validation message. */
87
+ .ol8-choice-group__message {
88
+ font-size: var(--ol8-font-size-012);
89
+ line-height: var(--ol8-font-line-018);
90
+ letter-spacing: 0.12px;
91
+ color: var(--ol8-color-text-secondary);
92
+ }
93
+ .ol8-choice-group[data-ol8-invalid="true"] > .ol8-choice-group__message {
94
+ color: var(--ol8-color-feedbackcritical-inlinecontent);
95
+ }