@oneli8/core 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 (125) 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 +130 -0
  6. package/ai-context.json +348 -0
  7. package/package.json +100 -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/icon.css +54 -0
  12. package/src/atoms/icon/icon.d.ts +19 -0
  13. package/src/atoms/icon/icon.js +81 -0
  14. package/src/atoms/icon/icons.generated.d.ts +48 -0
  15. package/src/atoms/icon/icons.generated.js +562 -0
  16. package/src/atoms/icon/index.d.ts +2 -0
  17. package/src/atoms/icon/index.js +2 -0
  18. package/src/atoms/index.d.ts +3 -0
  19. package/src/atoms/index.js +3 -0
  20. package/src/atoms/navigation-badge/index.d.ts +1 -0
  21. package/src/atoms/navigation-badge/index.js +1 -0
  22. package/src/atoms/navigation-badge/navigation-badge.css +35 -0
  23. package/src/atoms/navigation-badge/navigation-badge.d.ts +5 -0
  24. package/src/atoms/navigation-badge/navigation-badge.js +21 -0
  25. package/src/atoms/selection-indicator/index.d.ts +1 -0
  26. package/src/atoms/selection-indicator/index.js +1 -0
  27. package/src/atoms/selection-indicator/selection-indicator.css +160 -0
  28. package/src/atoms/selection-indicator/selection-indicator.d.ts +36 -0
  29. package/src/atoms/selection-indicator/selection-indicator.js +61 -0
  30. package/src/index.d.ts +3 -0
  31. package/src/index.js +3 -0
  32. package/src/materials/gem/gem.css +374 -0
  33. package/src/molecules/button/button.css +193 -0
  34. package/src/molecules/button/button.d.ts +51 -0
  35. package/src/molecules/button/button.js +134 -0
  36. package/src/molecules/button/index.d.ts +1 -0
  37. package/src/molecules/button/index.js +1 -0
  38. package/src/molecules/choice-chip/choice-chip.css +214 -0
  39. package/src/molecules/choice-chip/choice-chip.d.ts +27 -0
  40. package/src/molecules/choice-chip/choice-chip.js +170 -0
  41. package/src/molecules/choice-chip/index.d.ts +2 -0
  42. package/src/molecules/choice-chip/index.js +10 -0
  43. package/src/molecules/choice-item/choice-item.css +265 -0
  44. package/src/molecules/choice-item/choice-item.d.ts +54 -0
  45. package/src/molecules/choice-item/choice-item.js +153 -0
  46. package/src/molecules/choice-item/index.d.ts +1 -0
  47. package/src/molecules/choice-item/index.js +1 -0
  48. package/src/molecules/form-message/form-message.css +46 -0
  49. package/src/molecules/form-message/form-message.d.ts +23 -0
  50. package/src/molecules/form-message/form-message.js +73 -0
  51. package/src/molecules/form-message/index.d.ts +1 -0
  52. package/src/molecules/form-message/index.js +1 -0
  53. package/src/molecules/icon-button/icon-button.css +222 -0
  54. package/src/molecules/icon-button/icon-button.d.ts +40 -0
  55. package/src/molecules/icon-button/icon-button.js +121 -0
  56. package/src/molecules/icon-button/index.d.ts +1 -0
  57. package/src/molecules/icon-button/index.js +1 -0
  58. package/src/molecules/index.d.ts +10 -0
  59. package/src/molecules/index.js +10 -0
  60. package/src/molecules/link/index.d.ts +1 -0
  61. package/src/molecules/link/index.js +1 -0
  62. package/src/molecules/link/link.css +114 -0
  63. package/src/molecules/link/link.d.ts +27 -0
  64. package/src/molecules/link/link.js +76 -0
  65. package/src/molecules/navigation-item/index.js +1 -0
  66. package/src/molecules/navigation-item/navigation-item.css +153 -0
  67. package/src/molecules/navigation-item/navigation-item.js +95 -0
  68. package/src/molecules/select/index.js +1 -0
  69. package/src/molecules/select/select.css +31 -0
  70. package/src/molecules/select/select.js +127 -0
  71. package/src/molecules/selection-option/index.d.ts +1 -0
  72. package/src/molecules/selection-option/index.js +1 -0
  73. package/src/molecules/selection-option/selection-option.css +121 -0
  74. package/src/molecules/selection-option/selection-option.d.ts +42 -0
  75. package/src/molecules/selection-option/selection-option.js +97 -0
  76. package/src/molecules/text-field/index.d.ts +1 -0
  77. package/src/molecules/text-field/index.js +5 -0
  78. package/src/molecules/text-field/text-field.css +266 -0
  79. package/src/molecules/text-field/text-field.d.ts +69 -0
  80. package/src/molecules/text-field/text-field.js +234 -0
  81. package/src/molecules/token/index.d.ts +2 -0
  82. package/src/molecules/token/index.js +1 -0
  83. package/src/molecules/token/token.css +63 -0
  84. package/src/molecules/token/token.d.ts +35 -0
  85. package/src/molecules/token/token.js +83 -0
  86. package/src/organisms/choice-group/choice-group.css +93 -0
  87. package/src/organisms/choice-group/choice-group.d.ts +80 -0
  88. package/src/organisms/choice-group/choice-group.js +159 -0
  89. package/src/organisms/choice-group/index.d.ts +1 -0
  90. package/src/organisms/choice-group/index.js +1 -0
  91. package/src/organisms/choice-picker/choice-picker.css +98 -0
  92. package/src/organisms/choice-picker/choice-picker.d.ts +62 -0
  93. package/src/organisms/choice-picker/choice-picker.js +201 -0
  94. package/src/organisms/choice-picker/index.d.ts +1 -0
  95. package/src/organisms/choice-picker/index.js +1 -0
  96. package/src/organisms/combobox/combobox.css +40 -0
  97. package/src/organisms/combobox/combobox.js +309 -0
  98. package/src/organisms/combobox/index.js +5 -0
  99. package/src/organisms/index.d.ts +8 -0
  100. package/src/organisms/index.js +8 -0
  101. package/src/organisms/multi-select-field/index.d.ts +1 -0
  102. package/src/organisms/multi-select-field/index.js +1 -0
  103. package/src/organisms/multi-select-field/multi-select-field.css +76 -0
  104. package/src/organisms/multi-select-field/multi-select-field.d.ts +64 -0
  105. package/src/organisms/multi-select-field/multi-select-field.js +317 -0
  106. package/src/organisms/navigation-keys.js +58 -0
  107. package/src/organisms/segmented-control/index.d.ts +1 -0
  108. package/src/organisms/segmented-control/index.js +1 -0
  109. package/src/organisms/segmented-control/segmented-control.css +113 -0
  110. package/src/organisms/segmented-control/segmented-control.d.ts +43 -0
  111. package/src/organisms/segmented-control/segmented-control.js +163 -0
  112. package/src/organisms/selection-popup/index.js +1 -0
  113. package/src/organisms/selection-popup/selection-popup.css +90 -0
  114. package/src/organisms/selection-popup/selection-popup.js +100 -0
  115. package/src/organisms/tab-bar/index.d.ts +1 -0
  116. package/src/organisms/tab-bar/index.js +1 -0
  117. package/src/organisms/tab-bar/tab-bar.css +45 -0
  118. package/src/organisms/tab-bar/tab-bar.d.ts +37 -0
  119. package/src/organisms/tab-bar/tab-bar.js +104 -0
  120. package/src/organisms/tabs/index.d.ts +1 -0
  121. package/src/organisms/tabs/index.js +1 -0
  122. package/src/organisms/tabs/tabs.css +53 -0
  123. package/src/organisms/tabs/tabs.d.ts +48 -0
  124. package/src/organisms/tabs/tabs.js +152 -0
  125. package/styles.css +29 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Oneli8 · Button
3
+ * Figma: section "Button" (1034:9968) — Primary 15:823, Secondary 15:824,
4
+ * Quiet 15:825, Destructive 15:826. 4 families × 4 sizes × 6 states.
5
+ *
6
+ * The governing rule comes from the Figma component description itself:
7
+ *
8
+ * "State → deterministic visual evidence … Runtime-only native button
9
+ * semantics, disabled, loading, pressed, fullWidth and iconMotion remain
10
+ * implemented and tested in code; they must not become fake visual axes."
11
+ *
12
+ * So the six Figma States are NOT six markup variants:
13
+ * Default → the base rule
14
+ * Hover → :hover
15
+ * Pressed → :active (and [aria-pressed="true"] for toggles)
16
+ * Focus → :focus-visible
17
+ * Disabled → [disabled] (native, not a class)
18
+ * Loading → [data-ol8-loading] (which also implies disabled + aria-busy)
19
+ *
20
+ * There is no .ol8-btn--hover. Emitting one is a bug.
21
+ */
22
+
23
+ .ol8-btn {
24
+ /* Per-variant channels. Families set these; the base rule never hardcodes. */
25
+ --_bg: transparent;
26
+ --_fg: currentColor;
27
+ --_bd: transparent;
28
+ --_bw: 0px;
29
+
30
+ /* Size channels, defaulted to Standard. */
31
+ --_h: var(--ol8-component-button-height-standard);
32
+ --_px: var(--ol8-component-button-paddinginline-standard);
33
+ --_r: var(--ol8-component-button-radius-standard);
34
+ --_gap: var(--ol8-component-button-gap-compact);
35
+ --_icon: var(--ol8-size-icon-small);
36
+
37
+ box-sizing: border-box; /* Figma strokes are inside the frame */
38
+ display: inline-flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ gap: var(--_gap);
42
+ block-size: var(--_h);
43
+ padding-inline: var(--_px);
44
+ padding-block: 0;
45
+ border: var(--_bw) solid var(--_bd);
46
+ border-radius: var(--_r);
47
+ background: var(--_bg);
48
+ color: var(--_fg);
49
+
50
+ font-family: var(--ol8-font-family-functional);
51
+ font-size: var(--ol8-font-size-016);
52
+ line-height: var(--ol8-font-line-024);
53
+ font-weight: var(--ol8-font-weight-regular);
54
+ letter-spacing: 0;
55
+ /* AR One Sans variable axis. Figma sets this on every functional text node;
56
+ omitting it shifts glyph advances and the button measures narrow. */
57
+ font-variation-settings: "ARRR" var(--ol8-font-axis-arrr-baseline);
58
+
59
+ cursor: pointer;
60
+ user-select: none;
61
+ white-space: nowrap;
62
+ text-decoration: none; /* when rendered as <a class="ol8-btn"> */
63
+ -webkit-appearance: none;
64
+ appearance: none;
65
+ }
66
+
67
+ .ol8-btn__label { white-space: nowrap; }
68
+
69
+ /* Button size governs icon size — the Figma Icon Frame is driven by the
70
+ button, never set on the glyph. Higher specificity than .ol8-icon's own
71
+ [data-ol8-icon-size] rule, deliberately. */
72
+ .ol8-btn .ol8-icon { --_ol8-icon-size: var(--_icon); }
73
+
74
+ /* ---- Size (Figma: Size variant axis) --------------------------------- */
75
+ .ol8-btn[data-ol8-size="compact"] {
76
+ --_h: var(--ol8-component-button-height-compact);
77
+ --_px: var(--ol8-component-button-paddinginline-compact);
78
+ --_r: var(--ol8-component-button-radius-compact);
79
+ --_gap: var(--ol8-component-button-gap-compact);
80
+ --_icon: var(--ol8-size-icon-small);
81
+ }
82
+ .ol8-btn[data-ol8-size="standard"] {
83
+ --_h: var(--ol8-component-button-height-standard);
84
+ --_px: var(--ol8-component-button-paddinginline-standard);
85
+ --_r: var(--ol8-component-button-radius-standard);
86
+ --_gap: var(--ol8-component-button-gap-compact); /* shared, by design */
87
+ --_icon: var(--ol8-size-icon-small);
88
+ }
89
+ .ol8-btn[data-ol8-size="comfortable"] {
90
+ --_h: var(--ol8-component-button-height-comfortable);
91
+ --_px: var(--ol8-component-button-paddinginline-comfortable);
92
+ --_r: var(--ol8-component-button-radius-comfortable);
93
+ --_gap: var(--ol8-component-button-gap-comfortable);
94
+ --_icon: var(--ol8-size-icon-standard);
95
+ }
96
+ .ol8-btn[data-ol8-size="large"] {
97
+ --_h: var(--ol8-component-button-height-large);
98
+ --_px: var(--ol8-component-button-paddinginline-large);
99
+ --_r: var(--ol8-component-button-radius-large);
100
+ --_gap: var(--ol8-component-button-gap-comfortable); /* shared, by design */
101
+ --_icon: var(--ol8-size-icon-standard);
102
+ }
103
+
104
+ /* ---- Family: Primary ------------------------------------------------- */
105
+ .ol8-btn--primary { --_bg: var(--ol8-color-actionprimary-default); --_fg: var(--ol8-color-actionprimary-content); }
106
+ .ol8-btn--primary:hover:not(:disabled):not([data-ol8-loading]) { --_bg: var(--ol8-color-actionprimary-hover); }
107
+ .ol8-btn--primary:active:not(:disabled):not([data-ol8-loading]),
108
+ .ol8-btn--primary[aria-pressed="true"]:not(:disabled) { --_bg: var(--ol8-color-actionprimary-pressed); }
109
+ .ol8-btn--primary:disabled, .ol8-btn--primary[data-ol8-loading] {
110
+ --_bg: var(--ol8-color-actionprimary-disabled);
111
+ --_fg: var(--ol8-color-actionprimary-disabledcontent);
112
+ }
113
+
114
+ /* ---- Family: Destructive (same shape as Primary) --------------------- */
115
+ .ol8-btn--destructive { --_bg: var(--ol8-color-actiondestructive-default); --_fg: var(--ol8-color-actiondestructive-content); }
116
+ .ol8-btn--destructive:hover:not(:disabled):not([data-ol8-loading]) { --_bg: var(--ol8-color-actiondestructive-hover); }
117
+ .ol8-btn--destructive:active:not(:disabled):not([data-ol8-loading]),
118
+ .ol8-btn--destructive[aria-pressed="true"]:not(:disabled) { --_bg: var(--ol8-color-actiondestructive-pressed); }
119
+ .ol8-btn--destructive:disabled, .ol8-btn--destructive[data-ol8-loading] {
120
+ --_bg: var(--ol8-color-actiondestructive-disabled);
121
+ --_fg: var(--ol8-color-actiondestructive-disabledcontent);
122
+ }
123
+
124
+ /* ---- Family: Secondary ----------------------------------------------
125
+ Secondary and Quiet carry byte-identical color roles in Figma. The ONLY
126
+ differentiator is this boundary: Secondary has a 2px stroke whose color
127
+ tracks the content color; Quiet has none. Verified — Quiet's node binds no
128
+ --ol8-component-button-secondaryboundary at all. */
129
+ .ol8-btn--secondary {
130
+ --_bw: var(--ol8-component-button-secondaryboundary);
131
+ --_bg: var(--ol8-color-actionsecondary-surfacedefault);
132
+ --_fg: var(--ol8-color-actionsecondary-contentdefault);
133
+ --_bd: var(--ol8-color-actionsecondary-contentdefault);
134
+ }
135
+ .ol8-btn--secondary:hover:not(:disabled):not([data-ol8-loading]) {
136
+ --_bg: var(--ol8-color-actionsecondary-surfacehover);
137
+ --_fg: var(--ol8-color-actionsecondary-contenthover);
138
+ --_bd: var(--ol8-color-actionsecondary-contenthover);
139
+ }
140
+ .ol8-btn--secondary:active:not(:disabled):not([data-ol8-loading]),
141
+ .ol8-btn--secondary[aria-pressed="true"]:not(:disabled) {
142
+ --_bg: var(--ol8-color-actionsecondary-surfacepressed);
143
+ --_fg: var(--ol8-color-actionsecondary-contentpressed);
144
+ --_bd: var(--ol8-color-actionsecondary-contentpressed);
145
+ }
146
+ .ol8-btn--secondary:disabled, .ol8-btn--secondary[data-ol8-loading] {
147
+ --_bg: var(--ol8-color-actionsecondary-surfacedisabled);
148
+ --_fg: var(--ol8-color-actionsecondary-contentdisabled);
149
+ --_bd: var(--ol8-color-actionsecondary-contentdisabled);
150
+ }
151
+
152
+ /* ---- Family: Quiet (identical colors, no boundary) ------------------- */
153
+ .ol8-btn--quiet {
154
+ --_bg: var(--ol8-color-actionquiet-surfacedefault);
155
+ --_fg: var(--ol8-color-actionquiet-contentdefault);
156
+ }
157
+ .ol8-btn--quiet:hover:not(:disabled):not([data-ol8-loading]) {
158
+ --_bg: var(--ol8-color-actionquiet-surfacehover);
159
+ --_fg: var(--ol8-color-actionquiet-contenthover);
160
+ }
161
+ .ol8-btn--quiet:active:not(:disabled):not([data-ol8-loading]),
162
+ .ol8-btn--quiet[aria-pressed="true"]:not(:disabled) {
163
+ --_bg: var(--ol8-color-actionquiet-surfacepressed);
164
+ --_fg: var(--ol8-color-actionquiet-contentpressed);
165
+ }
166
+ .ol8-btn--quiet:disabled, .ol8-btn--quiet[data-ol8-loading] {
167
+ --_bg: var(--ol8-color-actionquiet-surfacedisabled);
168
+ --_fg: var(--ol8-color-actionquiet-contentdisabled);
169
+ }
170
+
171
+ /* ---- Focus ----------------------------------------------------------
172
+ Figma draws the focus ring as an INSIDE stroke, so the focused variant is
173
+ exactly the same size as the unfocused one (83×42 either way, Standard).
174
+ An inset box-shadow reproduces that without disturbing layout or fighting
175
+ Secondary's own 2px boundary — `outline` would sit outside and grow it. */
176
+ .ol8-btn:focus-visible {
177
+ outline: none;
178
+ box-shadow: inset 0 0 0 var(--ol8-focus-ring-innerwidth) var(--ol8-color-focus-outer);
179
+ }
180
+
181
+ /* ---- Disabled / Loading --------------------------------------------- */
182
+ .ol8-btn:disabled, .ol8-btn[data-ol8-loading] { cursor: not-allowed; }
183
+ .ol8-btn[data-ol8-loading] { cursor: progress; }
184
+
185
+ /* "Platform motion adapters may rotate it; Reduced Motion keeps the static
186
+ busy mark." — Figma 379:7. Rotation is opt-in and motion-guarded. */
187
+ @media (prefers-reduced-motion: no-preference) {
188
+ .ol8-btn[data-ol8-loading] .ol8-btn__spinner > svg { animation: ol8-spin 900ms linear infinite; }
189
+ }
190
+ @keyframes ol8-spin { to { transform: rotate(360deg); } }
191
+
192
+ /* ---- fullWidth (runtime-only, per the Figma description) ------------- */
193
+ .ol8-btn[data-ol8-fullwidth] { display: flex; inline-size: 100%; }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Button (MOLECULE) — headless behavior. Native semantics, disabled, loading,
3
+ * pressed and fullWidth live here, never as visual-only axes.
4
+ */
5
+ import type { Ol8IconName } from '../../atoms/icon/icons.generated.js';
6
+
7
+ export type Ol8ButtonVariant = 'primary' | 'secondary' | 'quiet' | 'destructive';
8
+ export type Ol8ButtonSize = 'compact' | 'standard' | 'comfortable' | 'large';
9
+ export type Ol8Material = 'regular' | 'gem';
10
+ /** Gem is approved for Primary and Secondary only (Figma 100:3). */
11
+ export type Ol8GemVariant = 'primary' | 'secondary';
12
+
13
+ export declare const OL8_BUTTON_VARIANTS: readonly Ol8ButtonVariant[];
14
+ export declare const OL8_BUTTON_SIZES: readonly Ol8ButtonSize[];
15
+ export declare const OL8_MATERIALS: readonly Ol8Material[];
16
+ export declare const GEM_VARIANTS: readonly Ol8GemVariant[];
17
+
18
+ /** Compact+Standard take the 18px icon; Comfortable+Large take 24px. */
19
+ export declare function iconSizeForButton(size: Ol8ButtonSize): 18 | 24;
20
+
21
+ export interface Ol8ButtonOptions {
22
+ variant?: Ol8ButtonVariant;
23
+ size?: Ol8ButtonSize;
24
+ leadingIcon?: Ol8IconName;
25
+ trailingIcon?: Ol8IconName;
26
+ disabled?: boolean;
27
+ loading?: boolean;
28
+ /** Emits aria-pressed. Omit entirely for a non-toggle button. */
29
+ pressed?: boolean;
30
+ fullWidth?: boolean;
31
+ /** Renders an <a> instead of a <button>. */
32
+ href?: string;
33
+ /** Ignored when href is set. Defaults to "button", never a stray submit. */
34
+ type?: 'button' | 'submit' | 'reset';
35
+ /** Throws for quiet and destructive: Gem has no approved treatment there. */
36
+ material?: Ol8Material;
37
+ /**
38
+ * By default the spinner takes the leading slot so the button never shows two
39
+ * competing glyphs. Set true for Figma's literal reading (icon AND spinner).
40
+ */
41
+ keepLeadingIconWhileLoading?: boolean;
42
+ className?: string;
43
+ }
44
+
45
+ /** @throws if variant, size or material is unknown, or Gem is asked for on quiet/destructive. */
46
+ export declare function renderButton(label: string, options?: Ol8ButtonOptions): string;
47
+
48
+ /** Enforces the runtime contract on existing `.ol8-btn` markup. Idempotent. */
49
+ export declare function hydrateButtons(root?: ParentNode): void;
50
+
51
+ export declare function setButtonLoading(el: Element, loading: boolean): void;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Oneli8 · Button — headless behavior.
3
+ *
4
+ * The Figma component description is explicit that native semantics, disabled,
5
+ * loading, pressed and fullWidth "remain implemented and tested in code; they
6
+ * must not become fake visual axes." This module is that code.
7
+ */
8
+ import { renderIcon, renderIconSvg, isOl8IconName } from '../../atoms/icon/icon.js';
9
+
10
+ export const OL8_BUTTON_VARIANTS = ['primary', 'secondary', 'quiet', 'destructive'];
11
+ export const OL8_BUTTON_SIZES = ['compact', 'standard', 'comfortable', 'large'];
12
+ export const OL8_MATERIALS = ['regular', 'gem'];
13
+ /** Gem is approved for Primary and Secondary only (Figma 100:3). */
14
+ export const GEM_VARIANTS = ['primary', 'secondary'];
15
+
16
+ /** Compact+Standard take the 18px icon; Comfortable+Large take 24px. */
17
+ export function iconSizeForButton(size) {
18
+ return size === 'comfortable' || size === 'large' ? 24 : 18;
19
+ }
20
+
21
+ /**
22
+ * @param {string} label
23
+ * @param {{variant?:string,size?:string,leadingIcon?:string,trailingIcon?:string,
24
+ * disabled?:boolean,loading?:boolean,pressed?:boolean,fullWidth?:boolean,
25
+ * href?:string,type?:string,className?:string}} [options]
26
+ */
27
+ export function renderButton(label, options = {}) {
28
+ const {
29
+ variant = 'primary', size = 'standard', leadingIcon, trailingIcon,
30
+ disabled = false, loading = false, pressed, fullWidth = false,
31
+ href, type = 'button', className, material = 'regular',
32
+ keepLeadingIconWhileLoading = false,
33
+ } = options;
34
+
35
+ if (!OL8_BUTTON_VARIANTS.includes(variant)) throw new Error(`[ol8] unknown button variant "${variant}"`);
36
+ if (!OL8_BUTTON_SIZES.includes(size)) throw new Error(`[ol8] unknown button size "${size}"`);
37
+ if (!OL8_MATERIALS.includes(material)) throw new Error(`[ol8] unknown material "${material}"`);
38
+ if (material === 'gem' && !GEM_VARIANTS.includes(variant)) {
39
+ throw new Error(`[ol8] Gem has no approved treatment for "${variant}" — Figma 100:3 says Quiet and Destructive use the regular material.`);
40
+ }
41
+
42
+ const isLink = typeof href === 'string';
43
+ const inert = disabled || loading;
44
+ const iconSize = iconSizeForButton(size);
45
+
46
+ const attrs = [
47
+ `class="ol8-btn ol8-btn--${variant}${className ? ` ${className}` : ''}"`,
48
+ `data-ol8-size="${size}"`,
49
+ isLink ? (inert ? 'role="button" aria-disabled="true" tabindex="-1"' : `href="${escapeAttr(href)}"`)
50
+ : `type="${type}"`,
51
+ !isLink && inert ? 'disabled' : '',
52
+ loading ? 'data-ol8-loading="true" aria-busy="true"' : '',
53
+ pressed !== undefined ? `aria-pressed="${pressed}"` : '',
54
+ fullWidth ? 'data-ol8-fullwidth="true"' : '',
55
+ material === 'gem' ? 'data-ol8-material="gem"' : '',
56
+ loading && keepLeadingIconWhileLoading ? 'data-ol8-loading-keep-icon="true"' : '',
57
+ ].filter(Boolean).join(' ');
58
+
59
+ // Loading + leading icon is genuinely ambiguous in the source. Figma's
60
+ // codegen emits BOTH the leading icon and the spinner, but the measured
61
+ // Loading widths (101/107/122/134 = default + spinner + gap) come from
62
+ // variants that carry no leading icon, so they do not disambiguate.
63
+ //
64
+ // Default here: the spinner TAKES the leading slot, so the button never
65
+ // shows two competing leading glyphs and its width stays predictable.
66
+ // Pass keepLeadingIconWhileLoading to get Figma's literal reading.
67
+ const spinner = `<span class="ol8-btn__spinner ol8-icon" aria-hidden="true">${renderIconSvg('loading')}</span>`;
68
+ const leadingGlyph = leadingIcon ? renderIcon(leadingIcon, { size: iconSize }) : '';
69
+ const leading = loading
70
+ ? (keepLeadingIconWhileLoading ? leadingGlyph + spinner : spinner)
71
+ : leadingGlyph;
72
+ const trailing = trailingIcon ? renderIcon(trailingIcon, { size: iconSize }) : '';
73
+
74
+ const tag = isLink ? 'a' : 'button';
75
+ return `<${tag} ${attrs}>${leading}<span class="ol8-btn__label">${escapeText(label)}</span>${trailing}</${tag}>`;
76
+ }
77
+
78
+ /**
79
+ * Enforces the runtime contract on existing `.ol8-btn` markup. Idempotent.
80
+ * - <button> gets an explicit type (default "button", never a stray submit)
81
+ * - [data-ol8-loading] implies disabled + aria-busy and grows a spinner
82
+ * - links can't be natively disabled, so they get aria-disabled + tabindex=-1
83
+ * and their activation is swallowed
84
+ */
85
+ export function hydrateButtons(root = document) {
86
+ for (const el of root.querySelectorAll('.ol8-btn')) {
87
+ const isLink = el.tagName === 'A';
88
+ const loading = el.dataset.ol8Loading === 'true';
89
+
90
+ if (!isLink && !el.hasAttribute('type')) el.setAttribute('type', 'button');
91
+
92
+ if (loading) {
93
+ el.setAttribute('aria-busy', 'true');
94
+ if (isLink) { el.setAttribute('aria-disabled', 'true'); el.setAttribute('tabindex', '-1'); }
95
+ else el.disabled = true;
96
+
97
+ if (!el.querySelector('.ol8-btn__spinner')) {
98
+ // Same call as renderButton: the spinner takes the leading slot unless
99
+ // data-ol8-loading-keep-icon opts into Figma's literal both-glyphs read.
100
+ if (el.dataset.ol8LoadingKeepIcon !== 'true') {
101
+ el.querySelector(':scope > .ol8-icon:first-child')?.remove();
102
+ }
103
+ const spinner = document.createElement('span');
104
+ spinner.className = 'ol8-btn__spinner ol8-icon';
105
+ spinner.setAttribute('aria-hidden', 'true');
106
+ spinner.innerHTML = renderIconSvg('loading');
107
+ const keep = el.dataset.ol8LoadingKeepIcon === 'true';
108
+ const after = keep ? el.querySelector(':scope > .ol8-icon:first-child') : null;
109
+ if (after) after.after(spinner); else el.prepend(spinner);
110
+ }
111
+ } else {
112
+ el.removeAttribute('aria-busy');
113
+ el.querySelector('.ol8-btn__spinner')?.remove();
114
+ }
115
+
116
+ if (isLink && (el.getAttribute('aria-disabled') === 'true') && !el.dataset.ol8InertBound) {
117
+ el.addEventListener('click', swallow);
118
+ el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') swallow(e); });
119
+ el.dataset.ol8InertBound = 'true';
120
+ }
121
+ }
122
+ }
123
+
124
+ function swallow(event) { event.preventDefault(); event.stopPropagation(); }
125
+
126
+ /** Flip a button into or out of its loading state at runtime. */
127
+ export function setButtonLoading(el, loading) {
128
+ if (loading) el.dataset.ol8Loading = 'true';
129
+ else delete el.dataset.ol8Loading;
130
+ hydrateButtons(el.parentNode ?? document);
131
+ }
132
+
133
+ function escapeAttr(v) { return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
134
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
@@ -0,0 +1 @@
1
+ export * from './button.js';
@@ -0,0 +1 @@
1
+ export * from './button.js';
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Oneli8 · Choice Chip (MOLECULE) — the Soft Hexagon
3
+ * Figma: Select and Combobox / Choice Chip / Soft Hexagon 636:847 (8 variants)
4
+ *
5
+ * GEOMETRY, read from all four sizes in Figma:
6
+ * size target visible rail inset gap label
7
+ * compact 48 30 9 9 12 / 18
8
+ * standard 48 36 12 9 12 / 18
9
+ * comfortable 48 42 15 9 16 / 24
10
+ * large 60 42 15 9 16 / 24
11
+ * The terminal is a fixed 12 at every size and never scales with the label,
12
+ * so peer chips of different lengths keep one shoulder depth and side angle.
13
+ *
14
+ * The visible silhouette sits inside a taller protected target, which is why
15
+ * `min-block-size` is the target and the rail carries the visible height.
16
+ */
17
+
18
+ .ol8-chip {
19
+ --_height: var(--ol8-component-choice-chip-height-standard);
20
+ --_target: var(--ol8-component-choice-chip-target-standard);
21
+ --_inset: var(--ol8-component-choice-chip-rail-inset-standard);
22
+ --_label-size: var(--ol8-font-size-012);
23
+ --_label-line: var(--ol8-font-line-018);
24
+
25
+ /* Inactive is the quiet Neutral treatment; Selected is Primary Soft. */
26
+ --_surface: var(--ol8-color-surface-sunken);
27
+ --_edge: var(--ol8-color-border-subtle);
28
+ --_ink: var(--ol8-color-text-primary);
29
+
30
+ /* The ring is painted at every state and is simply invisible until focus,
31
+ so focus never changes the geometry, only the colour. */
32
+ --_focus-outer: transparent;
33
+ --_focus-inner: transparent;
34
+
35
+ box-sizing: border-box;
36
+ position: relative;
37
+ display: inline-flex;
38
+ align-items: center;
39
+ min-block-size: var(--_target);
40
+ font-family: var(--ol8-font-family-functional);
41
+ font-variation-settings: "ARRR" var(--ol8-font-axis-arrr-baseline);
42
+ color: var(--_ink);
43
+ cursor: pointer;
44
+ }
45
+ .ol8-chip[data-ol8-availability="disabled"] { cursor: not-allowed; }
46
+
47
+ /* ---- Size ------------------------------------------------------------ */
48
+ .ol8-chip[data-ol8-size="compact"] {
49
+ --_height: var(--ol8-component-choice-chip-height-compact);
50
+ --_inset: var(--ol8-component-choice-chip-rail-inset-compact);
51
+ }
52
+ .ol8-chip[data-ol8-size="standard"] {
53
+ --_height: var(--ol8-component-choice-chip-height-standard);
54
+ --_inset: var(--ol8-component-choice-chip-rail-inset-standard);
55
+ }
56
+ .ol8-chip[data-ol8-size="comfortable"] {
57
+ --_height: var(--ol8-component-choice-chip-height-comfortable);
58
+ --_inset: var(--ol8-component-choice-chip-rail-inset-comfortable);
59
+ --_label-size: var(--ol8-font-size-016);
60
+ --_label-line: var(--ol8-font-line-024);
61
+ }
62
+ .ol8-chip[data-ol8-size="large"] {
63
+ --_height: var(--ol8-component-choice-chip-height-large);
64
+ --_target: var(--ol8-component-choice-chip-target-large);
65
+ --_inset: var(--ol8-component-choice-chip-rail-inset-large);
66
+ --_label-size: var(--ol8-font-size-016);
67
+ --_label-line: var(--ol8-font-line-024);
68
+ }
69
+
70
+ /* ---- Selection ------------------------------------------------------- */
71
+ /* Never colour alone: the native checked state carries the same fact, and the
72
+ selected edge is a stronger contrast than the inactive one. */
73
+ .ol8-chip[data-ol8-selection="selected"] {
74
+ --_surface: var(--ol8-color-selection-surface);
75
+ --_edge: var(--ol8-color-primary-360);
76
+ --_ink: var(--ol8-color-selection-content);
77
+ }
78
+ .ol8-chip[data-ol8-availability="disabled"] {
79
+ --_surface: var(--ol8-color-surface-disabled);
80
+ --_edge: var(--ol8-color-border-disabled);
81
+ --_ink: var(--ol8-color-text-disabled);
82
+ }
83
+
84
+ .ol8-chip__input {
85
+ position: absolute;
86
+ inline-size: 1px;
87
+ block-size: 1px;
88
+ opacity: 0;
89
+ pointer-events: none;
90
+ }
91
+
92
+ /* ---- Terminals ------------------------------------------------------- */
93
+ /* One viewBox serves every height: Figma's terminal paths share identical X
94
+ values at 30, 36 and 42, and Y values that are the same fractions of the
95
+ height, so stretching Y alone is exact rather than approximate. */
96
+ .ol8-chip__terminal {
97
+ flex: none;
98
+ inline-size: var(--ol8-component-choice-chip-terminal);
99
+ block-size: var(--_height);
100
+ /* The point reaches 0.15 beyond the box, as it does in Figma, and the focus
101
+ ring is painted outside the silhouette. Neither may be clipped. */
102
+ overflow: visible;
103
+ }
104
+ /* The trailing terminal is the leading one mirrored, so they cannot drift. */
105
+ .ol8-chip__terminal--trailing { transform: scaleX(-1); }
106
+
107
+ .ol8-chip__fill { fill: var(--_surface); }
108
+
109
+ /* Double width, clipped to the fill: the outer half is cut away and what
110
+ remains is an inside edge of exactly one border width, matching the rail's
111
+ own inside border. A centred stroke would stand 1px proud of the rail and
112
+ show a step at the shoulder. */
113
+ .ol8-chip__edge {
114
+ fill: none;
115
+ stroke: var(--_edge);
116
+ stroke-width: calc(var(--ol8-border-width-standard) * 2);
117
+ vector-effect: non-scaling-stroke;
118
+ }
119
+
120
+ /* Both rings are painted before the fill and at double width, so the inner
121
+ half of each lands under the fill and only the outer half shows. This is how
122
+ the ring follows the hexagon instead of boxing it. */
123
+ .ol8-chip__focus-outer,
124
+ .ol8-chip__focus-inner { fill: none; vector-effect: non-scaling-stroke; }
125
+ .ol8-chip__focus-outer {
126
+ stroke: var(--_focus-outer);
127
+ stroke-width: calc((var(--ol8-focus-ring-outerwidth) + var(--ol8-focus-ring-innerwidth)) * 2);
128
+ }
129
+ .ol8-chip__focus-inner {
130
+ stroke: var(--_focus-inner);
131
+ stroke-width: calc(var(--ol8-focus-ring-innerwidth) * 2);
132
+ }
133
+
134
+ .ol8-chip:has(> .ol8-chip__input:focus-visible) {
135
+ --_focus-outer: var(--ol8-color-focus-outer);
136
+ --_focus-inner: var(--ol8-color-focus-inner);
137
+ }
138
+
139
+ /* ---- Centre rail ----------------------------------------------------- */
140
+ /* Only the rail grows with the label. It owns the top and bottom of the
141
+ perimeter; the terminals own the angled ends. */
142
+ .ol8-chip__rail {
143
+ box-sizing: border-box;
144
+ position: relative;
145
+ display: flex;
146
+ align-items: center;
147
+ gap: var(--ol8-component-choice-gap-standard);
148
+ block-size: var(--_height);
149
+ padding-inline: var(--_inset);
150
+ background: var(--_surface);
151
+ border-block: var(--ol8-border-width-standard) solid var(--_edge);
152
+ }
153
+
154
+ /* The ring's flat runs, continuing the terminals' strokes across the rail.
155
+ Two colours in one band, ordered outward from the silhouette. */
156
+ .ol8-chip__rail::before,
157
+ .ol8-chip__rail::after {
158
+ content: "";
159
+ position: absolute;
160
+ inset-inline: 0;
161
+ block-size: calc(var(--ol8-focus-ring-outerwidth) + var(--ol8-focus-ring-innerwidth));
162
+ }
163
+ .ol8-chip__rail::before {
164
+ inset-block-end: 100%;
165
+ background: linear-gradient(
166
+ var(--_focus-outer) 0 var(--ol8-focus-ring-outerwidth),
167
+ var(--_focus-inner) var(--ol8-focus-ring-outerwidth) 100%);
168
+ }
169
+ .ol8-chip__rail::after {
170
+ inset-block-start: 100%;
171
+ background: linear-gradient(
172
+ var(--_focus-inner) 0 var(--ol8-focus-ring-innerwidth),
173
+ var(--_focus-outer) var(--ol8-focus-ring-innerwidth) 100%);
174
+ }
175
+
176
+ .ol8-chip__label {
177
+ font-size: var(--_label-size);
178
+ line-height: var(--_label-line);
179
+ font-weight: var(--ol8-font-weight-regular);
180
+ color: var(--_ink);
181
+ }
182
+
183
+ /* ---- Mark ------------------------------------------------------------ */
184
+ /* Figma places the mark BEFORE the label in every variant, Remove included. */
185
+ .ol8-chip__mark {
186
+ flex: none;
187
+ display: inline-flex;
188
+ align-items: center;
189
+ justify-content: center;
190
+ inline-size: var(--ol8-component-choicechip-mark);
191
+ block-size: var(--ol8-component-choicechip-mark);
192
+ color: var(--_ink);
193
+ }
194
+
195
+ /* The Check does not scale with its slot. Figma's `Icon / Selection / Check`
196
+ is a governed small master, not an Icon Frame instance: Size=Small and
197
+ Size=Standard both carry the same 10.2 x 6.9 artwork at the same 1.8 stroke,
198
+ centred in an 18 or 24 box. So the source box stays 24 units and overflows
199
+ the smaller slot, exactly as Figma draws it. */
200
+ .ol8-chip__mark > svg {
201
+ display: block;
202
+ flex: none;
203
+ inline-size: var(--ol8-size-icon-standard);
204
+ block-size: var(--ol8-size-icon-standard);
205
+ overflow: visible;
206
+ }
207
+
208
+ /* Both marks read at the one governed optical stroke, which is what Figma
209
+ draws and what the iconography contract calls contextual stroke
210
+ normalization: peer marks in one family match apparent weight. */
211
+ .ol8-chip__mark > svg > * {
212
+ vector-effect: non-scaling-stroke;
213
+ stroke-width: var(--ol8-component-choice-optical-stroke);
214
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Choice Chip (MOLECULE) — the Soft Hexagon. A real checkbox wearing the
3
+ * approved quiet chip appearance, so selection is carried by native checked
4
+ * state and a stronger edge, never by colour alone.
5
+ */
6
+ export type Ol8ChipSize = 'compact' | 'standard' | 'comfortable' | 'large';
7
+
8
+ export declare const OL8_CHIP_SIZES: readonly Ol8ChipSize[];
9
+
10
+ export interface Ol8ChoiceChipOptions {
11
+ size?: Ol8ChipSize;
12
+ /** Figma's Selection axis. Maps to the input's checked state. */
13
+ selected?: boolean;
14
+ /** Figma's Show Mark boolean: the optional Check inside a selected chip. */
15
+ showMark?: boolean;
16
+ disabled?: boolean;
17
+ name?: string;
18
+ value?: string;
19
+ id?: string;
20
+ className?: string;
21
+ }
22
+
23
+ export declare function renderChoiceChip(label: string, options?: Ol8ChoiceChipOptions): string;
24
+
25
+ /** Mirrors each chip's input state onto the attribute the stylesheet reads. */
26
+ export declare function hydrateChoiceChips(root?: ParentNode): number;
27
+ export declare function syncChip(chip: Element): void;