-
{{ section.label || "(no schema)" }}
+ {{ section.label || translate(section.fallbackKey) }}
{{ section.tables.length }} tables
diff --git a/apps/desktop/src/docs/diagramGeometry.ts b/apps/desktop/src/docs/diagramGeometry.ts
new file mode 100644
index 000000000..5a46afef8
--- /dev/null
+++ b/apps/desktop/src/docs/diagramGeometry.ts
@@ -0,0 +1,32 @@
+export interface Point {
+ x: number;
+ y: number;
+}
+
+export interface Size {
+ width: number;
+ height: number;
+}
+
+/**
+ * Where a line from one card's centre towards another leaves the first card.
+ *
+ * Without this, edges terminate under the card and appear to sprout from a
+ * table's middle. Scaling both axes and taking the smaller factor picks
+ * whichever edge the ray actually reaches first.
+ *
+ * `half` is the card's HALF width and height, measured from its centre.
+ */
+export function clipToCard(from: Point, to: Point, half: Size): Point {
+ const dx = to.x - from.x;
+ const dy = to.y - from.y;
+ if (dx === 0 && dy === 0) {
+ // Coincident centres would divide by zero and put NaN in the path data,
+ // which renders as nothing rather than as an error.
+ return { x: from.x, y: from.y };
+ }
+ const scaleX = dx === 0 ? Number.POSITIVE_INFINITY : half.width / Math.abs(dx);
+ const scaleY = dy === 0 ? Number.POSITIVE_INFINITY : half.height / Math.abs(dy);
+ const scale = Math.min(scaleX, scaleY);
+ return { x: from.x + dx * scale, y: from.y + dy * scale };
+}
diff --git a/apps/desktop/src/docs/docsIndex.ts b/apps/desktop/src/docs/docsIndex.ts
index 66579856f..faf3ac27f 100644
--- a/apps/desktop/src/docs/docsIndex.ts
+++ b/apps/desktop/src/docs/docsIndex.ts
@@ -5,6 +5,21 @@ export interface IndexSection {
/** Schema name, group id, or "" for the ungrouped bucket. */
key: string;
label: string;
+ /**
+ * Locale key to show in place of `label` when it's empty — "docs.noSchema"
+ * from groupBySchema, "docs.noGroup" from groupByTableGroup. This module
+ * stays translator-free (it is pure, tested without Vue, and every other
+ * pure module in src/docs/ follows the same rule), so it hands back a KEY
+ * rather than calling translate() itself; the render site decides how.
+ *
+ * Carrying the key on the section — rather than each render site inferring
+ * it from a `mode` prop — means a fallback can never silently pick the
+ * wrong word for the section it labels: WikiIndex.vue doesn't even receive
+ * `mode`, so a mode-based guess there would either require threading a prop
+ * that has no other use, or duplicate this same schema/group decision at a
+ * second call site to keep in sync with this one.
+ */
+ fallbackKey: "docs.noSchema" | "docs.noGroup";
/** Group hue, or null for schema sections and the ungrouped bucket. */
hue: number | null;
note: string | null;
@@ -32,6 +47,7 @@ export function groupBySchema(snapshot: SchemaSnapshot): IndexSection[] {
.map(([key, tables]) => ({
key,
label: key,
+ fallbackKey: "docs.noSchema" as const,
hue: null,
note: null,
tables: [...tables].sort(byName),
@@ -52,6 +68,7 @@ export function groupByTableGroup(snapshot: SchemaSnapshot): IndexSection[] {
sections.push({
key: group.id,
label: group.name,
+ fallbackKey: "docs.noGroup",
hue: group.hue,
note: group.note,
tables,
@@ -63,7 +80,10 @@ export function groupByTableGroup(snapshot: SchemaSnapshot): IndexSection[] {
const ungrouped = snapshot.tables.filter((table) => table.groupId === null || !known.has(table.groupId)).sort(byName);
if (ungrouped.length > 0) {
- sections.push({ key: "", label: "(no group)", hue: null, note: null, tables: ungrouped });
+ // Empty, not "(no group)": the render sites already fall back to a
+ // translated label when `label` is empty (see IndexSection.fallbackKey).
+ // A non-empty English literal here would bypass that fallback entirely.
+ sections.push({ key: "", label: "", fallbackKey: "docs.noGroup", hue: null, note: null, tables: ungrouped });
}
return sections;
diff --git a/apps/desktop/src/docs/docsRoute.ts b/apps/desktop/src/docs/docsRoute.ts
new file mode 100644
index 000000000..d258b9993
--- /dev/null
+++ b/apps/desktop/src/docs/docsRoute.ts
@@ -0,0 +1,61 @@
+import { qualifiedTableKey } from "./docsKeys";
+import type { SchemaSnapshot } from "./types";
+
+/**
+ * Where the viewer is pointing.
+ *
+ * This exists so the standalone export can drive navigation from
+ * `location.hash` without DocsApp itself touching the URL: DBX has no
+ * router, and a viewer that wrote to the address bar would hijack the host
+ * application's.
+ */
+export type DocsRoute = { kind: "index" } | { kind: "table"; key: string } | { kind: "enum"; name: string } | { kind: "diagram" };
+
+const INDEX: DocsRoute = { kind: "index" };
+
+/**
+ * Resolve a hash against a snapshot.
+ *
+ * Anything unrecognised — junk, a table that no longer exists, the diagram
+ * route on a host that did not enable it — resolves to the index. A saved
+ * file whose schema has since changed is the expected case, not an exotic
+ * one, and must never render blank.
+ */
+export function parseDocsHash(hash: string, snapshot: SchemaSnapshot, allowDiagram: boolean): DocsRoute {
+ if (!hash.startsWith("#")) return INDEX;
+ const segments = hash.slice(1).replace(/^\//, "").split("/");
+ const [kind, ...rest] = segments;
+ const identifier = rest.join("/");
+
+ if (kind === "diagram" && identifier === "") return allowDiagram ? { kind: "diagram" } : INDEX;
+
+ if (identifier === "") return INDEX;
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(identifier);
+ } catch {
+ // A malformed percent-escape throws rather than returning null.
+ return INDEX;
+ }
+
+ if (kind === "table") {
+ return snapshot.tables.some((table) => qualifiedTableKey(table) === decoded) ? { kind: "table", key: decoded } : INDEX;
+ }
+ if (kind === "enum") {
+ return (snapshot.enums ?? []).some((value) => value.name === decoded) ? { kind: "enum", name: decoded } : INDEX;
+ }
+ return INDEX;
+}
+
+export function formatDocsHash(route: DocsRoute): string {
+ switch (route.kind) {
+ case "table":
+ return `#/table/${encodeURIComponent(route.key)}`;
+ case "enum":
+ return `#/enum/${encodeURIComponent(route.name)}`;
+ case "diagram":
+ return "#/diagram";
+ default:
+ return "#/";
+ }
+}
diff --git a/apps/desktop/src/i18n/locales/docs/en.ts b/apps/desktop/src/i18n/locales/docs/en.ts
index f0f72d634..1ec86e9bd 100644
--- a/apps/desktop/src/i18n/locales/docs/en.ts
+++ b/apps/desktop/src/i18n/locales/docs/en.ts
@@ -23,6 +23,27 @@ export default {
saving: "Saving…",
saved: "Saved",
saveFailed: "Could not save notes: {error}",
+ overview: "Overview",
+ groupBy: "Group by",
+ searchLabel: "Search",
+ groups: "Groups",
+ relationships: "Relationships",
+ columnHeader: "Column",
+ typeHeader: "Type",
+ settingsHeader: "Settings",
+ noteHeader: "Note",
+ nameHeader: "Name",
+ definitionHeader: "Definition",
+ noOutgoingRelationships: "This table references no other table.",
+ noIncomingRelationships: "No table references this one.",
+ diagram: "Diagram",
+ language: "Language",
+ theme: "Theme",
+ themeLight: "Light",
+ themeDark: "Dark",
+ exportHtml: "Export HTML…",
+ exporting: "Exporting…",
+ exportFailed: "Could not export: {error}",
warnings: {
tableSkipped: {
title: "A table could not be documented",
diff --git a/apps/desktop/src/i18n/locales/docs/es.ts b/apps/desktop/src/i18n/locales/docs/es.ts
index eed9b3f0d..361af96d6 100644
--- a/apps/desktop/src/i18n/locales/docs/es.ts
+++ b/apps/desktop/src/i18n/locales/docs/es.ts
@@ -23,6 +23,27 @@ export default {
saving: "Guardando…",
saved: "Guardado",
saveFailed: "No se pudieron guardar las notas: {error}",
+ overview: "Información general",
+ groupBy: "Agrupar por",
+ searchLabel: "Buscar",
+ groups: "Grupos",
+ relationships: "Relaciones",
+ columnHeader: "Columna",
+ typeHeader: "Tipo",
+ settingsHeader: "Configuración",
+ noteHeader: "Nota",
+ nameHeader: "Nombre",
+ definitionHeader: "Definición",
+ noOutgoingRelationships: "Esta tabla no referencia ninguna otra tabla.",
+ noIncomingRelationships: "Ninguna tabla referencia esta.",
+ diagram: "Diagrama",
+ language: "Idioma",
+ theme: "Tema",
+ themeLight: "Claro",
+ themeDark: "Oscuro",
+ exportHtml: "Exportar HTML…",
+ exporting: "Exportando…",
+ exportFailed: "No se pudo exportar: {error}",
warnings: {
tableSkipped: {
title: "No se pudo documentar una tabla",
diff --git a/apps/desktop/src/i18n/locales/docs/it.ts b/apps/desktop/src/i18n/locales/docs/it.ts
index c78a5e9fe..b154d9a69 100644
--- a/apps/desktop/src/i18n/locales/docs/it.ts
+++ b/apps/desktop/src/i18n/locales/docs/it.ts
@@ -23,6 +23,27 @@ export default {
saving: "Salvataggio…",
saved: "Salvato",
saveFailed: "Impossibile salvare le note: {error}",
+ overview: "Panoramica",
+ groupBy: "Raggruppa per",
+ searchLabel: "Cerca",
+ groups: "Gruppi",
+ relationships: "Relazioni",
+ columnHeader: "Colonna",
+ typeHeader: "Tipo",
+ settingsHeader: "Impostazioni",
+ noteHeader: "Nota",
+ nameHeader: "Nome",
+ definitionHeader: "Definizione",
+ noOutgoingRelationships: "Questa tabella non fa riferimento a nessun'altra tabella.",
+ noIncomingRelationships: "Nessuna tabella fa riferimento a questa.",
+ diagram: "Diagramma",
+ language: "Lingua",
+ theme: "Tema",
+ themeLight: "Chiaro",
+ themeDark: "Scuro",
+ exportHtml: "Esporta HTML…",
+ exporting: "Esportazione…",
+ exportFailed: "Impossibile esportare: {error}",
warnings: {
tableSkipped: {
title: "Impossibile documentare una tabella",
diff --git a/apps/desktop/src/i18n/locales/docs/ja.ts b/apps/desktop/src/i18n/locales/docs/ja.ts
index b8267a63a..59337e46c 100644
--- a/apps/desktop/src/i18n/locales/docs/ja.ts
+++ b/apps/desktop/src/i18n/locales/docs/ja.ts
@@ -23,6 +23,27 @@ export default {
saving: "保存中…",
saved: "保存済み",
saveFailed: "メモを保存できませんでした: {error}",
+ overview: "概要",
+ groupBy: "グループ化",
+ searchLabel: "検索",
+ groups: "グループ",
+ relationships: "リレーションシップ",
+ columnHeader: "カラム",
+ typeHeader: "型",
+ settingsHeader: "設定",
+ noteHeader: "メモ",
+ nameHeader: "名前",
+ definitionHeader: "定義",
+ noOutgoingRelationships: "このテーブルは他のテーブルを参照していません。",
+ noIncomingRelationships: "このテーブルを参照しているテーブルはありません。",
+ diagram: "図",
+ language: "言語",
+ theme: "テーマ",
+ themeLight: "ライト",
+ themeDark: "ダーク",
+ exportHtml: "HTMLをエクスポート…",
+ exporting: "エクスポート中…",
+ exportFailed: "エクスポートできませんでした: {error}",
warnings: {
tableSkipped: {
title: "ドキュメント化できないテーブルがあります",
diff --git a/apps/desktop/src/i18n/locales/docs/ko.ts b/apps/desktop/src/i18n/locales/docs/ko.ts
index da6652376..3e4897fb9 100644
--- a/apps/desktop/src/i18n/locales/docs/ko.ts
+++ b/apps/desktop/src/i18n/locales/docs/ko.ts
@@ -23,6 +23,27 @@ export default {
saving: "저장 중…",
saved: "저장됨",
saveFailed: "메모를 저장할 수 없습니다: {error}",
+ overview: "개요",
+ groupBy: "그룹화 기준",
+ searchLabel: "검색",
+ groups: "그룹",
+ relationships: "관계",
+ columnHeader: "컬럼",
+ typeHeader: "유형",
+ settingsHeader: "설정",
+ noteHeader: "메모",
+ nameHeader: "이름",
+ definitionHeader: "정의",
+ noOutgoingRelationships: "이 테이블은 다른 테이블을 참조하지 않습니다.",
+ noIncomingRelationships: "이 테이블을 참조하는 테이블이 없습니다.",
+ diagram: "다이어그램",
+ language: "언어",
+ theme: "테마",
+ themeLight: "라이트",
+ themeDark: "다크",
+ exportHtml: "HTML 내보내기…",
+ exporting: "내보내는 중…",
+ exportFailed: "내보낼 수 없습니다: {error}",
warnings: {
tableSkipped: {
title: "문서화할 수 없는 테이블이 있습니다",
diff --git a/apps/desktop/src/i18n/locales/docs/pt-BR.ts b/apps/desktop/src/i18n/locales/docs/pt-BR.ts
index 276d90708..3225bb83e 100644
--- a/apps/desktop/src/i18n/locales/docs/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/docs/pt-BR.ts
@@ -23,6 +23,27 @@ export default {
saving: "Salvando…",
saved: "Salvo",
saveFailed: "Não foi possível salvar as notas: {error}",
+ overview: "Visão geral",
+ groupBy: "Agrupar por",
+ searchLabel: "Buscar",
+ groups: "Grupos",
+ relationships: "Relacionamentos",
+ columnHeader: "Coluna",
+ typeHeader: "Tipo",
+ settingsHeader: "Configurações",
+ noteHeader: "Nota",
+ nameHeader: "Nome",
+ definitionHeader: "Definição",
+ noOutgoingRelationships: "Esta tabela não referencia nenhuma outra tabela.",
+ noIncomingRelationships: "Nenhuma tabela referencia esta.",
+ diagram: "Diagrama",
+ language: "Idioma",
+ theme: "Tema",
+ themeLight: "Claro",
+ themeDark: "Escuro",
+ exportHtml: "Exportar HTML…",
+ exporting: "Exportando…",
+ exportFailed: "Não foi possível exportar: {error}",
warnings: {
tableSkipped: {
title: "Uma tabela não pôde ser documentada",
diff --git a/apps/desktop/src/i18n/locales/docs/zh-CN.ts b/apps/desktop/src/i18n/locales/docs/zh-CN.ts
index c968bc8ac..df85b0f7b 100644
--- a/apps/desktop/src/i18n/locales/docs/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/docs/zh-CN.ts
@@ -23,6 +23,27 @@ export default {
saving: "保存中…",
saved: "已保存",
saveFailed: "无法保存备注: {error}",
+ overview: "概览",
+ groupBy: "分组方式",
+ searchLabel: "搜索",
+ groups: "分组",
+ relationships: "关系",
+ columnHeader: "列",
+ typeHeader: "类型",
+ settingsHeader: "设置",
+ noteHeader: "备注",
+ nameHeader: "名称",
+ definitionHeader: "定义",
+ noOutgoingRelationships: "此表未引用任何其他表。",
+ noIncomingRelationships: "没有表引用此表。",
+ diagram: "图",
+ language: "语言",
+ theme: "主题",
+ themeLight: "浅色",
+ themeDark: "深色",
+ exportHtml: "导出 HTML…",
+ exporting: "导出中…",
+ exportFailed: "无法导出: {error}",
warnings: {
tableSkipped: {
title: "有一张表无法生成文档",
diff --git a/apps/desktop/src/i18n/locales/docs/zh-TW.ts b/apps/desktop/src/i18n/locales/docs/zh-TW.ts
index 48a53fd04..62ed4a756 100644
--- a/apps/desktop/src/i18n/locales/docs/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/docs/zh-TW.ts
@@ -23,6 +23,27 @@ export default {
saving: "儲存中…",
saved: "已儲存",
saveFailed: "無法儲存備註: {error}",
+ overview: "總覽",
+ groupBy: "分組方式",
+ searchLabel: "搜尋",
+ groups: "分組",
+ relationships: "關聯",
+ columnHeader: "欄位",
+ typeHeader: "類型",
+ settingsHeader: "設定",
+ noteHeader: "備註",
+ nameHeader: "名稱",
+ definitionHeader: "定義",
+ noOutgoingRelationships: "此資料表未參照任何其他資料表。",
+ noIncomingRelationships: "沒有資料表參照此資料表。",
+ diagram: "圖表",
+ language: "語言",
+ theme: "主題",
+ themeLight: "淺色",
+ themeDark: "深色",
+ exportHtml: "匯出 HTML…",
+ exporting: "匯出中…",
+ exportFailed: "無法匯出: {error}",
warnings: {
tableSkipped: {
title: "有一張資料表無法產生文件",
diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts
index 152502cfa..bb2f13a73 100644
--- a/apps/desktop/src/lib/backend/api.ts
+++ b/apps/desktop/src/lib/backend/api.ts
@@ -184,6 +184,7 @@ export const collectDocsSnapshot = forward("collectDocsSnapshot");
export const loadDocsAnnotations = forward("loadDocsAnnotations");
export const applyDocsAnnotations = forward("applyDocsAnnotations");
export const saveDocsAnnotations = forward("saveDocsAnnotations");
+export const exportDocsHtml = forward("exportDocsHtml");
// Query
export const executeQuery = forward("executeQuery");
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index dac91dc84..dfa586a2b 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -860,6 +860,21 @@ export async function saveDocsAnnotations(connectionId: string, annotations: Ann
return post("/api/docs/annotations/save", { connectionId, annotations });
}
+export async function exportDocsHtml(filePath: string, snapshot: SchemaSnapshot, annotations: AnnotationFile, lang: string): Promise
{
+ const result = await post<{ content: string }>("/api/docs/export", { snapshot, annotations, lang });
+ // No `downloadTextFile` here: it prepends a BOM, which the Tauri command's
+ // `std::fs::write(&file_path, html)` does not. The two callers must produce
+ // byte-identical output for the same inputs.
+ const fileName = filePath.split(/[\\/]/).pop() || "docs.html";
+ const blob = new Blob([result.content], { type: "text/html;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = fileName;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
// ---------------------------------------------------------------------------
// Query
// ---------------------------------------------------------------------------
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index f757116e8..45f7daef3 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -1681,6 +1681,10 @@ export async function saveDocsAnnotations(connectionId: string, annotations: Ann
return invoke("docs_save_annotations", { connectionId, annotations });
}
+export async function exportDocsHtml(filePath: string, snapshot: SchemaSnapshot, annotations: AnnotationFile, lang: string): Promise {
+ return invoke("docs_export_html", { filePath, snapshot, annotations, lang });
+}
+
export async function saveConnections(configs: ConnectionConfig[]): Promise {
return invoke("save_connections", { configs });
}
diff --git a/apps/desktop/src/styles/__tests__/cascadeCss.ts b/apps/desktop/src/styles/__tests__/cascadeCss.ts
new file mode 100644
index 000000000..0b8d4e7fc
--- /dev/null
+++ b/apps/desktop/src/styles/__tests__/cascadeCss.ts
@@ -0,0 +1,23 @@
+import { readFileSync } from "node:fs";
+
+const IMPORT_STATEMENT = '@import "./tokens.css";';
+
+/**
+ * Reads the desktop stylesheet the way the browser assembles it: globals.css
+ * with its `@import "./tokens.css"` replaced, in place, by the imported file.
+ *
+ * Design tokens live in tokens.css so the standalone documentation export can
+ * reuse them without pulling in the app shell. Assertions about declaration
+ * order are assertions about the cascade, so a spec that reads only globals.css
+ * sees half the stylesheet and draws the wrong conclusion.
+ */
+export function readCascadeCss(): string {
+ const globals = readFileSync(new URL("../globals.css", import.meta.url), "utf8");
+ const tokens = readFileSync(new URL("../tokens.css", import.meta.url), "utf8");
+ if (!globals.includes(IMPORT_STATEMENT)) {
+ // Failing loudly beats returning globals.css alone: a silent half-stylesheet
+ // would resurface as an unrelated-looking assertion failure somewhere else.
+ throw new Error(`globals.css no longer pulls in tokens.css with \`${IMPORT_STATEMENT}\``);
+ }
+ return globals.replace(IMPORT_STATEMENT, tokens);
+}
diff --git a/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts b/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts
index 007cf6f6b..41b02049b 100644
--- a/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts
+++ b/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts
@@ -1,7 +1,8 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
+import { readCascadeCss } from "./cascadeCss";
-const globalsCss = readFileSync(new URL("../globals.css", import.meta.url), "utf8");
+const globalsCss = readCascadeCss();
const dialogContentSource = readFileSync(new URL("../../components/ui/dialog/DialogContent.vue", import.meta.url), "utf8");
const dialogScrollContentSource = readFileSync(new URL("../../components/ui/dialog/DialogScrollContent.vue", import.meta.url), "utf8");
const dialogOverlaySource = readFileSync(new URL("../../components/ui/dialog/DialogOverlay.vue", import.meta.url), "utf8");
diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css
index 5b710c08e..8988ce883 100644
--- a/apps/desktop/src/styles/globals.css
+++ b/apps/desktop/src/styles/globals.css
@@ -66,128 +66,15 @@
}
}
-@custom-variant dark (&:is(.dark *));
-
-@theme inline {
- --font-sans: "Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
- --font-heading: var(--font-sans);
- --color-sidebar-ring: var(--sidebar-ring);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar: var(--sidebar);
- --color-chart-5: var(--chart-5);
- --color-chart-4: var(--chart-4);
- --color-chart-3: var(--chart-3);
- --color-chart-2: var(--chart-2);
- --color-chart-1: var(--chart-1);
- --color-ring: var(--ring);
- --color-input: var(--input);
- --color-border: var(--border);
- --color-destructive: var(--destructive);
- /* Aliases for legacy MQ panel styles (used across components/mq/*). */
- --color-error: var(--destructive);
- --color-error-bg: color-mix(in srgb, var(--destructive) 10%, transparent);
- --color-error-alpha: var(--color-error-bg);
- --color-success: var(--success);
- --color-success-bg: var(--success-bg);
- --color-success-alpha: var(--success-bg);
- --color-warning: var(--warning);
- --color-warning-bg: var(--warning-bg);
- --color-warning-alpha: var(--warning-bg);
- --color-info: var(--info);
- --color-info-bg: var(--info-bg);
- --color-info-alpha: var(--info-bg);
- --color-hover: var(--accent);
- --color-background-secondary: var(--muted);
- --color-text: var(--foreground);
- --color-text-secondary: var(--muted-foreground);
- --color-text-tertiary: color-mix(in srgb, var(--muted-foreground) 70%, transparent);
- --color-border-light: color-mix(in srgb, var(--border) 60%, transparent);
- --color-primary-alpha: color-mix(in srgb, var(--primary) 12%, transparent);
- --color-accent-foreground: var(--accent-foreground);
- --color-accent: var(--accent);
- --color-muted-foreground: var(--muted-foreground);
- --color-muted: var(--muted);
- --color-secondary-foreground: var(--secondary-foreground);
- --color-secondary: var(--secondary);
- --color-primary-foreground: var(--primary-foreground);
- --color-primary: var(--primary);
- --color-popover-foreground: var(--popover-foreground);
- --color-popover: var(--popover);
- --color-card-foreground: var(--card-foreground);
- --color-card: var(--card);
- --color-foreground: var(--foreground);
- --color-background: var(--background);
- --radius-sm: var(--dbx-radius-sm);
- --radius-md: var(--dbx-radius-md);
- --radius-lg: var(--dbx-radius-lg);
- --radius-xl: var(--dbx-radius-xl);
-}
-
-:root {
- --background: rgb(255 255 255);
- --foreground: rgb(10 10 10);
- --card: rgb(255 255 255);
- --card-foreground: rgb(10 10 10);
- --popover: rgb(255 255 255);
- --popover-foreground: rgb(10 10 10);
- --primary: rgb(23 23 23);
- --primary-foreground: rgb(250 250 250);
- --secondary: rgb(245 245 245);
- --secondary-foreground: rgb(23 23 23);
- --muted: rgb(245 245 245);
- --muted-foreground: rgb(115 115 115);
- --accent: rgb(245 245 245);
- --accent-foreground: rgb(23 23 23);
- --destructive: rgb(231 0 11);
- --destructive-rgb: 231, 0, 11;
- --border: rgb(229 229 229);
- --input: rgb(229 229 229);
- --ring: rgb(161 161 161);
- --chart-1: rgb(212 212 212);
- --chart-2: rgb(115 115 115);
- --chart-3: rgb(82 82 82);
- --chart-4: rgb(64 64 64);
- --chart-5: rgb(38 38 38);
- --radius: var(--dbx-radius-md);
- --dbx-radius-default: 4px;
- --dbx-radius-sm: 4px;
- --dbx-radius-md: 4px;
- --dbx-radius-lg: 6px;
- --dbx-radius-xl: 6px;
- --dbx-radius-fixed-4: 4px;
- --dbx-radius-fixed-5: 5px;
- --dbx-radius-fixed-6: 6px;
- --sidebar: rgb(250 250 250);
- --sidebar-foreground: rgb(10 10 10);
- --sidebar-primary: rgb(23 23 23);
- --sidebar-primary-foreground: rgb(250 250 250);
- --sidebar-accent: rgb(245 245 245);
- --sidebar-accent-foreground: rgb(23 23 23);
- --sidebar-border: rgb(229 229 229);
- --sidebar-ring: rgb(161 161 161);
- --success: rgb(22 163 74);
- --success-foreground: rgb(240 253 244);
- --success-bg: color-mix(in srgb, var(--success) 12%, transparent);
- --warning: rgb(217 119 6);
- --warning-foreground: rgb(255 251 235);
- --warning-bg: color-mix(in srgb, var(--warning) 14%, transparent);
- --info: rgb(37 99 235);
- --info-foreground: rgb(239 246 255);
- --info-bg: color-mix(in srgb, var(--info) 12%, transparent);
- --dbx-chrome: rgb(245 245 245);
- --dbx-chrome-muted: rgb(240 240 240);
- --dbx-content: rgb(255 255 255);
- --dbx-editor-toolbar: rgb(250 250 250);
- --dbx-gutter: rgb(243 243 243);
- --dbx-sidebar-header: rgb(250 250 250);
- --dbx-window-top-border: rgb(0 0 0 / 0.35);
- --dbx-viewport-height: 100vh;
-}
+/*
+ * `@custom-variant dark` and the `@theme inline` block that maps the raw
+ * tokens onto Tailwind's `--color-*` namespace both live in tokens.css now:
+ * they are plumbing for those tokens, and the standalone documentation export
+ * imports tokens.css without this file. Utilities like `bg-background` come
+ * from that block, so an entry point with the raw properties and no `@theme`
+ * emits none of them.
+ */
+@import "./tokens.css";
:root[data-corner-style="large"] {
--dbx-radius-default: 6px;
@@ -293,57 +180,6 @@
}
}
-.dark {
- --background: rgb(19 20 22);
- --foreground: rgb(215 215 219);
- --card: rgb(27 27 30);
- --card-foreground: rgb(215 215 219);
- --popover: rgb(30 30 32);
- --popover-foreground: rgb(221 221 226);
- --primary: rgb(208 208 214);
- --primary-foreground: rgb(19 20 22);
- --secondary: rgb(42 42 45);
- --secondary-foreground: rgb(215 215 219);
- --muted: rgb(42 42 45);
- --muted-foreground: rgb(151 152 157);
- --accent: rgb(46 47 51);
- --accent-foreground: rgb(221 221 226);
- --destructive: rgb(243 98 95);
- --destructive-rgb: 243, 98, 95;
- --border: rgb(110 110 114 / 0.28);
- --input: rgb(110 110 114 / 0.34);
- --ring: rgb(133 134 139);
- --chart-1: rgb(212 212 212);
- --chart-2: rgb(115 115 115);
- --chart-3: rgb(82 82 82);
- --chart-4: rgb(64 64 64);
- --chart-5: rgb(38 38 38);
- --sidebar: rgb(25 25 28);
- --sidebar-foreground: rgb(208 208 213);
- --sidebar-primary: rgb(208 208 214);
- --sidebar-primary-foreground: rgb(19 20 22);
- --sidebar-accent: rgb(44 44 48);
- --sidebar-accent-foreground: rgb(221 221 226);
- --sidebar-border: rgb(110 110 114 / 0.28);
- --sidebar-ring: rgb(133 134 139);
- --success: rgb(74 222 128);
- --success-foreground: rgb(20 30 24);
- --success-bg: color-mix(in srgb, var(--success) 16%, transparent);
- --warning: rgb(251 191 36);
- --warning-foreground: rgb(40 32 12);
- --warning-bg: color-mix(in srgb, var(--warning) 16%, transparent);
- --info: rgb(96 165 250);
- --info-foreground: rgb(18 28 46);
- --info-bg: color-mix(in srgb, var(--info) 16%, transparent);
- --dbx-chrome: rgb(27 27 30);
- --dbx-chrome-muted: rgb(32 32 36);
- --dbx-content: rgb(19 20 22);
- --dbx-editor-toolbar: rgb(25 25 28);
- --dbx-gutter: rgb(23 23 25);
- --dbx-sidebar-header: rgb(25 25 28);
- --dbx-window-top-border: rgb(255 255 255 / 0.18);
-}
-
html.theme-soft {
--background: rgb(250 251 253);
--foreground: rgb(40 44 52);
diff --git a/apps/desktop/src/styles/tokens.css b/apps/desktop/src/styles/tokens.css
new file mode 100644
index 000000000..64d7c055f
--- /dev/null
+++ b/apps/desktop/src/styles/tokens.css
@@ -0,0 +1,210 @@
+/*
+ * Design tokens, shared by the application shell and the standalone
+ * documentation export.
+ *
+ * These live apart from globals.css because the export's Tailwind entry
+ * cannot import globals.css: its `@source` scans the whole application and
+ * `@source` is additive, so the export would emit every utility in DBX. The
+ * export still needs these tokens, and duplicating them is how the values
+ * drift.
+ *
+ * Order matters: `.dark` must stay declared after `:root`.
+ *
+ * Scope: this file carries only the base light (`:root`) and dark (`.dark`)
+ * token sets, in rgb. It deliberately does NOT carry the alternate
+ * installable app themes (`html.theme-soft`, `.theme-graphite`, etc., still
+ * in globals.css), the `@supports (color: oklch(...))` progressive
+ * enhancement that re-declares these same tokens in oklch for wide-gamut
+ * displays (also still in globals.css, after the tokens.css import), or the
+ * `:root[data-corner-style]` radius variants. None of those apply to a
+ * static file:// export with no theme picker and no live preference to
+ * express — pulling them in here would just be duplication with nothing to
+ * show for it.
+ *
+ * The `@theme inline` block below is part of the same plumbing and has to
+ * travel with the raw properties. Tailwind generates `bg-background`,
+ * `border-border` and `fill-card` from the `--color-*` entries here, NOT from
+ * the `--background`/`--border` properties themselves — an entry point that
+ * imports only the raw tokens defines every custom property and emits none of
+ * the utilities that read them, which renders as a completely unstyled page
+ * while every build and test stays green.
+ *
+ * `@custom-variant dark` travels with it for the same reason: Tailwind's stock
+ * `dark:` variant keys off `prefers-color-scheme`, so without this the app's
+ * (and the export's) `.dark` class would flip the token values while every
+ * `dark:` utility kept following the OS.
+ */
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --font-sans: "Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
+ --font-heading: var(--font-sans);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ /* Aliases for legacy MQ panel styles (used across components/mq/*). */
+ --color-error: var(--destructive);
+ --color-error-bg: color-mix(in srgb, var(--destructive) 10%, transparent);
+ --color-error-alpha: var(--color-error-bg);
+ --color-success: var(--success);
+ --color-success-bg: var(--success-bg);
+ --color-success-alpha: var(--success-bg);
+ --color-warning: var(--warning);
+ --color-warning-bg: var(--warning-bg);
+ --color-warning-alpha: var(--warning-bg);
+ --color-info: var(--info);
+ --color-info-bg: var(--info-bg);
+ --color-info-alpha: var(--info-bg);
+ --color-hover: var(--accent);
+ --color-background-secondary: var(--muted);
+ --color-text: var(--foreground);
+ --color-text-secondary: var(--muted-foreground);
+ --color-text-tertiary: color-mix(in srgb, var(--muted-foreground) 70%, transparent);
+ --color-border-light: color-mix(in srgb, var(--border) 60%, transparent);
+ --color-primary-alpha: color-mix(in srgb, var(--primary) 12%, transparent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: var(--dbx-radius-sm);
+ --radius-md: var(--dbx-radius-md);
+ --radius-lg: var(--dbx-radius-lg);
+ --radius-xl: var(--dbx-radius-xl);
+}
+
+:root {
+ --background: rgb(255 255 255);
+ --foreground: rgb(10 10 10);
+ --card: rgb(255 255 255);
+ --card-foreground: rgb(10 10 10);
+ --popover: rgb(255 255 255);
+ --popover-foreground: rgb(10 10 10);
+ --primary: rgb(23 23 23);
+ --primary-foreground: rgb(250 250 250);
+ --secondary: rgb(245 245 245);
+ --secondary-foreground: rgb(23 23 23);
+ --muted: rgb(245 245 245);
+ --muted-foreground: rgb(115 115 115);
+ --accent: rgb(245 245 245);
+ --accent-foreground: rgb(23 23 23);
+ --destructive: rgb(231 0 11);
+ --destructive-rgb: 231, 0, 11;
+ --border: rgb(229 229 229);
+ --input: rgb(229 229 229);
+ --ring: rgb(161 161 161);
+ --chart-1: rgb(212 212 212);
+ --chart-2: rgb(115 115 115);
+ --chart-3: rgb(82 82 82);
+ --chart-4: rgb(64 64 64);
+ --chart-5: rgb(38 38 38);
+ --radius: var(--dbx-radius-md);
+ --dbx-radius-default: 4px;
+ --dbx-radius-sm: 4px;
+ --dbx-radius-md: 4px;
+ --dbx-radius-lg: 6px;
+ --dbx-radius-xl: 6px;
+ --dbx-radius-fixed-4: 4px;
+ --dbx-radius-fixed-5: 5px;
+ --dbx-radius-fixed-6: 6px;
+ --sidebar: rgb(250 250 250);
+ --sidebar-foreground: rgb(10 10 10);
+ --sidebar-primary: rgb(23 23 23);
+ --sidebar-primary-foreground: rgb(250 250 250);
+ --sidebar-accent: rgb(245 245 245);
+ --sidebar-accent-foreground: rgb(23 23 23);
+ --sidebar-border: rgb(229 229 229);
+ --sidebar-ring: rgb(161 161 161);
+ --success: rgb(22 163 74);
+ --success-foreground: rgb(240 253 244);
+ --success-bg: color-mix(in srgb, var(--success) 12%, transparent);
+ --warning: rgb(217 119 6);
+ --warning-foreground: rgb(255 251 235);
+ --warning-bg: color-mix(in srgb, var(--warning) 14%, transparent);
+ --info: rgb(37 99 235);
+ --info-foreground: rgb(239 246 255);
+ --info-bg: color-mix(in srgb, var(--info) 12%, transparent);
+ --dbx-chrome: rgb(245 245 245);
+ --dbx-chrome-muted: rgb(240 240 240);
+ --dbx-content: rgb(255 255 255);
+ --dbx-editor-toolbar: rgb(250 250 250);
+ --dbx-gutter: rgb(243 243 243);
+ --dbx-sidebar-header: rgb(250 250 250);
+ --dbx-window-top-border: rgb(0 0 0 / 0.35);
+ --dbx-viewport-height: 100vh;
+}
+
+.dark {
+ --background: rgb(19 20 22);
+ --foreground: rgb(215 215 219);
+ --card: rgb(27 27 30);
+ --card-foreground: rgb(215 215 219);
+ --popover: rgb(30 30 32);
+ --popover-foreground: rgb(221 221 226);
+ --primary: rgb(208 208 214);
+ --primary-foreground: rgb(19 20 22);
+ --secondary: rgb(42 42 45);
+ --secondary-foreground: rgb(215 215 219);
+ --muted: rgb(42 42 45);
+ --muted-foreground: rgb(151 152 157);
+ --accent: rgb(46 47 51);
+ --accent-foreground: rgb(221 221 226);
+ --destructive: rgb(243 98 95);
+ --destructive-rgb: 243, 98, 95;
+ --border: rgb(110 110 114 / 0.28);
+ --input: rgb(110 110 114 / 0.34);
+ --ring: rgb(133 134 139);
+ --chart-1: rgb(212 212 212);
+ --chart-2: rgb(115 115 115);
+ --chart-3: rgb(82 82 82);
+ --chart-4: rgb(64 64 64);
+ --chart-5: rgb(38 38 38);
+ --sidebar: rgb(25 25 28);
+ --sidebar-foreground: rgb(208 208 213);
+ --sidebar-primary: rgb(208 208 214);
+ --sidebar-primary-foreground: rgb(19 20 22);
+ --sidebar-accent: rgb(44 44 48);
+ --sidebar-accent-foreground: rgb(221 221 226);
+ --sidebar-border: rgb(110 110 114 / 0.28);
+ --sidebar-ring: rgb(133 134 139);
+ --success: rgb(74 222 128);
+ --success-foreground: rgb(20 30 24);
+ --success-bg: color-mix(in srgb, var(--success) 16%, transparent);
+ --warning: rgb(251 191 36);
+ --warning-foreground: rgb(40 32 12);
+ --warning-bg: color-mix(in srgb, var(--warning) 16%, transparent);
+ --info: rgb(96 165 250);
+ --info-foreground: rgb(18 28 46);
+ --info-bg: color-mix(in srgb, var(--info) 16%, transparent);
+ --dbx-chrome: rgb(27 27 30);
+ --dbx-chrome-muted: rgb(32 32 36);
+ --dbx-content: rgb(19 20 22);
+ --dbx-editor-toolbar: rgb(25 25 28);
+ --dbx-gutter: rgb(23 23 25);
+ --dbx-sidebar-header: rgb(25 25 28);
+ --dbx-window-top-border: rgb(255 255 255 / 0.18);
+}
diff --git a/apps/desktop/vite.docs-export.config.ts b/apps/desktop/vite.docs-export.config.ts
new file mode 100644
index 000000000..5c173d1be
--- /dev/null
+++ b/apps/desktop/vite.docs-export.config.ts
@@ -0,0 +1,160 @@
+import { createHash } from "node:crypto";
+import { readFileSync, statSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+import tailwindcss from "@tailwindcss/vite";
+
+const repoRoot = path.resolve(__dirname, "../..");
+const assetsDir = path.join(repoRoot, "crates/dbx-core/assets");
+const fontPath = path.join(__dirname, "public/fonts/geist-latin-wght-normal.woff2");
+
+function sha256(buffer: Buffer | string): string {
+ return createHash("sha256").update(buffer).digest("hex");
+}
+
+function isFile(file: string): boolean {
+ try {
+ return statSync(file).isFile();
+ } catch {
+ return false;
+ }
+}
+
+interface BundleChunk {
+ type: string;
+ source?: string | Uint8Array;
+ code?: string;
+ modules?: Record;
+}
+
+/**
+ * Every `@import` a stylesheet actually makes, followed to the files on disk.
+ *
+ * Tailwind resolves CSS `@import` inside its own plugin, so tokens.css never
+ * becomes a Rollup module and the module graph alone cannot see it. This
+ * follows the same edges the build follows, from the same bytes — a new
+ * `@import` is picked up because it is read out of the file, not matched
+ * against a list. Bare specifiers (`tailwindcss`) resolve inside a package and
+ * are left to `deps`.
+ */
+function cssImportsOf(file: string, seen: Set): void {
+ const source = readFileSync(file, "utf8");
+ for (const match of source.matchAll(/@import\s+(?:url\()?["']([^"']+)["']/g)) {
+ const specifier = match[1];
+ if (!specifier.startsWith(".") && !specifier.startsWith("/")) continue;
+ const resolved = path.resolve(path.dirname(file), specifier);
+ if (seen.has(resolved) || !isFile(resolved)) continue;
+ seen.add(resolved);
+ cssImportsOf(resolved, seen);
+ }
+}
+
+/**
+ * Inline the font and write the staleness manifest.
+ *
+ * The manifest is derived from Rollup's ACTUAL module graph — plus the CSS
+ * `@import` graph the module graph cannot see — never from a hand-written
+ * glob. SchemaDiagram.vue imports erDiagram.ts from outside src/docs/, so a
+ * glob of that directory would miss it and the guard would pass while the
+ * artefact was stale — the same shape as the three guards this feature has
+ * already had to widen after the fact.
+ *
+ * New files are covered for free: a module can only enter the bundle by being
+ * imported, which means editing an existing file, which changes that file's
+ * hash. The one hole would be `import.meta.glob`, which the viewer does not
+ * use.
+ */
+function exportBundlePlugin() {
+ return {
+ name: "dbx-docs-export",
+ // Vite's own `vite:css-post` creates the stylesheet asset in its
+ // generateBundle, and it runs after normal user plugins. Without `post`
+ // this hook fires while the CSS does not exist yet and the `@font-face`
+ // silently never lands.
+ enforce: "post" as const,
+ generateBundle(_options: unknown, bundle: Record) {
+ const font = readFileSync(fontPath).toString("base64");
+ const fontFace = `@font-face{font-family:"Geist Variable";font-style:normal;font-display:swap;font-weight:100 900;src:url("data:font/woff2;base64,${font}") format("woff2-variations")}\n`;
+
+ const sources: Record = {};
+ const deps: Record = {};
+
+ const record = (rawId: string): void => {
+ // Vue splits an SFC into `File.vue?vue&type=style&…` sub-requests and
+ // Vite tags CSS the same way. The file on disk is the part before the
+ // query; without stripping it every SFC would be missing from the
+ // manifest, which is the failure this whole derivation exists to avoid.
+ const id = rawId.split("?")[0];
+ if (!path.isAbsolute(id)) return;
+
+ // `lastIndexOf`: under pnpm a real path is
+ // `/node_modules/.pnpm/marked@18.0.4/node_modules/marked/…`, and
+ // the FIRST occurrence yields the package name `.pnpm`.
+ const nodeModules = id.lastIndexOf("/node_modules/");
+ if (nodeModules !== -1) {
+ const after = id.slice(nodeModules + "/node_modules/".length);
+ const name = after.startsWith("@") ? after.split("/").slice(0, 2).join("/") : after.split("/")[0];
+ if (name in deps) return;
+ const manifest = path.join(id.slice(0, nodeModules), "node_modules", name, "package.json");
+ if (!isFile(manifest)) return;
+ deps[name] = JSON.parse(readFileSync(manifest, "utf8")).version;
+ return;
+ }
+
+ if (!id.startsWith(repoRoot) || !isFile(id)) return;
+ sources[path.relative(repoRoot, id)] = sha256(readFileSync(id));
+ };
+
+ const stylesheets = new Set();
+ for (const chunk of Object.values(bundle)) {
+ for (const id of Object.keys(chunk.modules ?? {})) {
+ record(id);
+ const file = id.split("?")[0];
+ if (file.endsWith(".css") && isFile(file)) cssImportsOf(file, stylesheets);
+ }
+ }
+ for (const file of stylesheets) record(file);
+ record(fontPath);
+ // This file, and the tsconfig esbuild reads `target` out of. Neither is a
+ // module, and both decide emitted bytes: the @font-face template below,
+ // `format: "iife"`, the `@source` narrowing. Without them someone can
+ // change how the bundle is built, not rebuild, and leave the staleness
+ // guard green over artefacts that no longer match the tree.
+ record(__filename);
+ record(path.join(__dirname, "tsconfig.json"));
+
+ for (const [name, chunk] of Object.entries(bundle)) {
+ if (name.endsWith(".css") && typeof chunk.source === "string") chunk.source = fontFace + chunk.source;
+ }
+
+ writeFileSync(
+ path.join(assetsDir, "docs-export.manifest.json"),
+ `${JSON.stringify({ sources: Object.fromEntries(Object.entries(sources).sort()), deps: Object.fromEntries(Object.entries(deps).sort()) }, null, 2)}\n`,
+ );
+ },
+ };
+}
+
+export default defineConfig({
+ root: __dirname,
+ // The app's public/ holds the font files this build inlines. Left on, Vite
+ // would copy all of them into crates/dbx-core/assets beside the bundle.
+ publicDir: false,
+ plugins: [vue(), tailwindcss(), exportBundlePlugin()],
+ resolve: { alias: { "@": path.resolve(__dirname, "src") } },
+ build: {
+ outDir: assetsDir,
+ emptyOutDir: false,
+ cssCodeSplit: false,
+ rollupOptions: {
+ input: path.resolve(__dirname, "src/docs-export/main.ts"),
+ // `iife`, not the default `es`, for two reasons: Task 6 inlines this into
+ // a document opened over file://, where a module script is subject to
+ // CORS-flavoured rules no plain \n\n