## Summary Gives dashboard integrators (plugin views, embedded panels, theming tools) a supported way to match the dashboard's look and to layer overlay UI correctly — instead of scraping computed styles and guessing z-index values. This implements the CSS-token bridge slice of `docs/proposals/2026-07-01-dashboard-theme-plugin-system.md`. Two additions, both inert unless used: 1. **Documented theme-token contract.** A "Theme tokens" section in `docs/dashboard-guide.md` (referenced from `docs/PLUGIN_AUTHORING.md`) declares the stable set of CSS custom properties — colors, surfaces, status colors — that integrators may read. Tokens resolve to raw color strings (e.g. `#161b22`) in every theme, including the newer ones. A sync test (`theme-token-contract-docs.test.ts`) parses the doc's token table and asserts each documented token has a real definition in the dashboard CSS, so the contract cannot silently drift from the code. 2. **Overlay layering surface.** Overlay-style UI (palettes, pickers, floating panels) currently has no supported way to sit above the floating-window stack — the effective max z-index is runtime state inside `floatingWindowStack.ts`. This PR exposes it: - `--fusion-max-z` on `:root` — kept in sync by `floatingWindowStack` (written at module load and after every `nextFloatingZ()` claim), so it always reflects the true top of the dashboard-managed stack. Boot/floor value is `11001`, chosen to clear the highest statically-declared layer (the body-portaled model-combobox dropdown at `z-index: 11000`). - `#plugin-overlay-root` — an empty, `pointer-events: none` sibling of `#root` stacked at `calc(var(--fusion-max-z) + 1)`. React never renders into it, so it is hydration-safe; integrators portal into it and re-enable pointer events on their own elements. - The layer bands (base UI / floating windows / toasts / dropdown / overlay root) are documented in `styles.css` and the guide, and a guard test (`dashboard-max-z-guard.test.ts`) scans the structural + component CSS and fails if any static `z-index` is ever introduced above the floor — keeping the contract honest as the codebase evolves. ## Behavior No visual or behavioral change for existing users: `floatingWindowStack` still returns the same values from `nextFloatingZ()`; the overlay root is empty and click-through; tokens were already defined — this only documents and guards them. ## Tests - `theme-token-contract-docs.test.ts` — docs ↔ CSS sync (non-tautological: anchored matching against real definitions). - `floatingWindowStack.max-z.test.ts` — `--fusion-max-z` boot value and live tracking as the stack claims z-indexes. - `dashboard-max-z-guard.test.ts` — no static dashboard z-index above the floor (decorative `public/theme-data.css` INT_MAX scanline overlay deliberately excluded; it's non-interactive grain, documented in the test). - Changeset included (`minor`, `category: feature`). Typecheck clean. ## Open question for maintainers The token is named `--fusion-max-z`. The existing scale uses `--z-*` names (`--z-dropdown`, `--z-modal`) on a lower band — happy to rename to `--z-max` / `--z-plugin-overlay` or anything that fits your convention; the name is the only bikeshed here, the sync mechanism is independent of it. ## AI assistance disclosure Parts of this change were authored with AI assistance (Anthropic's Claude); the commit carries a `Co-authored-by` trailer accordingly. Everything was human-reviewed before submission, and the test suite and typecheck were run locally against the current `main`. If squash-merging with a rewritten message, please keep the attribution: ``` Co-authored-by: Claude <noreply@anthropic.com> ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added stable dashboard theme tokens for consistent plugin/integration styling. - Introduced a dedicated plugin overlay mount point with click-through defaults and an overlay stacking ceiling. - Overlay z-index now stays in sync with floating window layering automatically. - **Documentation** - Added an explicit stable “theme token contract” and “overlay layering contract,” including interaction and z-index usage rules and deprecation expectations. - **Bug Fixes** - Improved reliability of plugin overlay stacking so overlay content renders above intended dashboard layers. - **Tests** - Added guards validating CSS z-index ceilings and enforcing the documented theme token contract. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude <noreply@anthropic.com>
246 lines
14 KiB
HTML
246 lines
14 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<!-- Viewport configured for Capacitor mobile webview: disables pinch-zoom for app-like feel -->
|
|
<!-- interactive-widget=resizes-content: Android Chrome shrinks the layout
|
|
viewport when the soft keyboard opens, matching iOS behavior. Without
|
|
this, Chrome decouples layout viewport (innerHeight) from visual
|
|
viewport, leaving innerHeight stale and breaking keyboard-overlap math. -->
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
<script>
|
|
// Android Chrome (multi-window / split-screen / certain WebView modes)
|
|
// can leave the initial containing block (window.innerWidth/Height)
|
|
// stuck larger than the actual rendered canvas, while the DOM,
|
|
// visualViewport, and body all report the true dimensions. JS cannot
|
|
// force Chrome to recompute the ICB (neither setAttribute nor full
|
|
// element replacement of <meta viewport> works on those builds). So
|
|
// we publish the ICB→visible-viewport delta as CSS variables and
|
|
// `position: fixed` elements (nav bar, status bar) compensate via
|
|
// `bottom: var(--icb-bottom-offset)` etc. Healthy browsers see 0px.
|
|
(function () {
|
|
if (typeof window === 'undefined') return;
|
|
var baselineViewportHeight = null;
|
|
var NON_TEXT_INPUT_TYPES = {
|
|
checkbox: true,
|
|
radio: true,
|
|
button: true,
|
|
submit: true,
|
|
reset: true,
|
|
file: true,
|
|
range: true,
|
|
color: true,
|
|
hidden: true,
|
|
};
|
|
var isKeyboardFocusableElement = function (el) {
|
|
if (!el) return false;
|
|
if (el instanceof HTMLTextAreaElement) return true;
|
|
if (el instanceof HTMLInputElement) {
|
|
var type = (el.type || '').toLowerCase();
|
|
return !NON_TEXT_INPUT_TYPES[type];
|
|
}
|
|
return el instanceof HTMLElement && el.isContentEditable;
|
|
};
|
|
var apply = function () {
|
|
var vv = window.visualViewport;
|
|
var de = document.documentElement;
|
|
if (!de) return;
|
|
var innerW = window.innerWidth;
|
|
var innerH = window.innerHeight;
|
|
// Position relative to the visual viewport (what the user actually
|
|
// sees). This works correctly across states:
|
|
// - Healthy: vv == ICB, offsets are 0.
|
|
// - Chrome ICB-stuck-large bug: vv smaller than ICB → offset > 0
|
|
// pulls fixed elements up/left into the visible area.
|
|
// - Pinch-zoom in: vv smaller than ICB → ditto.
|
|
// - Pinch-zoom out: vv larger than ICB → offsets clamp at 0.
|
|
var vvW = vv ? Math.round(vv.width) : innerW;
|
|
var vvH = vv ? Math.round(vv.height) : innerH;
|
|
var vvOffTop = vv ? vv.offsetTop : 0;
|
|
var vvOffLeft = vv ? vv.offsetLeft : 0;
|
|
var vvScale = vv ? vv.scale : 1;
|
|
var rightOffset = Math.max(0, innerW - vvOffLeft - vvW);
|
|
var rawBottomOffset = Math.max(0, innerH - vvOffTop - vvH);
|
|
var activeElementIsKeyboardFocusable = isKeyboardFocusableElement(document.activeElement);
|
|
if (!activeElementIsKeyboardFocusable) {
|
|
baselineViewportHeight = baselineViewportHeight === null ? vvH : Math.max(baselineViewportHeight, vvH);
|
|
}
|
|
|
|
// Keep in sync with packages/dashboard/app/utils/viewportOffset.ts.
|
|
// Ignore keyboard-driven visualViewport shrink (iOS) so fixed mobile
|
|
// bars stay pinned to page bottom and are covered by keyboard.
|
|
var bottomOffset = rawBottomOffset;
|
|
if (activeElementIsKeyboardFocusable && vvScale <= 1.01 && baselineViewportHeight !== null) {
|
|
var keyboardShrink = Math.max(0, baselineViewportHeight - vvH);
|
|
if (keyboardShrink > 0) {
|
|
bottomOffset = Math.max(0, rawBottomOffset - keyboardShrink);
|
|
}
|
|
}
|
|
de.style.setProperty('--icb-right-offset', rightOffset + 'px');
|
|
de.style.setProperty('--icb-bottom-offset', bottomOffset + 'px');
|
|
};
|
|
window.addEventListener('orientationchange', apply);
|
|
window.addEventListener('resize', apply);
|
|
if (window.visualViewport) {
|
|
window.visualViewport.addEventListener('resize', apply);
|
|
window.visualViewport.addEventListener('scroll', apply);
|
|
}
|
|
apply();
|
|
// Tail: ICB can drift after task content renders.
|
|
setTimeout(apply, 100);
|
|
setTimeout(apply, 500);
|
|
setTimeout(apply, 1500);
|
|
setTimeout(apply, 3000);
|
|
})();
|
|
</script>
|
|
<script>
|
|
// Append ?vpdebug to the URL to show a fixed overlay with live viewport
|
|
// + media-query state. Diagnostic only.
|
|
(function () {
|
|
if (!/[?&]vpdebug\b/.test(location.search)) return;
|
|
var box = document.createElement('div');
|
|
box.style.cssText =
|
|
'position:fixed;top:0;right:0;z-index:2147483647;background:#000;color:#0f0;font:11px/1.3 monospace;padding:6px 8px;border:1px solid #0f0;white-space:pre;pointer-events:none;max-width:70vw;';
|
|
var update = function () {
|
|
if (document.body && box.parentNode !== document.body) document.body.appendChild(box);
|
|
var vv = window.visualViewport;
|
|
var nav = document.querySelector('.mobile-nav-bar');
|
|
var navInfo = 'null';
|
|
if (nav) {
|
|
var r = nav.getBoundingClientRect();
|
|
var cs = window.getComputedStyle(nav);
|
|
navInfo = Math.round(r.width) + 'x' + Math.round(r.height) + ' disp=' + cs.display + ' vis=' + cs.visibility;
|
|
}
|
|
var mqMobile768 = window.matchMedia('(max-width: 768px)').matches;
|
|
var mqMobileH = window.matchMedia('(max-height: 480px)').matches;
|
|
var mqMobileCombo = window.matchMedia('(max-width: 768px), (max-height: 480px)').matches;
|
|
var mqTablet = window.matchMedia('(min-width: 769px) and (max-width: 1024px)').matches;
|
|
box.textContent =
|
|
'win ' + window.innerWidth + 'x' + window.innerHeight + '\n' +
|
|
'vv ' + (vv ? Math.round(vv.width) + 'x' + Math.round(vv.height) + ' s' + vv.scale.toFixed(2) : 'n/a') + '\n' +
|
|
'dpr ' + window.devicePixelRatio + '\n' +
|
|
'html ' + document.documentElement.clientWidth + 'x' + document.documentElement.clientHeight + '\n' +
|
|
'body ' + (document.body ? document.body.clientWidth + 'x' + document.body.clientHeight : 'n/a') + '\n' +
|
|
'mq w<=768 ' + mqMobile768 + '\n' +
|
|
'mq h<=480 ' + mqMobileH + '\n' +
|
|
'mq combo ' + mqMobileCombo + '\n' +
|
|
'mq tablet ' + mqTablet + '\n' +
|
|
'nav ' + navInfo + '\n' +
|
|
'orient ' + (screen.orientation ? screen.orientation.type : 'n/a');
|
|
};
|
|
var attach = function () {
|
|
if (!document.body) { requestAnimationFrame(attach); return; }
|
|
document.body.appendChild(box);
|
|
update();
|
|
setInterval(update, 500);
|
|
window.addEventListener('resize', update);
|
|
window.addEventListener('orientationchange', update);
|
|
if (window.visualViewport) window.visualViewport.addEventListener('resize', update);
|
|
};
|
|
attach();
|
|
})();
|
|
</script>
|
|
<title>Fusion</title>
|
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
|
<link rel="manifest" href="/manifest.json" />
|
|
<link
|
|
rel="preload"
|
|
href="/fonts/SymbolsNerdFontMono-Regular.ttf"
|
|
as="font"
|
|
type="font/ttf"
|
|
crossorigin
|
|
/>
|
|
<meta name="theme-color" content="#1a1a2e" />
|
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
<script>
|
|
// Theme initialization - runs before React to prevent flash
|
|
(function() {
|
|
try {
|
|
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'system';
|
|
var validModes = ['dark', 'light', 'system'];
|
|
if (!validModes.includes(mode)) {
|
|
mode = 'system';
|
|
}
|
|
var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'shadcn-ember';
|
|
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime', 'brutalist', 'neon-city', 'parchment', 'terminal', 'glass', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'aurora', 'calm', 'dawn', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'];
|
|
// FNXC:DashboardTheming 2026-06-30-00:00: Unset startup theme is Shadcn Ember; explicit stored legacy ids such as "default" and "ocean" remain valid and must not be migrated.
|
|
// FNXC:DashboardTheming 2026-07-16-14:30: FN-8146 restores Shadcn Mono as a distinct persisted choice, so bootstrap must retain its stored id instead of remapping it to Mono Red.
|
|
if (!validThemes.includes(colorTheme)) {
|
|
colorTheme = 'shadcn-ember';
|
|
}
|
|
var fontScale = Number(localStorage.getItem('kb-dashboard-font-scale-pct') || '100');
|
|
if (!Number.isFinite(fontScale)) {
|
|
fontScale = 100;
|
|
}
|
|
fontScale = Math.min(125, Math.max(85, Math.round(fontScale)));
|
|
// FNXC:DashboardTheming 2026-07-03-00:00: Fresh web startup uses System mode by default so first paint follows prefers-color-scheme until the user stores an explicit Light/Dark/System choice.
|
|
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
var effectiveMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
|
|
document.documentElement.setAttribute('data-theme', effectiveMode);
|
|
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
|
document.documentElement.style.fontSize = fontScale + '%';
|
|
var shadcnCustomColorTokens = ['--accent', '--bg', '--surface', '--card', '--border', '--text', '--text-muted', '--todo', '--in-progress', '--in-review', '--triage', '--done', '--color-success', '--color-warning', '--color-error'];
|
|
for (var cleanupIndex = 0; cleanupIndex < shadcnCustomColorTokens.length; cleanupIndex += 1) {
|
|
document.documentElement.style.removeProperty(shadcnCustomColorTokens[cleanupIndex]);
|
|
}
|
|
if (colorTheme === 'shadcn-custom') {
|
|
try {
|
|
var shadcnCustomColors = JSON.parse(localStorage.getItem('kb-dashboard-shadcn-custom-colors') || '{}');
|
|
var validHex = /^#(?:[\da-f]{3}|[\da-f]{6})$/i;
|
|
for (var colorIndex = 0; colorIndex < shadcnCustomColorTokens.length; colorIndex += 1) {
|
|
var cssVar = shadcnCustomColorTokens[colorIndex];
|
|
var value = shadcnCustomColors && shadcnCustomColors[cssVar];
|
|
if (typeof value === 'string' && validHex.test(value.trim())) {
|
|
document.documentElement.style.setProperty(cssVar, value.trim());
|
|
}
|
|
}
|
|
} catch (customColorError) {}
|
|
}
|
|
if (colorTheme !== 'default') {
|
|
var base = document.baseURI || (document.location && document.location.href) || '';
|
|
var themeDataUrl;
|
|
if (!base) {
|
|
themeDataUrl = '/theme-data.css';
|
|
} else if (base.indexOf('http://') === 0 || base.indexOf('https://') === 0) {
|
|
themeDataUrl = new URL('/theme-data.css', base).toString();
|
|
} else if (base.indexOf('file://') === 0) {
|
|
if (base.endsWith('/')) {
|
|
themeDataUrl = base.slice(0, -1) + '/theme-data.css';
|
|
} else {
|
|
var lastSlashIndex = base.lastIndexOf('/');
|
|
themeDataUrl = lastSlashIndex >= 0
|
|
? base.slice(0, lastSlashIndex) + '/theme-data.css'
|
|
: '/theme-data.css';
|
|
}
|
|
} else {
|
|
themeDataUrl = '/theme-data.css';
|
|
}
|
|
var existingLink = document.getElementById('theme-data');
|
|
if (existingLink && existingLink.tagName === 'LINK' && existingLink.href !== themeDataUrl) {
|
|
existingLink.href = themeDataUrl;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
var fallbackSystemDark = false;
|
|
try {
|
|
fallbackSystemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
} catch (matchMediaError) {}
|
|
document.documentElement.setAttribute('data-theme', fallbackSystemDark ? 'dark' : 'light');
|
|
document.documentElement.setAttribute('data-color-theme', 'shadcn-ember');
|
|
document.documentElement.style.fontSize = '100%';
|
|
}
|
|
})();
|
|
</script>
|
|
<script type="module" src="./main.tsx"></script>
|
|
<!-- fusion:view-preload -->
|
|
<link rel="stylesheet" href="/theme-data.css" id="theme-data" />
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<!-- FNXC:PluginOverlayLayering 2026-07-23-01:21: Supported inert mount point for plugin overlays that must share the dashboard's root stacking context. -->
|
|
<div id="plugin-overlay-root"></div>
|
|
</body>
|
|
</html>
|