@apliteni/apliteni-ui 0.2.4

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/LICENSE ADDED
@@ -0,0 +1,6 @@
1
+ Copyright (c) Apliteni. All rights reserved.
2
+
3
+ This software and associated files (the "Software") are proprietary and
4
+ confidential. Unauthorized copying, distribution, or use of the Software,
5
+ in whole or in part, outside of Apliteni and its authorized products is
6
+ strictly prohibited without prior written permission.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # @apliteni/apliteni-ui
2
+
3
+ The Apliteni design system & UI kit β€” one source of UI for every product surface
4
+ (the strategy deck, the text portal, `/account`, the operating model, and whatever
5
+ ships next).
6
+
7
+ Framework-agnostic **HTML + CSS**, driven entirely by design tokens, themeable dark
8
+ and light with **accent sub-themes**. Showcased and reviewed in **Storybook**, and
9
+ published on **ui.apli.tech**.
10
+
11
+ - 🎨 **Live site + Storybook** β†’ [ui.apli.tech](https://ui.apli.tech)
12
+ - πŸ“¦ **Package** β†’ `@apliteni/apliteni-ui` (GitHub Packages)
13
+
14
+ ## Why HTML + CSS (not React)
15
+
16
+ The strategy portal (`apliteni/strategy`, `viz/`) server-renders HTML strings
17
+ (`.mjs` modules), not a component framework. So the kit ships the same shape: token
18
+ CSS + component CSS + tiny HTML-string factories. That makes it a *true* single source
19
+ of truth β€” the portal imports it with no rewrite and no framework drift. Storybook
20
+ (`@storybook/html-vite`) renders exactly what ships.
21
+
22
+ ## Install
23
+
24
+ The package lives in **GitHub Packages** (private to the `apliteni` org). Point the
25
+ `@apliteni` scope at it and authenticate with a GitHub token that has `read:packages`.
26
+
27
+ `.npmrc` in the consuming repo:
28
+
29
+ ```
30
+ @apliteni:registry=https://npm.pkg.github.com
31
+ //npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
32
+ ```
33
+
34
+ Then:
35
+
36
+ ```bash
37
+ NODE_AUTH_TOKEN=$(gh auth token) npm install @apliteni/apliteni-ui
38
+ ```
39
+
40
+ In CI, `NODE_AUTH_TOKEN` is the workflow's `GITHUB_TOKEN`. In Docker, pass it as a
41
+ build secret (see the strategy portal's Dockerfile for the pattern).
42
+
43
+ ## Use it
44
+
45
+ ```js
46
+ import '@apliteni/apliteni-ui/css'; // once, at app root (needs the Poppins font)
47
+ import { button, card, topbar, wireTopbar } from '@apliteni/apliteni-ui';
48
+
49
+ el.innerHTML = topbar({ word: 'Strategy', account: { name, email } })
50
+ + card({ title: 'Appearance', body: button({ label: 'Save', variant: 'primary' }) });
51
+ wireTopbar(document); // theme toggle, menus, segmented, copy buttons
52
+ ```
53
+
54
+ ### Reuse the account page
55
+
56
+ The whole `/account` layout (topbar + sticky sidebar + page body) ships as one
57
+ factory, so every product renders the same account shell instead of re-building it:
58
+
59
+ ```js
60
+ import { accountShell, card, switchToggle, wireTopbar } from '@apliteni/apliteni-ui';
61
+
62
+ el.innerHTML = accountShell({
63
+ word: 'Strategy', // the product word in the topbar
64
+ account: { name, email }, // signed-in user (drives the avatar menu)
65
+ active: 'prefs', // which sidebar item is current
66
+ title: 'Preferences',
67
+ sub: 'How the portal looks and speaks to you.',
68
+ body: card({ title: 'Appearance', body: switchToggle({ label: 'Reduce motion' }) }),
69
+ });
70
+ wireTopbar(el); // menus, theme toggle, segmented controls
71
+
72
+ // Custom sidebar nav? pass `nav: [['prefs','gear','Preferences'], ['billing','wallet','Billing']]`
73
+ ```
74
+
75
+ Server-rendered apps that inline CSS (like the strategy portal) import the stylesheet
76
+ as **strings** instead:
77
+
78
+ ```js
79
+ import { tokensCss, topbarCss, cssText } from '@apliteni/apliteni-ui/inline';
80
+ // …inline tokensCss + topbarCss into the <style> you serve.
81
+ ```
82
+
83
+ ## Theming
84
+
85
+ Theme is a `data-theme="dark|light"` attribute on `<html>`; accent is an orthogonal
86
+ `data-accent` on top:
87
+
88
+ ```html
89
+ <html data-theme="dark" data-accent="phoenix">
90
+ ```
91
+
92
+ Each accent re-points only the accent family (`--accent`, `--purple*`, `--glow-purple`,
93
+ `--ring`, `--grad-*`). Surfaces, text and signal colours (green = live, pink = danger)
94
+ stay put β€” so **every accent works in both themes** and every component follows with no
95
+ component-level change.
96
+
97
+ Shipped accents: **Nebula** (purple, default), **Phoenix** (ember), **Ocean** (azure),
98
+ **Emerald** (jade). Runtime helpers: `applyTheme('light')` / `applyAccent('phoenix')`
99
+ (both persist to `localStorage`); or the `accentPicker()` component wired by `wireTopbar()`.
100
+
101
+ ## Layout
102
+
103
+ ```
104
+ src/
105
+ tokens/tokens.css # colours, type, spacing, radius, elevation, motion β€” dark + light
106
+ tokens/accents.css # accent sub-themes (data-accent) for both themes
107
+ styles/*.css # one file per component (button, card, badge, segmented, input,
108
+ # table, callout, code, topbar, layout)
109
+ index.css # bundler entry β€” import '@apliteni/apliteni-ui/css'
110
+ inline.js # CSS as strings for server-render consumers (…/inline)
111
+ assets/ # brand mark (seedling) + line-icon set
112
+ components/ # HTML-string factories: button(), card(), badge(), topbar()…
113
+ stories/ # Storybook: Foundations, Components, Apps
114
+ site/ # ui.apli.tech landing page + static server
115
+ ```
116
+
117
+ ## Develop
118
+
119
+ ```bash
120
+ NODE_AUTH_TOKEN=$(gh auth token) npm install
121
+ npm run storybook # http://localhost:6006
122
+ npm run build-storybook # -> storybook-static/
123
+ node site/build.mjs # -> site/public/ (landing + kit.css)
124
+ ```
125
+
126
+ ## Publish (GitHub Packages)
127
+
128
+ Versioned publish runs from CI on a GitHub Release:
129
+
130
+ ```bash
131
+ npm version patch # or minor / major β€” bumps package.json + tags
132
+ git push --follow-tags
133
+ gh release create v$(node -p "require('./package.json').version") --generate-notes
134
+ ```
135
+
136
+ The **Release** workflow (`.github/workflows/release.yml`) then publishes with the
137
+ built-in `GITHUB_TOKEN` (`packages: write`). No manual `npm publish` needed.
138
+
139
+ ## Deploy (ui.apli.tech)
140
+
141
+ `Dockerfile` builds the landing page + Storybook and serves both (`/` and `/storybook`)
142
+ via a zero-dependency static server. Deployed on **Lessly** as its own product, on
143
+ `linux/amd64` (arm64 images fail there). Rebuild + redeploy:
144
+
145
+ ```bash
146
+ docker buildx build --platform linux/amd64 -t <registry>/ui-apli-tech:latest --push .
147
+ ```
148
+
149
+ ## Adopting into the strategy portal
150
+
151
+ The topbar CSS keeps the **same class names** the portal already uses (`.topbar`,
152
+ `.brand`, `.dtsw`, `.toggle`, `.acct`, `.amenu`), and the token names match `viz/`
153
+ verbatim β€” so migration is subtractive: swap the inlined token/topbar CSS for the
154
+ package's `tokensCss` / `topbarCss` and delete the duplication. The deck (`index.html`)
155
+ stays self-contained for the claude.ai Artifact CSP, baking tokens in via its build step.
156
+
157
+ ## License
158
+
159
+ Proprietary β€” Β© Apliteni. See [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@apliteni/apliteni-ui",
3
+ "version": "0.2.4",
4
+ "description": "Apliteni shared design system & UI kit β€” tokens, components and the deck theme, framework-agnostic (HTML + CSS).",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "author": "Apliteni",
8
+ "homepage": "https://ui.apli.tech",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/apliteni/apliteni-ui.git"
12
+ },
13
+ "publishConfig": {
14
+ "registry": "https://registry.npmjs.org",
15
+ "access": "public"
16
+ },
17
+ "exports": {
18
+ ".": "./src/index.js",
19
+ "./inline": "./src/inline.js",
20
+ "./css": "./src/index.css",
21
+ "./tokens": "./src/tokens/tokens.css",
22
+ "./accents": "./src/tokens/accents.css"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "!src/**/*.test.js",
27
+ "README.md"
28
+ ],
29
+ "sideEffects": [
30
+ "*.css"
31
+ ],
32
+ "scripts": {
33
+ "storybook": "storybook dev -p 6006 --no-open",
34
+ "build-storybook": "storybook build -o storybook-static",
35
+ "test": "node --test"
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "devDependencies": {
41
+ "@storybook/addon-a11y": "^8.6.14",
42
+ "@storybook/addon-essentials": "^8.6.14",
43
+ "@storybook/blocks": "^8.6.14",
44
+ "@storybook/html": "^8.6.14",
45
+ "@storybook/html-vite": "^8.6.14",
46
+ "storybook": "^8.6.14",
47
+ "vite": "^5.4.11"
48
+ }
49
+ }
@@ -0,0 +1,18 @@
1
+ // Apliteni seedling brand mark + wordmark.
2
+ // `p` prefixes gradient ids so multiple marks on one page never collide.
3
+
4
+ export const seedling = (p = 'lg', size = 21) => `<svg viewBox="0 0 38 36" width="${size}" height="${Math.round(size * 36 / 38)}" aria-hidden="true"><defs><linearGradient id="${p}1" x1="1" x2="0" y1="0.248" y2="0.752"><stop offset="0" stop-color="rgb(140,198,63)"/><stop offset="1" stop-color="rgb(0,146,69)"/></linearGradient><linearGradient id="${p}2" x1="1" x2="0" y1="0.497" y2="0.503"><stop offset="0" stop-color="rgb(140,198,63)"/><stop offset="1" stop-color="rgb(0,146,69)"/></linearGradient></defs><g transform="translate(1 1)"><path d="M 23.115 33.95 C 22.982 34.379 23.862 31.827 24.095 30.048 C 25.239 21.404 23.879 13.04 15.283 6.191 C 11.018 2.783 5.476 0.988 0 1.235 C 1.974 1.647 3.17 3.705 3.484 5.697 C 3.8 7.673 3.468 9.697 3.7 11.69 C 4.165 15.691 6.953 19.264 10.57 21.108 C 13.84 22.771 17.689 23.1 21.306 22.359 C 21.306 22.359 17.772 13.501 11.234 10.801 C 11.234 10.801 20.062 13.451 22.617 21.981 C 24.31 27.694 23.115 33.951 23.115 33.951 Z" fill="url(#${p}1)"/><path d="M 24.509 17.09 C 24.495 17.045 24.478 17.002 24.459 16.959 C 25.322 13.469 28.425 10.109 30.582 9.089 C 28.392 9.171 24.708 12.546 23.562 14.621 C 22.857 13.042 21.972 11.551 20.924 10.175 C 24.84 6.026 33.851 6.026 35.527 0 C 36.157 3.886 36.157 9.632 34.532 13.221 C 32.988 16.613 29.752 21.454 25.355 20.878 C 25.181 19.594 24.898 18.327 24.509 17.091 Z" fill="url(#${p}2)"/></g></svg>`;
5
+
6
+ // The Apliteni mark β€” a rounded "sub-theme prism" of the four ready-made accents
7
+ // (Nebula / Phoenix / Ocean / Emerald). This is the current mark used everywhere
8
+ // (the landing, the favicon, the brand() lockup below). The legacy seedling above
9
+ // is kept only for backward compatibility. `p` prefixes the clip id so multiple
10
+ // marks on one page never collide.
11
+ export const prism = (p = 'pr', size = 26) =>
12
+ `<svg viewBox="0 0 32 32" width="${size}" height="${size}" aria-hidden="true"><defs><clipPath id="${p}"><rect x="1" y="1" width="30" height="30" rx="9"/></clipPath></defs><g clip-path="url(#${p})"><rect x="1" y="1" width="15" height="15" fill="#9b5dff"/><rect x="16" y="1" width="15" height="15" fill="#ff6a3d"/><rect x="1" y="16" width="15" height="15" fill="#3b9dff"/><rect x="16" y="16" width="15" height="15" fill="#16c98a"/></g></svg>`;
13
+
14
+ // Brand lockup: the prism mark + product word. `word` defaults to "Strategy"
15
+ // (the strategy portal). Uses the prism β€” the current Apliteni mark β€” not the
16
+ // legacy seedling (still exported above for anything that needs it).
17
+ export const brand = ({ p = 'lg', word = 'Strategy', size = 22, href = '/' } = {}) =>
18
+ `<a class="brand" href="${href}" aria-label="Apliteni ${word}">${prism(p, size)}<span>${word}</span></a>`;
@@ -0,0 +1,36 @@
1
+ // Line-icon set (24Γ—24, stroke=currentColor). Feather-style, 1.7 stroke.
2
+ // Returned as inner SVG markup; wrap in <svg viewBox="0 0 24 24">…</svg> via icon().
3
+
4
+ const P = {
5
+ gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 13a7.9 7.9 0 0 0 0-2l1.6-1.3-1.6-2.7-1.9.8a7.6 7.6 0 0 0-1.7-1L15.5 5h-3l-.3 1.8a7.6 7.6 0 0 0-1.7 1l-1.9-.8-1.6 2.7L8.6 11a7.9 7.9 0 0 0 0 2l-1.6 1.3 1.6 2.7 1.9-.8a7.6 7.6 0 0 0 1.7 1l.3 1.8h3l.3-1.8a7.6 7.6 0 0 0 1.7-1l1.9.8 1.6-2.7z"/>',
6
+ key: '<rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
7
+ chat: '<path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z"/>',
8
+ logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
9
+ mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
10
+ lock: '<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
11
+ user: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
12
+ check: '<path d="M20 6 9 17l-5-5"/>',
13
+ x: '<path d="M18 6 6 18M6 6l12 12"/>',
14
+ copy: '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
15
+ eye: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/>',
16
+ bolt: '<path d="M13 2 3 14h8l-1 8 10-12h-8z"/>',
17
+ shield: '<path d="M12 3 4 6v6c0 5 3.5 8 8 9 4.5-1 8-4 8-9V6z"/>',
18
+ cube: '<path d="M12 2 3 7v10l9 5 9-5V7z"/><path d="M3 7l9 5 9-5M12 12v10"/>',
19
+ compass: '<circle cx="12" cy="12" r="9"/><path d="m15 9-2 6-4 2 2-6z"/>',
20
+ info: '<circle cx="12" cy="12" r="9"/><path d="M12 8h.01M11 12h1v4h1"/>',
21
+ alert: '<path d="M12 3 2 20h20z"/><path d="M12 9v5M12 17h.01"/>',
22
+ arrowRight: '<path d="M5 12h14M13 6l6 6-6 6"/>',
23
+ plug: '<path d="M9 2v6M15 2v6M7 8h10v3a5 5 0 0 1-10 0z"/><path d="M12 16v6"/>',
24
+ layers: '<path d="m12 2 9 5-9 5-9-5z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/>',
25
+ search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
26
+ globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z"/>',
27
+ sparkle: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2 2M16 16l2 2M18 6l-2 2M8 16l-2 2"/>',
28
+ };
29
+
30
+ export const icon = (name, cls = '') =>
31
+ `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"${cls ? ` class="${cls}"` : ''}>${P[name] || ''}</svg>`;
32
+
33
+ export const iconNames = Object.keys(P);
34
+
35
+ export const sun = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
36
+ export const moon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
@@ -0,0 +1,171 @@
1
+ // Inline feedback widget. Select a passage inside a content area, a "Give
2
+ // feedback" pill appears, click it to open a composer (quoted excerpt + note),
3
+ // Send calls your onSend() and shows a success / error state.
4
+ //
5
+ // document.body.insertAdjacentHTML('beforeend', feedbackWidget());
6
+ // wireFeedback({ container: 'main', onSend: async (p) => ({ ok: true }) });
7
+ //
8
+ // The kit owns everything visual + interactive; the app owns the backend
9
+ // (onSend does the POST / issue / whatever) and any deep-link shaping. Styles
10
+ // ship in styles/feedback.css (part of the kit stylesheet). Accent-aware.
11
+ import { esc } from './index.js';
12
+
13
+ const IC_MSG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z"/></svg>';
14
+ const IC_LINES = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 5h16M4 12h10M4 19h7"/></svg>';
15
+ const IC_X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>';
16
+ const CHECK = '<svg class="ui-fbck" viewBox="0 0 150 150" aria-hidden="true"><path class="ui-fbck-t" d="M40 78l24 24 46-50"/><path class="ui-fbck-m" d="M40 78l24 24 46-50"/><path class="ui-fbck-s" d="M40 78l24 24 46-50"/></svg>';
17
+
18
+ // The widget markup β€” append once to the page (e.g. document.body). Copy is
19
+ // static here; behaviour + the dynamic chip/quote come from wireFeedback().
20
+ export function feedbackWidget({
21
+ label = 'Give feedback',
22
+ placeholder = "What's off, missing, or worth adding here?",
23
+ doneTitle = 'Feedback sent. Thank you!',
24
+ doneBody = 'Thanks β€” your note is on its way.',
25
+ } = {}) {
26
+ return `
27
+ <div class="ui-fbpill" data-fb-pill>${IC_MSG}${esc(label)}</div>
28
+ <div class="ui-fbscrim" data-fb-scrim></div>
29
+ <div class="ui-fbcomposer" data-fb-composer role="dialog" aria-modal="true" aria-label="Leave feedback">
30
+ <div data-fb-form>
31
+ <div class="ui-fbc__head">
32
+ <span class="ui-fbc__chip">${IC_LINES}<span data-fb-chip>this page</span></span>
33
+ <button class="ui-fbc__x" data-fb-close aria-label="Close">${IC_X}</button>
34
+ </div>
35
+ <div class="ui-fbc__quote"><span class="ui-fbc__qm">&#8220;</span><q data-fb-quote></q></div>
36
+ <div class="ui-fbc__body"><textarea data-fb-note maxlength="6000" placeholder="${esc(placeholder)}" aria-label="Your note"></textarea></div>
37
+ <div class="ui-fbc__err" data-fb-err></div>
38
+ <div class="ui-fbc__foot">
39
+ <button class="ui-fbbtn ghost" data-fb-cancel>Cancel</button>
40
+ <button class="ui-fbbtn primary" data-fb-send disabled><span class="ui-fbc__send" data-fb-sendlb>Send feedback</span></button>
41
+ </div>
42
+ </div>
43
+ <div class="ui-fbc__done" data-fb-done style="display:none">
44
+ <div class="ui-fbc__ck">${CHECK}</div>
45
+ <h4>${esc(doneTitle)}</h4>
46
+ <p>${esc(doneBody)}</p>
47
+ <div class="row"><button class="ui-fbbtn ghost" data-fb-done-close>Close</button></div>
48
+ </div>
49
+ </div>`;
50
+ }
51
+
52
+ // Pure: the closest ancestor-or-preceding h2|h3 that carries an id, else the
53
+ // first such heading in `root`. Exported for testing.
54
+ export function nearestSection(node, root) {
55
+ let el = node && node.nodeType === 3 ? node.parentNode : node;
56
+ while (el && el !== root) {
57
+ let p = el;
58
+ while (p) {
59
+ if (p.tagName && /^H[23]$/.test(p.tagName) && p.id) return p;
60
+ p = p.previousElementSibling;
61
+ }
62
+ el = el.parentElement;
63
+ }
64
+ return root ? root.querySelector('h2[id],h3[id]') : null;
65
+ }
66
+
67
+ const defaultSection = (h) => {
68
+ if (!h) return { label: 'this page', title: '', anchor: '' };
69
+ const t = (h.textContent || '').replace(/\s+/g, ' ').trim();
70
+ return { label: t, title: t, anchor: h.id || '' };
71
+ };
72
+
73
+ // Wire the widget's behaviour. Call once after the markup is mounted.
74
+ // opts.container selector or element to watch (default 'main')
75
+ // opts.onSend async (payload) => { ok, error? } β€” required to submit
76
+ // opts.minChars minimum selection length to offer the pill (default 3)
77
+ // opts.section (headingEl) => { label, title, anchor } β€” override formatting
78
+ // payload = { note, excerpt, anchor, sectionLabel, sectionTitle }
79
+ export function wireFeedback(opts = {}) {
80
+ const container = typeof opts.container === 'string'
81
+ ? document.querySelector(opts.container)
82
+ : (opts.container || document.querySelector('main'));
83
+ const pill = document.querySelector('[data-fb-pill]');
84
+ const scrim = document.querySelector('[data-fb-scrim]');
85
+ const composer = document.querySelector('[data-fb-composer]');
86
+ if (!container || !pill || !scrim || !composer) return; // widget not mounted
87
+
88
+ const q = (sel) => composer.querySelector(sel);
89
+ const chip = q('[data-fb-chip]'), quoteEl = q('[data-fb-quote]'), note = q('[data-fb-note]');
90
+ const form = q('[data-fb-form]'), done = q('[data-fb-done]');
91
+ const sendBtn = q('[data-fb-send]'), sendLb = q('[data-fb-sendlb]'), errEl = q('[data-fb-err]');
92
+ const minChars = opts.minChars == null ? 3 : opts.minChars;
93
+ const sectionFn = typeof opts.section === 'function' ? opts.section : defaultSection;
94
+ const onSend = typeof opts.onSend === 'function' ? opts.onSend : async () => ({ ok: false, error: 'No submit handler configured.' });
95
+ let pending = null, markSpan = null;
96
+
97
+ function showPill(range) {
98
+ const r = range.getBoundingClientRect();
99
+ if (!r.width && !r.height) return;
100
+ pill.style.left = (window.scrollX + r.left + r.width / 2) + 'px';
101
+ pill.style.top = (window.scrollY + r.top) + 'px';
102
+ pill.classList.add('show');
103
+ }
104
+ const hidePill = () => pill.classList.remove('show');
105
+
106
+ function onSelect() {
107
+ const sel = window.getSelection();
108
+ if (!sel || sel.isCollapsed || !sel.rangeCount) return hidePill();
109
+ const range = sel.getRangeAt(0);
110
+ if (!container.contains(range.commonAncestorContainer)) return hidePill();
111
+ const text = sel.toString().trim();
112
+ if (text.length < minChars) return hidePill();
113
+ const s = sectionFn(nearestSection(range.startContainer, container)) || {};
114
+ pending = { text, anchor: s.anchor || '', label: s.label || '', title: s.title || '', range: range.cloneRange() };
115
+ showPill(range);
116
+ }
117
+ container.addEventListener('mouseup', () => setTimeout(onSelect, 0));
118
+ container.addEventListener('keyup', (e) => { if (e.shiftKey || e.key === 'Shift') setTimeout(onSelect, 0); });
119
+ window.addEventListener('scroll', hidePill, { passive: true });
120
+
121
+ function highlight(range) {
122
+ try { markSpan = document.createElement('span'); markSpan.className = 'ui-fbmark'; range.surroundContents(markSpan); }
123
+ catch (e) { markSpan = null; }
124
+ }
125
+ function clearHighlight() {
126
+ if (markSpan && markSpan.parentNode) {
127
+ const p = markSpan.parentNode;
128
+ markSpan.replaceWith(document.createTextNode(markSpan.textContent));
129
+ p.normalize && p.normalize();
130
+ }
131
+ markSpan = null;
132
+ }
133
+
134
+ function open() {
135
+ if (!pending) return;
136
+ hidePill();
137
+ quoteEl.textContent = pending.text.length > 200 ? pending.text.slice(0, 200) + '…' : pending.text;
138
+ chip.textContent = pending.label || 'this page';
139
+ note.value = ''; sendBtn.disabled = true; errEl.classList.remove('show');
140
+ form.style.display = ''; done.style.display = 'none'; sendLb.textContent = 'Send feedback';
141
+ highlight(pending.range);
142
+ window.getSelection().removeAllRanges();
143
+ scrim.classList.add('show'); composer.classList.add('show');
144
+ setTimeout(() => note.focus(), 60);
145
+ }
146
+ pill.addEventListener('mousedown', (e) => e.preventDefault());
147
+ pill.addEventListener('click', open);
148
+
149
+ function close() { composer.classList.remove('show'); scrim.classList.remove('show'); clearHighlight(); pending = null; }
150
+ q('[data-fb-close]').addEventListener('click', close);
151
+ q('[data-fb-cancel]').addEventListener('click', close);
152
+ q('[data-fb-done-close]').addEventListener('click', close);
153
+ scrim.addEventListener('click', close);
154
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && composer.classList.contains('show')) close(); });
155
+ note.addEventListener('input', () => { sendBtn.disabled = note.value.trim().length === 0; });
156
+
157
+ function fail(msg) {
158
+ errEl.textContent = msg || 'Could not send just now β€” try again in a moment.';
159
+ errEl.classList.add('show'); sendLb.textContent = 'Send feedback'; sendBtn.disabled = false;
160
+ }
161
+ sendBtn.addEventListener('click', async () => {
162
+ if (sendBtn.disabled || !pending) return;
163
+ errEl.classList.remove('show'); sendBtn.disabled = true; sendLb.innerHTML = '<span class="ui-fbspin"></span>Sending';
164
+ const payload = { note: note.value.trim(), excerpt: pending.text, anchor: pending.anchor, sectionLabel: pending.label, sectionTitle: pending.title };
165
+ try {
166
+ const res = await onSend(payload);
167
+ if (!res || !res.ok) { fail(res && res.error); return; }
168
+ form.style.display = 'none'; done.style.display = ''; clearHighlight();
169
+ } catch (e) { fail(); }
170
+ });
171
+ }
@@ -0,0 +1,126 @@
1
+ // apliteni-ui component factories β€” each returns an HTML string, matching the
2
+ // viz/ server-render idiom so the portal can adopt them with no framework.
3
+ import { icon } from '../assets/icons.js';
4
+
5
+ const cx = (...a) => a.filter(Boolean).join(' ');
6
+ export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
7
+
8
+ // ---- Button --------------------------------------------------------------
9
+ export function button({
10
+ label = 'Button', variant = 'secondary', size = 'md', icon: ic, iconRight,
11
+ block = false, disabled = false, busy = false, type = 'button', href, iconOnly = false,
12
+ } = {}) {
13
+ const cls = cx(
14
+ 'ui-btn',
15
+ variant && `ui-btn--${variant}`,
16
+ size !== 'md' && `ui-btn--${size}`,
17
+ block && 'ui-btn--block',
18
+ iconOnly && 'ui-btn--icon',
19
+ );
20
+ const bars = busy ? '<span class="ui-btn__bars"><i></i><i></i></span>' : '';
21
+ const inner = `${ic ? icon(ic) : ''}${iconOnly ? '' : `<span>${esc(label)}</span>`}${iconRight ? icon(iconRight) : ''}${bars}`;
22
+ // busy β‡’ disabled (not clickable while it works)
23
+ const attrs = `class="${cls}"${disabled || busy ? ' disabled aria-disabled="true"' : ''}${busy ? ' aria-busy="true"' : ''}${iconOnly ? ` aria-label="${esc(label)}"` : ''}`;
24
+ return href
25
+ ? `<a href="${href}" ${attrs}>${inner}</a>`
26
+ : `<button type="${type}" ${attrs}>${inner}</button>`;
27
+ }
28
+
29
+ // ---- Badge / Pill --------------------------------------------------------
30
+ export function badge(label, variant = 'neutral') {
31
+ const v = variant === 'neutral' ? '' : `ui-badge--${variant}`;
32
+ return `<span class="${cx('ui-badge', v)}">${esc(label)}</span>`;
33
+ }
34
+ export function pill(label, variant) {
35
+ return `<span class="${cx('ui-pill', variant && `ui-pill--${variant}`)}">${esc(label)}</span>`;
36
+ }
37
+ export function statusDot(live = false) {
38
+ return `<span class="${cx('ui-dot', live && 'is-live')}"></span>`;
39
+ }
40
+
41
+ // ---- Card ----------------------------------------------------------------
42
+ export function card({ title, sub, body = '', variant, pad, icon: ic } = {}) {
43
+ const cls = cx('ui-card', variant && `ui-card--${variant}`, pad && `ui-card--pad-${pad}`);
44
+ // title/sub are trusted markup (may carry a badge/icon) β€” not escaped.
45
+ const head = title
46
+ ? `<div class="ui-card__title">${ic ? `<span class="ui-card__icon">${icon(ic)}</span>` : ''}${title}</div>${sub ? `<div class="ui-card__sub">${sub}</div>` : ''}`
47
+ : '';
48
+ return `<div class="${cls}">${head}${body}</div>`;
49
+ }
50
+
51
+ // ---- Segmented control ---------------------------------------------------
52
+ export function segmented({ options = [], active = 0, size, block, name = 'seg' } = {}) {
53
+ const cls = cx('ui-seg', size && `ui-seg--${size}`, block && 'ui-seg--block');
54
+ const btns = options.map((o, i) => {
55
+ const label = typeof o === 'string' ? o : o.label;
56
+ const val = typeof o === 'string' ? o : (o.value ?? o.label);
57
+ const on = i === active;
58
+ return `<button type="button" role="tab" aria-selected="${on}" data-value="${esc(val)}"${on ? ' class="is-active"' : ''}>${esc(label)}</button>`;
59
+ }).join('');
60
+ return `<div class="${cls}" role="tablist" data-seg="${name}">${btns}</div>`;
61
+ }
62
+
63
+ // ---- Accent picker -------------------------------------------------------
64
+ const ACCENT_SWATCH = {
65
+ default: 'linear-gradient(135deg,#9b5dff,#6a2dcc)',
66
+ phoenix: 'linear-gradient(135deg,#ff8a5c,#ff6a3d)',
67
+ ocean: 'linear-gradient(135deg,#5ab0ff,#3b9dff)',
68
+ emerald: 'linear-gradient(135deg,#3ad9a0,#16c98a)',
69
+ };
70
+ export function accentPicker({ active = 'default', options = ['default', 'phoenix', 'ocean', 'emerald'] } = {}) {
71
+ return `<div class="ui-accent-picker" data-accent-group>${options.map((o) =>
72
+ `<button type="button" data-accent-pick="${o}"${o === active ? ' class="is-active"' : ''} style="--swatch:${ACCENT_SWATCH[o]}" aria-label="${o} accent" title="${o[0].toUpperCase() + o.slice(1)}"></button>`).join('')}</div>`;
73
+ }
74
+
75
+ // ---- Field / input -------------------------------------------------------
76
+ export function field({ label, hint, error, control } = {}) {
77
+ return `<div class="ui-field">${label ? `<label class="ui-field__label">${esc(label)}</label>` : ''}${control || ''}${error ? `<div class="ui-field__error">${icon('alert')}${esc(error)}</div>` : hint ? `<div class="ui-field__hint">${esc(hint)}</div>` : ''}</div>`;
78
+ }
79
+ export function input({ type = 'text', placeholder = '', value = '', icon: ic, invalid, disabled, name } = {}) {
80
+ const el = `<input class="${cx('ui-input', invalid && 'is-invalid')}" type="${type}" placeholder="${esc(placeholder)}" value="${esc(value)}"${name ? ` name="${name}"` : ''}${disabled ? ' disabled' : ''}>`;
81
+ if (!ic) return el;
82
+ return `<div class="ui-input-group"><span class="ui-input-group__icon">${icon(ic)}</span>${el}</div>`;
83
+ }
84
+ export function textarea({ placeholder = '', value = '', rows = 4 } = {}) {
85
+ return `<textarea class="ui-textarea" rows="${rows}" placeholder="${esc(placeholder)}">${esc(value)}</textarea>`;
86
+ }
87
+ export function checkbox({ label, checked, type = 'checkbox', name } = {}) {
88
+ return `<label class="ui-check"><input type="${type}"${name ? ` name="${name}"` : ''}${checked ? ' checked' : ''}><span>${label}</span></label>`;
89
+ }
90
+ // `label` becomes the input's accessible name (a bare switch has no visible text,
91
+ // so it needs one). Defaults to "Toggle" so a control is never left unlabelled.
92
+ export function switchToggle({ checked = false, disabled = false, name, label = 'Toggle' } = {}) {
93
+ return `<label class="ui-switch"><input type="checkbox"${name ? ` name="${name}"` : ''}${checked ? ' checked' : ''}${disabled ? ' disabled' : ''} aria-label="${esc(label)}"><span class="ui-switch__track"></span></label>`;
94
+ }
95
+
96
+ // ---- Callout / toast / success ------------------------------------------
97
+ export function callout({ variant, icon: ic = 'info', body } = {}) {
98
+ return `<div class="${cx('ui-callout', variant && `ui-callout--${variant}`)}"><span class="ui-callout__icon">${icon(ic)}</span><div>${body}</div></div>`;
99
+ }
100
+ export function toast({ variant = 'success', title, body, icon: ic = 'check' } = {}) {
101
+ return `<div class="${cx('ui-toast', `ui-toast--${variant}`)}"><span class="ui-toast__icon">${icon(ic)}</span><div class="ui-toast__body">${title ? `<div class="ui-toast__title">${esc(title)}</div>` : ''}${body ? `<div>${esc(body)}</div>` : ''}</div><button class="ui-toast__close" aria-label="Dismiss">${icon('x')}</button></div>`;
102
+ }
103
+ export function successPanel({ title = 'Done', sub = '' } = {}) {
104
+ return `<div class="ui-success"><div class="ui-success__check">${icon('check')}</div><div class="ui-success__title">${esc(title)}</div>${sub ? `<div class="ui-success__sub">${esc(sub)}</div>` : ''}</div>`;
105
+ }
106
+
107
+ // ---- Snippet -------------------------------------------------------------
108
+ export function snippet({ label = 'shell', code = '', reveal = false, copy = true } = {}) {
109
+ return `<div class="${cx('ui-snippet', reveal && 'ui-snippet--reveal')}"><div class="ui-snippet__bar"><span>${esc(label)}</span>${copy ? `<button class="ui-snippet__copy">${icon('copy')}Copy</button>` : ''}</div><pre>${code}</pre></div>`;
110
+ }
111
+
112
+ // Tiny shell highlighter: escapes first, then wraps comments/strings/URLs/flags/command.
113
+ // Matches the class names in styles/code.css (.k .f .s .u .c). Ported from viz/account.mjs.
114
+ export const hlShell = (raw) =>
115
+ esc(raw).replace(
116
+ /(#[^\n]*)|(&quot;(?:[^&]|&(?!quot;))*&quot;|'[^']*')|(https?:\/\/[^\s"'&]+)|(\B--?[A-Za-z][\w-]*)|(^[a-z][\w.-]*)/gm,
117
+ (m, c, s, u, f, cmd) =>
118
+ c ? `<span class="c">${c}</span>`
119
+ : s ? `<span class="s">${s}</span>`
120
+ : u ? `<span class="u">${u}</span>`
121
+ : f ? `<span class="f">${f}</span>`
122
+ : cmd ? `<span class="k">${cmd}</span>`
123
+ : m,
124
+ );
125
+
126
+ export { icon };
@@ -0,0 +1,46 @@
1
+ // Account page shell β€” topbar + sticky sidebar + page body. Drop it into any
2
+ // product to reuse the exact /account layout: pass the product `word`, the
3
+ // signed-in `account`, the current nav `active` id, and the page `title`/`body`.
4
+ // Call wireTopbar() once after mounting to wire the account menu + theme toggle.
5
+ import { topbar } from './topbar.js';
6
+ import { icon } from './index.js';
7
+
8
+ // Default account navigation: [id, icon, label, href?, target?]. Override with
9
+ // the `nav` option. href defaults to `#{id}` β€” pass your real route (and an
10
+ // optional target) to make each item a working link.
11
+ export const ACCOUNT_NAV = [
12
+ ['prefs', 'gear', 'Preferences'],
13
+ ['access', 'key', 'Access &amp; agents'],
14
+ ];
15
+
16
+ const sidebar = (nav, active) =>
17
+ `<nav class="ui-side"><div class="cap">Account</div>` +
18
+ nav.map(([id, ic, label, href, target]) =>
19
+ `<a href="${href || '#' + id}"${target ? ` target="${target}"` : ''}${id === active ? ' class="on"' : ''}>${icon(ic)}${label}</a>`).join('') +
20
+ `<div class="ssep"></div><a class="out" href="#logout">${icon('logout')}Sign out</a></nav>`;
21
+
22
+ export function accountShell({
23
+ word = 'Account',
24
+ versions,
25
+ account = {},
26
+ nav = ACCOUNT_NAV,
27
+ active = 'prefs',
28
+ crumb,
29
+ title = '',
30
+ sub = '',
31
+ body = '',
32
+ } = {}) {
33
+ return `<div style="position:relative;overflow:hidden;min-height:100vh">
34
+ <span class="ui-glow ui-glow--purple" style="top:-120px;right:6%;opacity:.35"></span>
35
+ ${topbar({ word, view: 'text', versions, account: { ...account, active, nav } })}
36
+ <div class="ui-shell">
37
+ ${sidebar(nav, active)}
38
+ <div class="ui-shell__page">
39
+ <div class="ui-shell__crumbs">Account / <b>${crumb || title}</b></div>
40
+ <h1>${title}</h1>
41
+ <div class="sub">${sub}</div>
42
+ <div class="ui-card-stack">${body}</div>
43
+ </div>
44
+ </div>
45
+ </div>`;
46
+ }