Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 12 additions & 166 deletions packages/web-app/src/embedded.js
Original file line number Diff line number Diff line change
@@ -1,177 +1,23 @@
// embedded.js — Datalex-Cloud embed shim. Brought to parity with the DQL
// notebook embed (apps/dql-notebook/src/embedded.ts) so the cloud shell can
// drive DataLex the same way over postMessage.
// embedded.js — minimal embed marker for the DataLex web-app.
//
// Activated when the URL contains ?embedded=1. Responsibilities:
// All cloud<->OSS integration (theme via [data-theme]/`dm_theme`, fit,
// x-oss-app, auth bearer, embed context, chrome-hiding, route sync, ready
// handshake) is driven from OUTSIDE the OSS app by the cloud's build-injected
// adapter (governed-analytics-cloud: scripts/embed-adapter.js). This module only
// exposes the embed flag + a reader for the cloud context the adapter publishes.
// Standalone DataLex is unaffected (no-op without ?embedded=1).
//
// 1. Storage isolation — namespace localStorage by project id.
// 2. Theme bridge — apply `datalex.theme` tokens to Luna CSS variables.
// 3. Context injection — accept `datalex.cloud.context` { config } and expose
// it on window.__DATALEX_CLOUD_EMBED__ (tenant / project / role /
// capabilities / repo_context / warehouse_context).
// 4. Capability-driven chrome hiding — when capabilities.hide_activity_bar /
// hide_sidebar are set, the cloud rail is the only nav, so we hide
// DataLex's own topbar / activity rail / layer spine via injected CSS.
// 5. Auth pass-through — add `Authorization: Bearer <token>` to /api/* and
// /projects/* calls. Token arrives via `datalex.auth.token` or #token=.
// 6. Route reporting — post `datalex.route.changed` on hash change so the
// cloud can keep its outer URL in sync for deep links.
//
// Self-installs only when the embed flag is present; standalone DataLex is
// unaffected.
// NOTE: localStorage is intentionally NOT namespaced here. The cloud owns the
// embedded app's persisted UI state — including the theme, which the uiStore
// reads from the `dm_theme` key. Namespacing hid the adapter's pre-boot writes
// behind a project-scoped key the store never reads, so the theme never applied.

const params = new URLSearchParams(window.location.search);
const isEmbedded = params.get("embedded") === "1";
const projectId = params.get("project") || "shared";

/** Read the injected cloud embed config (set via postMessage or boot global). */
/** Read the cloud embed config the adapter publishes on the window global. */
export function getCloudEmbedConfig() {
return (typeof window !== "undefined" && window.__DATALEX_CLOUD_EMBED__) || null;
}

// Token via hash so it never enters server logs / browser history.
function readAndStripToken() {
const hash = window.location.hash;
if (!hash) return null;
const m = hash.match(/(?:^|[#&])token=([^&]+)/);
if (!m) return null;
const token = decodeURIComponent(m[1]);
const next = hash.replace(/(?:^|[#&])token=[^&]+/, "").replace(/^[#&]/, "").trim();
history.replaceState(null, "", `${window.location.pathname}${window.location.search}${next ? `#${next}` : ""}`);
return token;
}

// Inject (once) the CSS that hides DataLex's own chrome when the cloud shell
// already provides the rail/topbar. Keyed off a <html> data attribute so it
// only applies in capability-restricted embeds.
function installChromeHidingStyles() {
if (document.getElementById("datalex-embed-chrome-css")) return;
const style = document.createElement("style");
style.id = "datalex-embed-chrome-css";
style.textContent = `
html[data-datalex-embed="minimal"] .topbar,
html[data-datalex-embed="minimal"] .project-tabs,
html[data-datalex-embed="minimal"] .activity-rail { display: none !important; }
html[data-datalex-embed="no-sidebar"] .activity-rail,
html[data-datalex-embed="no-sidebar"] .layer-spine { display: none !important; }
`;
document.head.appendChild(style);
}

function applyCapabilities(config) {
const caps = (config && config.capabilities) || {};
const root = document.documentElement;
installChromeHidingStyles();
if (caps.hide_activity_bar && caps.hide_sidebar) {
root.dataset.datalexEmbed = "minimal";
} else if (caps.hide_sidebar) {
root.dataset.datalexEmbed = "no-sidebar";
}
root.dataset.datalexCloudKind = config && config.kind ? String(config.kind) : "datalex";
root.dataset.datalexCloudSurface = config && config.surface ? String(config.surface) : "";
}

function applyTheme(tokens) {
const root = document.documentElement;
const map = {
brand: "--lux-color-accent",
ink900: "--lux-color-text",
bg: "--lux-color-bg",
surface: "--lux-color-surface",
border: "--lux-color-border",
};
for (const [k, cssVar] of Object.entries(map)) {
if (tokens && tokens[k]) root.style.setProperty(cssVar, tokens[k]);
}
}

if (isEmbedded) {
// 1. localStorage namespace.
const namespacedKey = (key) => `dlx:${projectId}:${key}`;
const realStorage = window.localStorage;
const storageProxy = {
getItem: (k) => realStorage.getItem(namespacedKey(k)),
setItem: (k, v) => realStorage.setItem(namespacedKey(k), v),
removeItem: (k) => realStorage.removeItem(namespacedKey(k)),
clear: () => {
const prefix = `dlx:${projectId}:`;
for (let i = realStorage.length - 1; i >= 0; i--) {
const k = realStorage.key(i);
if (k && k.startsWith(prefix)) realStorage.removeItem(k);
}
},
key: (i) => {
const prefix = `dlx:${projectId}:`;
const matched = [];
for (let j = 0; j < realStorage.length; j++) {
const k = realStorage.key(j);
if (k && k.startsWith(prefix)) matched.push(k.slice(prefix.length));
}
return matched[i] ?? null;
},
get length() {
const prefix = `dlx:${projectId}:`;
let n = 0;
for (let i = 0; i < realStorage.length; i++) {
const k = realStorage.key(i);
if (k && k.startsWith(prefix)) n++;
}
return n;
},
};
Object.defineProperty(window, "localStorage", { value: storageProxy, configurable: true });

// 5. Auth pass-through.
let bearerToken = readAndStripToken();
const realFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const isApi =
url.startsWith("/api/") || url.startsWith(`${window.location.origin}/api/`) ||
url.startsWith("/projects/") || url.startsWith(`${window.location.origin}/projects/`);
if (!isApi) return realFetch(input, init);
const headers = new Headers((init && init.headers) || {});
// Identify this app to the cloud gateway so it routes to the DataLex backend.
headers.set("x-oss-app", "datalex");
if (bearerToken && !headers.has("authorization")) headers.set("authorization", `Bearer ${bearerToken}`);
return realFetch(input, { ...init, headers });
};

// 2/3. Message bridge — theme, context, auth token.
window.addEventListener("message", (ev) => {
const data = ev.data || {};
if (data.type === "datalex.theme") {
applyTheme(data.tokens || {});
return;
}
if ((data.type === "datalex.cloud.context" || data.type === "dql.cloud.context") && data.config) {
window.__DATALEX_CLOUD_EMBED__ = data.config;
applyCapabilities(data.config);
window.dispatchEvent(new CustomEvent("datalex:cloud-context", { detail: data.config }));
return;
}
if (data.type === "datalex.auth.token" && typeof data.token === "string") {
bearerToken = data.token;
}
});

// Boot-time global (if the host injected it before scripts ran).
if (window.__DATALEX_CLOUD_EMBED__) applyCapabilities(window.__DATALEX_CLOUD_EMBED__);

// 6. Route reporting — keep the parent's outer hash in sync for deep links.
let lastHash = window.location.hash;
window.addEventListener("hashchange", () => {
if (window.location.hash === lastHash) return;
lastHash = window.location.hash;
if (window.parent !== window) {
window.parent.postMessage({ type: "datalex.route.changed", path: lastHash, projectId }, "*");
}
});

// Tell the parent we're ready — it responds with context + theme + token.
if (window.parent !== window) {
window.parent.postMessage({ type: "datalex.embedded.ready", projectId }, "*");
}
}

export { isEmbedded };
66 changes: 66 additions & 0 deletions packages/web-app/src/styles/datalex-design.css
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,72 @@
--scrim: rgba(40,35,25,0.35);
}

/* ================================================================
WHITE — crisp light (shared contract; see @duckcodeai/design-tokens).
Added so the cloud's "white" global theme maps 1:1 onto DataLex.
Core vars (bg/text/accent/border) use the canonical white values;
DataLex-specific extras are tuned to match.
================================================================ */
[data-theme="white"] {
color-scheme: light;
--bg-0: #f5f6f8;
--bg-1: #ffffff;
--bg-2: #ffffff;
--bg-3: #eef0f3;
--bg-4: #e1e4e9;
--bg-canvas: #ffffff;
--bg-grid: rgba(15, 23, 42, 0.04);

--border-subtle: rgba(15,23,42,0.08);
--border-default: rgba(15,23,42,0.14);
--border-strong: rgba(15,23,42,0.22);
--border-focus: #2563eb;

--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #64748b;
--text-muted: #94a3b8;

--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-dim: rgba(37,99,235,0.10);
--accent-fg: #ffffff;
--accent-on: #ffffff;

--bg-hover: rgba(15, 23, 42, 0.045);

--status-error: #dc2626; --status-error-bg: rgba(220,38,38,0.08); --status-error-border: rgba(220,38,38,0.30);
--status-warning: #ca8a04; --status-warning-bg: rgba(202,138,4,0.10); --status-warning-border: rgba(202,138,4,0.32);
--status-success: #16a34a; --status-success-bg: rgba(22,163,74,0.08); --status-success-border: rgba(22,163,74,0.30);
--status-info: var(--accent); --status-info-bg: var(--accent-dim); --status-info-border: rgba(37,99,235,0.30);

--pk: #ca8a04;
--fk: #7c3aed;
--idx: #0891b2;
--nn: #64748b;
--nullable: #7c3aed;

--cat-users: #7c3aed; --cat-users-soft: rgba(124,58,237,0.09);
--cat-billing: #16a34a; --cat-billing-soft: rgba(22,163,74,0.09);
--cat-product: #ea580c; --cat-product-soft: rgba(234,88,12,0.09);
--cat-system: #64748b; --cat-system-soft: rgba(100,116,139,0.10);
--cat-access: #db2777; --cat-access-soft: rgba(219,39,119,0.09);
--cat-audit: #0891b2; --cat-audit-soft: rgba(8,145,178,0.09);

--rel-line: #cbd5e1;
--rel-line-strong: #94a3b8;
--rel-line-active: #2563eb;
--rel-line-dim: rgba(203, 213, 225, 0.45);

--sql-keyword: #1d4ed8; --sql-type: #0891b2; --sql-string: #15803d;
--sql-number: #c2410c; --sql-function: #7c3aed; --sql-punctuation: #475569;
--sql-ident: var(--text-primary); --sql-comment: var(--text-tertiary);

--shadow-card: 0 1px 2px rgba(15,23,42,0.04), 0 8px 24px rgba(15,23,42,0.06);
--shadow-pop: 0 20px 50px rgba(15,23,42,0.14);
--scrim: rgba(15,23,42,0.30);
}

/* ================================================================
ARCTIC — cool light (IBM Carbon / crisp blue-white enterprise)
================================================================ */
Expand Down
Loading