(wrapper around the grid)
+ ------------------------------------------------ */
+.home-feature-cards {
+ margin: 2rem 0 3rem;
+}
+
+.home-quick-start {
+ margin: 1.5rem 0 3rem;
+}
+
+.home-feature-cards.card-group,
+.home-feature-cards .card-group,
+.home-quick-start.card-group,
+.home-quick-start .card-group {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: 1fr;
+}
+
+@media (min-width: 640px) {
+ .home-feature-cards.card-group,
+ .home-feature-cards .card-group,
+ .home-quick-start.card-group,
+ .home-quick-start .card-group {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (min-width: 960px) {
+ .home-feature-cards.card-group,
+ .home-feature-cards .card-group,
+ .home-quick-start.card-group,
+ .home-quick-start .card-group {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+.home-feature-cards .card-link,
+.home-quick-start .card-link {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 10rem;
+ border: 1px solid transparent;
+ background: #f7f8f9;
+ padding: 1.5rem;
+ border-radius: 0.875rem;
+ box-shadow: none;
+ transition:
+ background 0.2s ease,
+ border-color 0.2s ease,
+ transform 0.2s ease;
+}
+
+.home-feature-cards .card-link:hover,
+.home-quick-start .card-link:hover {
+ background: #eef0f2;
+ border-color: color-mix(in srgb, var(--vp-c-brand-1) 12%, transparent);
+ box-shadow: none;
+ transform: translateY(-1px);
+}
+
+.home-quick-start .card-link--static:hover {
+ background: #f7f8f9;
+ border-color: transparent;
+ transform: none;
+}
+
+:is(.dark:not([data-theme]), [data-theme="dark"]) .home-feature-cards .card-link,
+:is(.dark:not([data-theme]), [data-theme="dark"]) .home-quick-start .card-link {
+ background: #181a1b;
+ border-color: transparent;
+}
+
+:is(.dark:not([data-theme]), [data-theme="dark"]) .home-feature-cards .card-link:hover,
+:is(.dark:not([data-theme]), [data-theme="dark"]) .home-quick-start .card-link:hover {
+ background: #1f2224;
+ border-color: color-mix(in srgb, var(--vp-c-brand-1) 22%, transparent);
+ box-shadow: none;
+}
+
+:is(.dark:not([data-theme]), [data-theme="dark"]) .home-quick-start .card-link--static:hover {
+ background: #181a1b;
+ border-color: transparent;
+}
+
+.home-feature-cards .card-head,
+.home-quick-start .card-head {
+ margin-bottom: 0.625rem;
+}
+
+.home-feature-cards .card-link .card-title,
+.home-quick-start .card-link .card-title,
+.home-feature-cards .card-link:hover .card-title,
+.home-quick-start .card-link:hover .card-title {
+ color: #0a0a0a !important;
+ font-size: 1rem !important;
+}
+
+:is(.dark:not([data-theme]), [data-theme="dark"]) .home-feature-cards .card-link .card-title,
+:is(.dark:not([data-theme]), [data-theme="dark"]) .home-quick-start .card-link .card-title,
+:is(.dark:not([data-theme]), [data-theme="dark"]) .home-feature-cards .card-link:hover .card-title,
+:is(.dark:not([data-theme]), [data-theme="dark"]) .home-quick-start .card-link:hover .card-title {
+ color: #f4f4f4 !important;
+}
+
+.home-feature-cards .card-description,
+.home-quick-start .card-description {
+ font-size: 0.875rem;
+ line-height: 1.55;
+ color: #5c6570;
+}
+
+:is(.dark:not([data-theme]), [data-theme="dark"]) .home-feature-cards .card-description,
+:is(.dark:not([data-theme]), [data-theme="dark"]) .home-quick-start .card-description {
+ color: #9ca3af;
+}
+
+.home-feature-cards .card-cta,
+.home-quick-start .card-links {
+ margin-top: auto;
+ padding-top: 1rem;
+}
+
+.home-feature-cards .card-icon__surface,
+.home-quick-start .card-icon__surface {
+ width: 2.75rem;
+ height: 2.75rem;
+ border-radius: 0.6875rem;
+}
+
+.home-feature-cards .card-icon__custom svg,
+.home-quick-start .card-icon__custom svg {
+ width: 1.375rem;
+ height: 1.375rem;
+}
+
+/* Hide footer prev/next on home when card grids are present */
+.Layout:has(.home-feature-cards) .VPDocFooter .prev,
+.Layout:has(.home-feature-cards) .VPDocFooter .next,
+.Layout:has(.home-feature-cards) .VPDocFooter .pager-link,
+.Layout:has(.home-quick-start) .VPDocFooter .prev,
+.Layout:has(.home-quick-start) .VPDocFooter .next,
+.Layout:has(.home-quick-start) .VPDocFooter .pager-link {
+ display: none;
+}
+
+/* ------------------------------------------------
+ Hero image frames in markdown: 
+ The suffix picks which corner of the screenshot is anchored to the frame.
+ ------------------------------------------------ */
+.vp-doc p:has(> img[src$="#hero"]),
+.vp-doc p:has(> img[src$="#hero-tl"]),
+.vp-doc p:has(> img[src$="#hero-tr"]),
+.vp-doc p:has(> img[src$="#hero-bl"]),
+.vp-doc p:has(> img[src$="#hero-br"]),
+article p:has(> img[src$="#hero"]),
+article p:has(> img[src$="#hero-tl"]),
+article p:has(> img[src$="#hero-tr"]),
+article p:has(> img[src$="#hero-bl"]),
+article p:has(> img[src$="#hero-br"]) {
+ max-width: 841px;
+ background: #f1f3f3;
+ border-radius: 12px;
+ box-sizing: border-box;
+ overflow: hidden;
+}
+
+.vp-doc img[src$="#hero"],
+.vp-doc img[src$="#hero-tl"],
+.vp-doc img[src$="#hero-tr"],
+.vp-doc img[src$="#hero-bl"],
+.vp-doc img[src$="#hero-br"],
+article img[src$="#hero"],
+article img[src$="#hero-tl"],
+article img[src$="#hero-tr"],
+article img[src$="#hero-bl"],
+article img[src$="#hero-br"] {
+ width: 100%;
+ height: auto;
+ display: block;
+ margin: 0;
+ border-style: solid;
+ border-color: #eaebeb;
+ object-fit: cover;
+ box-shadow:
+ 0px 2px 4px -1px rgba(41, 47, 61, 0.04),
+ 0px 4px 6px -1px rgba(41, 47, 61, 0.05);
+}
+
+.vp-doc p:has(> img[src$="#hero"]),
+article p:has(> img[src$="#hero"]) {
+ padding: 36px 56px 0;
+}
+
+.vp-doc img[src$="#hero"],
+article img[src$="#hero"] {
+ border-radius: 8px 8px 0 0;
+ border-width: 1px 1px 0 1px;
+}
+
+.vp-doc p:has(> img[src$="#hero-tl"]),
+article p:has(> img[src$="#hero-tl"]) {
+ padding: 36px 0 0 56px;
+}
+
+.vp-doc img[src$="#hero-tl"],
+article img[src$="#hero-tl"] {
+ border-radius: 8px 0 0 0;
+ border-width: 1px 0 0 1px;
+}
+
+.vp-doc p:has(> img[src$="#hero-tr"]),
+article p:has(> img[src$="#hero-tr"]) {
+ padding: 36px 56px 0 0;
+}
+
+.vp-doc img[src$="#hero-tr"],
+article img[src$="#hero-tr"] {
+ border-radius: 0 8px 0 0;
+ border-width: 1px 1px 0 0;
+}
+
+.vp-doc p:has(> img[src$="#hero-bl"]),
+article p:has(> img[src$="#hero-bl"]) {
+ padding: 0 0 36px 56px;
+}
+
+.vp-doc img[src$="#hero-bl"],
+article img[src$="#hero-bl"] {
+ border-radius: 0 0 0 8px;
+ border-width: 0 0 1px 1px;
+}
+
+.vp-doc p:has(> img[src$="#hero-br"]),
+article p:has(> img[src$="#hero-br"]) {
+ padding: 0 56px 36px 0;
+}
+
+.vp-doc img[src$="#hero-br"],
+article img[src$="#hero-br"] {
+ border-radius: 0 0 8px 0;
+ border-width: 0 1px 1px 0;
+}
diff --git a/docs/.vitepress/theme/plane/css/fonts.css b/docs/.vitepress/theme/plane/css/fonts.css
new file mode 100644
index 00000000..a4bf5d5d
--- /dev/null
+++ b/docs/.vitepress/theme/plane/css/fonts.css
@@ -0,0 +1,41 @@
+/* Self-hosted fonts (files live in docs/public/fonts/ in both repos). */
+
+@font-face {
+ font-family: "Inter";
+ src: url("/fonts/Inter/InterVariable.woff2") format("woff2");
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "IBM Plex Mono";
+ src: url("/fonts/IBMPlexMono/IBMPlexMono-Regular.ttf") format("truetype");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "IBM Plex Mono";
+ src: url("/fonts/IBMPlexMono/IBMPlexMono-Medium.ttf") format("truetype");
+ font-weight: 500;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "IBM Plex Mono";
+ src: url("/fonts/IBMPlexMono/IBMPlexMono-SemiBold.ttf") format("truetype");
+ font-weight: 600;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "IBM Plex Mono";
+ src: url("/fonts/IBMPlexMono/IBMPlexMono-Bold.ttf") format("truetype");
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
diff --git a/docs/.vitepress/theme/plane/css/index.css b/docs/.vitepress/theme/plane/css/index.css
new file mode 100644
index 00000000..3cbae5ce
--- /dev/null
+++ b/docs/.vitepress/theme/plane/css/index.css
@@ -0,0 +1,21 @@
+/* ================================================
+ PLANE DOCS THEME — shared stylesheet entry
+ Byte-identical in makeplane/docs and makeplane/developer-docs
+ (docs/.vitepress/theme/plane/). Edit in one repo, copy to the other,
+ run `pnpm check:theme-sync`.
+ ================================================ */
+
+/* VoidZero design system (tokens → base → docs → marketing) — includes Tailwind v4 */
+@import "@voidzero-dev/vitepress-theme/src/styles/index.css";
+
+/* Plane layer, in cascade order */
+@import "./fonts.css";
+@import "./tokens.css";
+@import "./base.css";
+@import "./layout.css";
+@import "./components.css";
+@import "./api.css";
+
+/* Tailwind content sources: the shared theme's Vue components and every page/component under docs/ */
+@source "../**/*.vue";
+@source "../../../../**/*.{vue,md}";
diff --git a/docs/.vitepress/theme/plane/css/layout.css b/docs/.vitepress/theme/plane/css/layout.css
new file mode 100644
index 00000000..f01a9d09
--- /dev/null
+++ b/docs/.vitepress/theme/plane/css/layout.css
@@ -0,0 +1,442 @@
+/* ================================================
+ PLANE DOCS THEME — layout shell, header skin, sidebar / aside geometry
+ ================================================ */
+
+/* ------------------------------------------------
+ Layout borders & corner ticks
+ ------------------------------------------------ */
+@media (min-width: 768px) {
+ .docs-layout .content-wrapper,
+ .docs-layout .wrapper {
+ border-left-color: var(--docs-divider);
+ border-right-color: var(--docs-divider);
+ }
+}
+
+.docs-layout .tick-left::before {
+ border-left-color: var(--docs-divider);
+}
+
+.docs-layout .tick-right::after {
+ border-right-color: var(--docs-divider);
+}
+
+.docs-layout .VPSidebar {
+ border-right: 1px solid var(--docs-divider);
+}
+
+/* Hide local nav when the sidebar is present */
+@media (min-width: 1024px) {
+ .docs-layout .VPLocalNav {
+ display: none !important;
+ }
+}
+
+/* ------------------------------------------------
+ Sidebars: flush under the fixed header
+ ------------------------------------------------ */
+@media (min-width: 1024px) {
+ /* Both rails keep VitePress' 20px top inset so their first rows share a baseline. */
+ .docs-layout .VPSidebar {
+ top: calc(var(--vp-nav-height) + var(--vp-banner-height, 0px) - 4px);
+ }
+
+ .docs-layout .VPDoc .aside-container {
+ top: calc(var(--vp-nav-height) + var(--vp-banner-height, 0px) - 4px);
+ }
+}
+
+/* --- Doc layout shell (1280px+): left nav + right outline share one baseline --- */
+@media (min-width: 1280px) {
+ .docs-layout .VPSidebar .nav {
+ padding-top: 0;
+ }
+
+ .docs-layout .VPDoc .content {
+ padding-top: 32px;
+ }
+
+ .docs-layout .VPDoc .aside {
+ max-width: 256px;
+ }
+
+ .docs-layout .VPDoc .aside-container {
+ width: 256px;
+ padding-left: 24px;
+ border-left-color: var(--docs-divider);
+ }
+
+ /* Outline: allow long headings to wrap, add air between siblings */
+ .docs-layout .VPDocOutlineItem .outline-link {
+ white-space: normal;
+ text-overflow: unset;
+ overflow: visible;
+ line-height: 1.45;
+ padding: 2px 0 4px;
+ }
+
+ .docs-layout .VPDocAsideOutline .VPDocOutlineItem li + li {
+ margin-top: 6px;
+ }
+
+ .docs-layout .VPDocAsideOutline .VPDocOutlineItem li > .VPDocOutlineItem.nested {
+ margin-top: 4px;
+ }
+}
+
+/* --- No right outline (aside: false, or the page has no headings): widen main column --- */
+.docs-layout .VPDoc:not(.has-aside) .content-container {
+ max-width: 960px;
+ padding-left: 24px;
+ padding-right: 24px;
+}
+
+@media (min-width: 1280px) {
+ .docs-layout .VPDoc:not(.has-aside) .content-container {
+ max-width: 1104px;
+ padding-left: 48px;
+ padding-right: 48px;
+ }
+
+ .docs-layout
+ .VPDoc.has-aside:has(.VPDocAsideOutline:not(.has-outline)):not(:has(.VPDocAsideCarbonAds))
+ .aside {
+ display: none !important;
+ flex: 0 0 0 !important;
+ width: 0 !important;
+ min-width: 0 !important;
+ max-width: 0 !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ overflow: hidden !important;
+ }
+
+ .docs-layout
+ .VPDoc.has-aside:has(.VPDocAsideOutline:not(.has-outline)):not(:has(.VPDocAsideCarbonAds))
+ .content {
+ flex: 1 1 auto;
+ max-width: none !important;
+ }
+
+ .docs-layout
+ .VPDoc.has-aside:has(.VPDocAsideOutline:not(.has-outline)):not(:has(.VPDocAsideCarbonAds))
+ .content-container {
+ max-width: 1104px;
+ padding-left: 48px;
+ padding-right: 48px;
+ }
+}
+
+/* ------------------------------------------------
+ Copy page menu (components/CopyPageMenu.vue) — placement
+ The control is server-rendered in the `doc-before` slot (first child of
+ `.VPDoc .content-container`, above
) and, once mounted, teleported into a
+ `.copy-page-slot` host inserted right after the page H1 inside `.vp-doc`. Both
+ positions are styled here; visual styles are scoped in the component.
+ ------------------------------------------------ */
+.docs-layout .VPDoc .content-container {
+ position: relative; /* anchor for the pre-teleport position */
+}
+
+/* < 768px: below the title, left-aligned, in flow */
+@media (max-width: 767px) {
+ /* Pre-teleport position would sit above the title — keep it hidden to avoid a jump. */
+ .docs-layout .VPDoc .content-container > .copy-page {
+ display: none;
+ }
+
+ .docs-layout .vp-doc > div > .copy-page-slot {
+ margin: 14px 0 16px;
+ }
+}
+
+/* ≥ 768px: on the title row, flush with the right edge of the text column */
+@media (min-width: 768px) {
+ .docs-layout .VPDoc .content-container > .copy-page,
+ .docs-layout .vp-doc > div > .copy-page-slot > .copy-page {
+ position: absolute; /* relative to .content-container / .vp-doc — same top edge */
+ top: 2px; /* (h1 line box ~38px − 34px button) / 2 */
+ right: 0;
+ margin: 0;
+ z-index: 10; /* below --vp-z-index-local-nav (20) and the image lightbox */
+ }
+
+ /* Reserve the control's footprint on the H1 only when it actually rendered
+ (so `copyPage: false` pages keep the full width). */
+ .docs-layout .vp-doc > div > h1:has(+ .copy-page-slot > .copy-page),
+ .docs-layout .VPDoc .content-container > .copy-page ~ .main .vp-doc > div > h1 {
+ padding-right: 156px; /* ≈ 132px control + 24px gap — re-measure if the label changes */
+ }
+}
+
+/* ------------------------------------------------
+ Header (components/PlaneHeader.vue) — shell, search, nav, actions
+ ------------------------------------------------ */
+header.plane-header.wrapper,
+.plane-header-shell {
+ font-family: var(--vp-font-family-base);
+ background-color: var(--plane-header-bg) !important;
+ border-bottom-color: var(--plane-header-border) !important;
+}
+
+@media (min-width: 768px) {
+ .docs-layout header.plane-header.wrapper,
+ .docs-layout .plane-header-shell .wrapper {
+ border-left-color: var(--docs-divider) !important;
+ border-right-color: var(--docs-divider) !important;
+ }
+}
+
+.docs-layout .border-stroke {
+ border-color: var(--docs-divider) !important;
+}
+
+/* Match content-wrapper width (centered when fixed) */
+@media (min-width: 768px) {
+ .docs-layout .plane-header-shell {
+ width: calc(100vw - 2rem);
+ max-width: calc(100vw - 2rem);
+ margin-left: auto;
+ margin-right: auto;
+ box-sizing: border-box;
+ }
+}
+
+@media (min-width: 90rem) {
+ .docs-layout .plane-header-shell {
+ width: 90rem;
+ max-width: 90rem;
+ }
+}
+
+@media (min-width: 1024px) {
+ .docs-layout .plane-header-shell--docs {
+ left: 50%;
+ right: auto;
+ transform: translateX(-50%);
+ width: calc(100vw - 2rem);
+ max-width: calc(100vw - 2rem);
+ }
+
+ .docs-layout .plane-header-shell--docs.plane-header-shell {
+ overflow-x: clip;
+ }
+}
+
+@media (min-width: 90rem) {
+ .docs-layout .plane-header-shell--docs {
+ width: 90rem;
+ max-width: 90rem;
+ }
+}
+
+@media (min-width: 768px) {
+ .plane-header .VPNavBarSearchButton,
+ .plane-header .search-bar {
+ background-color: var(--plane-header-search-bg) !important;
+ border: 1px solid var(--plane-header-search-border) !important;
+ border-radius: 12px;
+ color: var(--plane-header-search-text) !important;
+ }
+}
+
+@media (min-width: 1024px) {
+ .plane-header-shell {
+ overflow-x: clip;
+ }
+
+ .docs-layout .plane-header.wrapper {
+ width: 100%;
+ max-width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+ box-sizing: border-box;
+ overflow-x: clip;
+ }
+
+ .plane-header .plane-header__start,
+ .plane-header .plane-header__actions {
+ min-width: 0;
+ overflow: hidden;
+ }
+
+ .plane-header .plane-header__search {
+ width: 240px;
+ max-width: 240px;
+ }
+
+ .plane-header .VPNavBarSearch {
+ width: 100%;
+ min-width: 0;
+ max-width: 240px;
+ }
+
+ .plane-header .VPNavBarSearchButton,
+ .plane-header .DocSearch.DocSearch-Button {
+ width: 100% !important;
+ max-width: 240px !important;
+ min-width: 0 !important;
+ }
+
+ .plane-header .DocSearch.DocSearch-Button,
+ .plane-header .VPNavBarSearchButton,
+ .plane-header .search-bar {
+ height: 36px;
+ border-radius: 12px;
+ background: var(--plane-header-search-bg) !important;
+ border: 1px solid var(--plane-header-search-border) !important;
+ color: var(--plane-header-search-text) !important;
+ justify-content: flex-start !important;
+ text-align: left !important;
+ gap: 8px;
+ }
+
+ .plane-header .DocSearch-Button-Container {
+ justify-content: flex-start !important;
+ flex: 1;
+ min-width: 0;
+ gap: 0.5rem;
+ }
+
+ .plane-header .DocSearch-Search-Icon {
+ color: var(--plane-header-search-text) !important;
+ }
+
+ .plane-header .DocSearch-Button-Placeholder,
+ .plane-header .VPNavBarSearchButton .text,
+ .plane-header .search-bar__text {
+ color: var(--plane-header-search-text) !important;
+ text-align: left !important;
+ flex: 0 1 auto;
+ font-size: 13px;
+ }
+
+ .plane-header .DocSearch-Button-Keys,
+ .plane-header .VPNavBarSearchButton .keys,
+ .plane-header .search-bar__keys {
+ color: var(--plane-header-search-text) !important;
+ border: 1px solid var(--plane-header-search-keys-border) !important;
+ margin-left: auto !important;
+ flex-shrink: 0;
+ font-size: 12px;
+ font-weight: 500;
+ }
+
+ .plane-header__nav .VPLink.link,
+ .plane-header__nav .VPLink.text-base,
+ .plane-header .VPNavBarMenuLink:not(.home-doc-actions__btn),
+ .plane-header .VPNavBarMenuGroup .button {
+ color: var(--plane-header-text) !important;
+ font-size: 14px !important;
+ font-weight: 400;
+ padding: 0.375rem 0.5rem;
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ transition: color 0.2s ease;
+ }
+
+ .plane-header__nav .VPLink.link:hover,
+ .plane-header .VPNavBarMenuLink:not(.home-doc-actions__btn):hover,
+ .plane-header .VPNavBarMenuGroup .button:hover {
+ color: var(--vp-c-brand-1) !important;
+ opacity: 1;
+ }
+
+ .plane-header a.home-doc-actions__btn--secondary.VPNavBarMenuLink,
+ .plane-header a.home-doc-actions__btn--secondary.VPNavBarMenuLink:hover {
+ color: inherit !important;
+ opacity: 1 !important;
+ }
+
+ .plane-header .VPNavBarMenuLink.active,
+ .plane-header__nav .VPLink.link.active {
+ color: var(--vp-c-brand-1) !important;
+ }
+
+ .plane-header .VPNavBarAppearance,
+ .plane-header .VPNavBarSocialLinks {
+ display: flex !important;
+ align-items: center;
+ flex-shrink: 0;
+ }
+
+ .plane-header .VPNavBarSocialLinks {
+ gap: 0.25rem;
+ }
+
+ .plane-header .VPNavBarSocialLinks .VPSocialLink {
+ color: var(--plane-header-text);
+ transition: color 0.2s ease;
+ }
+
+ .plane-header .VPNavBarSocialLinks .VPSocialLink:hover {
+ color: var(--vp-c-brand-1);
+ }
+
+ .plane-header .VPSwitchAppearance {
+ --vp-c-bg: var(--plane-header-search-bg);
+ }
+}
+
+/* Theme switcher border follows the layout hairlines */
+.docs-layout .VPSwitchAppearance {
+ --vp-c-border: var(--docs-divider);
+ --vp-input-border-color: var(--vp-c-border);
+}
+
+@media (min-width: 1024px) and (max-width: 1279px) {
+ .plane-header__title {
+ display: none;
+ }
+}
+
+@media (min-width: 1280px) {
+ .plane-header {
+ gap: 0.5rem 1.25rem;
+ --plane-header-padding-x: 1.5rem;
+ }
+
+ .plane-header__nav .VPLink.link,
+ .plane-header__nav .VPLink.text-base,
+ .plane-header .VPNavBarMenuLink:not(.home-doc-actions__btn),
+ .plane-header .VPNavBarMenuGroup .button {
+ font-size: 14px !important;
+ padding: 0.375rem 0.75rem;
+ }
+}
+
+/* Navbar buttons — hide external-link ↗ icon */
+.plane-header a.home-doc-actions__btn.vp-external-link-icon::after,
+.plane-header .VPLink.home-doc-actions__btn.vp-external-link-icon::after,
+.plane-header a.home-doc-actions__btn.no-icon::after,
+.plane-header .VPLink.home-doc-actions__btn.no-icon::after {
+ display: none !important;
+ content: none !important;
+}
+
+.plane-header a.home-doc-actions__btn .vp-external-link-icon,
+.plane-header a.home-doc-actions__btn svg {
+ display: none !important;
+}
+
+/* Navbar primary & secondary buttons */
+.plane-header a.home-doc-actions__btn--primary.VPNavBarMenuLink,
+.plane-header a.home-doc-actions__btn--secondary.VPNavBarMenuLink,
+.plane-header .VPLink.home-doc-actions__btn--primary,
+.plane-header .VPLink.home-doc-actions__btn--secondary {
+ padding: 4px 15px !important;
+ font-size: 14px !important;
+ border-radius: 6px !important;
+ min-height: auto !important;
+ line-height: 1.25 !important;
+ font-weight: 500 !important;
+ white-space: nowrap;
+}
+
+/* Don't apply nav text-link hover to the button variants */
+.plane-header .VPNavBarMenuLink.home-doc-actions__btn:hover {
+ opacity: 1 !important;
+}
diff --git a/docs/.vitepress/theme/plane/css/tokens.css b/docs/.vitepress/theme/plane/css/tokens.css
new file mode 100644
index 00000000..e5bb735a
--- /dev/null
+++ b/docs/.vitepress/theme/plane/css/tokens.css
@@ -0,0 +1,202 @@
+/* ================================================
+ PLANE DOCS THEME — design tokens
+ Loaded after @voidzero-dev/vitepress-theme/src/styles/index.css.
+
+ Selector contract
+ - Light values live on `:root`.
+ - Dark values live on `.dark:not([data-theme]), [data-theme="dark"]` — the
+ exact pair VoidZero uses, so our declarations win by source order with the
+ same specificity and no `!important`. Never use bare `html.dark` here.
+ ================================================ */
+
+:root {
+ /* --- Fonts (VoidZero maps --font-* → --vp-font-family-*; headings read --font-heading) --- */
+ --vp-font-family-base: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --vp-font-family-mono:
+ "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
+ monospace;
+ --font-sans: var(--vp-font-family-base);
+ --font-mono: var(--vp-font-family-mono);
+ --font-heading: var(--vp-font-family-base);
+
+ /* --- Layout --- */
+ --vp-nav-height: 84px; /* PlaneHeader height (VoidZero default 82px) */
+ --docs-divider: #ececec; /* header / sidebar / wrapper hairlines */
+ --color-stroke: var(--docs-divider); /* VoidZero .content-wrapper / ticks / border-stroke */
+
+ /* --- Brand --- */
+ --color-brand: #006399;
+ --vp-c-brand-1: #006399;
+ --vp-c-brand-2: #0078b8; /* hover (VoidZero uses brand-2 for hover states) */
+ --vp-c-brand-3: #006399;
+ --vp-c-brand-soft: rgba(0, 99, 153, 0.14);
+ --plane-brand-hover: #0078b8;
+ --plane-link-hover: #0078b8;
+
+ /* --- Text / backgrounds / borders --- */
+ --color-primary: #0a0a0a; /* Tailwind text-primary utility (VoidZero #16171d) */
+ --vp-c-text-1: #0a0a0a;
+ --vp-c-text-2: #6b7280;
+ --vp-c-text-3: #9ca3af;
+ --vp-c-bg: #ffffff;
+ --vp-c-bg-alt: #f6f6f7;
+ --vp-c-bg-soft: #f6f6f7;
+ --vp-c-bg-elv: #ffffff;
+ --vp-c-divider: #e5e7eb;
+ --vp-c-border: #e5e7eb;
+ --vp-c-gutter: #e5e7eb;
+
+ /* --- Code --- */
+ /* Inline code in neutral body-text color (VitePress default is brand blue,
+ hard to read on the pill background). Linked code keeps the brand color. */
+ --vp-code-color: var(--vp-c-text-1);
+ --vp-code-block-bg: #fafafa;
+
+ /* --- Buttons (VPButton / hero actions) --- */
+ --vp-button-brand-border: #006399;
+ --vp-button-brand-bg: #006399;
+ --vp-button-brand-text: #ffffff;
+ --vp-button-brand-hover-border: #0078b8;
+ --vp-button-brand-hover-bg: #0078b8;
+ --vp-button-brand-hover-text: #ffffff;
+ --vp-button-alt-border: #e5e7eb;
+ --vp-button-alt-text: #0a0a0a;
+ --vp-button-alt-bg: transparent;
+ --vp-button-alt-hover-border: #d1d5db;
+ --vp-button-alt-hover-text: #0a0a0a;
+ --vp-button-alt-hover-bg: #f7f8f9;
+
+ /* --- Callouts (tip / info / warning / danger / caution / details) --- */
+ --vp-c-tip-1: #166534;
+ --vp-c-tip-2: #14532d;
+ --vp-c-tip-3: #166534;
+ --vp-c-tip-soft: #dcfce7;
+ --vp-custom-block-tip-text: #166534;
+ --vp-custom-block-tip-bg: #dcfce7;
+ --vp-custom-block-tip-code-bg: #dcfce7;
+
+ --vp-custom-block-info-text: #1e40af;
+ --vp-custom-block-info-bg: #dbeafe;
+ --vp-custom-block-info-code-bg: #dbeafe;
+
+ --vp-c-warning-1: #92400e;
+ --vp-c-warning-2: #78350f;
+ --vp-c-warning-3: #92400e;
+ --vp-c-warning-soft: #fef3c7;
+ --vp-custom-block-warning-text: #92400e;
+ --vp-custom-block-warning-bg: #fef3c7;
+ --vp-custom-block-warning-code-bg: #fef3c7;
+
+ --vp-c-danger-1: #991b1b;
+ --vp-c-danger-2: #7f1d1d;
+ --vp-c-danger-3: #991b1b;
+ --vp-c-danger-soft: #fee2e2;
+ --vp-custom-block-danger-text: #991b1b;
+ --vp-custom-block-danger-bg: #fee2e2;
+ --vp-custom-block-danger-code-bg: #fee2e2;
+
+ --vp-c-caution-1: var(--vp-c-danger-1);
+ --vp-c-caution-2: var(--vp-c-danger-2);
+ --vp-c-caution-3: var(--vp-c-danger-3);
+ --vp-c-caution-soft: var(--vp-c-danger-soft);
+ --vp-custom-block-caution-text: var(--vp-custom-block-danger-text);
+ --vp-custom-block-caution-bg: var(--vp-custom-block-danger-bg);
+ --vp-custom-block-caution-code-bg: var(--vp-custom-block-danger-code-bg);
+
+ /* `::: details` stays neutral (VoidZero aliases it to info → blue). */
+ --vp-custom-block-details-text: var(--vp-c-text-1);
+ --vp-custom-block-details-bg: var(--vp-c-default-soft);
+ --vp-custom-block-details-code-bg: var(--vp-c-default-soft);
+
+ /* --- Header (components/PlaneHeader.vue) --- */
+ --plane-header-padding-y: 1.125rem;
+ --plane-header-padding-x: 1.375rem;
+ --plane-header-logo-height: 1.6875rem;
+ --plane-header-bg: #ffffff;
+ --plane-header-border: var(--docs-divider);
+ --plane-header-text: var(--color-primary);
+ --plane-header-muted: var(--vp-c-text-2);
+ --plane-header-divider: var(--docs-divider);
+ --plane-header-search-bg: #ffffff;
+ --plane-header-search-border: #e7e7e7;
+ --plane-header-search-text: #867e8e;
+ --plane-header-search-keys-border: #e5e4e7;
+}
+
+.dark:not([data-theme]),
+[data-theme="dark"] {
+ --docs-divider: #2a2a2a;
+ --color-nickel: var(--docs-divider); /* VoidZero dark:border-nickel / .content-wrapper */
+
+ --color-brand: #2893cc;
+ --vp-c-brand-1: #2893cc;
+ --vp-c-brand-2: #3aa5d4;
+ --vp-c-brand-3: #2893cc;
+ --vp-c-brand-soft: rgba(40, 147, 204, 0.14);
+ --plane-brand-hover: #3aa5d4;
+ --plane-link-hover: #3aa5d4;
+
+ --color-primary: #141415;
+ --vp-c-text-1: rgba(255, 255, 255, 0.9);
+ --vp-c-text-2: #9ca3af;
+ --vp-c-text-3: #6b7280;
+ --vp-c-bg: #141415;
+ --vp-c-bg-alt: #141618;
+ --vp-c-bg-soft: #1f2122;
+ --vp-c-bg-mute: #252829;
+ --vp-c-bg-elv: #141618;
+ --vp-c-divider: #2a2a2a;
+ --vp-c-border: #2a2a2a;
+ --vp-c-gutter: #2a2a2a;
+
+ --vp-code-block-bg: #0f0f0f;
+
+ --vp-button-brand-border: #2893cc;
+ --vp-button-brand-bg: #2893cc;
+ --vp-button-brand-text: #ffffff;
+ --vp-button-brand-hover-border: #3aa5d4;
+ --vp-button-brand-hover-bg: #3aa5d4;
+ --vp-button-brand-hover-text: #ffffff;
+ --vp-button-alt-border: #3f4244;
+ --vp-button-alt-text: #ffffff;
+ --vp-button-alt-bg: transparent;
+ --vp-button-alt-hover-border: #4a4e52;
+ --vp-button-alt-hover-text: #ffffff;
+ --vp-button-alt-hover-bg: #252829;
+ --vp-button-alt-active-bg: #2f3236;
+
+ --vp-c-tip-1: #4ade80;
+ --vp-c-tip-2: #86efac;
+ --vp-c-tip-3: #4ade80;
+ --vp-c-tip-soft: rgba(34, 197, 94, 0.15);
+ --vp-custom-block-tip-text: #4ade80;
+ --vp-custom-block-tip-bg: rgba(34, 197, 94, 0.15);
+ --vp-custom-block-tip-code-bg: rgba(34, 197, 94, 0.15);
+
+ --vp-custom-block-info-text: #60a5fa;
+ --vp-custom-block-info-bg: rgba(59, 130, 246, 0.15);
+ --vp-custom-block-info-code-bg: rgba(59, 130, 246, 0.15);
+
+ --vp-c-warning-1: #fbbf24;
+ --vp-c-warning-2: #fcd34d;
+ --vp-c-warning-3: #fbbf24;
+ --vp-c-warning-soft: rgba(251, 191, 36, 0.15);
+ --vp-custom-block-warning-text: #fbbf24;
+ --vp-custom-block-warning-bg: rgba(251, 191, 36, 0.15);
+ --vp-custom-block-warning-code-bg: rgba(251, 191, 36, 0.15);
+
+ --vp-c-danger-1: #f87171;
+ --vp-c-danger-2: #fca5a5;
+ --vp-c-danger-3: #f87171;
+ --vp-c-danger-soft: rgba(239, 68, 68, 0.15);
+ --vp-custom-block-danger-text: #f87171;
+ --vp-custom-block-danger-bg: rgba(239, 68, 68, 0.15);
+ --vp-custom-block-danger-code-bg: rgba(239, 68, 68, 0.15);
+
+ --plane-header-bg: var(--color-primary);
+ --plane-header-text: #ffffff;
+ --plane-header-search-bg: #111111;
+ --plane-header-search-border: #323232;
+ --plane-header-search-text: #867e8e;
+ --plane-header-search-keys-border: #323232;
+}
diff --git a/docs/.vitepress/theme/plane/index.ts b/docs/.vitepress/theme/plane/index.ts
new file mode 100644
index 00000000..ad4c2c9a
--- /dev/null
+++ b/docs/.vitepress/theme/plane/index.ts
@@ -0,0 +1,203 @@
+/**
+ * Plane docs theme — shared between makeplane/docs and makeplane/developer-docs.
+ *
+ * `docs/.vitepress/theme/plane/` is byte-identical in both repos; each site's
+ * `theme/index.ts` is a thin `createPlaneTheme({...})` call. Edit here in one repo,
+ * copy to the other, and run `pnpm check:theme-sync`.
+ */
+import type { Theme } from "vitepress";
+import { useData, useRoute } from "vitepress";
+import { h, nextTick, onMounted, onUnmounted, watch } from "vue";
+import { enhanceAppWithTabs } from "vitepress-plugin-tabs/client";
+import mediumZoom from "medium-zoom";
+import { themeContextKey } from "@voidzero-dev/vitepress-theme";
+import VPBadge from "@vp-default/VPBadge.vue";
+
+import "./css/index.css";
+
+import PlaneLayout from "./layout/Layout.vue";
+import Card from "./components/Card.vue";
+import CardGroup from "./components/CardGroup.vue";
+import Tags from "./components/Tags.vue";
+import CookieConsent from "./components/CookieConsent.vue";
+import { planeOptionsKey, type PlaneThemeOptions } from "./options";
+
+export type { PlaneThemeOptions } from "./options";
+export { planeOptionsKey } from "./options";
+export { Card, CardGroup, Tags, CookieConsent, PlaneLayout };
+
+/* ---------------------------------------------------------------------------
+ * Client-side helpers
+ * ------------------------------------------------------------------------- */
+
+/** Mirror `html.dark` onto the header shell (`data-theme`), which is SSR-rendered light. */
+function syncHeaderTheme() {
+ if (typeof document === "undefined") return;
+ const isDark = document.documentElement.classList.contains("dark");
+ document.querySelectorAll("header.plane-header, header.wrapper").forEach((header) => {
+ if (isDark) header.setAttribute("data-theme", "dark");
+ else header.removeAttribute("data-theme");
+ });
+}
+
+/** Deep links to tabs (`vitepress-plugin-tabs`): `#label` activates the matching tab. */
+function handleTabHash() {
+ if (typeof document === "undefined") return;
+ const hash = window.location.hash.slice(1);
+ if (!hash) return;
+ document.querySelectorAll('[role="tab"]').forEach((button) => {
+ const labelText = button.textContent?.trim().toLowerCase().replace(/\s+/g, "-");
+ if (labelText === hash) {
+ button.click();
+ button.focus();
+ }
+ });
+}
+
+function handleHashChange() {
+ nextTick(handleTabHash);
+}
+
+function updateHashOnTabClick(event: Event) {
+ const button = event.currentTarget as HTMLElement;
+ const labelText = button.textContent?.trim().toLowerCase().replace(/\s+/g, "-");
+ if (labelText) history.replaceState(null, "", `#${labelText}`);
+}
+
+function setupTabHashUpdates() {
+ if (typeof document === "undefined") return;
+ document.querySelectorAll('[role="tab"]').forEach((button) => {
+ button.removeEventListener("click", updateHashOnTabClick);
+ button.addEventListener("click", updateHashOnTabClick);
+ });
+}
+
+/* ---------------------------------------------------------------------------
+ * Theme factory
+ * ------------------------------------------------------------------------- */
+
+export function createPlaneTheme(options: PlaneThemeOptions): Theme {
+ const cookieConsent = options.cookieConsent ?? true;
+
+ return {
+ Layout() {
+ return h(PlaneLayout, null, cookieConsent ? { "layout-bottom": () => h(CookieConsent) } : {});
+ },
+
+ enhanceApp(ctx) {
+ const { app, router, siteData } = ctx;
+
+ // What VoidZeroTheme.enhanceApp does, minus its "default to dark when the visitor
+ // has no stored preference" behaviour — both sites follow the system setting.
+ const variant = siteData.value.themeConfig?.variant || "voidzero";
+ if (typeof document !== "undefined") {
+ document.documentElement.setAttribute("data-variant", variant);
+ watch(
+ () => router.route.data.frontmatter?.theme,
+ (theme) => {
+ if (theme) document.documentElement.setAttribute("data-theme", theme);
+ else document.documentElement.removeAttribute("data-theme");
+ },
+ { immediate: true },
+ );
+ }
+ app.component("Badge", VPBadge);
+
+ app.provide(themeContextKey, {
+ /* VoidZero naming: logoDark = dark mark (light bg), logoLight = light mark (dark bg) */
+ logoDark: options.brand.logoOnLight,
+ logoLight: options.brand.logoOnDark,
+ logoAlt: options.brand.logoAlt ?? "Plane",
+ footerBg: options.brand.footerBg,
+ monoIcon: options.brand.monoIcon,
+ });
+ app.provide(planeOptionsKey, options);
+
+ enhanceAppWithTabs(app);
+
+ app.component("Card", Card);
+ app.component("CardGroup", CardGroup);
+ app.component("Tags", Tags);
+ for (const [name, component] of Object.entries(options.components ?? {})) {
+ app.component(name, component);
+ }
+
+ options.enhanceApp?.(ctx);
+ },
+
+ setup() {
+ if (typeof window === "undefined") return;
+
+ const route = useRoute();
+ const { isDark } = useData();
+ let zoom: ReturnType | null = null;
+ let htmlClassObserver: MutationObserver | null = null;
+
+ // Keep `html.dark` in lock-step with VitePress' `isDark` (VitePress' inline
+ // check-dark-mode script only ever *adds* the class).
+ watch(
+ isDark,
+ (dark) => {
+ document.documentElement.classList.toggle("dark", dark);
+ syncHeaderTheme();
+ },
+ { immediate: true },
+ );
+
+ const initZoom = () => {
+ zoom?.detach();
+ zoom = mediumZoom(".vp-doc :not(a) > img:not(.VPImage)", {
+ background: "rgba(0, 0, 0, 0.8)",
+ });
+ };
+
+ const scheduleHeaderSync = () => {
+ nextTick(() => {
+ syncHeaderTheme();
+ requestAnimationFrame(syncHeaderTheme);
+ });
+ };
+
+ onMounted(() => {
+ nextTick(() => {
+ initZoom();
+ scheduleHeaderSync();
+ });
+
+ setTimeout(() => {
+ handleTabHash();
+ setupTabHashUpdates();
+ syncHeaderTheme();
+ }, 100);
+
+ window.addEventListener("hashchange", handleHashChange);
+
+ htmlClassObserver = new MutationObserver(syncHeaderTheme);
+ htmlClassObserver.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+ });
+
+ onUnmounted(() => {
+ window.removeEventListener("hashchange", handleHashChange);
+ htmlClassObserver?.disconnect();
+ zoom?.detach();
+ });
+
+ watch(
+ () => route.path,
+ () => {
+ nextTick(() => {
+ initZoom();
+ handleTabHash();
+ setupTabHashUpdates();
+ scheduleHeaderSync();
+ });
+ },
+ );
+
+ options.setup?.();
+ },
+ };
+}
diff --git a/docs/.vitepress/theme/plane/layout/Layout.vue b/docs/.vitepress/theme/plane/layout/Layout.vue
new file mode 100644
index 00000000..d23a5cae
--- /dev/null
+++ b/docs/.vitepress/theme/plane/layout/Layout.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/plane/layout/default-layout.ts b/docs/.vitepress/theme/plane/layout/default-layout.ts
new file mode 100644
index 00000000..41b63b86
--- /dev/null
+++ b/docs/.vitepress/theme/plane/layout/default-layout.ts
@@ -0,0 +1,10 @@
+import { defineComponent, h } from "vue";
+import Layout from "./doc-layout.vue";
+import { slotsToChildren } from "./slots";
+
+export default defineComponent({
+ name: "PlaneVoidzeroDefaultLayout",
+ setup(_, { slots }) {
+ return () => h(Layout, null, slotsToChildren(slots));
+ },
+});
diff --git a/docs/.vitepress/theme/plane/layout/doc-layout.vue b/docs/.vitepress/theme/plane/layout/doc-layout.vue
new file mode 100644
index 00000000..2ff8c45b
--- /dev/null
+++ b/docs/.vitepress/theme/plane/layout/doc-layout.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/plane/layout/header.ts b/docs/.vitepress/theme/plane/layout/header.ts
new file mode 100644
index 00000000..600b3d49
--- /dev/null
+++ b/docs/.vitepress/theme/plane/layout/header.ts
@@ -0,0 +1,10 @@
+import { defineComponent, h } from "vue";
+import Header from "../components/PlaneHeader.vue";
+import { slotsToChildren } from "./slots";
+
+export default defineComponent({
+ name: "PlaneVoidzeroHeader",
+ setup(_, { slots }) {
+ return () => h(Header, null, slotsToChildren(slots));
+ },
+});
diff --git a/docs/.vitepress/theme/plane/layout/slots.ts b/docs/.vitepress/theme/plane/layout/slots.ts
new file mode 100644
index 00000000..eb223fb9
--- /dev/null
+++ b/docs/.vitepress/theme/plane/layout/slots.ts
@@ -0,0 +1,13 @@
+import type { Slots, VNode } from "vue";
+
+/** Normalize Vue 3 slots for use with `h(Component, props, children)` */
+export function slotsToChildren(slots: Slots): Record VNode[]> {
+ const children: Record VNode[]> = {};
+ for (const name of Object.keys(slots)) {
+ const slot = slots[name];
+ if (slot) {
+ children[name] = () => slot() as VNode[];
+ }
+ }
+ return children;
+}
diff --git a/docs/.vitepress/theme/plane/layout/top-banner.ts b/docs/.vitepress/theme/plane/layout/top-banner.ts
new file mode 100644
index 00000000..02492af3
--- /dev/null
+++ b/docs/.vitepress/theme/plane/layout/top-banner.ts
@@ -0,0 +1,9 @@
+import { defineComponent, h } from "vue";
+import Banner from "@voidzero-dev/vitepress-theme/src/components/oss/TopBanner.vue";
+
+export default defineComponent({
+ name: "PlaneVoidzeroTopBanner",
+ setup() {
+ return () => h(Banner);
+ },
+});
diff --git a/docs/.vitepress/theme/plane/manifest.json b/docs/.vitepress/theme/plane/manifest.json
new file mode 100644
index 00000000..9af2f540
--- /dev/null
+++ b/docs/.vitepress/theme/plane/manifest.json
@@ -0,0 +1,36 @@
+{
+ "root": "docs/.vitepress/theme/plane",
+ "files": [
+ "manifest.json",
+ "README.md",
+ "components/Card.vue",
+ "components/CardGroup.vue",
+ "components/CookieConsent.vue",
+ "components/CopyPageMenu.vue",
+ "components/PlaneHeader.vue",
+ "components/Tags.vue",
+ "components/card-brand-icons.ts",
+ "components/copy-page-icons.ts",
+ "css/api.css",
+ "css/base.css",
+ "css/components.css",
+ "css/fonts.css",
+ "css/index.css",
+ "css/layout.css",
+ "css/tokens.css",
+ "index.ts",
+ "layout/Layout.vue",
+ "layout/default-layout.ts",
+ "layout/doc-layout.vue",
+ "layout/header.ts",
+ "layout/slots.ts",
+ "layout/top-banner.ts",
+ "options.ts",
+ "scripts/check-theme-sync.mjs",
+ "scripts/check-vue-types.mjs",
+ "types/shims.d.ts",
+ "types/vitepress-augment.d.ts",
+ "types/voidzero-theme.ts",
+ "types/vp-theme-modules.d.ts"
+ ]
+}
diff --git a/docs/.vitepress/theme/plane/options.ts b/docs/.vitepress/theme/plane/options.ts
new file mode 100644
index 00000000..e33b624a
--- /dev/null
+++ b/docs/.vitepress/theme/plane/options.ts
@@ -0,0 +1,30 @@
+import type { Component, InjectionKey } from "vue";
+import type { EnhanceAppContext } from "vitepress";
+
+/** Per-site branding + hooks for the shared Plane docs theme. */
+export interface PlaneThemeOptions {
+ brand: {
+ /** Logo shown on light backgrounds (dark mark). */
+ logoOnLight: string;
+ /** Logo shown on dark backgrounds (light mark). */
+ logoOnDark: string;
+ /** Alt text for the logo. Default: "Plane". */
+ logoAlt?: string;
+ /** Wordmark next to the logo in the mobile menu, e.g. "Plane Docs". */
+ menuTitle: string;
+ /** Footer background image (consumed by the VoidZero footer). */
+ footerBg: string;
+ /** Monochrome icon (consumed by the VoidZero top banner). */
+ monoIcon: string;
+ };
+ /** Render the cookie-consent banner. Default: true. */
+ cookieConsent?: boolean;
+ /** Extra globally-registered components (site-specific markdown components). */
+ components?: Record;
+ /** Extra `enhanceApp` work, run after the shared setup. */
+ enhanceApp?: (ctx: EnhanceAppContext) => void;
+ /** Extra client-side `setup()` work, run inside the shared theme's `setup()`. */
+ setup?: () => void;
+}
+
+export const planeOptionsKey: InjectionKey = Symbol.for("plane-theme-options");
diff --git a/docs/.vitepress/theme/plane/scripts/check-theme-sync.mjs b/docs/.vitepress/theme/plane/scripts/check-theme-sync.mjs
new file mode 100644
index 00000000..a3b33133
--- /dev/null
+++ b/docs/.vitepress/theme/plane/scripts/check-theme-sync.mjs
@@ -0,0 +1,158 @@
+#!/usr/bin/env node
+/**
+ * check-theme-sync — verify that docs/.vitepress/theme/plane/ is byte-identical to the
+ * sibling repo's copy (makeplane/docs ⇄ makeplane/developer-docs).
+ *
+ * node docs/.vitepress/theme/plane/scripts/check-theme-sync.mjs --sibling makeplane/developer-docs
+ *
+ * Sibling source, in order of preference:
+ * 1. THEME_SIBLING_PATH — local checkout root (e.g. ../developer-docs)
+ * 2. https://raw.githubusercontent.com//[/… — ref from THEME_SIBLING_REF,
+ * then GITHUB_HEAD_REF (same-named PR branch), then master.
+ *
+ * While only one repo has adopted the shared theme the sibling has no `plane/` folder yet.
+ * That is reported and skipped (exit 0) rather than failing, so unrelated PRs in the repo
+ * that merged first are not blocked; the check becomes binding as soon as both sides have it.
+ *
+ * Exit codes: 0 identical (or sibling not adopted yet) · 1 drift (or manifest out of date)
+ * · 2 sibling unreachable / bad args
+ */
+import { createHash } from "node:crypto";
+import { readdir, readFile } from "node:fs/promises";
+import { dirname, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const THEME_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const THEME_REL = "docs/.vitepress/theme/plane";
+
+const args = process.argv.slice(2);
+const siblingArg = args[args.indexOf("--sibling") + 1];
+if (!args.includes("--sibling") || !siblingArg || !siblingArg.includes("/")) {
+ console.error("usage: check-theme-sync.mjs --sibling ");
+ process.exit(2);
+}
+
+const sha = (buf) => createHash("sha256").update(buf).digest("hex");
+
+async function walk(dir, base = dir) {
+ const out = [];
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
+ if (entry.name.startsWith(".")) continue;
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) out.push(...(await walk(full, base)));
+ else out.push(relative(base, full).split("\\").join("/"));
+ }
+ return out.sort();
+}
+
+async function readLocal(file) {
+ return readFile(join(THEME_DIR, file));
+}
+
+/** Present on every ref of both repos — tells "ref exists" apart from "ref has no plane/". */
+const ROOT_PROBE = "package.json";
+
+async function makeSiblingReader() {
+ const localPath = process.env.THEME_SIBLING_PATH;
+ if (localPath) {
+ const checkout = resolve(process.cwd(), localPath);
+ const root = join(checkout, THEME_REL);
+ const exists = (file) =>
+ readFile(file).then(
+ () => true,
+ () => false,
+ );
+ if (await exists(join(root, "manifest.json"))) {
+ return {
+ status: "ok",
+ label: root,
+ read: (file) => readFile(join(root, file)).catch(() => null),
+ };
+ }
+ return (await exists(join(checkout, ROOT_PROBE)))
+ ? { status: "not-adopted", label: checkout }
+ : null;
+ }
+
+ const refs = [process.env.THEME_SIBLING_REF, process.env.GITHUB_HEAD_REF, "master"].filter(
+ Boolean,
+ );
+ let reachable = null;
+ for (const ref of refs) {
+ const root = `https://raw.githubusercontent.com/${siblingArg}/${ref}/`;
+ const base = `${root}${THEME_REL}/`;
+ const probe = await fetch(base + "manifest.json").catch(() => null);
+ if (probe?.ok) {
+ return {
+ status: "ok",
+ label: base,
+ read: async (file) => {
+ const res = await fetch(base + file).catch(() => null);
+ return res?.ok ? Buffer.from(await res.arrayBuffer()) : null;
+ },
+ };
+ }
+ if (!reachable) {
+ const rootProbe = await fetch(root + ROOT_PROBE).catch(() => null);
+ if (rootProbe?.ok) reachable = `${siblingArg}@${ref}`;
+ }
+ }
+ return reachable ? { status: "not-adopted", label: reachable } : null;
+}
+
+const manifest = JSON.parse(await readLocal("manifest.json"));
+const listed = [...manifest.files].sort();
+const onDisk = await walk(THEME_DIR);
+
+let failed = false;
+const missingFromManifest = onDisk.filter((f) => !listed.includes(f));
+const missingFromDisk = listed.filter((f) => !onDisk.includes(f));
+if (missingFromManifest.length || missingFromDisk.length) {
+ failed = true;
+ console.error("manifest.json is out of date:");
+ for (const f of missingFromManifest) console.error(` not listed: ${f}`);
+ for (const f of missingFromDisk) console.error(` not on disk: ${f}`);
+}
+
+const sibling = await makeSiblingReader();
+if (!sibling) {
+ console.error(
+ `Could not reach the sibling repo (${siblingArg}). Set THEME_SIBLING_PATH=../ for a local checkout.`,
+ );
+ process.exit(2);
+}
+
+if (sibling.status === "not-adopted") {
+ console.log(
+ `${sibling.label} has no ${THEME_REL} yet — skipping the cross-repo comparison.\n` +
+ `This is expected only until the companion PR lands; the check binds once both repos have the folder.`,
+ );
+ process.exit(failed ? 1 : 0);
+}
+
+console.log(`Comparing ${THEME_REL} against ${sibling.label}`);
+
+const siblingManifestRaw = await sibling.read("manifest.json");
+const siblingFiles = siblingManifestRaw ? JSON.parse(siblingManifestRaw.toString()).files : [];
+const all = [...new Set([...listed, ...siblingFiles])].sort();
+
+for (const file of all) {
+ const [local, remote] = await Promise.all([
+ readLocal(file).catch(() => null),
+ sibling.read(file),
+ ]);
+ let status;
+ if (!local) status = "MISSING (local)";
+ else if (!remote) status = "MISSING (sibling)";
+ else status = sha(local) === sha(remote) ? "OK" : "DIFF";
+ if (status !== "OK") failed = true;
+ console.log(`${status.padEnd(18)} ${file}`);
+}
+
+if (failed) {
+ console.error(
+ "\nTheme drift detected. Copy the folder to/from the sibling repo so both are identical.",
+ );
+ process.exit(1);
+}
+console.log("\nShared theme is in sync.");
diff --git a/docs/.vitepress/theme/plane/scripts/check-vue-types.mjs b/docs/.vitepress/theme/plane/scripts/check-vue-types.mjs
new file mode 100644
index 00000000..5f757678
--- /dev/null
+++ b/docs/.vitepress/theme/plane/scripts/check-vue-types.mjs
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+/**
+ * check-vue-types — type-check the VitePress config and theme, SFCs included.
+ *
+ * node docs/.vitepress/theme/plane/scripts/check-vue-types.mjs [tsconfig]
+ *
+ * Plain `tsc` cannot parse `.vue`, so it silently skips every component in the
+ * `include` globs — `tsc --noEmit` stays green with an outright type error in an
+ * SFC. `vue-tsc` reads them, but it also surfaces errors from the vendored
+ * `@voidzero-dev/vitepress-theme` sources our components import (unlisted optional
+ * peer deps, loose types), which we cannot fix from here and `skipLibCheck` does
+ * not cover because they are `.vue`, not `.d.ts`.
+ *
+ * So: everything is type-checked, node_modules diagnostics are summarised as a
+ * note, and only first-party files decide the exit code.
+ *
+ * Exit codes: 0 clean · 1 type errors in first-party files · 2 vue-tsc failed to run
+ */
+import { spawnSync } from "node:child_process";
+
+const project = process.argv[2] ?? "docs/.vitepress/tsconfig.json";
+
+const run = spawnSync("vue-tsc", ["--noEmit", "-p", project], {
+ encoding: "utf8",
+ shell: process.platform === "win32",
+});
+
+if (run.error) {
+ console.error(`Could not run vue-tsc: ${run.error.message}`);
+ process.exit(2);
+}
+
+const lines = `${run.stdout ?? ""}${run.stderr ?? ""}`.split("\n").filter(Boolean);
+const isDiagnostic = (line) => /\(\d+,\d+\): error TS\d+:/.test(line);
+const isVendored = (line) => line.startsWith("node_modules/") || line.includes("/node_modules/");
+
+const ours = lines.filter((line) => isDiagnostic(line) && !isVendored(line));
+const vendored = lines.filter((line) => isDiagnostic(line) && isVendored(line));
+const other = lines.filter((line) => !isDiagnostic(line));
+
+for (const line of ours) console.error(line);
+
+if (vendored.length) {
+ console.log(
+ `\nnote: ignored ${vendored.length} diagnostic(s) from node_modules ` +
+ `(vendored @voidzero-dev/vitepress-theme sources — not ours to fix).`,
+ );
+}
+
+if (ours.length) {
+ console.error(`\n${ours.length} type error(s) in first-party files.`);
+ process.exit(1);
+}
+
+// vue-tsc can fail for reasons other than type errors (bad tsconfig, crash).
+if (run.status !== 0 && !vendored.length) {
+ for (const line of other) console.error(line);
+ console.error(`\nvue-tsc exited with status ${run.status}.`);
+ process.exit(2);
+}
+
+console.log("Types are clean (config, theme and .vue components).");
diff --git a/docs/.vitepress/theme/plane/types/shims.d.ts b/docs/.vitepress/theme/plane/types/shims.d.ts
new file mode 100644
index 00000000..87efbe0e
--- /dev/null
+++ b/docs/.vitepress/theme/plane/types/shims.d.ts
@@ -0,0 +1,40 @@
+/*
+ * Ambient module shims (this file must stay a script: no top-level import/export).
+ */
+
+declare module "*.vue" {
+ import type { DefineComponent } from "vue";
+ const component: DefineComponent]