@africode/core 5.0.4 → 5.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,7 @@ Following the rollout framework, AfriCode v5.0.0 implements a phased Pan-African
43
43
  - **33+ Web Components**: African-themed UI elements with cultural semiotics
44
44
  - **Procedural SVG Engine**: Fractal geometry for traditional patterns
45
45
  - **Mobile-First Design**: Optimized for African connectivity constraints
46
+ - **Native UI Kit**: Shadcn-style Web Components like `af-ui-button`, `af-ui-card`, and `af-ui-input` for consistent app UI
46
47
 
47
48
  ## 🚀 Quick Start
48
49
 
@@ -342,6 +343,35 @@ Then use them in your HTML:
342
343
  <af-button onclick="handleClick()">Click Me</af-button>
343
344
  ```
344
345
 
346
+ ### Native AfriCode UI Kit
347
+
348
+ AfriCode now includes a native UI kit with shadcn-inspired Web Components for rapid interface design. The Tailwind starter demonstrates the new UI kit using components like `af-ui-button`, `af-ui-card`, `af-ui-input`, `af-ui-badge`, and `af-ui-switch` in a Shadow DOM-friendly workflow.
349
+
350
+ The library is optimized for consistent spacing, elevation, and interactive states across theme-aware apps.
351
+
352
+ Use the Tailwind starter template for a ready-made UI experience:
353
+
354
+ ```bash
355
+ create-africode my-app --template starter-tailwind
356
+ cd my-app
357
+ bun install
358
+ bun run dev
359
+ ```
360
+
361
+ Example usage:
362
+
363
+ ```html
364
+ <af-ui-card variant="outlined" clickable>
365
+ <h3>Actionable Panel</h3>
366
+ <p>Build data-driven experiences with native AfriCode UI components.</p>
367
+ <af-ui-badge variant="success" pill>Ready</af-ui-badge>
368
+ </af-ui-card>
369
+
370
+ <af-ui-input label="Email" placeholder="you@example.com"></af-ui-input>
371
+ <af-ui-switch checked></af-ui-switch>
372
+ <af-ui-button variant="primary">Submit</af-ui-button>
373
+ ```
374
+
345
375
  ## ⚙️ Development
346
376
 
347
377
  ### Hot Module Replacement (HMR)
@@ -8,6 +8,13 @@
8
8
  export { AfriCodeComponent } from './base.js';
9
9
  export { AfriErrorBoundary } from './error-boundary.js';
10
10
 
11
+ // UI Kit Components
12
+ export { AfriUIButton } from './ui-button.js';
13
+ export { AfriUICard } from './ui-card.js';
14
+ export { AfriUIInput } from './ui-input.js';
15
+ export { AfriUIBadge } from './ui-badge.js';
16
+ export { AfriUISwitch } from './ui-switch.js';
17
+
11
18
  // Map of Tag Name -> Dynamic Import
12
19
  export const componentMap = {
13
20
  // Core UI
@@ -20,6 +27,13 @@ export const componentMap = {
20
27
  'af-section': () => import('./section.js'),
21
28
  'af-icon': () => import('./icon.js'),
22
29
 
30
+ // AfriCode UI Kit
31
+ 'af-ui-button': () => import('./ui-button.js'),
32
+ 'af-ui-card': () => import('./ui-card.js'),
33
+ 'af-ui-input': () => import('./ui-input.js'),
34
+ 'af-ui-badge': () => import('./ui-badge.js'),
35
+ 'af-ui-switch': () => import('./ui-switch.js'),
36
+
23
37
  // Enterprise
24
38
  'af-accordion': () => import('./accordion.js'),
25
39
  'af-hero': () => import('./hero.js'),
@@ -0,0 +1,61 @@
1
+ /**
2
+ * AfriCode UI Badge
3
+ *
4
+ * A minimal badge component for status labels and counters.
5
+ * @module components/ui-badge
6
+ */
7
+
8
+ import { AfriCodeComponent, registerComponent, html } from './base.js';
9
+
10
+ export class AfriUIBadge extends AfriCodeComponent {
11
+ static get observedAttributes() {
12
+ return ['variant', 'size', 'pill'];
13
+ }
14
+
15
+ constructor() {
16
+ super();
17
+ this.render();
18
+ }
19
+
20
+ attributeChangedCallback() {
21
+ this.render();
22
+ }
23
+
24
+ render() {
25
+ const variant = this.getAttribute('variant') || 'secondary';
26
+ const size = this.getAttribute('size') || 'md';
27
+ const pill = this.hasAttribute('pill');
28
+
29
+ this.shadowRoot.innerHTML = html`
30
+ <style>
31
+ :host { display: inline-block; font-family: 'Inter', system-ui, sans-serif; }
32
+
33
+ .badge {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+ gap: 0.4rem;
38
+ border-radius: ${pill ? '999px' : '0.6rem'};
39
+ padding: ${size === 'sm' ? '0.25rem 0.65rem' : size === 'lg' ? '0.55rem 1rem' : '0.4rem 0.8rem'};
40
+ font-size: ${size === 'sm' ? '0.75rem' : size === 'lg' ? '0.95rem' : '0.85rem'};
41
+ font-weight: 700;
42
+ color: #111827;
43
+ background: var(--af-ui-badge-bg, #e2e8f0);
44
+ border: 1px solid transparent;
45
+ white-space: nowrap;
46
+ }
47
+
48
+ .badge.primary { background: #2563eb; color: #ffffff; }
49
+ .badge.success { background: #16a34a; color: #ffffff; }
50
+ .badge.warning { background: #f59e0b; color: #0f172a; }
51
+ .badge.danger { background: #dc2626; color: #ffffff; }
52
+ .badge.secondary { background: #f8fafc; color: #0f172a; }
53
+ </style>
54
+
55
+ <span class="badge ${variant}"><slot></slot></span>
56
+ `;
57
+ }
58
+ }
59
+
60
+ registerComponent('af-ui-badge', AfriUIBadge);
61
+ export default AfriUIBadge;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * AfriCode UI Button
3
+ *
4
+ * A shadcn-style button component with variant and size options.
5
+ * Works well with Tailwind-style theming and Shadow DOM.
6
+ *
7
+ * @module components/ui-button
8
+ */
9
+
10
+ import { AfriCodeComponent, registerComponent, html } from './base.js';
11
+
12
+ export class AfriUIButton extends AfriCodeComponent {
13
+ static get observedAttributes() {
14
+ return ['variant', 'size', 'disabled', 'loading', 'full-width', 'aria-label'];
15
+ }
16
+
17
+ constructor() {
18
+ super();
19
+ this.render();
20
+ }
21
+
22
+ connectedCallback() {
23
+ const button = this.shadowRoot.querySelector('button');
24
+ button?.addEventListener('click', (event) => {
25
+ if (this.hasAttribute('disabled') || this.hasAttribute('loading')) {
26
+ event.preventDefault();
27
+ return;
28
+ }
29
+ this.emit('af-ui-button-click', { originalEvent: event });
30
+ });
31
+ }
32
+
33
+ attributeChangedCallback() {
34
+ this.render();
35
+ }
36
+
37
+ render() {
38
+ const variant = this.getAttribute('variant') || 'primary';
39
+ const size = this.getAttribute('size') || 'md';
40
+ const isDisabled = this.hasAttribute('disabled');
41
+ const isLoading = this.hasAttribute('loading');
42
+ const ariaLabel = this.getAttribute('aria-label') || '';
43
+
44
+ this.shadowRoot.innerHTML = html`
45
+ <style>
46
+ :host { display: inline-block; }
47
+
48
+ button {
49
+ all: unset;
50
+ box-sizing: border-box;
51
+ min-height: 42px;
52
+ padding: 0 18px;
53
+ border-radius: 999px;
54
+ font-family: 'Inter', system-ui, sans-serif;
55
+ font-weight: 600;
56
+ cursor: pointer;
57
+ display: inline-flex;
58
+ align-items: center;
59
+ justify-content: center;
60
+ gap: 0.5rem;
61
+ transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
62
+ text-decoration: none;
63
+ border: 1px solid transparent;
64
+ user-select: none;
65
+ white-space: nowrap;
66
+ }
67
+
68
+ :host([full-width]) button { width: 100%; }
69
+
70
+ button.primary {
71
+ background: #2563eb;
72
+ color: #fff;
73
+ box-shadow: 0 8px 24px rgba(37, 99, 235, 0.24);
74
+ }
75
+
76
+ button.primary:hover:not(:disabled) {
77
+ transform: translateY(-1px);
78
+ background: #1d4ed8;
79
+ }
80
+
81
+ button.secondary {
82
+ background: #f8fafc;
83
+ color: #1f2937;
84
+ border-color: #cbd5e1;
85
+ }
86
+
87
+ button.secondary:hover:not(:disabled) {
88
+ background: #e2e8f0;
89
+ }
90
+
91
+ button.ghost {
92
+ background: transparent;
93
+ color: #0f172a;
94
+ }
95
+
96
+ button.ghost:hover:not(:disabled) {
97
+ background: rgba(15, 23, 42, 0.05);
98
+ }
99
+
100
+ button.link {
101
+ background: transparent;
102
+ color: #2563eb;
103
+ box-shadow: none;
104
+ padding: 0;
105
+ border-radius: 0;
106
+ }
107
+
108
+ button.link:hover:not(:disabled) {
109
+ text-decoration: underline;
110
+ }
111
+
112
+ button.size-sm { min-height: 34px; padding: 0 14px; font-size: 0.85rem; }
113
+ button.size-md { min-height: 42px; padding: 0 18px; font-size: 0.95rem; }
114
+ button.size-lg { min-height: 50px; padding: 0 22px; font-size: 1rem; }
115
+
116
+ button:disabled {
117
+ opacity: 0.55;
118
+ cursor: not-allowed;
119
+ transform: none;
120
+ box-shadow: none;
121
+ }
122
+
123
+ .spinner {
124
+ width: 18px;
125
+ height: 18px;
126
+ border: 2px solid currentColor;
127
+ border-bottom-color: transparent;
128
+ border-radius: 999px;
129
+ animation: spin 0.8s linear infinite;
130
+ }
131
+
132
+ @keyframes spin { to { transform: rotate(360deg); } }
133
+ </style>
134
+
135
+ <button
136
+ class="${variant} ${size}"
137
+ ${isDisabled ? 'disabled' : ''}
138
+ ${ariaLabel ? `aria-label="${ariaLabel}"` : ''}
139
+ ${isLoading ? 'aria-busy="true"' : ''}
140
+ >
141
+ ${isLoading ? html`<span class="spinner"></span>` : ''}
142
+ <slot></slot>
143
+ </button>
144
+ `;
145
+ }
146
+ }
147
+
148
+ registerComponent('af-ui-button', AfriUIButton);
149
+ export default AfriUIButton;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * AfriCode UI Card
3
+ *
4
+ * A lightweight card component for content grouping.
5
+ * Provides subtle elevation and variant support.
6
+ *
7
+ * @module components/ui-card
8
+ */
9
+
10
+ import { AfriCodeComponent, registerComponent, html } from './base.js';
11
+
12
+ export class AfriUICard extends AfriCodeComponent {
13
+ static get observedAttributes() {
14
+ return ['variant', 'bordered', 'clickable'];
15
+ }
16
+
17
+ constructor() {
18
+ super();
19
+ this.render();
20
+ }
21
+
22
+ connectedCallback() {
23
+ this.loadStyles();
24
+ if (this.hasAttribute('clickable')) {
25
+ this.shadowRoot.querySelector('.card')?.addEventListener('click', () => {
26
+ this.emit('af-ui-card-click');
27
+ });
28
+ }
29
+ }
30
+
31
+ attributeChangedCallback() {
32
+ this.render();
33
+ }
34
+
35
+ render() {
36
+ const variant = this.getAttribute('variant') || 'elevated';
37
+ const bordered = this.hasAttribute('bordered');
38
+ const clickable = this.hasAttribute('clickable');
39
+
40
+ this.shadowRoot.innerHTML = html`
41
+ <style>
42
+ :host { display: block; }
43
+
44
+ .card {
45
+ border-radius: 1rem;
46
+ padding: 1.5rem;
47
+ background: #ffffff;
48
+ color: #0f172a;
49
+ transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
50
+ border: 1px solid transparent;
51
+ box-shadow: 0 16px 32px rgba(15, 23, 42, 0.08);
52
+ }
53
+
54
+ .card.elevated { background: #fff; }
55
+ .card.flat { background: #f8fafc; box-shadow: none; }
56
+ .card.outlined { background: #fff; border-color: #e2e8f0; box-shadow: none; }
57
+
58
+ .card:hover:not(.disabled) {
59
+ transform: translateY(-2px);
60
+ }
61
+
62
+ :host([bordered]) .card { border-color: #cbd5e1; }
63
+
64
+ :host([clickable]) .card { cursor: pointer; }
65
+ </style>
66
+
67
+ <article class="card ${variant}">
68
+ <slot></slot>
69
+ </article>
70
+ `;
71
+ }
72
+ }
73
+
74
+ registerComponent('af-ui-card', AfriUICard);
75
+ export default AfriUICard;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * AfriCode UI Input
3
+ *
4
+ * A styled input component with label support for forms and data entry.
5
+ *
6
+ * @module components/ui-input
7
+ */
8
+
9
+ import { AfriCodeComponent, registerComponent, html } from './base.js';
10
+
11
+ export class AfriUIInput extends AfriCodeComponent {
12
+ static get observedAttributes() {
13
+ return ['label', 'placeholder', 'type', 'value', 'disabled', 'error'];
14
+ }
15
+
16
+ constructor() {
17
+ super();
18
+ this.render();
19
+ }
20
+
21
+ connectedCallback() {
22
+ this._attachListeners();
23
+ this.loadStyles();
24
+ }
25
+
26
+ attributeChangedCallback() {
27
+ this.render();
28
+ this._attachListeners();
29
+ }
30
+
31
+ _attachListeners() {
32
+ const input = this.shadowRoot.querySelector('input');
33
+ if (!input) return;
34
+ input.addEventListener('input', () => {
35
+ this.emit('af-ui-input', { value: input.value });
36
+ });
37
+ }
38
+
39
+ render() {
40
+ const label = this.getAttribute('label') || '';
41
+ const placeholder = this.getAttribute('placeholder') || '';
42
+ const type = this.getAttribute('type') || 'text';
43
+ const value = this.getAttribute('value') || '';
44
+ const disabled = this.hasAttribute('disabled');
45
+ const error = this.getAttribute('error') || '';
46
+
47
+ this.shadowRoot.innerHTML = html`
48
+ <style>
49
+ :host { display: block; font-family: 'Inter', system-ui, sans-serif; }
50
+
51
+ label {
52
+ display: block;
53
+ font-size: 0.85rem;
54
+ font-weight: 600;
55
+ margin-bottom: 0.5rem;
56
+ color: #111827;
57
+ }
58
+
59
+ .field {
60
+ display: grid;
61
+ gap: 0.45rem;
62
+ }
63
+
64
+ input {
65
+ width: 100%;
66
+ border: 1px solid #d1d5db;
67
+ border-radius: 0.75rem;
68
+ padding: 0.95rem 1rem;
69
+ font-size: 0.95rem;
70
+ color: #111827;
71
+ background: #ffffff;
72
+ transition: border-color 150ms ease, box-shadow 150ms ease;
73
+ }
74
+
75
+ input:focus {
76
+ outline: none;
77
+ border-color: #2563eb;
78
+ box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
79
+ }
80
+
81
+ input:disabled {
82
+ opacity: 0.65;
83
+ cursor: not-allowed;
84
+ background: #f8fafc;
85
+ }
86
+
87
+ .error {
88
+ color: #dc2626;
89
+ font-size: 0.8rem;
90
+ min-height: 1rem;
91
+ }
92
+ </style>
93
+
94
+ <div class="field">
95
+ ${label ? html`<label>${label}</label>` : ''}
96
+ <input
97
+ type="${type}"
98
+ placeholder="${placeholder}"
99
+ value="${value}"
100
+ ${disabled ? 'disabled' : ''}
101
+ aria-invalid="${Boolean(error)}"
102
+ />
103
+ <div class="error">${error}</div>
104
+ </div>
105
+ `;
106
+ }
107
+ }
108
+
109
+ registerComponent('af-ui-input', AfriUIInput);
110
+ export default AfriUIInput;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * AfriCode UI Switch
3
+ *
4
+ * A form control switch component with accessible state.
5
+ * @module components/ui-switch
6
+ */
7
+
8
+ import { AfriCodeComponent, registerComponent, html } from './base.js';
9
+
10
+ export class AfriUISwitch extends AfriCodeComponent {
11
+ static get observedAttributes() {
12
+ return ['checked', 'disabled', 'size'];
13
+ }
14
+
15
+ constructor() {
16
+ super();
17
+ this.render();
18
+ }
19
+
20
+ connectedCallback() {
21
+ this.shadowRoot.querySelector('button')?.addEventListener('click', (event) => {
22
+ if (this.hasAttribute('disabled')) return;
23
+ const next = !this.hasAttribute('checked');
24
+ if (next) {
25
+ this.setAttribute('checked', '');
26
+ } else {
27
+ this.removeAttribute('checked');
28
+ }
29
+ this.emit('af-ui-switch-toggle', { checked: next, originalEvent: event });
30
+ });
31
+ }
32
+
33
+ attributeChangedCallback() {
34
+ this.render();
35
+ }
36
+
37
+ render() {
38
+ const checked = this.hasAttribute('checked');
39
+ const disabled = this.hasAttribute('disabled');
40
+ const size = this.getAttribute('size') || 'md';
41
+ const dimensions = size === 'sm' ? { width: 36, height: 20, knob: 14 } : size === 'lg' ? { width: 56, height: 32, knob: 24 } : { width: 48, height: 28, knob: 20 };
42
+
43
+ const ariaChecked = checked ? 'true' : 'false';
44
+
45
+ this.shadowRoot.innerHTML = html`
46
+ <style>
47
+ :host { display: inline-block; }
48
+
49
+ button {
50
+ all: unset;
51
+ position: relative;
52
+ width: ${dimensions.width}px;
53
+ height: ${dimensions.height}px;
54
+ border-radius: ${dimensions.height / 2}px;
55
+ background: ${checked ? '#2563eb' : '#e5e7eb'};
56
+ cursor: ${disabled ? 'not-allowed' : 'pointer'};
57
+ transition: background 180ms ease;
58
+ box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
59
+ }
60
+
61
+ .knob {
62
+ position: absolute;
63
+ top: 50%;
64
+ left: ${checked ? `${dimensions.width - dimensions.knob - 4}px` : '4px'};
65
+ transform: translateY(-50%);
66
+ width: ${dimensions.knob}px;
67
+ height: ${dimensions.knob}px;
68
+ border-radius: 999px;
69
+ background: #ffffff;
70
+ transition: left 180ms ease;
71
+ box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
72
+ }
73
+
74
+ button:disabled { opacity: 0.65; }
75
+ </style>
76
+
77
+ <button role="switch" aria-checked="${ariaChecked}" ${disabled ? 'disabled' : ''}>
78
+ <span class="knob"></span>
79
+ </button>
80
+ `;
81
+ }
82
+ }
83
+
84
+ registerComponent('af-ui-switch', AfriUISwitch);
85
+ export default AfriUISwitch;
@@ -6,6 +6,35 @@ import { rm, cp, mkdir, readdir, writeFile } from 'fs/promises';
6
6
  import { existsSync } from 'fs';
7
7
  import path from 'path';
8
8
  import { fileURLToPath } from 'url';
9
+ import { RawHtml } from '../../html.js';
10
+
11
+ export async function normalizeRenderOutput(htmlContent) {
12
+ if (htmlContent && typeof htmlContent.text === 'function') {
13
+ return await htmlContent.text();
14
+ }
15
+
16
+ if (htmlContent instanceof RawHtml) {
17
+ return htmlContent.toString();
18
+ }
19
+
20
+ if (typeof htmlContent === 'string') {
21
+ return htmlContent;
22
+ }
23
+
24
+ if (htmlContent === null || htmlContent === undefined) {
25
+ return '';
26
+ }
27
+
28
+ if (typeof htmlContent === 'object') {
29
+ try {
30
+ return JSON.stringify(htmlContent);
31
+ } catch (err) {
32
+ return String(htmlContent);
33
+ }
34
+ }
35
+
36
+ return String(htmlContent);
37
+ }
9
38
 
10
39
  export async function build() {
11
40
  logo();
@@ -90,7 +119,8 @@ export async function build() {
90
119
  }
91
120
 
92
121
  if (typeof pageModule.default === 'function') {
93
- const htmlContent = pageModule.default({ data: loaderData });
122
+ let htmlContent = await pageModule.default({ data: loaderData });
123
+ htmlContent = await normalizeRenderOutput(htmlContent);
94
124
  const outputPath = path.join(distDir, `${name}.html`);
95
125
  await writeFile(outputPath, htmlContent);
96
126
  const renderEnd = performance.now();
@@ -1,13 +1,50 @@
1
1
  import { logo, colors } from '../ui.js';
2
2
  import * as nodePath from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { createConnection } from 'node:net';
4
5
  import { createHMRServer } from '../../hmr.js';
5
6
 
7
+ async function isPortAvailable(port) {
8
+ return new Promise((resolve) => {
9
+ const socket = createConnection({ port, host: '127.0.0.1', timeout: 300 }, () => {
10
+ socket.destroy();
11
+ resolve(false);
12
+ });
13
+
14
+ socket.on('error', (err) => {
15
+ if (err.code === 'ECONNREFUSED' || err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH') {
16
+ resolve(true);
17
+ } else {
18
+ resolve(false);
19
+ }
20
+ });
21
+
22
+ socket.on('timeout', () => {
23
+ socket.destroy();
24
+ resolve(true);
25
+ });
26
+ });
27
+ }
28
+
29
+ async function findAvailablePort(startPort, attempts = 10) {
30
+ for (let offset = 0; offset < attempts; offset++) {
31
+ const portToCheck = startPort + offset;
32
+ if (await isPortAvailable(portToCheck)) {
33
+ return portToCheck;
34
+ }
35
+ }
36
+ throw new Error(`No available ports found between ${startPort} and ${startPort + attempts - 1}`);
37
+ }
38
+
6
39
  export async function dev() {
7
40
  logo();
8
41
  console.log(`${colors.green}▶ Starting development server...${colors.reset}\n`);
9
42
 
10
- const PORT = process.env.PORT || 3000;
43
+ const desiredPort = Number(process.env.PORT) || 3000;
44
+ const PORT = await findAvailablePort(desiredPort, 10);
45
+ if (PORT !== desiredPort) {
46
+ console.log(`${colors.gold}⚠ Port ${desiredPort} was unavailable; using ${PORT} instead.${colors.reset}`);
47
+ }
11
48
  const __filename = fileURLToPath(import.meta.url);
12
49
  const __dirname = nodePath.dirname(__filename);
13
50
  const frameworkRoot = nodePath.resolve(__dirname, '../../../');