@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,46 @@
1
+ /**
2
+ * Oneli8 · Form Message (MOLECULE)
3
+ * Figma: Form Message 177:13, five tones.
4
+ *
5
+ * Anatomy read from Figma: a horizontal row, gap Component / Text Field / Gap /
6
+ * Status, items centred, one Small status icon beside twelve over eighteen
7
+ * regular text. Tone changes exactly one thing, the inline content colour,
8
+ * which both the icon stroke and the text take.
9
+ */
10
+
11
+ .ol8-form-message {
12
+ display: flex;
13
+ align-items: center;
14
+ gap: var(--ol8-component-textfield-gap-status);
15
+ color: var(--_tone);
16
+ font-family: var(--ol8-font-family-functional);
17
+ font-size: var(--ol8-font-size-012);
18
+ font-weight: var(--ol8-font-weight-regular);
19
+ line-height: var(--ol8-font-line-018);
20
+ }
21
+
22
+ .ol8-form-message__icon { flex: none; color: var(--_tone); }
23
+
24
+ /* ---- Tone ------------------------------------------------------------- */
25
+ .ol8-form-message[data-ol8-tone="critical"] { --_tone: var(--ol8-color-feedbackcritical-inlinecontent); }
26
+ .ol8-form-message[data-ol8-tone="caution"] { --_tone: var(--ol8-color-feedbackcaution-inlinecontent); }
27
+ .ol8-form-message[data-ol8-tone="positive"] { --_tone: var(--ol8-color-feedbackpositive-inlinecontent); }
28
+ .ol8-form-message[data-ol8-tone="informative"] { --_tone: var(--ol8-color-feedbackinformative-inlinecontent); }
29
+
30
+ /* Pending reads as informative text, and Figma gives its loading mark the
31
+ primary icon colour rather than a feedback colour, because nothing has been
32
+ decided yet. */
33
+ .ol8-form-message[data-ol8-tone="pending"] {
34
+ --_tone: var(--ol8-color-feedbackinformative-inlinecontent);
35
+ }
36
+ .ol8-form-message[data-ol8-tone="pending"] .ol8-form-message__icon {
37
+ color: var(--ol8-color-icon-primary);
38
+ }
39
+
40
+ /* Same treatment the Button gives its busy mark: rotation is opt in and motion
41
+ guarded, and Reduced Motion keeps the static mark. */
42
+ @media (prefers-reduced-motion: no-preference) {
43
+ .ol8-form-message[data-ol8-tone="pending"] .ol8-form-message__icon > svg {
44
+ animation: ol8-spin 900ms linear infinite;
45
+ }
46
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Form Message (MOLECULE). Figma 177:13. Tone maps to semantic message tone;
3
+ * Pending is the validating state rather than a fifth error tone.
4
+ */
5
+ export type Ol8FormMessageTone = 'critical' | 'caution' | 'positive' | 'informative' | 'pending';
6
+
7
+ export declare const OL8_FORM_MESSAGE_TONES: readonly Ol8FormMessageTone[];
8
+ export declare const OL8_FORM_MESSAGE_ICONS: Readonly<Record<Ol8FormMessageTone, string>>;
9
+
10
+ export interface Ol8FormMessageOptions {
11
+ tone?: Ol8FormMessageTone;
12
+ /** Figma's Show Icon. */
13
+ icon?: boolean;
14
+ /** Give it an id so a field can point aria-describedby at it. */
15
+ id?: string;
16
+ className?: string;
17
+ }
18
+
19
+ /** @throws on an unknown tone. */
20
+ export declare function renderFormMessage(message: string, options?: Ol8FormMessageOptions): string;
21
+
22
+ /** Enforces role and live region on existing `.ol8-form-message` markup. */
23
+ export declare function hydrateFormMessages(root?: ParentNode): void;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Oneli8 · Form Message — headless behavior.
3
+ *
4
+ * Figma (177:13): "Canonical Oneli8 Form Message molecule. Tone maps to
5
+ * semantic message tone; Pending is resolved through validationStatus rather
6
+ * than treated as a fifth error tone. Icon and Message remain modular content
7
+ * atoms. Runtime role, status announcement and relationships live in code."
8
+ *
9
+ * So this module owns what Figma says code owns: the live region, the role that
10
+ * follows from the tone, and the icon each tone carries.
11
+ */
12
+ import { renderIcon } from '../../atoms/icon/index.js';
13
+
14
+ /** The five tones Figma enumerates. Pending is the validating state, not an error. */
15
+ export const OL8_FORM_MESSAGE_TONES = ['critical', 'caution', 'positive', 'informative', 'pending'];
16
+
17
+ /** Figma pairs one status icon with each tone. Pending carries the loading mark. */
18
+ export const OL8_FORM_MESSAGE_ICONS = {
19
+ critical: 'critical',
20
+ caution: 'caution',
21
+ positive: 'positive',
22
+ informative: 'informative',
23
+ pending: 'loading',
24
+ };
25
+
26
+ /**
27
+ * @param {string} message
28
+ * @param {{tone?:string,icon?:boolean,id?:string,className?:string}} [options]
29
+ */
30
+ export function renderFormMessage(message, options = {}) {
31
+ const { tone = 'critical', icon = true, id, className } = options;
32
+
33
+ if (!OL8_FORM_MESSAGE_TONES.includes(tone)) {
34
+ throw new Error(`[ol8] unknown form message tone "${tone}"`);
35
+ }
36
+
37
+ // Critical is the only tone a screen reader must interrupt for. Pending is
38
+ // progress, so it is polite. The rest report a result that already happened.
39
+ const live = tone === 'critical' ? 'assertive' : 'polite';
40
+
41
+ const attrs = [
42
+ `class="ol8-form-message${className ? ` ${className}` : ''}"`,
43
+ id ? `id="${escapeAttr(id)}"` : '',
44
+ `data-ol8-tone="${tone}"`,
45
+ tone === 'critical' ? 'role="alert"' : 'role="status"',
46
+ `aria-live="${live}"`,
47
+ ].filter(Boolean).join(' ');
48
+
49
+ const mark = icon
50
+ ? renderIcon(OL8_FORM_MESSAGE_ICONS[tone], { size: 18, className: 'ol8-form-message__icon' })
51
+ : '';
52
+
53
+ return `<div ${attrs}>${mark}<span class="ol8-form-message__text">${escapeText(message)}</span></div>`;
54
+ }
55
+
56
+ /** Enforces the runtime contract on existing markup. Idempotent. */
57
+ export function hydrateFormMessages(root = document) {
58
+ for (const el of root.querySelectorAll('.ol8-form-message')) {
59
+ const tone = el.dataset.ol8Tone || 'critical';
60
+ if (!OL8_FORM_MESSAGE_TONES.includes(tone)) {
61
+ console.warn(`[ol8] unknown form message tone "${tone}"`, el);
62
+ continue;
63
+ }
64
+ el.dataset.ol8Tone = tone;
65
+ if (!el.hasAttribute('role')) el.setAttribute('role', tone === 'critical' ? 'alert' : 'status');
66
+ if (!el.hasAttribute('aria-live')) {
67
+ el.setAttribute('aria-live', tone === 'critical' ? 'assertive' : 'polite');
68
+ }
69
+ }
70
+ }
71
+
72
+ function escapeAttr(v) { return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
73
+ function escapeText(v) { return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
@@ -0,0 +1 @@
1
+ export * from './form-message.js';
@@ -0,0 +1 @@
1
+ export { renderFormMessage, hydrateFormMessages, OL8_FORM_MESSAGE_TONES, OL8_FORM_MESSAGE_ICONS } from './form-message.js';
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Oneli8 · Icon Button
3
+ * Figma: section "Icon Button" (1034:10546) — Primary 86:2, Secondary 87:26,
4
+ * Quiet 88:50, Destructive 89:74, plus the four Circle sets.
5
+ * 4 families × 2 shapes × 4 sizes × 6 states = 192 components.
6
+ *
7
+ * Documentation (85:9):
8
+ * "Visible boxes: 36/42/48/60px · protected targets: 48/48/48/60px
9
+ * Artwork: 18/18/24/36px
10
+ * Hover changes the container only; the symbol never translates, rotates
11
+ * or scales. Accessible name is mandatory. Rounded Square is the canonical
12
+ * base; Circle is a governed presentation extension."
13
+ *
14
+ * The component is TWO boxes, and that is the whole point:
15
+ * .ol8-icon-btn — the protected TARGET. Transparent, no paint.
16
+ * Never smaller than 48px even when the visible
17
+ * box is 36px, so the hit area stays compliant.
18
+ * .ol8-icon-btn__surface — the VISIBLE box. Carries bg, 2px border, radius.
19
+ *
20
+ * Collapsing these into one element would silently shrink the touch target
21
+ * to 36px at Compact. Don't.
22
+ */
23
+
24
+ .ol8-icon-btn {
25
+ --_target: var(--ol8-component-iconbutton-target-standard);
26
+ --_box: var(--ol8-component-iconbutton-box-standard);
27
+ --_radius: var(--ol8-component-iconbutton-radius-standard);
28
+ --_artwork: var(--ol8-component-iconbutton-artwork-standard);
29
+
30
+ --_bg: transparent;
31
+ --_bd: transparent;
32
+ --_ink: var(--ol8-color-icon-primary);
33
+
34
+ box-sizing: border-box;
35
+ display: inline-flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ inline-size: var(--_target);
39
+ block-size: var(--_target);
40
+ padding: 0;
41
+ border: 0;
42
+ background: none; /* the target paints nothing */
43
+ cursor: pointer;
44
+ -webkit-appearance: none;
45
+ appearance: none;
46
+ }
47
+
48
+ .ol8-icon-btn__surface {
49
+ box-sizing: border-box;
50
+ display: inline-flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ inline-size: var(--_box);
54
+ block-size: var(--_box);
55
+ border: var(--ol8-border-width-standard) solid var(--_bd);
56
+ border-radius: var(--_radius);
57
+ background: var(--_bg);
58
+ color: var(--_ink);
59
+ }
60
+
61
+ /* The surface governs artwork size — never set a size on the glyph. */
62
+ .ol8-icon-btn .ol8-icon { --_ol8-icon-size: var(--_artwork); }
63
+
64
+ /* "the symbol never translates, rotates, or scales" — no transform on the
65
+ glyph in any state. Hover changes the container only. */
66
+
67
+ /* ---- Size ------------------------------------------------------------ */
68
+ .ol8-icon-btn[data-ol8-size="compact"] {
69
+ --_target: var(--ol8-component-iconbutton-target-compact);
70
+ --_box: var(--ol8-component-iconbutton-box-compact);
71
+ --_radius: var(--ol8-component-iconbutton-radius-compact);
72
+ --_artwork: var(--ol8-component-iconbutton-artwork-compact);
73
+ }
74
+ .ol8-icon-btn[data-ol8-size="standard"] {
75
+ --_target: var(--ol8-component-iconbutton-target-standard);
76
+ --_box: var(--ol8-component-iconbutton-box-standard);
77
+ --_radius: var(--ol8-component-iconbutton-radius-standard);
78
+ --_artwork: var(--ol8-component-iconbutton-artwork-standard);
79
+ }
80
+ .ol8-icon-btn[data-ol8-size="comfortable"] {
81
+ --_target: var(--ol8-component-iconbutton-target-comfortable);
82
+ --_box: var(--ol8-component-iconbutton-box-comfortable);
83
+ --_radius: var(--ol8-component-iconbutton-radius-comfortable);
84
+ --_artwork: var(--ol8-component-iconbutton-artwork-comfortable);
85
+ }
86
+ .ol8-icon-btn[data-ol8-size="large"] {
87
+ --_target: var(--ol8-component-iconbutton-target-large);
88
+ --_box: var(--ol8-component-iconbutton-box-large);
89
+ --_radius: var(--ol8-component-iconbutton-radius-large);
90
+ --_artwork: var(--ol8-component-iconbutton-artwork-large);
91
+ }
92
+
93
+ /* ---- Shape: Circle is a governed presentation extension -------------- */
94
+ .ol8-icon-btn[data-ol8-shape="circle"] { --_radius: var(--ol8-shape-radius-full); }
95
+
96
+ /* ---- Family: Primary ------------------------------------------------- */
97
+ /* The glyph binds to the action's CONTENT colour, not the neutral ink role.
98
+ Figma binds --ol8-color-icon-primary (#151817) here, which lands a near-black
99
+ glyph on a #2c438b surface: 1.94:1, against the 3:1 WCAG 1.4.11 minimum for
100
+ graphical objects. --ol8-color-actionprimary-content measures 8.81:1. */
101
+ .ol8-icon-btn--primary {
102
+ --_bg: var(--ol8-color-actionprimary-default);
103
+ --_bd: var(--ol8-color-actionprimary-default);
104
+ --_ink: var(--ol8-color-actionprimary-content);
105
+ }
106
+ .ol8-icon-btn--primary:hover:not(:disabled):not([data-ol8-loading]) > .ol8-icon-btn__surface,
107
+ .ol8-icon-btn--primary[data-ol8-loading] > .ol8-icon-btn__surface {
108
+ --_bg: var(--ol8-color-actionprimary-hover); --_bd: var(--ol8-color-actionprimary-hover);
109
+ }
110
+ .ol8-icon-btn--primary:active:not(:disabled) > .ol8-icon-btn__surface,
111
+ .ol8-icon-btn--primary[aria-pressed="true"]:not(:disabled) > .ol8-icon-btn__surface {
112
+ --_bg: var(--ol8-color-actionprimary-pressed); --_bd: var(--ol8-color-actionprimary-pressed);
113
+ }
114
+ .ol8-icon-btn--primary:disabled > .ol8-icon-btn__surface {
115
+ --_bg: var(--ol8-color-actionprimary-disabled);
116
+ --_bd: var(--ol8-color-border-disabled);
117
+ /* Disabled flips to a LIGHT surface, so the glyph flips back to dark ink. */
118
+ --_ink: var(--ol8-color-actionprimary-disabledcontent);
119
+ }
120
+
121
+ /* ---- Family: Destructive --------------------------------------------- */
122
+ /* Same defect as Primary: #151817 on #922824 measures 2.17:1. */
123
+ .ol8-icon-btn--destructive {
124
+ --_bg: var(--ol8-color-actiondestructive-default);
125
+ --_bd: var(--ol8-color-actiondestructive-default);
126
+ --_ink: var(--ol8-color-actiondestructive-content);
127
+ }
128
+ .ol8-icon-btn--destructive:hover:not(:disabled):not([data-ol8-loading]) > .ol8-icon-btn__surface,
129
+ .ol8-icon-btn--destructive[data-ol8-loading] > .ol8-icon-btn__surface {
130
+ --_bg: var(--ol8-color-actiondestructive-hover); --_bd: var(--ol8-color-actiondestructive-hover);
131
+ }
132
+ .ol8-icon-btn--destructive:active:not(:disabled) > .ol8-icon-btn__surface,
133
+ .ol8-icon-btn--destructive[aria-pressed="true"]:not(:disabled) > .ol8-icon-btn__surface {
134
+ --_bg: var(--ol8-color-actiondestructive-pressed); --_bd: var(--ol8-color-actiondestructive-pressed);
135
+ }
136
+ .ol8-icon-btn--destructive:disabled > .ol8-icon-btn__surface {
137
+ --_bg: var(--ol8-color-actiondestructive-disabled);
138
+ --_bd: var(--ol8-color-border-disabled);
139
+ --_ink: var(--ol8-color-actiondestructive-disabledcontent);
140
+ }
141
+
142
+ /* ---- Family: Secondary (bordered, transparent) ----------------------- */
143
+ .ol8-icon-btn--secondary {
144
+ --_bg: var(--ol8-color-actionsecondary-surfacedefault);
145
+ --_bd: var(--ol8-color-actionsecondary-contentdefault);
146
+ --_ink: var(--ol8-color-actionsecondary-contentdefault);
147
+ }
148
+ .ol8-icon-btn--secondary:hover:not(:disabled):not([data-ol8-loading]) > .ol8-icon-btn__surface,
149
+ .ol8-icon-btn--secondary[data-ol8-loading] > .ol8-icon-btn__surface {
150
+ --_bg: var(--ol8-color-actionsecondary-surfacehover);
151
+ --_bd: var(--ol8-color-actionsecondary-contenthover);
152
+ --_ink: var(--ol8-color-actionsecondary-contenthover);
153
+ }
154
+ .ol8-icon-btn--secondary:active:not(:disabled) > .ol8-icon-btn__surface,
155
+ .ol8-icon-btn--secondary[aria-pressed="true"]:not(:disabled) > .ol8-icon-btn__surface {
156
+ --_bg: var(--ol8-color-actionsecondary-surfacepressed);
157
+ --_bd: var(--ol8-color-actionsecondary-contentpressed);
158
+ --_ink: var(--ol8-color-actionsecondary-contentpressed);
159
+ }
160
+ .ol8-icon-btn--secondary:disabled > .ol8-icon-btn__surface {
161
+ --_bg: var(--ol8-color-actionsecondary-surfacedisabled);
162
+ --_bd: var(--ol8-color-border-disabled);
163
+ --_ink: var(--ol8-color-actionsecondary-contentdisabled);
164
+ }
165
+
166
+ /* ---- Family: Quiet (no boundary) ------------------------------------- */
167
+ .ol8-icon-btn--quiet {
168
+ --_bg: var(--ol8-color-actionquiet-surfacedefault);
169
+ --_bd: transparent;
170
+ --_ink: var(--ol8-color-actionquiet-contentdefault);
171
+ }
172
+ .ol8-icon-btn--quiet:hover:not(:disabled):not([data-ol8-loading]) > .ol8-icon-btn__surface,
173
+ .ol8-icon-btn--quiet[data-ol8-loading] > .ol8-icon-btn__surface {
174
+ --_bg: var(--ol8-color-actionquiet-surfacehover); --_ink: var(--ol8-color-actionquiet-contenthover);
175
+ }
176
+ .ol8-icon-btn--quiet:active:not(:disabled) > .ol8-icon-btn__surface,
177
+ .ol8-icon-btn--quiet[aria-pressed="true"]:not(:disabled) > .ol8-icon-btn__surface {
178
+ --_bg: var(--ol8-color-actionquiet-surfacepressed); --_ink: var(--ol8-color-actionquiet-contentpressed);
179
+ }
180
+ .ol8-icon-btn--quiet:disabled > .ol8-icon-btn__surface {
181
+ --_bg: var(--ol8-color-actionquiet-surfacedisabled); --_ink: var(--ol8-color-actionquiet-contentdisabled);
182
+ }
183
+
184
+ /* ---- Pressed scrim ---------------------------------------------------
185
+ Figma layers a 3px inset scrim over the pressed surface, in addition to
186
+ the darker fill. Reproduced as an inset shadow rather than an extra node. */
187
+ .ol8-icon-btn:active:not(:disabled) > .ol8-icon-btn__surface,
188
+ .ol8-icon-btn[aria-pressed="true"]:not(:disabled) > .ol8-icon-btn__surface {
189
+ box-shadow: inset 0 0 0 3px var(--ol8-component-iconbutton-pressedscrim);
190
+ }
191
+
192
+ /* ---- Focus -----------------------------------------------------------
193
+ OUTSIDE two-tone ring, unlike the labelled Button's inside stroke.
194
+
195
+ Figma declares the 6px outer shadow FIRST, and box-shadow paints the first
196
+ shadow on top — so the 6px dark spread completely covers the 3px light one
197
+ and only a flat dark band renders. Sampling the Figma export confirms it:
198
+ uniform #101b3a at every perimeter position and at both 2px and 4px depth.
199
+
200
+ The inner band is declared here first so both are visible: light from the
201
+ edge out to 3px, dark from 3px to 6px. That is the ring the two tokens
202
+ describe. To restore the Figma-exact single-band ring, swap these two
203
+ lines back.
204
+
205
+ Both focus-ring tokens are BAND THICKNESSES (3px each). box-shadow spread is
206
+ cumulative from the edge, so the outer band's spread is the sum of the two —
207
+ 3px light from the edge, then dark from 3px out to 6px. Do not pass
208
+ --ol8-focus-ring-outerwidth here on its own: that would paint the dark band
209
+ at the same 3px radius as the light one and hide it completely. */
210
+ .ol8-icon-btn:focus-visible { outline: none; }
211
+ .ol8-icon-btn:focus-visible > .ol8-icon-btn__surface {
212
+ box-shadow:
213
+ 0 0 0 var(--ol8-focus-ring-innerwidth) var(--ol8-color-focus-inner),
214
+ 0 0 0 calc(var(--ol8-focus-ring-innerwidth) + var(--ol8-focus-ring-outerwidth)) var(--ol8-color-focus-outer);
215
+ }
216
+
217
+ /* ---- Disabled / Loading ---------------------------------------------- */
218
+ .ol8-icon-btn:disabled { cursor: not-allowed; }
219
+ .ol8-icon-btn[data-ol8-loading] { cursor: progress; }
220
+ @media (prefers-reduced-motion: no-preference) {
221
+ .ol8-icon-btn[data-ol8-loading] .ol8-icon > svg { animation: ol8-spin 900ms linear infinite; }
222
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Icon Button (MOLECULE) — headless behavior. An accessible name is mandatory
3
+ * and enforced here rather than left to reviewers.
4
+ */
5
+ import type { Ol8IconName } from '../../atoms/icon/icons.generated.js';
6
+
7
+ export type Ol8IconButtonVariant = 'primary' | 'secondary' | 'quiet' | 'destructive';
8
+ export type Ol8IconButtonSize = 'compact' | 'standard' | 'comfortable' | 'large';
9
+ export type Ol8IconButtonShape = 'rounded' | 'circle';
10
+
11
+ export declare const OL8_ICON_BUTTON_VARIANTS: readonly Ol8IconButtonVariant[];
12
+ export declare const OL8_ICON_BUTTON_SIZES: readonly Ol8IconButtonSize[];
13
+ export declare const OL8_ICON_BUTTON_SHAPES: readonly Ol8IconButtonShape[];
14
+
15
+ /** Artwork sizes as BUILT in Figma: 18 / 18 / 24 / 24. */
16
+ export declare function artworkSizeForIconButton(size: Ol8IconButtonSize): 18 | 24;
17
+
18
+ export interface Ol8IconButtonOptions {
19
+ variant?: Ol8IconButtonVariant;
20
+ size?: Ol8IconButtonSize;
21
+ shape?: Ol8IconButtonShape;
22
+ disabled?: boolean;
23
+ loading?: boolean;
24
+ pressed?: boolean;
25
+ type?: 'button' | 'submit' | 'reset';
26
+ material?: 'regular' | 'gem';
27
+ className?: string;
28
+ }
29
+
30
+ /**
31
+ * @param icon registry name, e.g. "add"
32
+ * @param label accessible name — REQUIRED, throws if empty
33
+ */
34
+ export declare function renderIconButton(
35
+ icon: Ol8IconName,
36
+ label: string,
37
+ options?: Ol8IconButtonOptions,
38
+ ): string;
39
+
40
+ export declare function hydrateIconButtons(root?: ParentNode): void;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Oneli8 · Icon Button — headless behavior.
3
+ *
4
+ * Figma (86:2): "Icon is an INSTANCE_SWAP sourced from the governed 24px Icon
5
+ * Library atom and resized only through Icon Frame; Accessible Name is
6
+ * mandatory. Size and State are deterministic visual evidence. Runtime
7
+ * disabled, loading, pressed, motion, tooltip policy, and native button
8
+ * semantics live in code."
9
+ *
10
+ * "Accessible Name is mandatory" is enforced here, not left to reviewers.
11
+ */
12
+ import { renderIcon, renderIconSvg, isOl8IconName } from '../../atoms/icon/icon.js';
13
+ import { GEM_VARIANTS } from '../button/button.js';
14
+
15
+ export const OL8_ICON_BUTTON_VARIANTS = ['primary', 'secondary', 'quiet', 'destructive'];
16
+ export const OL8_ICON_BUTTON_SIZES = ['compact', 'standard', 'comfortable', 'large'];
17
+ export const OL8_ICON_BUTTON_SHAPES = ['rounded', 'circle'];
18
+
19
+ /** Artwork sizes as BUILT in Figma: 18 / 18 / 24 / 24. */
20
+ export function artworkSizeForIconButton(size) {
21
+ return size === 'comfortable' || size === 'large' ? 24 : 18;
22
+ }
23
+
24
+ /**
25
+ * @param {string} icon registry name, e.g. "add"
26
+ * @param {string} label accessible name — REQUIRED
27
+ * @param {{variant?:string,size?:string,shape?:string,disabled?:boolean,
28
+ * loading?:boolean,pressed?:boolean,type?:string,className?:string}} [options]
29
+ */
30
+ export function renderIconButton(icon, label, options = {}) {
31
+ const {
32
+ variant = 'primary', size = 'standard', shape = 'rounded',
33
+ disabled = false, loading = false, pressed, type = 'button', className, material = 'regular',
34
+ } = options;
35
+
36
+ if (!isOl8IconName(icon)) throw new Error(`[ol8] unknown icon "${icon}"`);
37
+ if (!OL8_ICON_BUTTON_VARIANTS.includes(variant)) throw new Error(`[ol8] unknown icon-button variant "${variant}"`);
38
+ if (!OL8_ICON_BUTTON_SIZES.includes(size)) throw new Error(`[ol8] unknown icon-button size "${size}"`);
39
+ if (!OL8_ICON_BUTTON_SHAPES.includes(shape)) throw new Error(`[ol8] unknown icon-button shape "${shape}"`);
40
+ if (material === 'gem' && !GEM_VARIANTS.includes(variant)) {
41
+ throw new Error(`[ol8] Gem has no approved treatment for "${variant}" — Figma 100:3 says Quiet and Destructive use the regular material.`);
42
+ }
43
+ if (!label || !String(label).trim()) {
44
+ throw new Error('[ol8] Icon Button requires an accessible name — Figma marks it mandatory (86:2).');
45
+ }
46
+
47
+ const artwork = artworkSizeForIconButton(size);
48
+ const glyph = loading
49
+ ? `<span class="ol8-icon" aria-hidden="true">${renderIconSvg('loading')}</span>`
50
+ : renderIcon(icon, { size: artwork });
51
+
52
+ const attrs = [
53
+ `class="ol8-icon-btn ol8-icon-btn--${variant}${className ? ` ${className}` : ''}"`,
54
+ `type="${type}"`,
55
+ `data-ol8-size="${size}"`,
56
+ shape === 'circle' ? 'data-ol8-shape="circle"' : '',
57
+ `aria-label="${escapeAttr(label)}"`,
58
+ disabled || loading ? 'disabled' : '',
59
+ loading ? 'data-ol8-loading="true" aria-busy="true"' : '',
60
+ pressed !== undefined ? `aria-pressed="${pressed}"` : '',
61
+ material === 'gem' ? 'data-ol8-material="gem"' : '',
62
+ ].filter(Boolean).join(' ');
63
+
64
+ return `<button ${attrs}><span class="ol8-icon-btn__surface">${glyph}</span></button>`;
65
+ }
66
+
67
+ /**
68
+ * Enforces the runtime contract on existing `.ol8-icon-btn` markup. Idempotent.
69
+ * Wraps a bare glyph in the required surface, and reports any button that has
70
+ * no accessible name — the one rule Figma calls mandatory.
71
+ */
72
+ export function hydrateIconButtons(root = document) {
73
+ const unnamed = [];
74
+ for (const el of root.querySelectorAll('.ol8-icon-btn')) {
75
+ if (!el.hasAttribute('type') && el.tagName === 'BUTTON') el.setAttribute('type', 'button');
76
+
77
+ // The protected target must always wrap a visible surface.
78
+ if (!el.querySelector(':scope > .ol8-icon-btn__surface')) {
79
+ const surface = document.createElement('span');
80
+ surface.className = 'ol8-icon-btn__surface';
81
+ while (el.firstChild) surface.appendChild(el.firstChild);
82
+ el.appendChild(surface);
83
+ }
84
+
85
+ const loading = el.dataset.ol8Loading === 'true';
86
+ if (loading) {
87
+ el.setAttribute('aria-busy', 'true');
88
+ el.disabled = true;
89
+ const surface = el.querySelector(':scope > .ol8-icon-btn__surface');
90
+ if (!surface.querySelector('[data-ol8-loading-glyph]')) {
91
+ surface.innerHTML = `<span class="ol8-icon" aria-hidden="true" data-ol8-loading-glyph="true">${renderIconSvg('loading')}</span>`;
92
+ }
93
+ } else {
94
+ el.removeAttribute('aria-busy');
95
+ }
96
+
97
+ if (!accessibleName(el)) unnamed.push(el);
98
+ }
99
+ if (unnamed.length) {
100
+ console.error(`[ol8] ${unnamed.length} icon button(s) have no accessible name; Figma marks it mandatory.`, unnamed);
101
+ }
102
+ return { unnamed };
103
+ }
104
+
105
+ function accessibleName(el) {
106
+ const aria = el.getAttribute('aria-label');
107
+ if (aria && aria.trim()) return aria.trim();
108
+ const labelledby = el.getAttribute('aria-labelledby');
109
+ if (labelledby) {
110
+ const text = labelledby.split(/\s+/)
111
+ .map(id => el.ownerDocument.getElementById(id)?.textContent ?? '')
112
+ .join(' ').trim();
113
+ if (text) return text;
114
+ }
115
+ // Visible text that is not aria-hidden also names the control.
116
+ const clone = el.cloneNode(true);
117
+ for (const h of clone.querySelectorAll('[aria-hidden="true"]')) h.remove();
118
+ return clone.textContent.trim();
119
+ }
120
+
121
+ function escapeAttr(v) { return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
@@ -0,0 +1 @@
1
+ export * from './icon-button.js';
@@ -0,0 +1 @@
1
+ export * from './icon-button.js';
@@ -0,0 +1,10 @@
1
+ export * from './button/index.js';
2
+ export * from './icon-button/index.js';
3
+ export * from './choice-item/index.js';
4
+ export * from './selection-option/index.js';
5
+ export * from './link/index.js';
6
+ export * from './form-message/index.js';
7
+ export * from './text-field/index.js';
8
+ export * from './select/index.js';
9
+ export * from './choice-chip/index.js';
10
+ export * from './token/index.js';
@@ -0,0 +1,10 @@
1
+ export * from './button/index.js';
2
+ export * from './link/index.js';
3
+ export * from './icon-button/index.js';
4
+ export * from './choice-item/index.js';
5
+ export * from './selection-option/index.js';
6
+ export * from './form-message/index.js';
7
+ export * from './text-field/index.js';
8
+ export * from './select/index.js';
9
+ export * from './choice-chip/index.js';
10
+ export * from './token/index.js';
@@ -0,0 +1 @@
1
+ export * from './link.js';
@@ -0,0 +1 @@
1
+ export * from './link.js';