@fikar-ai/design 1.5.0 → 1.6.0

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
@@ -435,7 +435,7 @@ Since 1.2.0 the launcher can also use the shared behaviour, the same way as the
435
435
  - Plain HTML and Jinja: add `data-fk-menu` to the root, or call `launcher(apps, data_fk_menu=true)`. `shell.js` then opens it on a click or Enter, closes it on Escape (focus returns to the button), on a press outside and on choosing a link, and moves between the links with the arrow keys, Home and End. The recipe file itself has no attribute, because it is the markup a page that wires the launcher itself already copies. The shell recipes carry it.
436
436
  - The current app has `.current` and `aria-current="page"`, and its tile is the primary colour. The apps come from the page, and nothing is fetched.
437
437
  - Each tile is a 36px `.launcher-tile` with a 20px glyph over the app name, and the name may wrap to two lines. The hint is not shown. The panel carries `.launcher-apps`, which is what makes it a grid; every grid rule is scoped to that class, so the search results, the user menu and the theme menu, which reuse `.launcher-panel` and `.launcher-item`, keep their look, and a host whose copy of the markup has no `.launcher-apps` keeps the old list.
438
- - The package owns the glyphs, keyed by app id: `chat`, `platform`, `crs`, `dictionary`, `cost-calculator`, `super-admin` and `account`. They are lucide 0.469 SVG files in `brand/apps/<id>.svg` (24px frame, 1.5 stroke, `currentColor`). An id the package does not know shows the first letter of the app name in the same tile. The Jinja `launcher` macro and the React component need the app `id` for this; an item without one shows the letter.
438
+ - The package owns the glyphs, keyed by app id: `chat`, `platform`, `crs`, `dictionary`, `pii`, `cost-calculator`, `super-admin` and `account`. They are lucide 0.469 SVG files in `brand/apps/<id>.svg` (24px frame, 1.5 stroke, `currentColor`). An id the package does not know shows the first letter of the app name in the same tile. The Jinja `launcher` macro and the React component need the app `id` for this; an item without one shows the letter.
439
439
  - `react/src/app-icons.ts` and the icon map in `templates/jinja/launcher.html` are generated from those files by `node scripts/build-app-icons.mjs`. Change an SVG, run the script and commit the result; a test fails when they differ.
440
440
  - React: `AppLauncher` renders the recipe and owns its behaviour, so it does not render `data-fk-menu`.
441
441
 
@@ -1309,6 +1309,351 @@ Docs imports only the raw ramp and aliases Infima to it. Do **not** delete the d
1309
1309
 
1310
1310
  Docusaurus toggles `[data-theme='dark']`, not `.dark`, so docs keeps its own dark block (it already references `--fk-*`).
1311
1311
 
1312
+ ## Plain-JS apps (`esm/`)
1313
+
1314
+ Apps with no bundler (the dictionary, PII and crs services) sign in, renew the session, theme the page and
1315
+ build the app launcher with the same five scripts. They used to each carry a copy; the package now ships
1316
+ one, as plain ES modules under `esm/` with no build step. Every module takes its collaborators as options
1317
+ (storage, fetch, crypto, location, timers), so `node --test` runs them with no browser.
1318
+
1319
+ ```
1320
+ @fikar-ai/design/esm/oidc.js auth.js theme.js launcher.js dom.js app-icons.js
1321
+ ```
1322
+
1323
+ | Module | Exports |
1324
+ |---|---|
1325
+ | `oidc.js` | `createOidc({ storagePrefix, authUrl, clientId, redirectUri, postLogoutRedirectUri })` returning `{ beginSignIn(returnTo), completeSignIn(callbackUrl), signOutUrl(), accountUrl() }`. Also `base64url`, `codeChallenge`, `decodeJwtPayload`. Authorization code flow with PKCE (S256); the three endpoints are built from `authUrl`. |
1326
+ | `auth.js` | `createAuth({ storagePrefix, getAuthUrl, onSessionCleared })` returning the session object (`token`, `setSession`, `clearSession`, `trySso`, `authedFetch`, `armRenewal`, `clearRenewal`, `refreshInFlight`, `generation`). Also `parseJwtClaims`, `decodeJwtExpMs`, `decodeJwtEmail`, `computeDelayMs`. Single-flight cookie refresh and proactive renewal. |
1327
+ | `theme.js` | `createTheme({ storagePrefix, storage, root, matchMedia, onChange })` returning `{ get, set, apply, resolved }`. Also `themeKey(prefix)`, `normalizeTheme`, `resolveTheme`, `THEMES`. |
1328
+ | `launcher.js` | `launcherItems(apps, currentId)` returns the launcher panel's markup (one `<a class="launcher-item">` per app, the current one marked), or `''` for an empty list. Names and URLs are escaped. |
1329
+ | `dom.js` | `h(tag, props, ...children)`, `fill(node, ...children)`, `clear(node)`, `renderable(children)`. Text goes in as text nodes, never as markup. |
1330
+ | `app-icons.js` | `APP_ICONS`, the inner markup of each `brand/apps/*.svg` glyph keyed by app id. Generated by `scripts/build-app-icons.mjs`; `launcher.js` imports it as `./app-icons.js`, so the files must sit in one directory. |
1331
+
1332
+ ### storagePrefix
1333
+
1334
+ `storagePrefix` is required by `createAuth`, `createOidc` and `createTheme` (each throws without it). It is
1335
+ the only per-app setting in these modules. One app name gives three keys:
1336
+
1337
+ | Key | Holds |
1338
+ |---|---|
1339
+ | `${storagePrefix}.token` | the access token (`auth.js`) |
1340
+ | `${storagePrefix}.oidc.pending` | the PKCE verifier, state and nonce between redirect and callback (`oidc.js`, `sessionStorage`) |
1341
+ | `${storagePrefix}-theme` | the theme choice (`theme.js`, `localStorage`); `themeKey(prefix)` returns it |
1342
+
1343
+ Use the app's client id as the prefix (`dictionary`, `pii`, `crs`), which keeps the existing keys, so nobody
1344
+ is signed out by the move. The page's inline first-paint script reads `localStorage.getItem('<prefix>-theme')`
1345
+ before any module has run, so it repeats the literal key.
1346
+
1347
+ ### Recipe: a new plain-JS app
1348
+
1349
+ 1. Copy `scripts/fetch-design.sh` below into the app, pin `DESIGN_VERSION`, and run it. It writes
1350
+ `static/design/` and `static/design/design.lock.json`.
1351
+ 2. Build `static/index.html` from the recipes. The three menus carry `data-fk-menu`, so `shell.js` opens and
1352
+ closes them and the app wires none by hand. The shell markup is `recipes/shell.html`, the launcher is
1353
+ `recipes/launcher.html`, the theme menu `recipes/theme-menu.html`, the user menu `recipes/user-menu.html`.
1354
+ 3. Write `static/js/app.js` with the glue below.
1355
+ 4. Register the client with the provider (client id = the prefix, redirect URI `<origin>/auth/callback`,
1356
+ post-logout URI `<origin>/`) and have the service answer `/auth/callback` with the same `index.html`.
1357
+ The page also needs `GET /config` returning `{ "auth_url": "..." }`.
1358
+
1359
+ `static/index.html`, reduced to what the modules need:
1360
+
1361
+ ```html
1362
+ <!doctype html>
1363
+ <html lang="en">
1364
+ <head>
1365
+ <meta charset="utf-8">
1366
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1367
+ <title>My app</title>
1368
+ <link rel="stylesheet" href="/static/design/tokens.css">
1369
+ <link rel="stylesheet" href="/static/design/components.css">
1370
+ <script>
1371
+ // First paint: same key as createTheme({ storagePrefix: 'myapp' }) writes.
1372
+ (function () {
1373
+ try {
1374
+ var stored = localStorage.getItem('myapp-theme');
1375
+ var theme = stored === 'light' || stored === 'dark' ? stored : 'system';
1376
+ var dark = theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
1377
+ document.documentElement.classList.toggle('dark', dark);
1378
+ document.documentElement.setAttribute('data-theme', theme);
1379
+ } catch (_) {}
1380
+ })();
1381
+ </script>
1382
+ </head>
1383
+ <body>
1384
+ <div id="authError" class="overlay-screen" hidden>
1385
+ <p id="authError-message"></p>
1386
+ <button id="authError-retry" type="button">Try again</button>
1387
+ <button id="authError-signout" type="button">Sign out</button>
1388
+ </div>
1389
+ <div id="noAccess" class="overlay-screen" hidden>
1390
+ <p id="noAccess-signed-in" hidden></p>
1391
+ <a id="noAccess-account" href="/">Account settings</a>
1392
+ <button id="noAccess-switch" type="button">Switch account</button>
1393
+ </div>
1394
+
1395
+ <!-- recipes/shell.html goes here. In its topbar cluster:
1396
+ recipes/launcher.html with id="launcher" (hidden) and its panel id="launcher-panel"
1397
+ recipes/theme-menu.html with id="thememenu"
1398
+ recipes/user-menu.html with ids usermenu-btn, usermenu-avatar, usermenu-email, account, logout
1399
+ each wrapper keeps its data-fk-menu attribute. -->
1400
+
1401
+ <script src="/static/design/shell.js"></script>
1402
+ <script type="module" src="/static/js/app.js"></script>
1403
+ </body>
1404
+ </html>
1405
+ ```
1406
+
1407
+ `static/js/app.js`, the glue the dictionary and PII apps share. Everything app-specific is the prefix, the
1408
+ client id, the launcher id and the screens at the end.
1409
+
1410
+ ```js
1411
+ import { createAuth, decodeJwtEmail } from '/static/design/auth.js';
1412
+ import { createOidc } from '/static/design/oidc.js';
1413
+ import { launcherItems } from '/static/design/launcher.js';
1414
+ import { createTheme } from '/static/design/theme.js';
1415
+
1416
+ const APP = 'myapp'; // storagePrefix, client id and launcher id
1417
+ const $ = (id) => document.getElementById(id);
1418
+
1419
+ let config = null;
1420
+ async function getAuthUrl() {
1421
+ if (!config) config = await (await fetch('/config')).json();
1422
+ return config.auth_url;
1423
+ }
1424
+
1425
+ let oidc = null;
1426
+ // Two races to guard: onSessionCleared firing mid-redirect, and a session the
1427
+ // service rejects bouncing through the provider (which still has its own
1428
+ // session) into an unbroken redirect loop.
1429
+ let redirecting = false;
1430
+ let justSignedIn = false;
1431
+
1432
+ function showAuthError(message) {
1433
+ $('authError-message').textContent = message;
1434
+ $('authError').hidden = false;
1435
+ }
1436
+
1437
+ function startSignIn(returnTo) {
1438
+ if (redirecting || !oidc) return;
1439
+ if (justSignedIn) {
1440
+ showAuthError('Signed in, but the session was rejected. Sign out and try again.');
1441
+ return;
1442
+ }
1443
+ redirecting = true;
1444
+ oidc.beginSignIn(returnTo);
1445
+ }
1446
+
1447
+ const auth = createAuth({
1448
+ storagePrefix: APP,
1449
+ getAuthUrl,
1450
+ onSessionCleared: () => startSignIn(location.pathname + location.search),
1451
+ });
1452
+ const token = auth.token;
1453
+
1454
+ function switchAccount() {
1455
+ // Set before clearSession() so its sign-in callback no-ops instead of racing
1456
+ // the redirect to the provider's end-session page.
1457
+ redirecting = true;
1458
+ auth.clearSession();
1459
+ location.assign(oidc.signOutUrl());
1460
+ }
1461
+ $('authError-retry').addEventListener('click', () => { $('authError').hidden = true; oidc.beginSignIn('/'); });
1462
+ $('authError-signout').addEventListener('click', () => location.assign(oidc.signOutUrl()));
1463
+ $('logout').addEventListener('click', switchAccount);
1464
+ $('noAccess-switch').addEventListener('click', switchAccount);
1465
+
1466
+ // Theme: the local choice paints at once; the account's choice arrives with /me.
1467
+ const thememenuEl = $('thememenu');
1468
+ const theme = createTheme({
1469
+ storagePrefix: APP,
1470
+ storage: localStorage,
1471
+ root: document.documentElement,
1472
+ matchMedia: window.matchMedia.bind(window),
1473
+ onChange: () => {
1474
+ for (const b of thememenuEl.querySelectorAll('[data-value]')) {
1475
+ const on = b.dataset.value === theme.get();
1476
+ b.classList.toggle('current', on);
1477
+ b.setAttribute('aria-checked', String(on));
1478
+ }
1479
+ },
1480
+ });
1481
+ thememenuEl.addEventListener('fk-theme-change', async (e) => {
1482
+ theme.set(e.detail.value);
1483
+ if (!token()) return;
1484
+ try {
1485
+ await auth.authedFetch(`${await getAuthUrl()}/api/auth/me`, {
1486
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ theme: theme.get() }),
1487
+ });
1488
+ } catch { /* the local choice stands */ }
1489
+ });
1490
+ theme.apply();
1491
+
1492
+ // Identity and launcher: the provider returns only the apps this account may open.
1493
+ async function syncLauncher() {
1494
+ if (!token()) return;
1495
+ const email = decodeJwtEmail(token());
1496
+ $('usermenu-email').textContent = email || '';
1497
+ const initial = email ? email[0].toUpperCase() : '?';
1498
+ $('usermenu-avatar').textContent = initial;
1499
+ $('usermenu-btn').dataset.initials = initial;
1500
+ try {
1501
+ const authUrl = await getAuthUrl();
1502
+ const [appsRes, meRes] = await Promise.all([
1503
+ auth.authedFetch(`${authUrl}/api/auth/me/apps`),
1504
+ auth.authedFetch(`${authUrl}/api/auth/me`),
1505
+ ]);
1506
+ if (appsRes.ok) {
1507
+ const apps = await appsRes.json();
1508
+ $('launcher-panel').innerHTML = launcherItems(apps, APP);
1509
+ $('launcher').hidden = apps.length === 0;
1510
+ }
1511
+ if (meRes.ok) {
1512
+ const me = await meRes.json();
1513
+ if (me.preferences && me.preferences.theme) theme.set(me.preferences.theme);
1514
+ }
1515
+ } catch { /* the launcher is not worth an error state */ }
1516
+ }
1517
+
1518
+ function startApp() {
1519
+ // The first accepted response proves the service took the token, which is
1520
+ // when the redirect-loop guard can stand down.
1521
+ const authedFetch = async (...args) => {
1522
+ const res = await auth.authedFetch(...args);
1523
+ if (res.ok) justSignedIn = false;
1524
+ return res;
1525
+ };
1526
+ // ... build the app's screens with `authedFetch` ...
1527
+ syncLauncher();
1528
+ }
1529
+
1530
+ async function init() {
1531
+ try {
1532
+ await getAuthUrl();
1533
+ } catch {
1534
+ showAuthError('Cannot reach the service. Try reloading the page.');
1535
+ return;
1536
+ }
1537
+ oidc = createOidc({
1538
+ storagePrefix: APP,
1539
+ authUrl: config.auth_url,
1540
+ clientId: APP,
1541
+ redirectUri: `${location.origin}/auth/callback`,
1542
+ postLogoutRedirectUri: `${location.origin}/`,
1543
+ });
1544
+ $('account').href = oidc.accountUrl();
1545
+
1546
+ if (location.pathname === '/auth/callback') {
1547
+ try {
1548
+ const { accessToken, returnTo } = await oidc.completeSignIn(location.href);
1549
+ justSignedIn = true;
1550
+ auth.setSession(accessToken);
1551
+ history.replaceState(null, '', returnTo);
1552
+ startApp();
1553
+ } catch (e) {
1554
+ showAuthError(e.message || 'Sign-in failed');
1555
+ }
1556
+ return;
1557
+ }
1558
+ if (token()) {
1559
+ auth.armRenewal();
1560
+ startApp();
1561
+ return;
1562
+ }
1563
+ // No refresh-cookie auto-login: the provider session behind /authorize gives
1564
+ // the silent re-entry and keeps each app's refresh family attached to a
1565
+ // session the account settings can list.
1566
+ startSignIn(location.pathname + location.search + location.hash);
1567
+ }
1568
+
1569
+ init();
1570
+ ```
1571
+
1572
+ The dictionary and PII apps also revalidate the session when the tab regains focus (a sign-out elsewhere
1573
+ clears the shared cookie) and show the `noAccess` overlay when the token lacks what the screen needs
1574
+ (`showNoAccess` sets `noAccess-signed-in` and points `noAccess-account` at `oidc.accountUrl()`). Both are
1575
+ about 30 lines and identical in the two apps, so copy them from `services/dictionary/static/js/app.js`
1576
+ (`revalidateSession`, `showNoAccess`) when the app wants them. They are left out of `esm/` because they
1577
+ reach into the page's DOM and the crs explorer wires them inline in `ui.html`.
1578
+
1579
+ `scripts/fetch-design.sh`:
1580
+
1581
+ ```bash
1582
+ #!/usr/bin/env bash
1583
+ # Pull the pinned @fikar-ai/design release into static/design.
1584
+ #
1585
+ # The page links the design system's CSS and runs its shell.js and esm/ modules, so the files
1586
+ # live in this repo. This script is the only sanctioned way to update them, and a test fails when
1587
+ # a committed file drifts from design.lock.json. Bump DESIGN_VERSION, run this, commit the result.
1588
+ set -euo pipefail
1589
+
1590
+ # The release must ship esm/ (the first one after 1.5.0).
1591
+ DESIGN_VERSION="${DESIGN_VERSION:?set DESIGN_VERSION to an @fikar-ai/design release that ships esm/}"
1592
+ HERE="$(cd "$(dirname "$0")/.." && pwd)"
1593
+ DEST="${DEST:-$HERE/static/design}"
1594
+ TMP="$(mktemp -d)"
1595
+ trap 'rm -rf "$TMP"' EXIT
1596
+
1597
+ (cd "$TMP" && npm pack "@fikar-ai/design@${DESIGN_VERSION}" --silent >/dev/null && tar xzf fikar-ai-design-*.tgz)
1598
+
1599
+ FILES="tokens.css components.css shell.js brand/fikar-logo.svg brand/fikar-logo-reversed.svg"
1600
+ ESM_FILES="esm/oidc.js esm/auth.js esm/theme.js esm/dom.js esm/launcher.js esm/app-icons.js"
1601
+ NOTE="Vendored verbatim from @fikar-ai/design@${DESIGN_VERSION} by scripts/fetch-design.sh; do not edit."
1602
+
1603
+ mkdir -p "$DEST"
1604
+ for f in $FILES $ESM_FILES; do
1605
+ out="$DEST/$(basename "$f")"
1606
+ case "$f" in
1607
+ *.svg)
1608
+ # An XML comment must come after the XML declaration, when there is one.
1609
+ python3 - "$TMP/package/$f" "$out" "$NOTE" <<'PY'
1610
+ import pathlib, re, sys
1611
+ src, out, note = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), sys.argv[3]
1612
+ text = src.read_text()
1613
+ decl = re.match(r"\s*<\?xml[^>]*\?>\s*", text)
1614
+ head = decl.group(0) if decl else ""
1615
+ out.write_text(f"{head}<!-- {note} -->\n{text[len(head):]}")
1616
+ PY
1617
+ ;;
1618
+ *)
1619
+ { echo "/* $NOTE */"; cat "$TMP/package/$f"; } > "$out"
1620
+ ;;
1621
+ esac
1622
+ done
1623
+
1624
+ # The favicon set is copied byte for byte: the .ico and .png files are binary and a manifest
1625
+ # cannot carry a comment. Serve it at the site root.
1626
+ rm -rf "$DEST/favicon"
1627
+ mkdir -p "$DEST/favicon"
1628
+ cp "$TMP"/package/brand/favicon/* "$DEST/favicon/"
1629
+
1630
+ python3 - "$DEST" "$DESIGN_VERSION" $(for f in $FILES $ESM_FILES; do basename "$f"; done) <<'PY'
1631
+ import hashlib, json, pathlib, sys
1632
+ dest, version, names = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3:]
1633
+ digest = lambda p: hashlib.sha256(p.read_bytes()).hexdigest()
1634
+ lock = {
1635
+ "package": "@fikar-ai/design",
1636
+ "version": version,
1637
+ "sha256": {n: digest(dest / n) for n in names},
1638
+ "favicon_sha256": {p.name: digest(p) for p in sorted((dest / "favicon").iterdir())},
1639
+ }
1640
+ (dest / "design.lock.json").write_text(json.dumps(lock, indent=2) + "\n")
1641
+ print(f"vendored @fikar-ai/design@{version}: {', '.join(names)}")
1642
+ PY
1643
+ ```
1644
+
1645
+ The `brand/apps/` glyphs are not vendored any more. `app-icons.js` carries them, already generated.
1646
+
1647
+ A guard test in the app repo keeps the vendored files honest. It reads `design.lock.json`, hashes each file
1648
+ under `static/design/` (and each file in `favicon/`) and fails when one differs, when a file on disk is not
1649
+ in the lock, or when the `@fikar-ai/design@<version>` named in a file's first line is not the lock's version.
1650
+ The dictionary's `tests/test_design_vendoring.py` is the working example (pytest); a Node version is a
1651
+ dozen lines with `node:crypto`.
1652
+
1653
+ Vendored modules run unchanged under `node --test`, so the app's own tests import them from
1654
+ `static/design/` and inject fakes. Keep the app's tests for its own code only; the modules' tests live in
1655
+ this package (`test/esm-*.test.mjs`).
1656
+
1312
1657
  ## Tokens
1313
1658
 
1314
1659
  - Ramp is `--fk-*` (e.g. `--fk-blue600`, `--fk-n200`). Compose with `hsl()`: `hsl(var(--fk-blue600) / 0.5)`.
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
2
+ <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>
3
+ <path d="m9 12 2 2 4-4"/>
4
+ </svg>
@@ -0,0 +1,12 @@
1
+ // Generated by scripts/build-app-icons.mjs from brand/apps/*.svg. Do not edit; change the SVG and run the script.
2
+ // The inner markup of each app's launcher glyph, keyed by app id. Each is drawn in the shared 24px, 1.5 stroke frame.
3
+ export const APP_ICONS = {
4
+ "account": "<circle cx=\"12\" cy=\"8\" r=\"5\"/><path d=\"M20 21a8 8 0 0 0-16 0\"/>",
5
+ "chat": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>",
6
+ "cost-calculator": "<rect width=\"16\" height=\"20\" x=\"4\" y=\"2\" rx=\"2\"/><line x1=\"8\" x2=\"16\" y1=\"6\" y2=\"6\"/><line x1=\"16\" x2=\"16\" y1=\"14\" y2=\"18\"/><path d=\"M16 10h.01\"/><path d=\"M12 10h.01\"/><path d=\"M8 10h.01\"/><path d=\"M12 14h.01\"/><path d=\"M8 14h.01\"/><path d=\"M12 18h.01\"/><path d=\"M8 18h.01\"/>",
7
+ "crs": "<circle cx=\"12\" cy=\"4.5\" r=\"2.5\"/><path d=\"m10.2 6.3-3.9 3.9\"/><circle cx=\"4.5\" cy=\"12\" r=\"2.5\"/><path d=\"M7 12h10\"/><circle cx=\"19.5\" cy=\"12\" r=\"2.5\"/><path d=\"m13.8 17.7 3.9-3.9\"/><circle cx=\"12\" cy=\"19.5\" r=\"2.5\"/>",
8
+ "dictionary": "<path d=\"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20\"/><path d=\"m8 13 4-7 4 7\"/><path d=\"M9.1 11h5.7\"/>",
9
+ "pii": "<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\"/><path d=\"m9 12 2 2 4-4\"/>",
10
+ "platform": "<line x1=\"21\" x2=\"14\" y1=\"4\" y2=\"4\"/><line x1=\"10\" x2=\"3\" y1=\"4\" y2=\"4\"/><line x1=\"21\" x2=\"12\" y1=\"12\" y2=\"12\"/><line x1=\"8\" x2=\"3\" y1=\"12\" y2=\"12\"/><line x1=\"21\" x2=\"16\" y1=\"20\" y2=\"20\"/><line x1=\"12\" x2=\"3\" y1=\"20\" y2=\"20\"/><line x1=\"14\" x2=\"14\" y1=\"2\" y2=\"6\"/><line x1=\"8\" x2=\"8\" y1=\"10\" y2=\"14\"/><line x1=\"16\" x2=\"16\" y1=\"18\" y2=\"22\"/>",
11
+ "super-admin": "<path d=\"M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z\"/><path d=\"M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2\"/><path d=\"M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2\"/><path d=\"M10 6h4\"/><path d=\"M10 10h4\"/><path d=\"M10 14h4\"/><path d=\"M10 18h4\"/>",
12
+ };
package/esm/auth.js ADDED
@@ -0,0 +1,240 @@
1
+ // Auth state machine for a plain-JS FIKAR app: the shared single-flight cookie
2
+ // refresh, the authGeneration guard, and the proactive access-token renewal
3
+ // timer. Shared by the dictionary, PII and crs apps (KAN-385).
4
+ //
5
+ // Kept free of the DOM, with every dependency injected, so node --test can run
6
+ // it (test/esm-auth.test.mjs in @fikar-ai/design). The page wires the login
7
+ // redirect and sign-out into the object createAuth() returns. The access token
8
+ // is stored under `${storagePrefix}.token`.
9
+
10
+ // 90s margin absorbs clock skew and the refresh call's own latency; the
11
+ // jitter (redrawn on every arm) staggers tabs that share the same stored
12
+ // token so several tabs don't rotate the shared .fikar.ai cookie at once.
13
+ const MARGIN_MS = 90_000;
14
+ const JITTER_MAX_MS = 45_000;
15
+
16
+ /** Dependency-free JWT payload decode, no signature check: every caller here
17
+ * already holds a token the server accepted on some prior request, so this
18
+ * is just reading claims back out of it, not trusting it as a credential.
19
+ * Returns null for anything that isn't a three-segment JWT with a JSON
20
+ * payload, rather than throwing, since both callers below treat "can't read
21
+ * this claim" as a normal case (an unrecognized token, a claim the server
22
+ * didn't set) and not an error. */
23
+ export function parseJwtClaims(token) {
24
+ if (typeof token !== 'string') return null;
25
+ const parts = token.split('.');
26
+ if (parts.length !== 3) return null;
27
+ try {
28
+ return JSON.parse(base64UrlDecode(parts[1]));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /** The `exp` claim (seconds since epoch), in milliseconds. Needed to arm the
35
+ * renewal timer, and the token lifetime differs per environment (15 or 60
36
+ * minutes), so it must come from the token itself rather than a hardcoded
37
+ * constant. Exported for direct testing. */
38
+ export function decodeJwtExpMs(token) {
39
+ const payload = parseJwtClaims(token);
40
+ return typeof payload?.exp === 'number' ? payload.exp * 1000 : null;
41
+ }
42
+
43
+ /** The `email` claim the user service puts on every access token (KAN-255):
44
+ * used only for the "Signed in as ..." line on the no-access screen, so a
45
+ * token that doesn't decode or carries no email claim just means that line
46
+ * is omitted, not an error. Exported for direct testing. */
47
+ export function decodeJwtEmail(token) {
48
+ const payload = parseJwtClaims(token);
49
+ return typeof payload?.email === 'string' ? payload.email : null;
50
+ }
51
+
52
+ function base64UrlDecode(segment) {
53
+ const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
54
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
55
+ const binary = atob(padded);
56
+ // atob yields a binary string; re-decode as UTF-8 so multi-byte claim
57
+ // values don't get mangled.
58
+ const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
59
+ return new TextDecoder().decode(bytes);
60
+ }
61
+
62
+ /** Pure so jitter/margin math can be asserted directly. Exported for tests. */
63
+ export function computeDelayMs(expiresAtMs, now, jitterMs) {
64
+ return Math.max(0, expiresAtMs - MARGIN_MS - jitterMs - now);
65
+ }
66
+
67
+ /**
68
+ * Builds the auth object ui.html holds for its lifetime. Dependencies are
69
+ * injected (storage, fetch, timers, randomness, the clock, the document) so
70
+ * tests can run this with no DOM, no real network and no real delays.
71
+ *
72
+ * `documentImpl` defaults to the real `document` when one exists (the
73
+ * `typeof` guard keeps that default from throwing in Node, where no global
74
+ * `document` is declared) and is only used to listen for `visibilitychange`
75
+ * (KAN-238); pass an object with `addEventListener`/`visibilityState` to
76
+ * drive that from a test, or `undefined` to skip wiring it up at all.
77
+ */
78
+ export function createAuth({
79
+ storagePrefix,
80
+ storage = localStorage,
81
+ fetchImpl = fetch,
82
+ getAuthUrl,
83
+ onSessionCleared,
84
+ now = Date.now,
85
+ random = Math.random,
86
+ setTimeoutImpl = setTimeout,
87
+ clearTimeoutImpl = clearTimeout,
88
+ documentImpl = typeof document === 'undefined' ? undefined : document,
89
+ } = {}) {
90
+ if (typeof storagePrefix !== 'string' || !storagePrefix) throw new Error('storagePrefix is required');
91
+ // Shares one cookie rotation across startup, the proactive timer, and
92
+ // concurrent expired requests.
93
+ let refreshPromise = null;
94
+ let authGeneration = 0;
95
+ let timerId = null;
96
+
97
+ const TOKEN_KEY = `${storagePrefix}.token`;
98
+ const token = () => storage.getItem(TOKEN_KEY);
99
+
100
+ function clearRenewal() {
101
+ if (timerId !== null) {
102
+ clearTimeoutImpl(timerId);
103
+ timerId = null;
104
+ }
105
+ }
106
+
107
+ /** (Re)arms the proactive renewal timer off the stored token's own `exp`.
108
+ * A no-op (no stored token, or one with no decodable exp) just clears any
109
+ * timer already scheduled. */
110
+ function armRenewal() {
111
+ clearRenewal();
112
+ const current = token();
113
+ if (!current) return;
114
+ const expiresAtMs = decodeJwtExpMs(current);
115
+ if (expiresAtMs === null) return;
116
+ const jitterMs = random() * JITTER_MAX_MS;
117
+ const delay = computeDelayMs(expiresAtMs, now(), jitterMs);
118
+ const generation = authGeneration;
119
+ timerId = setTimeoutImpl(() => {
120
+ timerId = null;
121
+ // A logout (or another generation-bumping event) between arming and
122
+ // firing must not let a stale timer touch the new session.
123
+ if (generation !== authGeneration) return;
124
+ // Shares trySso()'s single-flight guard with the reactive 401 path in
125
+ // authedFetch(), so a timer firing mid-refresh just waits on it. A
126
+ // proactive failure is silent by design: no login form, token left
127
+ // alone, the reactive path in authedFetch() is the fallback.
128
+ void trySso();
129
+ }, delay);
130
+ }
131
+
132
+ function clearSession() {
133
+ authGeneration++;
134
+ clearRenewal();
135
+ storage.removeItem(TOKEN_KEY);
136
+ onSessionCleared?.();
137
+ }
138
+
139
+ /** Stores a freshly issued access token (login) and arms the timer for it. */
140
+ function setSession(accessToken) {
141
+ authGeneration++;
142
+ storage.setItem(TOKEN_KEY, accessToken);
143
+ armRenewal();
144
+ }
145
+
146
+ function trySso() {
147
+ if (refreshPromise) return refreshPromise;
148
+ const generation = authGeneration;
149
+ refreshPromise = (async () => {
150
+ try {
151
+ const res = await fetchImpl(`${await getAuthUrl()}/api/auth/refresh`, {
152
+ method: 'POST',
153
+ credentials: 'include',
154
+ });
155
+ if (!res.ok) return false;
156
+ const data = await res.json();
157
+ if (generation !== authGeneration || !data.access_token) return false;
158
+ storage.setItem(TOKEN_KEY, data.access_token);
159
+ armRenewal();
160
+ return true;
161
+ } catch {
162
+ return false;
163
+ }
164
+ })().finally(() => { refreshPromise = null; });
165
+ return refreshPromise;
166
+ }
167
+
168
+ /** True when the stored token cannot carry a request without a refresh
169
+ * first: no token at all, or one that has already passed `exp` or sits
170
+ * inside the same MARGIN_MS the renewal timer uses. A token that decodes
171
+ * to no `exp` claim (never issued by the real user-service, but a stand-in
172
+ * some tests use) is left alone here and handled by the existing 401 path
173
+ * instead, since there is nothing to preflight-check about it. */
174
+ function needsPreflightRefresh() {
175
+ const current = token();
176
+ if (!current) return true;
177
+ const expiresAtMs = decodeJwtExpMs(current);
178
+ if (expiresAtMs === null) return false;
179
+ return expiresAtMs - MARGIN_MS <= now();
180
+ }
181
+
182
+ async function authedFetch(url, init = {}) {
183
+ // `init` lets a caller PATCH (the theme, KAN-294); the bearer header is
184
+ // always ours, whatever the caller passed.
185
+ const withAuth = (bearer) => ({ ...init, headers: { ...(init.headers || {}), Authorization: `Bearer ${bearer}` } });
186
+ const generation = authGeneration;
187
+ // Pre-flight (KAN-238): a token that is already expired, or due to
188
+ // expire within the renewal margin, is refreshed silently before the
189
+ // request goes out, instead of sending it, getting a 401, and refreshing
190
+ // reactively. This is what makes a laptop wake silent: the overdue
191
+ // renewal timer and the app's first request both want a refresh, and
192
+ // trySso()'s single-flight guard collapses them into one. A failed
193
+ // refresh here (dead cookie) falls straight through to the unchanged
194
+ // 401 handling below.
195
+ if (needsPreflightRefresh()) {
196
+ await trySso();
197
+ if (generation !== authGeneration) throw new Error('session changed');
198
+ }
199
+ const sentToken = token();
200
+ let res = await fetchImpl(url, withAuth(sentToken));
201
+ if (generation !== authGeneration) throw new Error('session changed');
202
+ if (res.status === 401) {
203
+ // A late 401 may arrive after another request already refreshed. Reuse
204
+ // that token instead of rotating the shared cookie for a second time.
205
+ if (token() && (token() !== sentToken || await trySso())) {
206
+ if (generation !== authGeneration) throw new Error('session changed');
207
+ res = await fetchImpl(url, withAuth(token()));
208
+ if (generation !== authGeneration) throw new Error('session changed');
209
+ if (res.status !== 401) return res;
210
+ }
211
+ if (generation === authGeneration) clearSession();
212
+ throw new Error('unauthorized');
213
+ }
214
+ return res;
215
+ }
216
+
217
+ // Re-arm on wake (KAN-238): a setTimeout cannot fire while the machine is
218
+ // asleep, so the armed timer is stale the moment the tab becomes visible
219
+ // again. Re-arming off the stored token here (an expired token arms with
220
+ // delay 0, see computeDelayMs) puts the next renewal on a correct footing
221
+ // even if this races the pre-flight check in authedFetch(); both paths
222
+ // share trySso()'s single-flight guard.
223
+ if (documentImpl?.addEventListener) {
224
+ documentImpl.addEventListener('visibilitychange', () => {
225
+ if (documentImpl.visibilityState === 'visible') armRenewal();
226
+ });
227
+ }
228
+
229
+ return {
230
+ token,
231
+ setSession,
232
+ clearSession,
233
+ trySso,
234
+ authedFetch,
235
+ armRenewal,
236
+ clearRenewal,
237
+ refreshInFlight: () => refreshPromise,
238
+ generation: () => authGeneration,
239
+ };
240
+ }
package/esm/dom.js ADDED
@@ -0,0 +1,39 @@
1
+ // A tiny element builder. Shared by the plain-JS apps (KAN-385). Text goes in as text nodes, never as markup, so a
2
+ // name or definition typed by a person cannot inject HTML into the page.
3
+
4
+ /** Children worth rendering: nested lists flattened, and the false, null and
5
+ * undefined that `cond && el` leaves behind dropped. Native append() would
6
+ * write those as the text "false" or "null". */
7
+ export function renderable(children) {
8
+ return children.flat(Infinity).filter((c) => c !== undefined && c !== null && c !== false);
9
+ }
10
+
11
+ /** h('button', { class: 'btn', onclick: fn, disabled: true }, 'Save', child) */
12
+ export function h(tag, props = {}, ...children) {
13
+ const node = document.createElement(tag);
14
+ for (const [key, value] of Object.entries(props || {})) {
15
+ if (value === undefined || value === null || value === false) continue;
16
+ if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2), value);
17
+ else if (key === 'class') node.className = value;
18
+ else if (key === 'value') node.value = value;
19
+ else if (value === true) node.setAttribute(key, '');
20
+ else node.setAttribute(key, String(value));
21
+ }
22
+ for (const child of renderable(children)) {
23
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)));
24
+ }
25
+ return node;
26
+ }
27
+
28
+ export function clear(node) {
29
+ node.replaceChildren();
30
+ return node;
31
+ }
32
+
33
+ /** Replace a node's children; use this instead of clear(node).append(...) for
34
+ * any list that can hold `cond && el`. */
35
+ export function fill(node, ...children) {
36
+ node.replaceChildren();
37
+ for (const child of renderable(children)) node.append(child instanceof Node ? child : document.createTextNode(String(child)));
38
+ return node;
39
+ }
@@ -0,0 +1,44 @@
1
+ // The app launcher's panel markup from the provider's list, kept out of the
2
+ // page so it can be tested with node --test. Shared by the dictionary, PII and
3
+ // crs apps (KAN-385). The open/close behaviour is
4
+ // shell.js's, through data-fk-menu.
5
+
6
+ import { APP_ICONS } from './app-icons.js';
7
+
8
+ function escapeHtml(value) {
9
+ return String(value)
10
+ .replace(/&/g, '&amp;')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;');
14
+ }
15
+
16
+ // The glyph for a known app id (the body comes from the vendored package, so it
17
+ // is markup we trust); an unknown id shows the first letter of its name.
18
+ function tile(app) {
19
+ const body = APP_ICONS[app.id];
20
+ if (body) {
21
+ return (
22
+ '<svg class="app-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"' +
23
+ ` stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`
24
+ );
25
+ }
26
+ return escapeHtml(String(app.name || '').trim().charAt(0).toUpperCase());
27
+ }
28
+
29
+ /** One <a class="launcher-item"> per app, the current one marked. The list
30
+ * arrives already filtered and ordered by the provider; nothing is reordered
31
+ * or dropped here. Returns '' for an empty list, which the caller treats as
32
+ * "no launcher". */
33
+ export function launcherItems(apps, currentId) {
34
+ return (apps || [])
35
+ .map((app) => {
36
+ const current = app.id === currentId;
37
+ return (
38
+ `<a class="launcher-item${current ? ' current' : ''}" role="menuitem"` +
39
+ ` href="${escapeHtml(app.url)}"${current ? ' aria-current="page"' : ''}>` +
40
+ `<span class="launcher-tile" aria-hidden="true">${tile(app)}</span>${escapeHtml(app.name)}</a>`
41
+ );
42
+ })
43
+ .join('');
44
+ }
package/esm/oidc.js ADDED
@@ -0,0 +1,163 @@
1
+ // OIDC authorization-code + PKCE flow for a plain-JS FIKAR app, against the
2
+ // user service acting as the provider. Shared by the dictionary, PII and crs
3
+ // apps (KAN-385).
4
+ //
5
+ // The three endpoints this app needs never move independently of the provider,
6
+ // so they are built from authUrl rather than fetched from discovery, saving a
7
+ // round trip before the redirect. Dependencies are injected so node --test
8
+ // runs it with no browser (test/esm-oidc.test.mjs in @fikar-ai/design). The
9
+ // pending sign-in is stored under `${storagePrefix}.oidc.pending`.
10
+
11
+ const VERIFIER_BYTES = 64;
12
+
13
+ /** Bytes -> base64url, no padding. Used for both the PKCE verifier and the
14
+ * S256 challenge. Exported for direct testing. */
15
+ export function base64url(bytes) {
16
+ let binary = '';
17
+ for (const b of bytes) binary += String.fromCharCode(b);
18
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
19
+ }
20
+
21
+ /** RFC 7636 S256: base64url(SHA-256(ascii(verifier))). Exported so the RFC
22
+ * appendix B test vector can be checked directly against this function. */
23
+ export async function codeChallenge(verifier, cryptoImpl) {
24
+ const bytes = new TextEncoder().encode(verifier);
25
+ const digest = await cryptoImpl.subtle.digest('SHA-256', bytes);
26
+ return base64url(new Uint8Array(digest));
27
+ }
28
+
29
+ /** Decodes a JWT's payload segment with no signature check. Safe here only
30
+ * because the id_token was read straight off the token endpoint's response
31
+ * body over TLS, not handed to us by a third party the way a redirect
32
+ * parameter would be; nothing forwards this token or trusts it beyond the
33
+ * nonce/aud/iss checks completeSignIn() makes right after decoding it.
34
+ * Exported for direct testing. */
35
+ export function decodeJwtPayload(idToken) {
36
+ const parts = idToken.split('.');
37
+ if (parts.length !== 3) throw new Error('malformed id_token');
38
+ const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
39
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
40
+ const binary = atob(padded);
41
+ const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
42
+ return JSON.parse(new TextDecoder().decode(bytes));
43
+ }
44
+
45
+ function randomString(cryptoImpl, byteLength) {
46
+ const bytes = new Uint8Array(byteLength);
47
+ cryptoImpl.getRandomValues(bytes);
48
+ return base64url(bytes);
49
+ }
50
+
51
+ /** A same-origin path only: '/x' is fine, '//evil.example/x' and an absolute
52
+ * URL are not. Guards against an open redirect via a crafted returnTo. */
53
+ function isSameOriginPath(value) {
54
+ return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//');
55
+ }
56
+
57
+ /**
58
+ * Builds the OIDC client ui.html holds for its lifetime. Dependencies are
59
+ * injected (fetch, storage, crypto, location) so tests can drive this with
60
+ * no browser and no real network.
61
+ */
62
+ export function createOidc({
63
+ storagePrefix,
64
+ authUrl,
65
+ clientId,
66
+ redirectUri,
67
+ postLogoutRedirectUri,
68
+ fetchImpl = fetch,
69
+ storage = typeof sessionStorage === 'undefined' ? undefined : sessionStorage,
70
+ cryptoImpl = globalThis.crypto,
71
+ locationImpl = typeof window === 'undefined' ? undefined : window.location,
72
+ } = {}) {
73
+ if (typeof storagePrefix !== 'string' || !storagePrefix) throw new Error('storagePrefix is required');
74
+ const PENDING_KEY = `${storagePrefix}.oidc.pending`;
75
+ const issuer = authUrl.replace(/\/$/, '');
76
+ const authorizeEndpoint = `${issuer}/authorize`;
77
+ const tokenEndpoint = `${issuer}/token`;
78
+ const endSessionEndpoint = `${issuer}/end-session`;
79
+
80
+ async function beginSignIn(returnTo) {
81
+ const verifier = randomString(cryptoImpl, VERIFIER_BYTES);
82
+ const challenge = await codeChallenge(verifier, cryptoImpl);
83
+ const state = randomString(cryptoImpl, 32);
84
+ const nonce = randomString(cryptoImpl, 32);
85
+ storage.setItem(PENDING_KEY, JSON.stringify({ verifier, state, nonce, returnTo }));
86
+
87
+ const params = new URLSearchParams({
88
+ client_id: clientId,
89
+ redirect_uri: redirectUri,
90
+ response_type: 'code',
91
+ scope: 'openid email profile',
92
+ state,
93
+ nonce,
94
+ code_challenge: challenge,
95
+ code_challenge_method: 'S256',
96
+ });
97
+ locationImpl.assign(`${authorizeEndpoint}?${params}`);
98
+ }
99
+
100
+ async function completeSignIn(callbackUrl) {
101
+ const url = new URL(callbackUrl);
102
+ const error = url.searchParams.get('error');
103
+ if (error) {
104
+ const description = url.searchParams.get('error_description') || error;
105
+ throw new Error(description);
106
+ }
107
+ const code = url.searchParams.get('code');
108
+ const state = url.searchParams.get('state');
109
+ if (!code) throw new Error('callback URL has no code');
110
+
111
+ const raw = storage.getItem(PENDING_KEY);
112
+ const pending = raw ? JSON.parse(raw) : null;
113
+ if (!pending || !state || pending.state !== state) {
114
+ throw new Error('sign-in state mismatch');
115
+ }
116
+
117
+ const body = new URLSearchParams({
118
+ grant_type: 'authorization_code',
119
+ code,
120
+ redirect_uri: redirectUri,
121
+ client_id: clientId,
122
+ code_verifier: pending.verifier,
123
+ });
124
+ const res = await fetchImpl(tokenEndpoint, {
125
+ method: 'POST',
126
+ // The provider sets the refresh cookie on this response; without
127
+ // credentials: 'include' the browser drops that Set-Cookie header.
128
+ credentials: 'include',
129
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
130
+ body,
131
+ });
132
+ if (!res.ok) throw new Error(`token exchange failed (${res.status})`);
133
+ const data = await res.json();
134
+
135
+ const payload = decodeJwtPayload(data.id_token);
136
+ if (payload.nonce !== pending.nonce) throw new Error('id_token nonce mismatch');
137
+ if (payload.aud !== clientId) throw new Error('id_token aud mismatch');
138
+ if (String(payload.iss).replace(/\/$/, '') !== issuer) throw new Error('id_token iss mismatch');
139
+
140
+ storage.removeItem(PENDING_KEY);
141
+ const returnTo = isSameOriginPath(pending.returnTo) ? pending.returnTo : '/';
142
+ return { accessToken: data.access_token, returnTo };
143
+ }
144
+
145
+ function signOutUrl() {
146
+ const params = new URLSearchParams({
147
+ client_id: clientId,
148
+ post_logout_redirect_uri: postLogoutRedirectUri,
149
+ });
150
+ return `${endSessionEndpoint}?${params}`;
151
+ }
152
+
153
+ /** The hosted account console (KAN-241), told which app to link back to. */
154
+ function accountUrl() {
155
+ const params = new URLSearchParams({
156
+ client_id: clientId,
157
+ return_to: postLogoutRedirectUri,
158
+ });
159
+ return `${issuer}/account?${params}`;
160
+ }
161
+
162
+ return { beginSignIn, completeSignIn, signOutUrl, accountUrl };
163
+ }
package/esm/theme.js ADDED
@@ -0,0 +1,47 @@
1
+ // Theme plumbing: the same contract as the other FIKAR apps. The choice
2
+ // (light, dark, system) is the account's; this module mirrors it locally so
3
+ // the first paint is right, resolves system against the OS, and paints by
4
+ // toggling `dark` on <html> plus data-theme, which the vendored @fikar-ai/design
5
+ // tokens key on. The choice is stored under `${storagePrefix}-theme`.
6
+
7
+ /** The localStorage key for an app's theme choice. The page's inline
8
+ * first-paint script reads the same key before any module has run. */
9
+ export const themeKey = (prefix) => `${prefix}-theme`;
10
+ export const THEMES = ['light', 'dark', 'system'];
11
+
12
+ export function normalizeTheme(value) {
13
+ return THEMES.includes(value) ? value : 'system';
14
+ }
15
+
16
+ export function resolveTheme(theme, prefersDark) {
17
+ return theme === 'dark' || (theme === 'system' && prefersDark) ? 'dark' : 'light';
18
+ }
19
+
20
+ /** Wires storage, the root element and the OS query together. `onChange`
21
+ * receives the resolved theme after every paint, so canvases can repaint. */
22
+ export function createTheme({ storagePrefix, storage, root, matchMedia, onChange = () => {} }) {
23
+ if (typeof storagePrefix !== 'string' || !storagePrefix) throw new Error('storagePrefix is required');
24
+ const THEME_KEY = themeKey(storagePrefix);
25
+ let theme = normalizeTheme(storage.getItem(THEME_KEY));
26
+ const query = matchMedia('(prefers-color-scheme: dark)');
27
+
28
+ function apply() {
29
+ const resolved = resolveTheme(theme, query.matches);
30
+ root.classList.toggle('dark', resolved === 'dark');
31
+ root.setAttribute('data-theme', theme);
32
+ onChange(resolved);
33
+ return resolved;
34
+ }
35
+
36
+ function set(next) {
37
+ theme = normalizeTheme(next);
38
+ storage.setItem(THEME_KEY, theme);
39
+ return apply();
40
+ }
41
+
42
+ if (typeof query.addEventListener === 'function') {
43
+ query.addEventListener('change', () => { if (theme === 'system') apply(); });
44
+ }
45
+
46
+ return { get: () => theme, set, apply, resolved: () => resolveTheme(theme, query.matches) };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fikar-ai/design",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Fikar Ink design language — tokens, Tailwind preset + component layer. Single source of truth across all Fikar surfaces.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -21,6 +21,7 @@
21
21
  "./fonts/*": "./fonts/*",
22
22
  "./recipes/*": "./recipes/*",
23
23
  "./shell.js": "./shell.js",
24
+ "./esm/*": "./esm/*",
24
25
  "./templates/*": "./templates/*",
25
26
  "./preset": {
26
27
  "types": "./preset.d.ts",
@@ -51,6 +52,7 @@
51
52
  "recipes/status-badge.html",
52
53
  "recipes/time.html",
53
54
  "shell.js",
55
+ "esm/",
54
56
  "templates/",
55
57
  "preset.js",
56
58
  "preset.d.ts",
@@ -18,6 +18,7 @@
18
18
  'cost-calculator': '<rect width="16" height="20" x="4" y="2" rx="2"/><line x1="8" x2="16" y1="6" y2="6"/><line x1="16" x2="16" y1="14" y2="18"/><path d="M16 10h.01"/><path d="M12 10h.01"/><path d="M8 10h.01"/><path d="M12 14h.01"/><path d="M8 14h.01"/><path d="M12 18h.01"/><path d="M8 18h.01"/>',
19
19
  'crs': '<circle cx="12" cy="4.5" r="2.5"/><path d="m10.2 6.3-3.9 3.9"/><circle cx="4.5" cy="12" r="2.5"/><path d="M7 12h10"/><circle cx="19.5" cy="12" r="2.5"/><path d="m13.8 17.7 3.9-3.9"/><circle cx="12" cy="19.5" r="2.5"/>',
20
20
  'dictionary': '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/><path d="m8 13 4-7 4 7"/><path d="M9.1 11h5.7"/>',
21
+ 'pii': '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
21
22
  'platform': '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>',
22
23
  'super-admin': '<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/>',
23
24
  } %}