From 8356b822f944210af055db22e0092da5ba9413a8 Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Wed, 16 Sep 2026 19:01:42 +0530 Subject: [PATCH 01/32] Redesign the homepage and documentation shell Rebuild the site around the Goravel mark. The mark is treated as real geometry rather than a logo: the lattice it is cut from, its five pieces, and the path a request takes through it are derived from public/logo.svg and shared by every drawing on the site. Homepage (layout: goravel-home) The stock VitePress home layout showed an adjective headline and six emoji feature cards with no code on the page. It is replaced by seven full height sections, each carrying one idea and something the reader can work: - Hero: the same route written in Laravel PHP and in Goravel Go, so the reason a Laravel developer would switch is visible before any scroll - Follow one request: GET /tasks stop by stop, with the file and the lines that run at each stop - Laravel's structure, written in Go: eight concepts in both languages, with the matching lines lit together in each file - Five layers, thirty facades: the mark pulled apart, a piece at a time - Start with the core: Goravel Lite filling in from 4 of 30 facades - Open source: stars, contributors and the community links the page has always carried Sections advance themselves while on screen, stop the moment the reader picks a tab, and hold still under prefers-reduced-motion. Documentation shell - Three column grid with one rule per column edge: the sidebar column runs unbroken from the top of the screen, and everything to its right is capped by a single hairline under the header - Rules inside a column run from that column's rule to the edge of the screen, so no rule stops in the middle of the page - Header is one row on the page's own grid: wordmark, links, search, language and project links, with no dividers of its own - Search is a first class control, matching the outlined button, and its modal is one white sheet - Sidebar rows carry no rules; section, page and current page are told apart by weight and a 2px accent on the column edge - Outline rail leads with On this page; the method index, built from the rendered page, follows it No markdown content changes. Sidebar groups are collapsed by default and reordered so Upgrade and Prologue come last. --- .vitepress/config/en.ts | 53 +- .vitepress/config/goravel-code.ts | 142 ++ .vitepress/config/shared.ts | 7 +- .vitepress/config/uz_UZ.ts | 67 +- .vitepress/config/zh_CN.ts | 67 +- .vitepress/theme/Layout.vue | 43 + .vitepress/theme/components/DocMeta.vue | 85 + .vitepress/theme/components/MethodIndex.vue | 84 + .vitepress/theme/components/NotFound.vue | 28 + .vitepress/theme/components/VersionSelect.vue | 29 + .vitepress/theme/goravel.css | 1410 ++++++++++++++ .vitepress/theme/home/CommunitySection.vue | 113 ++ .vitepress/theme/home/GoravelHome.vue | 315 ++++ .vitepress/theme/home/GoravelMark.vue | 134 ++ .vitepress/theme/home/HomeFooter.vue | 53 + .vitepress/theme/home/Stage.vue | 167 ++ .vitepress/theme/home/content.ts | 210 +++ .vitepress/theme/home/geometry.ts | 271 +++ .vitepress/theme/home/home.css | 1397 ++++++++++++++ .vitepress/theme/home/useCycle.ts | 116 ++ .vitepress/theme/index.ts | 8 + .vitepress/theme/shell.css | 1653 +++++++++++++++++ en/index.md | 30 +- 23 files changed, 6373 insertions(+), 109 deletions(-) create mode 100644 .vitepress/config/goravel-code.ts create mode 100644 .vitepress/theme/Layout.vue create mode 100644 .vitepress/theme/components/DocMeta.vue create mode 100644 .vitepress/theme/components/MethodIndex.vue create mode 100644 .vitepress/theme/components/NotFound.vue create mode 100644 .vitepress/theme/components/VersionSelect.vue create mode 100644 .vitepress/theme/goravel.css create mode 100644 .vitepress/theme/home/CommunitySection.vue create mode 100644 .vitepress/theme/home/GoravelHome.vue create mode 100644 .vitepress/theme/home/GoravelMark.vue create mode 100644 .vitepress/theme/home/HomeFooter.vue create mode 100644 .vitepress/theme/home/Stage.vue create mode 100644 .vitepress/theme/home/content.ts create mode 100644 .vitepress/theme/home/geometry.ts create mode 100644 .vitepress/theme/home/home.css create mode 100644 .vitepress/theme/home/useCycle.ts create mode 100644 .vitepress/theme/shell.css diff --git a/.vitepress/config/en.ts b/.vitepress/config/en.ts index 9a23cef46..ff0423070 100644 --- a/.vitepress/config/en.ts +++ b/.vitepress/config/en.ts @@ -7,60 +7,61 @@ export const config = defineConfig({ themeConfig: { nav: nav(), sidebar: [ - { - text: 'Prologue', - base: '/prologue/', - items: sidebarPrologue() - }, - { - text: 'Upgrade', - base: '/upgrade/', - items: sidebarUpgrade() - }, { text: 'Getting Started', base: '/getting-started/', + collapsed: true, items: sidebarGettingStarted() - }, - { + }, { text: 'Architecture Concepts', base: '/architecture-concepts/', + collapsed: true, items: sidebarFoundation() - }, - { + }, { text: 'The Basics', base: '/the-basics/', + collapsed: true, items: sidebarBasic() - }, - { + }, { text: 'Digging Deeper', base: '/digging-deeper/', + collapsed: true, items: sidebarAdvanced() - }, - { + }, { text: 'AI', base: '/ai/', + collapsed: true, items: sidebarAI() - }, - { + }, { text: 'Security', base: '/security/', + collapsed: true, items: sidebarSecurity() - }, - { + }, { text: 'Database', base: '/database/', + collapsed: true, items: sidebarDatabase() - }, - { + }, { text: 'ORM', base: '/orm/', + collapsed: true, items: sidebarOrm() - }, - { + }, { text: 'Testing', base: '/testing/', + collapsed: true, items: sidebarTesting() + }, { + text: 'Upgrade', + base: '/upgrade/', + collapsed: true, + items: sidebarUpgrade() + }, { + text: 'Prologue', + base: '/prologue/', + collapsed: true, + items: sidebarPrologue() } ], diff --git a/.vitepress/config/goravel-code.ts b/.vitepress/config/goravel-code.ts new file mode 100644 index 000000000..8f24745f2 --- /dev/null +++ b/.vitepress/config/goravel-code.ts @@ -0,0 +1,142 @@ +import type { ThemeRegistration } from 'shiki' + +/* + * Goravel syntax themes, drawn only from the logo's blues and the neutral + * greys: keywords in Goravel blue, strings in the logo's light (dark theme) or + * deep (light theme) face, and framework calls in bold ink so APIs stand out. + */ + +interface Palette { + fg: string + bg: string + keyword: string + type: string + string: string + comment: string + call: string + punctuation: string + variable: string + inserted: string + deleted: string +} + +function theme(name: string, type: 'light' | 'dark', p: Palette): ThemeRegistration { + return { + name, + type, + colors: { + 'editor.background': p.bg, + 'editor.foreground': p.fg + }, + tokenColors: [ + { settings: { foreground: p.fg } }, + { + scope: ['comment', 'punctuation.definition.comment', 'string.comment'], + settings: { foreground: p.comment, fontStyle: 'italic' } + }, + { + scope: [ + 'keyword', + 'storage', + 'storage.type', + 'storage.modifier', + 'keyword.control', + 'keyword.function', + 'keyword.package', + 'keyword.import', + 'keyword.type', + 'keyword.struct', + 'keyword.interface', + 'keyword.map', + 'keyword.var', + 'keyword.const', + 'constant.language', + 'variable.language' + ], + settings: { foreground: p.keyword } + }, + { + scope: [ + 'entity.name.type', + 'support.type', + 'support.class', + 'entity.name.class', + 'storage.type.numeric.go', + 'storage.type.string.go', + 'storage.type.boolean.go', + 'storage.type.byte.go', + 'storage.type.error.go', + 'entity.name.package', + 'entity.name.import.go' + ], + settings: { foreground: p.type } + }, + { + scope: ['string', 'string.quoted', 'constant.numeric', 'constant.character', 'string.template'], + settings: { foreground: p.string } + }, + { + scope: [ + 'entity.name.function', + 'support.function', + 'meta.function-call entity.name.function', + 'support.function.builtin', + 'entity.name.command' + ], + settings: { foreground: p.call, fontStyle: 'bold' } + }, + { + scope: ['punctuation', 'keyword.operator', 'meta.brace', 'punctuation.separator', 'punctuation.terminator'], + settings: { foreground: p.punctuation } + }, + { + scope: ['variable', 'variable.other', 'variable.parameter', 'meta.definition.variable'], + settings: { foreground: p.variable } + }, + { + scope: ['variable.other.php', 'punctuation.definition.variable.php'], + settings: { foreground: p.type } + }, + { + scope: ['markup.inserted', 'meta.diff.header.to-file'], + settings: { foreground: p.inserted } + }, + { + scope: ['markup.deleted', 'meta.diff.header.from-file'], + settings: { foreground: p.deleted } + }, + { + scope: ['string.unquoted.argument.shell', 'constant.other.option'], + settings: { foreground: p.fg } + } + ] + } +} + +export const goravelLight = theme('goravel-light', 'light', { + fg: '#101820', + bg: '#f7f9fa', + keyword: '#101820', + type: '#101820', + string: '#68747d', + comment: '#68747d', + call: '#0077b3', + punctuation: '#68747d', + variable: '#101820', + inserted: '#0077b3', + deleted: '#68747d' +}) + +export const goravelDark = theme('goravel-dark', 'dark', { + fg: '#d5dee5', + bg: '#12191f', + keyword: '#009fe8', + type: '#d5dee5', + string: '#86d3fd', + comment: '#6f7f8c', + call: '#ffffff', + punctuation: '#8c9ba7', + variable: '#d5dee5', + inserted: '#4cc38a', + deleted: '#f0766e' +}) diff --git a/.vitepress/config/shared.ts b/.vitepress/config/shared.ts index 8a25556cd..62aabed8b 100644 --- a/.vitepress/config/shared.ts +++ b/.vitepress/config/shared.ts @@ -3,6 +3,7 @@ import { createFileSystemTypesCache } from '@shikijs/vitepress-twoslash/cache-fs import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vitepress' import timeline from 'vitepress-markdown-timeline' +import { goravelDark, goravelLight } from './goravel-code' import { groupIconMdPlugin, groupIconVitePlugin @@ -37,6 +38,8 @@ export const shared = defineConfig({ ], markdown: { + theme: { light: goravelLight, dark: goravelDark }, + lineNumbers: true, codeTransformers: [ transformerTwoslash({ typesCache: createFileSystemTypesCache() @@ -65,10 +68,10 @@ export const shared = defineConfig({ indexName: 'goravel', locales: { root: { - placeholder: 'Search Documentation', + placeholder: 'Search documentation', translations: { button: { - buttonText: 'Search' + buttonText: 'Search documentation' } } }, diff --git a/.vitepress/config/uz_UZ.ts b/.vitepress/config/uz_UZ.ts index 3ee325456..0201370ae 100644 --- a/.vitepress/config/uz_UZ.ts +++ b/.vitepress/config/uz_UZ.ts @@ -4,51 +4,64 @@ export const config = defineConfig({ description: "Goravel — bu to‘liq funksiyalarga ega va yuqori darajada kengaytiriladigan web-ilovalar frameworki hisoblanadi. U Go dasturchilari (gopherlar) uchun boshlang‘ich platforma (scaffolding) sifatida xizmat qiladi va ularga o‘z ilovalarini tez, tartibli va professional tarzda yaratishga yordam beradi.", themeConfig: { nav: nav(), - sidebar: [{ - text: "Kirish", - base: "/uz_UZ/prologue/", - items: sidebarPrologue() - }, { - text: "Yangilash", - base: "/uz_UZ/upgrade/", - items: sidebarUpgrade() - }, { + sidebar: [ + { text: "Boshlash", base: "/uz_UZ/getting-started/", - items: sidebarGettingStarted() - }, { + collapsed: true, + items: sidebarGettingStarted() + }, { text: "Arxitektura tushunchalari", base: "/uz_UZ/architecture-concepts/", - items: sidebarFoundation() - }, { + collapsed: true, + items: sidebarFoundation() + }, { text: "Asosiy tushunchalar", base: "/uz_UZ/the-basics/", - items: sidebarBasic() - }, { + collapsed: true, + items: sidebarBasic() + }, { text: "Chuqurroq o'rganish", base: "/uz_UZ/digging-deeper/", - items: sidebarAdvanced() - }, { + collapsed: true, + items: sidebarAdvanced() + }, { text: "AI", base: "/uz_UZ/ai/", - items: sidebarAI() - }, { + collapsed: true, + items: sidebarAI() + }, { text: "Xavfsizlik", base: "/uz_UZ/security/", - items: sidebarSecurity() - }, { + collapsed: true, + items: sidebarSecurity() + }, { text: "Ma'lumotlar bazasi", base: "/uz_UZ/database/", - items: sidebarDatabase() - }, { + collapsed: true, + items: sidebarDatabase() + }, { text: 'ORM', base: "/uz_UZ/orm/", - items: sidebarOrm() - }, { + collapsed: true, + items: sidebarOrm() + }, { text: "Testlash", base: "/uz_UZ/testing/", - items: sidebarTesting() - }], + collapsed: true, + items: sidebarTesting() + }, { + text: "Yangilash", + base: "/uz_UZ/upgrade/", + collapsed: true, + items: sidebarUpgrade() + }, { + text: "Kirish", + base: "/uz_UZ/prologue/", + collapsed: true, + items: sidebarPrologue() + } + ], editLink: { pattern: 'https://github.com/goravel/docs/edit/master/:path', text: "Ushbu sahifani GitHub-da tahrirlash" diff --git a/.vitepress/config/zh_CN.ts b/.vitepress/config/zh_CN.ts index dc70bd581..084897d37 100644 --- a/.vitepress/config/zh_CN.ts +++ b/.vitepress/config/zh_CN.ts @@ -4,51 +4,64 @@ export const config = defineConfig({ description: "Goravel 是一个功能完整且可扩展性极强的 Web 应用程序框架。作为一个起始脚手架,帮助 Gopher 快速构建自己的应用程序。", themeConfig: { nav: nav(), - sidebar: [{ - text: "序言", - base: "/zh_CN/prologue/", - items: sidebarPrologue() - }, { - text: "升级", - base: "/zh_CN/upgrade/", - items: sidebarUpgrade() - }, { + sidebar: [ + { text: "快速开始", base: "/zh_CN/getting-started/", - items: sidebarGettingStarted() - }, { + collapsed: true, + items: sidebarGettingStarted() + }, { text: "核心架构", base: "/zh_CN/architecture-concepts/", - items: sidebarFoundation() - }, { + collapsed: true, + items: sidebarFoundation() + }, { text: "基础功能", base: "/zh_CN/the-basics/", - items: sidebarBasic() - }, { + collapsed: true, + items: sidebarBasic() + }, { text: "高级功能", base: "/zh_CN/digging-deeper/", - items: sidebarAdvanced() - }, { + collapsed: true, + items: sidebarAdvanced() + }, { text: "安全", base: "/zh_CN/security/", - items: sidebarSecurity() - }, { + collapsed: true, + items: sidebarSecurity() + }, { text: 'AI', base: '/zh_CN/ai/', - items: sidebarAI() - }, { + collapsed: true, + items: sidebarAI() + }, { text: "数据库", base: "/zh_CN/database/", - items: sidebarDatabase() - }, { + collapsed: true, + items: sidebarDatabase() + }, { text: 'ORM', base: "/zh_CN/orm/", - items: sidebarOrm() - }, { + collapsed: true, + items: sidebarOrm() + }, { text: "测试", base: "/zh_CN/testing/", - items: sidebarTesting() - }], + collapsed: true, + items: sidebarTesting() + }, { + text: "升级", + base: "/zh_CN/upgrade/", + collapsed: true, + items: sidebarUpgrade() + }, { + text: "序言", + base: "/zh_CN/prologue/", + collapsed: true, + items: sidebarPrologue() + } + ], editLink: { pattern: 'https://github.com/goravel/docs/edit/master/:path', text: "在 GitHub 上编辑此页面" diff --git a/.vitepress/theme/Layout.vue b/.vitepress/theme/Layout.vue new file mode 100644 index 000000000..f93c6d935 --- /dev/null +++ b/.vitepress/theme/Layout.vue @@ -0,0 +1,43 @@ + + + diff --git a/.vitepress/theme/components/DocMeta.vue b/.vitepress/theme/components/DocMeta.vue new file mode 100644 index 000000000..e3823d4a9 --- /dev/null +++ b/.vitepress/theme/components/DocMeta.vue @@ -0,0 +1,85 @@ + + + diff --git a/.vitepress/theme/components/MethodIndex.vue b/.vitepress/theme/components/MethodIndex.vue new file mode 100644 index 000000000..699cb1241 --- /dev/null +++ b/.vitepress/theme/components/MethodIndex.vue @@ -0,0 +1,84 @@ + + + diff --git a/.vitepress/theme/components/NotFound.vue b/.vitepress/theme/components/NotFound.vue new file mode 100644 index 000000000..f4affd0a1 --- /dev/null +++ b/.vitepress/theme/components/NotFound.vue @@ -0,0 +1,28 @@ + + + diff --git a/.vitepress/theme/components/VersionSelect.vue b/.vitepress/theme/components/VersionSelect.vue new file mode 100644 index 000000000..10b976b84 --- /dev/null +++ b/.vitepress/theme/components/VersionSelect.vue @@ -0,0 +1,29 @@ + + + diff --git a/.vitepress/theme/goravel.css b/.vitepress/theme/goravel.css new file mode 100644 index 000000000..b1505e1ff --- /dev/null +++ b/.vitepress/theme/goravel.css @@ -0,0 +1,1410 @@ +/** + * Goravel visual language, stage 1: the docs, restyled. + * + * This file only changes how the default VitePress theme looks. No markdown changes, no component + * overrides, no theme fork. Everything below targets DOM the default theme already renders. + * + * Rules of the system: + * - four colours: white, ink, grey, Goravel cyan, plus tints of those four + * - two faces: IBM Plex Sans for text, IBM Plex Mono for labels, paths and code + * - every rule ends on another line: it runs to a vertical rule or to the edge of the screen + * - no cards: borders separate rows, they never draw a box around content + */ + +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap'); + +/* ------------------------------------------------------------------ tokens */ +:root { + --g-white: #ffffff; + --g-soft: #f7f9fa; + --g-ink: #101820; + --g-grey: #68747d; + --g-line: #e5eaed; + --g-cyan: #009fe8; + --g-cyan-text: #0077b3; + --g-cyan-tint: #ebf7fd; + --g-construct: rgba(104, 116, 125, 0.4); + + --g-gutter: 64px; /* from the sidebar rule to the first character */ + --g-measure: 704px; /* about 70 characters at 16px */ + --g-rail: 32px; /* from the column rule to the outline */ + --g-sidebar: 272px; /* the sidebar rule */ + --g-aside: 368px; /* outline 240 + rail 32 + margin 96 */ + --g-col: calc(var(--g-measure) + var(--g-gutter) + var(--g-rail)); /* sidebar rule to column rule */ + + /* type */ + --vp-font-family-base: 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --vp-font-family-mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + + /* colour */ + --vp-c-brand-1: var(--g-cyan-text); + --vp-c-brand-2: var(--g-ink); + --vp-c-brand-3: var(--g-cyan); + --vp-c-brand-soft: var(--g-cyan-tint); + --vp-c-text-1: var(--g-ink); + --vp-c-text-2: var(--g-grey); + --vp-c-text-3: var(--g-grey); + --vp-c-divider: var(--g-line); + --vp-c-gutter: var(--g-line); + --vp-c-border: var(--g-line); + --vp-c-bg: var(--g-white); + --vp-c-bg-alt: var(--g-white); + --vp-c-bg-soft: var(--g-soft); + --vp-c-bg-elv: var(--g-white); + + /* buttons */ + --vp-button-brand-bg: var(--g-cyan); + --vp-button-brand-text: var(--g-ink); + --vp-button-brand-border: var(--g-cyan); + --vp-button-brand-hover-bg: var(--g-cyan-text); + --vp-button-brand-hover-text: var(--g-white); + --vp-button-brand-active-bg: var(--g-cyan-text); + + /* code */ + --vp-code-block-bg: var(--g-soft); + --vp-code-line-highlight-color: var(--g-cyan-tint); + --vp-code-line-number-color: var(--g-grey); + --vp-code-copy-code-bg: var(--g-soft); + --vp-code-copy-code-border-color: var(--g-line); + --vp-code-copy-code-hover-bg: var(--g-white); + --vp-code-tab-divider: var(--g-line); + --vp-code-tab-text-color: var(--g-grey); + --vp-code-tab-active-text-color: var(--g-ink); + --vp-code-tab-active-bar-color: var(--g-cyan); + + /* custom blocks */ + --vp-custom-block-font-size: 16px; + --vp-custom-block-tip-border: var(--g-cyan); + --vp-custom-block-tip-text: var(--g-ink); + --vp-custom-block-tip-bg: transparent; + --vp-custom-block-warning-border: var(--g-ink); + --vp-custom-block-warning-text: var(--g-ink); + --vp-custom-block-warning-bg: transparent; + --vp-custom-block-info-border: var(--g-line); + --vp-custom-block-info-text: var(--g-ink); + --vp-custom-block-info-bg: transparent; + --vp-custom-block-danger-border: var(--g-ink); + --vp-custom-block-danger-text: var(--g-ink); + --vp-custom-block-danger-bg: transparent; + + /* layout */ + --vp-nav-height: 64px; + --vp-sidebar-width: 272px; + + /* search */ + --docsearch-primary-color: var(--g-cyan); + --docsearch-highlight-color: var(--g-cyan-text); + --docsearch-searchbox-shadow: inset 0 0 0 1px var(--g-ink); + --docsearch-modal-shadow: none; + --docsearch-hit-shadow: none; + --docsearch-hit-active-color: var(--g-ink); + --docsearch-hit-background: var(--g-white); + --docsearch-hit-color: var(--g-ink); + --docsearch-footer-shadow: inset 0 1px 0 0 var(--g-line); + --docsearch-modal-background: var(--g-white); + --docsearch-searchbox-background: var(--g-white); + --docsearch-searchbox-focus-background: var(--g-white); + --docsearch-key-gradient: var(--g-soft); + --docsearch-key-shadow: none; +} + +html { + font-size: 16px; +} + +body { + -webkit-font-smoothing: antialiased; +} + +/* ------------------------------------------------------------------ header + The bar draws no rule of its own. The two verticals the page runs on carry straight + through it from the top of the screen, so the header reads as the first row of the + grid instead of a band sitting on top of it. */ + + +/* the default theme parks two 72px stubs under the bar: the sidebar's own rule already + runs the full height, so they are two lines that end in mid air */ + + + + + + + + + + +/* the title divider runs the full height of the bar, so it ends on two lines */ + + +/* the wordmark link owns nothing but the wordmark: no width, no rule, no label */ + + + + +/* ------------------------------------------------------------------ sidebar */ + + + + + + + + +/* group rules run from the edge of the screen to the sidebar rule */ + + + + + + + + + + + + + + +/* ------------------------------------------------------------------ doc layout and the column rule */ +/* the default theme scopes these with a data attribute, so the overrides need !important */ + + +/* rules inside the article run from the sidebar rule to the column rule */ +.vp-doc h2, +.vp-doc table, +.vp-doc .custom-block, +.vp-doc blockquote, +.vp-doc hr, +.vp-doc p:has(> strong:only-child):not(li > p):not(blockquote p):not(.custom-block p) { + margin-left: calc(var(--g-gutter) * -1); + margin-right: calc(var(--g-rail) * -1); + padding-left: var(--g-gutter); + padding-right: var(--g-rail); +} + +@media (max-width: 959px) { + .vp-doc h2, + .vp-doc hr, + .vp-doc table, + .vp-doc .custom-block, + .vp-doc blockquote, + .vp-doc p:has(> strong:only-child):not(li > p):not(blockquote p):not(.custom-block p) { + margin-left: -24px; + margin-right: -24px; + padding-left: 24px; + padding-right: 24px; + } +} + + +/* ------------------------------------------------------------------ article typography */ +.vp-doc h1 { + font-size: 48px; + line-height: 52px; + font-weight: 600; + letter-spacing: -0.035em; + margin-bottom: 12px; +} + +.vp-doc h1 + p { + font-size: 18px; + line-height: 30px; + color: var(--g-grey); +} + +.vp-doc h2 { + margin-top: 56px; + padding-top: 28px; + border-top: 1px solid var(--g-line); + font-size: 28px; + line-height: 36px; + font-weight: 600; + letter-spacing: -0.02em; +} + +.vp-doc h3 { + margin-top: 40px; + font-size: 19px; + line-height: 28px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.vp-doc h4 { + margin-top: 28px; + font-size: 16px; + font-weight: 600; +} + +.vp-doc h2 .header-anchor, +.vp-doc h3 .header-anchor { + color: var(--g-cyan-text); + font-weight: 400; +} + +.vp-doc p, +.vp-doc li { + font-size: 16px; + line-height: 28px; +} + +.vp-doc a { + color: var(--g-cyan-text); + font-weight: 400; + text-decoration: underline; + text-decoration-color: rgba(0, 119, 179, 0.3); + text-underline-offset: 3px; +} + +.vp-doc a:hover { + text-decoration-color: var(--g-cyan-text); +} + +.vp-doc :not(pre) > code { + font-size: 14px; + padding: 2px 6px; + border-radius: 0; + background: var(--g-soft); + color: var(--g-ink); +} + +.vp-doc strong { + font-weight: 600; +} + +/* reference pages write method names as a bold paragraph of their own: make it an entry */ +.vp-doc p:has(> strong:only-child):not(li > p):not(blockquote p):not(.custom-block p) { + margin-top: 36px; + padding-top: 18px; + border-top: 1px solid var(--g-line); +} + +.vp-doc p:has(> strong:only-child) > strong { + font-family: var(--vp-font-family-mono); + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; +} + +/* ------------------------------------------------------------------ code */ +.vp-doc div[class*='language-'], +.vp-code-group .blocks { + border-radius: 0; + background: var(--g-soft); + margin: 20px 0; +} + +.vp-doc [class*='language-'] > span.lang, +.vp-doc [class*='language-'] > button.copy { + border-radius: 0; +} + +.vp-doc [class*='language-'] code { + font-size: 14px; + line-height: 24px; +} + +.vp-doc [class*='language-'] .line.highlighted { + background: var(--g-cyan-tint); +} + +.vp-doc [class*='language-'] .line.diff.add { + background: var(--g-cyan-tint); +} + +.vp-doc [class*='language-'] .line.diff.add::before { + color: var(--g-cyan-text); +} + +.vp-doc [class*='language-'] .line.diff.remove { + background: transparent; + opacity: 0.55; +} + +.vp-code-group .tabs { + border-radius: 0; + background: var(--g-soft); + box-shadow: inset 0 -1px 0 var(--g-line); +} + +.vp-code-group .tabs label { + font-family: var(--vp-font-family-mono); + font-size: 12px; + letter-spacing: 0.02em; +} + +/* ------------------------------------------------------------------ callouts: a band, not a box */ +.vp-doc .custom-block { + position: relative; + margin: 28px calc(var(--g-rail) * -1) 28px calc(var(--g-gutter) * -1); + padding: 14px var(--g-rail) 16px var(--g-gutter); + border: 0; + border-top: 1px solid var(--g-line); + border-bottom: 1px solid var(--g-line); + border-radius: 0; + background: transparent; + font-size: 16px; + line-height: 28px; +} + +.vp-doc .custom-block.tip { + border-top-color: var(--g-cyan); +} + +.vp-doc .custom-block.warning, +.vp-doc .custom-block.danger { + border-top: 2px solid var(--g-ink); +} + +.vp-doc .custom-block .custom-block-title { + position: absolute; + left: 0; + top: 17px; + width: var(--g-gutter); + font-family: var(--vp-font-family-mono); + font-size: 12px; + line-height: 16px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--g-grey); +} + +.vp-doc .custom-block.tip .custom-block-title { + color: var(--g-cyan-text); +} + +.vp-doc .custom-block.warning .custom-block-title, +.vp-doc .custom-block.danger .custom-block-title { + color: var(--g-ink); +} + +.vp-doc .custom-block p { + margin: 0; +} + +.vp-doc .custom-block p + p { + margin-top: 8px; +} + +.vp-doc .custom-block a { + font-weight: 400; +} + +/* blockquote notes read the same way */ +.vp-doc blockquote { + margin: 28px calc(var(--g-rail) * -1) 28px calc(var(--g-gutter) * -1); + padding: 14px var(--g-rail) 16px var(--g-gutter); + border: 0; + border-top: 1px solid var(--g-line); + border-bottom: 1px solid var(--g-line); + color: var(--g-ink); +} + +.vp-doc blockquote > p { + margin: 0; + font-size: 16px; + line-height: 28px; +} + +.vp-doc blockquote > p:first-child > strong:first-child { + font-family: var(--vp-font-family-mono); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--g-grey); +} + +/* ------------------------------------------------------------------ tables */ +.vp-doc table { + display: table; + width: 100%; + margin: 24px 0; + border-collapse: collapse; + overflow-x: auto; +} + +.vp-doc th { + font-family: var(--vp-font-family-mono); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--g-grey); + text-align: left; + padding: 0 24px 10px 0; + border: 0; + background: transparent; +} + +.vp-doc td { + font-size: 15px; + line-height: 24px; + padding: 10px 24px 10px 0; + border: 0; + border-top: 1px solid var(--g-line); +} + +.vp-doc tr { + background: transparent; + border: 0; +} + +.vp-doc tr:nth-child(2n) { + background: transparent; +} + +/* ------------------------------------------------------------------ outline */ + + + + +/* the active row is marked by its own node, so the floating marker goes */ + + + + + + + + + + +/* ------------------------------------------------------------------ page footer */ +.VPDocFooter { + margin-top: 56px; +} + +.VPDocFooter .edit-info { + padding-bottom: 20px; + border-top: 1px solid var(--g-line); + padding-top: 20px; + margin-left: calc(var(--g-gutter) * -1); + margin-right: calc(var(--g-rail) * -1); + padding-left: var(--g-gutter); + padding-right: var(--g-rail); +} + +.VPDocFooter .edit-link-button { + font-size: 15px; + font-weight: 500; + color: var(--g-ink) !important; + text-decoration: none; +} + +.VPDocFooter .edit-link-icon { + display: none; +} + +.VPDocFooter .last-updated { + font-family: var(--vp-font-family-mono); + font-size: 12px; + letter-spacing: 0.02em; + color: var(--g-grey); +} + +.VPDocFooter .prev-next { + border-top: 0; + gap: 24px; +} + +.VPDocFooter .pager-link { + border: 0; + border-radius: 0; + border-top: 1px solid var(--g-line); + padding: 20px 0 0; + background: transparent; +} + +.VPDocFooter .pager-link.next { + border-top: 2px solid var(--g-cyan); + text-align: right; +} + +.VPDocFooter .pager-link .desc { + font-family: var(--vp-font-family-mono); + font-size: 12px; + letter-spacing: 0.02em; + color: var(--g-grey); +} + +.VPDocFooter .pager-link .title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--g-ink); +} + +/* the comment thread that is already enabled on every page */ +.giscus, +#giscus, +#giscus-container { + margin-top: 40px; + padding-top: 28px; + border-top: 1px solid var(--g-line); + margin-left: calc(var(--g-gutter) * -1); + margin-right: calc(var(--g-rail) * -1); + padding-left: var(--g-gutter); + padding-right: var(--g-rail); +} + +/* ------------------------------------------------------------------ search */ + + + + + + + + + + + + + + +/* ------------------------------------------------------------------ home page: keep it quiet until stage 3 */ +:root { + --vp-home-hero-name-color: var(--g-ink); + --vp-home-hero-name-background: none; +} + +.VPHome .VPHero .name, +.VPHome .VPHero .text { + letter-spacing: -0.035em; +} + +/* ------------------------------------------------------------------ not found */ + + + + + + + + +/* ------------------------------------------------------------------ the [[toc]] list every page starts with */ +.vp-doc .table-of-contents { + margin: 24px 0 8px; + padding: 0; + list-style: none; + columns: 1; + column-gap: 32px; +} + +@media (min-width: 640px) { + .vp-doc .table-of-contents { + columns: 2; + } +} + + +.vp-doc .table-of-contents li { + list-style: none; + break-inside: avoid; + font-size: 15px; + line-height: 28px; +} + +.vp-doc .table-of-contents ul { + margin: 0; + padding-left: 16px; +} + + + +/* ------------------------------------------------------------------ stage 2: the small components */ + +/* where the page sits, and the layer it belongs to */ + + + + + + + + +/* on a phone the trail is just the page and its layer */ + + + + + + +/* the method index on reference pages */ + + + + + + + + + + + + + + + + + + +/* test coverage on the packages page */ +.goravel-coverage { + display: inline-flex; + align-items: center; + margin-left: 10px; + vertical-align: middle; +} + +.goravel-coverage .track { + display: block; + width: 72px; + height: 2px; + background: var(--g-line); +} + +.goravel-coverage .fill { + display: block; + height: 2px; + background: var(--g-cyan); +} + +/* the block an upgrade guide hands to an AI agent */ +.vp-doc div[class*='language-markdown'] { + position: relative; + padding-top: 12px; + border-top: 2px solid var(--g-ink); +} + +.vp-doc div[class*='language-markdown'] > span.lang { + top: 14px; + left: 24px; + right: auto; + font-family: var(--vp-font-family-mono); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--g-ink); + opacity: 1; +} + +.vp-doc div[class*='language-markdown'] > span.lang::after { + content: ' guide for your AI agent'; +} + +.vp-doc div[class*='language-markdown'] > button.copy { + top: 8px; +} + +.vp-doc div[class*='language-markdown'] pre { + padding-top: 40px; +} + +/* not found: two columns split by a rule that runs the height of the page, + so every rule in it ends on that split or on the screen edge */ + + + + + + + + + + + + + + + + + + + + + + + + + + +/* the AI guide is long: cap it, and let the copy button take the whole thing */ +.vp-doc div[class*='language-markdown'] > span.lang { + font-size: 0; +} + +.vp-doc div[class*='language-markdown'] > span.lang::after { + content: 'Guide for your AI agent'; + font-size: 12px; +} + +.vp-doc div[class*='language-markdown'] pre { + max-height: 420px; + overflow: auto; +} + +/* coverage sits on one line with its number; every other cell wraps */ +.vp-doc table td:has(> .goravel-coverage) { + white-space: nowrap; +} + +/* ------------------------------------------------------------------ matching the drawing: code, outline, header */ + +/* code blocks carry a header band with the language and the copy button, as drawn */ +.vp-doc div[class*='language-']:not([class*='language-markdown']) { + padding-top: 44px; +} + +.vp-doc div[class*='language-']::after { + content: ''; + position: absolute; + top: 43px; + left: 0; + right: 0; + border-top: 1px solid var(--g-line); +} + +.vp-doc [class*='language-'] > span.lang { + top: 14px; + right: 56px; + font-family: var(--vp-font-family-mono); + font-size: 12px; + letter-spacing: 0.02em; + color: var(--g-grey); + opacity: 1; +} + +.vp-doc [class*='language-'] > button.copy { + top: 6px; + right: 12px; + width: 32px; + height: 32px; + border: 0; + background-color: transparent; + opacity: 1; +} + +.vp-doc [class*='language-'] > button.copy:hover { + background-color: var(--g-white); +} + +.vp-doc [class*='language-'] code .line-number, +.vp-doc [class*='language-'] .line-numbers-wrapper { + font-family: var(--vp-font-family-mono); + font-size: 12px; + color: var(--g-grey); +} + +.vp-doc [class*='language-'] .line-numbers-wrapper { + padding-top: 16px; +} + +/* the outline is a route: a node per section, cyan for the one in view */ + + + + + + + + +/* the sidebar's version selector */ + + + + + + + + + + + + + + + + + + + + + + +/* the header: the wordmark, then Docs, then a search field over the article column */ + + +/* ------------------------------------------------------------------ corrections */ + +/* line numbers sit with the code, below the header band */ +.vp-doc [class*='language-'] > .line-numbers-wrapper { + top: 44px; + padding-top: 16px; + border-right: 1px solid var(--g-line); + width: 40px; + text-align: center; +} + +.vp-doc div[class*='language-'] > pre { + padding-left: 0; +} + +.vp-doc div[class*='language-'].line-numbers-mode > pre .shiki code { + padding-left: 12px; +} + +/* sidebar rows keep the rhythm of the drawing */ + + + + + + + + + + +/* the nav has to hold four menus and three icons beside the search */ + + + + +/* ------------------------------------------------------------------ closing the gap with the drawing */ + +/* sidebar rows keep a 44px pitch: the caret is what was stretching them */ + + + + + + + + +/* the opening paragraph of a page reads as the lead, wherever the markdown puts it */ +.vp-doc h1 + p, +.vp-doc h2:first-of-type + p { + font-size: 18px; + line-height: 30px; + color: var(--g-grey); +} + +/* the method index comes first on a reference page, and its entries carry an anchor */ + + +.vp-doc p:has(> strong:only-child) > strong::after { + content: '#'; + margin-left: 10px; + font-family: var(--vp-font-family-mono); + font-size: 14px; + font-weight: 400; + color: var(--g-cyan-text); + opacity: 0; +} + +.vp-doc p:has(> strong:only-child):hover > strong::after { + opacity: 1; +} + +/* the sidebar's group wrapper adds its own gap; the rule is what separates groups */ + + + + +/* ================================================================== + Every rule ends on a line. Nothing floats. + The page has four verticals: the screen edges (0, 100%), the sidebar + rule (272px) and the column rule. Every horizontal rule runs between + two of them, and every control is a band between them, never a box. + ================================================================== */ + +/* sidebar rows: from the screen edge to the sidebar rule */ + + +/* the version selector is a band, not a box */ + + + + +/* the first section's rule is the one under the version, so there is only ever one */ + + + + + + +/* the rail column: every block fills it, and insets its text with padding, so the rules + land on the column rule and on the screen edge at every width */ + + + + +/* The header runs on the same grid as the page. Four cells divided by full height + rules: the wordmark, the menu, the utilities, and the search. Every rule in the bar + runs from the top of the bar to the line under it. */ + + +/* the utilities tighten before the menu does, so the links keep the article's edge */ + + +/* under 1280 there is no outline column: the search takes only what it needs, the + project links move into the overflow menu, and the menu keeps every item readable */ + + +/* code stays inside its column at every width */ +.vp-doc div[class*='language-'] { + max-width: 100%; + overflow: hidden; +} + +.vp-doc div[class*='language-'] > pre { + overflow-x: auto; + max-width: 100%; +} + +@media (max-width: 959px) { + .vp-doc div[class*='language-'] { + margin-left: -24px !important; + margin-right: -24px !important; + width: calc(100% + 48px) !important; + max-width: calc(100% + 48px) !important; + padding-left: 0 !important; + border-radius: 0; + } + + .vp-doc div[class*='language-'] > .line-numbers-wrapper { + display: none; + } + + .vp-doc div[class*='language-'] > span.lang, + .vp-doc div[class*='language-'] > button.copy { + display: flex; + } + + .vp-doc div[class*='language-'] > span.lang { + left: 24px; + } + + .vp-doc div[class*='language-'] code { + padding-left: 24px !important; + padding-right: 24px !important; + } +} + + +/* the pager is one rule across the column: grey for the page behind, cyan for the page ahead */ +.VPDocFooter .prev-next { + gap: 0 !important; + margin-left: calc(var(--g-gutter) * -1); + margin-right: calc(var(--g-rail) * -1); + padding-left: var(--g-gutter); + padding-right: var(--g-rail); +} + +.VPDocFooter .pager-link.prev { + padding-right: 24px; +} + +.VPDocFooter .pager-link.next { + padding-left: 24px; +} + +.VPDocFooter .edit-info { + margin-left: calc(var(--g-gutter) * -1) !important; + margin-right: calc(var(--g-rail) * -1) !important; + padding-left: var(--g-gutter) !important; + padding-right: var(--g-rail) !important; +} + +/* dropdowns are bands too, not boxes */ + + +/* narrow screens: the article keeps a 24px gutter and code scrolls inside it */ +@media (max-width: 959px) { + + + + + + + .vp-doc h2, + .vp-doc hr, + .vp-doc table, + .vp-doc .custom-block, + .vp-doc blockquote, + .vp-doc p:has(> strong:only-child):not(li > p):not(blockquote p):not(.custom-block p), + .VPDocFooter .edit-info, + .VPDocFooter .prev-next { + margin-left: -24px !important; + margin-right: -24px !important; + padding-left: 24px !important; + padding-right: 24px !important; + } + + .vp-doc .custom-block .custom-block-title { + position: static; + width: auto; + display: block; + margin-bottom: 6px; + } + + .vp-doc table { + display: block; + overflow-x: auto; + } +} + + +/* the pager's outer ends land on the two verticals */ +.VPDocFooter .prev-next { + margin: 0 !important; + padding: 0 !important; +} + +.VPDocFooter .pager-link.prev { + margin-left: calc(var(--g-gutter) * -1); + padding-left: var(--g-gutter); +} + +.VPDocFooter .pager-link.next { + margin-right: calc(var(--g-rail) * -1); + padding-right: var(--g-rail); +} + +/* giscus carries inline styles from its plugin, so the band needs to win */ +#giscus { + margin-left: calc(var(--g-gutter) * -1) !important; + margin-right: calc(var(--g-rail) * -1) !important; + padding-left: var(--g-gutter) !important; + padding-right: var(--g-rail) !important; + padding-top: 28px !important; + border-top: 1px solid var(--g-line) !important; +} + +@media (max-width: 959px) { + #giscus { + margin-left: -24px !important; + margin-right: -24px !important; + padding-left: 24px !important; + padding-right: 24px !important; + } +} + + +/* table rules run the whole band: the rule belongs to the row, the inset to the cells */ +.vp-doc table { + margin-left: calc(var(--g-gutter) * -1) !important; + margin-right: calc(var(--g-rail) * -1) !important; + padding: 0 !important; + width: calc(100% + var(--g-gutter) + var(--g-rail)) !important; + border-collapse: collapse; +} + +.vp-doc tr { + border-top: 1px solid var(--g-line); +} + +.vp-doc thead tr { + border-top: 0; +} + +.vp-doc th, +.vp-doc td { + border: 0 !important; + white-space: normal; +} + +.vp-doc th:first-child, +.vp-doc td:first-child { + padding-left: var(--g-gutter); +} + +/* a long token in a cell must not push the table past its column */ +.vp-doc th code, +.vp-doc td code, +.vp-doc th a, +.vp-doc td a { + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.vp-doc th:last-child, +.vp-doc td:last-child { + padding-right: var(--g-rail); +} + +/* the two halves of the pager meet in the middle */ +.VPDocFooter .prev-next { + column-gap: 0 !important; + grid-column-gap: 0 !important; +} + +@media (max-width: 959px) { + .vp-doc table { + margin-left: -24px !important; + margin-right: -24px !important; + width: calc(100% + 48px) !important; + } + + .vp-doc th:first-child, + .vp-doc td:first-child { + padding-left: 24px; + } + + .vp-doc th:last-child, + .vp-doc td:last-child { + padding-right: 24px; + } +} + + +.VPDocFooter .pager-link { + width: auto !important; + display: block; +} + +/* the AI guide is a band across the column like everything else */ +.vp-doc div[class*='language-markdown'] { + margin-left: calc(var(--g-gutter) * -1) !important; + margin-right: calc(var(--g-rail) * -1) !important; + width: calc(100% + var(--g-gutter) + var(--g-rail)) !important; +} + +.vp-doc div[class*='language-markdown'] > span.lang { + left: var(--g-gutter); +} + +@media (max-width: 959px) { + .vp-doc div[class*='language-markdown'] { + margin-left: -24px !important; + margin-right: -24px !important; + } + + .vp-doc div[class*='language-markdown'] > span.lang { + left: 24px; + } +} + + +.vp-doc div[class*='language-markdown'] { + max-width: none !important; +} + +/* nav dropdowns: a surface with no floating rule */ + + +/* narrow screens: the article fills the width so its rules reach both edges */ + + +@media (max-width: 959px) { + .VPDocFooter .pager-link.prev { + margin-left: -24px; + padding-left: 24px; + } + + .VPDocFooter .pager-link.next { + margin-right: -24px; + padding-right: 24px; + } + + .vp-doc table { + min-width: calc(100% + 48px); + } +} + + +@media (max-width: 959px) { + /* tables fill the width and wrap, instead of shrinking to their content */ + .vp-doc table { + display: table !important; + width: calc(100% + 48px) !important; + min-width: 0 !important; + table-layout: auto; + } + + .vp-doc td:last-child, + .vp-doc td { + white-space: normal; + word-break: break-word; + } +} + + +/* the nav's white curtain was covering the top of the sidebar */ + + + + +/* any divider inside the header runs its full height, so it ends on two rules */ + + +/* ------------------------------------------------------------------ responsive column + The column rule is always at sidebar + column width. Between 1280 and 1440 the column + gives its slack to the outline; below 1280 the outline is gone, so the column runs to + the edge of the screen and the header's last cell goes with it. */ + + + + +/* the outline never sets the width of the column: it takes what the grid gives it */ + + + + +/* the nav always has a ground of its own, at every width */ + + +/* the nav must never grow past the screen: the menu is what gives way */ + + +/* ------------------------------------------------------------------ callouts that carry + A callout is a band across the column with a ground of its own, a mark from the lattice + and a label. Tip is cyan, note is quiet, warning is ink. */ +.vp-doc .custom-block { + background: var(--g-soft); + padding-top: 16px; + padding-bottom: 18px; +} + +.vp-doc .custom-block.tip { + background: var(--g-cyan-tint); + border-top: 2px solid var(--g-cyan); + border-bottom-color: var(--g-cyan); +} + +.vp-doc .custom-block.info, +.vp-doc .custom-block.note { + background: var(--g-soft); +} + +.vp-doc .custom-block.warning, +.vp-doc .custom-block.danger { + background: var(--g-soft); + border-top: 2px solid var(--g-ink); + border-bottom-color: var(--g-ink); +} + +.vp-doc .custom-block .custom-block-title { + position: static; + width: auto; + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + font-size: 12px; + letter-spacing: 0.12em; +} + +.vp-doc .custom-block .custom-block-title::before { + content: ''; + width: 9px; + height: 9px; + transform: rotate(45deg); + background: currentColor; + flex-shrink: 0; +} + +.vp-doc .custom-block.warning .custom-block-title, +.vp-doc .custom-block.danger .custom-block-title { + color: var(--g-ink); +} + +.vp-doc .custom-block.tip .custom-block-title { + color: var(--g-cyan-text); +} + +.vp-doc .custom-block code { + background: rgba(255, 255, 255, 0.72); +} + +/* a blockquote note gets the same ground and mark */ +.vp-doc blockquote { + background: var(--g-soft); + padding-top: 16px; + padding-bottom: 18px; +} + +/* ------------------------------------------------------------------ the rail at any width */ + + + + +/* tables never run past their column */ +.vp-doc td, +.vp-doc th { + overflow-wrap: anywhere; +} + +.vp-doc table { + table-layout: auto; + max-width: calc(100% + var(--g-gutter) + var(--g-rail)); +} + +/* the outline follows the layout instead of being pinned to absolute pixels, so it lands on + the column rule and the screen edge at every width */ + + +/* ------------------------------------------------------------------ buttons + A button is a filled block, not an outlined box: fill and weight carry the + emphasis so nothing draws a rule that ends in mid air. */ + + + + + + + + + + + + +/* ------------------------------------------------------------------ the bar on phones + Two icon buttons, no boxes: the bar keeps the same flat vocabulary as the page. */ + + +/* ------------------------------------------------------------------ the bar, up close + Everything in the bar is flat text at one weight: the links carry the emphasis, the + utilities sit back, and nothing in it draws a box. */ + + + + +/* the chevrons are a hint, not a mark */ + + +/* the utilities are grey until you reach for them */ + + + + + + + + + + +/* the search reads as a field, not as a button with a chip in it */ + + + + +/* DocSearch nests the button two divs deep: the cell only reads as a field if every + one of them fills it */ + + +/* the shortcut sits at the far end of the cell, on the page's right inset */ + + + + +/* the wordmark is a mark in a 64px bar, not a headline */ + + +/* the two halves of the shortcut are rendered differently by DocSearch: make them one */ + diff --git a/.vitepress/theme/home/CommunitySection.vue b/.vitepress/theme/home/CommunitySection.vue new file mode 100644 index 000000000..511efee5b --- /dev/null +++ b/.vitepress/theme/home/CommunitySection.vue @@ -0,0 +1,113 @@ + + + diff --git a/.vitepress/theme/home/GoravelHome.vue b/.vitepress/theme/home/GoravelHome.vue new file mode 100644 index 000000000..4202f276b --- /dev/null +++ b/.vitepress/theme/home/GoravelHome.vue @@ -0,0 +1,315 @@ + + + diff --git a/.vitepress/theme/home/GoravelMark.vue b/.vitepress/theme/home/GoravelMark.vue new file mode 100644 index 000000000..3aece7a91 --- /dev/null +++ b/.vitepress/theme/home/GoravelMark.vue @@ -0,0 +1,134 @@ + + + diff --git a/.vitepress/theme/home/HomeFooter.vue b/.vitepress/theme/home/HomeFooter.vue new file mode 100644 index 000000000..1eae28c48 --- /dev/null +++ b/.vitepress/theme/home/HomeFooter.vue @@ -0,0 +1,53 @@ + + + diff --git a/.vitepress/theme/home/Stage.vue b/.vitepress/theme/home/Stage.vue new file mode 100644 index 000000000..66201738b --- /dev/null +++ b/.vitepress/theme/home/Stage.vue @@ -0,0 +1,167 @@ + + + diff --git a/.vitepress/theme/home/content.ts b/.vitepress/theme/home/content.ts new file mode 100644 index 000000000..4136877dc --- /dev/null +++ b/.vitepress/theme/home/content.ts @@ -0,0 +1,210 @@ +/** + * Everything the page says, in one place, so the stage can be driven from it. + * + * The page is a single scroll: one mark on a sticky stage, and a stream of blocks beside it. + * Each block declares the state the mark should be in while it is the block in view. + */ +import type { LayerKey, PieceState } from './geometry' + +export type StopKey = 'route' | 'mw' | 'ctrl' | 'svc' | 'orm' | 'event' | 'resp' + +export const FILES: Record = { + web: { + name: 'routes/web.go', + lines: [ + 'func Web() {', + '\t[[facades.Route]]().[[Middleware]](middleware.Auth()).', + '\t\t[[Group]](func(router route.Router) {', + '\t\t\trouter.[[Get]]("/tasks", controllers.NewTaskController().Index)', + '\t\t})', + '}' + ] + }, + auth: { + name: 'app/http/middleware/auth.go', + lines: [ + 'func (m *Auth) Handle(ctx http.Context) {', + '\ttoken := ctx.[[Request]]().[[Header]]("Authorization", "")', + '\tif _, err := [[facades.Auth]](ctx).[[Parse]](token); err != nil {', + '\t\tctx.[[Request]]().[[Abort]](http.StatusUnauthorized)', + '\t\treturn', + '\t}', + '\tctx.[[Request]]().[[Next]]()', + '}' + ] + }, + controller: { + name: 'app/http/controllers/task_controller.go', + lines: [ + 'func (r *TaskController) Index(ctx http.Context) http.Response {', + '\ttasks, err := r.taskService.Open(ctx)', + '\tif err != nil {', + '\t\treturn ctx.[[Response]]().[[String]](http.StatusInternalServerError, err.Error())', + '\t}', + '\treturn ctx.[[Response]]().[[Success]]().[[Json]](tasks)', + '}' + ] + }, + service: { + name: 'app/services/task_service.go', + lines: [ + 'func (s *TaskService) Open(ctx context.Context) ([]models.Task, error) {', + '\tvar tasks []models.Task', + '\terr := [[facades.Orm]]().[[WithContext]](ctx).[[Query]]().[[Where]]("done", false).[[Get]](&tasks)', + '\tif err != nil {', + '\t\treturn nil, err', + '\t}', + '\t[[facades.Event]]().[[Job]](&events.TasksViewed{}, []event.Arg{}).[[Dispatch]]()', + '\t[[facades.Queue]]().[[Job]](&jobs.SyncTasks{}, []queue.Arg{}).[[Dispatch]]()', + '\treturn tasks, nil', + '}' + ] + } +} + +export interface Stop { + key: StopKey + name: string + layer: LayerKey | null + file: string + lit: number[] + desc: string +} + +export const STOPS: Stop[] = [ + { key: 'route', name: 'Route', layer: 'http', file: 'web', lit: [2, 4], desc: 'The router matches GET /tasks and runs the middleware on the group.' }, + { key: 'mw', name: 'Middleware', layer: 'app', file: 'auth', lit: [2, 3, 7], desc: 'The middleware reads the token. If it is not valid, the request stops with 401.' }, + { key: 'ctrl', name: 'Controller', layer: 'http', file: 'controller', lit: [1, 2], desc: 'The controller asks the service for the open tasks.' }, + { key: 'svc', name: 'Service', layer: null, file: 'service', lit: [1, 2], desc: 'Your own code. Plain Go, calling the facades it needs.' }, + { key: 'orm', name: 'ORM', layer: 'data', file: 'service', lit: [3], desc: 'The ORM builds the query and the Postgres driver runs it.' }, + { key: 'event', name: 'Event and Queue', layer: 'async', file: 'service', lit: [7, 8], desc: 'The service fires an event and pushes a job onto the queue.' }, + { key: 'resp', name: 'Response', layer: 'http', file: 'controller', lit: [6], desc: 'The tasks are returned as JSON. 200 OK.' } +] + +export interface Concept { + name: string + layer: LayerKey + phpFile: string + goFile: string + php: string + go: string + ties: [number, number][] +} + +export const CONCEPTS: Concept[] = [ + { + name: 'Routing', layer: 'http', phpFile: 'routes/web.php', goFile: 'routes/web.go', + php: "Route::{{middleware}}('auth')->{{group}}(function () {\n Route::{{get}}('/tasks', [TaskController::class, 'index']);\n});", + go: '[[facades.Route]]().[[Middleware]](middleware.Auth()).\n\t[[Group]](func(router route.Router) {\n\t\trouter.[[Get]]("/tasks", taskController.Index)\n\t})', + ties: [[1, 1], [1, 2], [2, 3]] + }, + { + name: 'ORM', layer: 'data', phpFile: 'app/Services/TaskService.php', goFile: 'app/services/task_service.go', + php: "$tasks = Task::{{where}}('done', false)\n ->{{orderByDesc}}('created_at')\n ->{{get}}();", + go: 'var tasks []models.Task\n[[facades.Orm]]().[[Query]]().\n\t[[Where]]("done", false).\n\t[[OrderByDesc]]("created_at").\n\t[[Get]](&tasks)', + ties: [[1, 3], [2, 4], [3, 5]] + }, + { + name: 'Validation', layer: 'app', phpFile: 'app/Http/Controllers/TaskController.php', goFile: 'app/http/controllers/task_controller.go', + php: "$request->{{validate}}([\n 'title' => 'required|max:255',\n]);", + go: 'validator, err := ctx.[[Request]]().[[Validate]](map[string]any{\n\t"title": "required|max:255",\n})', + ties: [[1, 1], [2, 2]] + }, + { + name: 'Queues', layer: 'async', phpFile: 'app/Services/TaskService.php', goFile: 'app/services/task_service.go', + php: 'SyncTasks::{{dispatch}}();', + go: '[[facades.Queue]]().\n\t[[Job]](&jobs.SyncTasks{}, []queue.Arg{}).\n\t[[Dispatch]]()', + ties: [[1, 3]] + }, + { + name: 'Events', layer: 'async', phpFile: 'app/Services/TaskService.php', goFile: 'app/services/task_service.go', + php: 'TasksViewed::{{dispatch}}();', + go: '[[facades.Event]]().\n\t[[Job]](&events.TasksViewed{}, []event.Arg{}).\n\t[[Dispatch]]()', + ties: [[1, 3]] + }, + { + name: 'Scheduling', layer: 'async', phpFile: 'routes/console.php', goFile: 'bootstrap/app.go', + php: "Schedule::{{command}}('emails:send')->{{daily}}();", + go: '[[WithSchedule]](func() []schedule.Event {\n\treturn []schedule.Event{\n\t\t[[facades.Schedule]]().[[Command]]("emails:send").[[Daily]](),\n\t}\n})', + ties: [[1, 3]] + }, + { + name: 'Cache', layer: 'data', phpFile: 'app/Services/TaskService.php', goFile: 'app/services/task_service.go', + php: "Cache::{{put}}('key', 'value', 60);", + go: '[[facades.Cache]]().[[Put]]("key", "value", time.Minute)', + ties: [[1, 1]] + }, + { + name: 'Artisan', layer: 'core', phpFile: 'Terminal', goFile: 'Terminal', + php: 'php artisan {{make:controller}} TaskController', + go: './artisan make:controller TaskController', + ties: [[1, 1]] + } +] + +/** How far each piece travels when it is pulled out of the mark, in lattice steps. */ +export const MOVES: Record = { + http: [0, 0, 1.5], + app: [0, 0, 0.75], + core: [0, 0, 0], + data: [0.9, 0, 0], + async: [0, 0.9, 0] +} + +/** Where a piece's leader starts, so the line runs along the axis it moved on. */ +export const ANCHOR: Record = { + http: [2.4, 0.5, 3], + app: [2.4, 2.5, 3], + core: [3, 2.5, 2.5], + data: [3, 0.5, 2.2], + async: [0.5, 3, 2.2] +} + +export const LAYER_CODE: Record = { + http: '[[facades.Route]]().[[Middleware]](middleware.Auth()).\n\t[[Get]]("/tasks", taskController.Index)', + app: 'validator, err := ctx.[[Request]]().[[Validate]](map[string]any{\n\t"title": "required|max:255",\n})', + core: '[[facades.Config]]().[[GetString]]("app.name", "goravel")', + data: '[[facades.Orm]]().[[Query]]().\n\t[[Where]]("done", false).\n\t[[Get]](&tasks)', + async: '[[facades.Queue]]().\n\t[[Job]](&jobs.SyncTasks{}, []queue.Arg{}).\n\t[[Dispatch]]()' +} + +export const LITE_STEPS = [ + { title: 'Create a Lite project', cmd: 'goravel new blog', add: [] as string[], desc: 'Choose Goravel Lite in the installer. It includes App, Artisan, Config and Process.' }, + { title: 'Add routing', cmd: './artisan package:install Route', add: ['Route'], desc: 'The Route facade joins the HTTP layer, and the piece appears.' }, + { title: 'Add the ORM', cmd: './artisan package:install Orm', add: ['Orm'], desc: 'The Orm facade joins the Data layer.' }, + { title: 'Add queues', cmd: './artisan package:install Queue', add: ['Queue'], desc: 'The Queue facade joins the Async layer.' }, + { title: 'Install everything', cmd: './artisan package:install --all', add: ['*'], desc: 'All 30 facades, the same set as a full Goravel project. The mark is whole.' } +] + +export const ROLES: Record = { + http: 'route · middleware', + app: 'validation · auth', + core: 'container · config', + data: 'orm · cache · storage', + async: 'queue · events · mail' +} + +/** What the mark is doing while a block is in view. */ +export interface View { + kind: 'hero' | 'journey' | 'parity' | 'map' | 'lite' + states: Partial> + labels?: Partial> + moves?: Partial> + turn?: number + stop?: StopKey + /** How far the request has run, 0 to 1 of the whole route. Set while scrolling the journey. */ + progress?: number + caption?: string + meta?: string +} + +/** `[[Name]]` in Go, `{{name}}` in PHP: the call is the one thing the eye should land on. */ +export function tokenize(src: string, marker: 'go' | 'php' = 'go') { + const re = marker === 'go' ? /(\[\[[^\]]+\]\])/ : /(\{\{[^}]+\}\})/ + return src.split('\n').map((line) => + line.split(re).filter(Boolean).map((part) => { + const call = marker === 'go' ? part.startsWith('[[') : part.startsWith('{{') + return { call, text: call ? part.slice(2, -2) : part } + }) + ) +} diff --git a/.vitepress/theme/home/geometry.ts b/.vitepress/theme/home/geometry.ts new file mode 100644 index 000000000..88ad4a189 --- /dev/null +++ b/.vitepress/theme/home/geometry.ts @@ -0,0 +1,271 @@ +/** + * The Goravel mark as geometry. + * + * Every drawing on the homepage is the same object seen from the same angle: the lattice the mark + * is built on, the five pieces cut out of it, and the path a request takes through it. Nothing here + * is decorative; the coordinates come from public/logo.svg. + * + * P(0, 0, 3) is the top vertex. A 120 degree turn of the picture is exactly a turn of the object + * about the line of sight: it maps (a, b, c) to (c, a, b), so the three faces trade places. + */ + +export const U = 133 / 3 // horizontal step of one cell +export const V = 25.6 // vertical rise of one cell (30 degrees) +export const H = 51.2 // vertical edge of one cell + +export type Cell = [number, number, number] +export type LayerKey = 'http' | 'app' | 'core' | 'data' | 'async' +export type PieceState = 'solid' | 'active' | 'ghost' | 'faint' + +export function iso(a: number, b: number, c: number): [number, number] { + return [133 + (a - b) * U, 154 + (a + b) * V - c * H] +} + +/** A placed, scaled view of the lattice. */ +export class Iso { + constructor(public s: number, public ox: number, public oy: number) {} + + p(a: number, b: number, c: number): [number, number] { + const [x, y] = iso(a, b, c) + return [this.ox + x * this.s, this.oy + y * this.s] + } + + path(pts: Cell[], close = true): string { + const d = 'M' + pts.map((q) => this.p(...q)).map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(' L') + return close ? d + ' Z' : d + } + + /** The mark's outline, scaled about its centre. Used to clip the lattice. */ + hexagon(factor: number): string { + const [cx, cy] = this.p(1.5, 1.5, 1.5) + const pts: Cell[] = [[0, 0, 3], [3, 0, 3], [3, 0, 0], [3, 3, 0], [0, 3, 0], [0, 3, 3]] + return ( + 'M' + + pts + .map(([a, b, c]) => { + const [x, y] = iso(a, b, c) + const sx = this.ox + x * this.s + const sy = this.oy + y * this.s + return `${(cx + (sx - cx) * factor).toFixed(1)} ${(cy + (sy - cy) * factor).toFixed(1)}` + }) + .join(' L') + + ' Z' + ) + } +} + +/** The five pieces of the mark, each one layer of the framework. */ +export const PIECES: Record = { + http: [[0, 0, 3], [3, 0, 3], [3, 1, 3], [0, 1, 3]], + app: [[0, 2, 3], [3, 2, 3], [3, 3, 3], [0, 3, 3]], + data: [[3, 0, 3], [3, 0, 0], [3, 3, 0], [3, 3, 1], [3, 1, 1], [3, 1, 3]], + async: [[0, 3, 3], [1, 3, 3], [1, 3, 1], [3, 3, 1], [3, 3, 0], [0, 3, 0]], + core: [[3, 2, 3], [3, 2, 2], [3, 3, 2], [3, 3, 3]] +} + +export const LAYER_ORDER: LayerKey[] = ['http', 'app', 'core', 'data', 'async'] + +export interface Layer { + key: LayerKey + name: string + desc: string + facades: string[] + guides: string[] +} + +export const LAYERS: Layer[] = [ + { + key: 'http', + name: 'HTTP', + desc: 'Route, middleware, requests, responses, sessions, gRPC', + facades: ['Route', 'Http', 'Session', 'Grpc', 'RateLimiter', 'View'], + guides: ['Routing', 'Middleware', 'Controllers'] + }, + { + key: 'app', + name: 'Application', + desc: 'Validation, authentication, authorization, hashing, AI', + facades: ['Validation', 'Auth', 'Gate', 'Hash', 'Crypt', 'Lang', 'AI'], + guides: ['Validation', 'Authentication', 'AI SDK'] + }, + { + key: 'core', + name: 'Core', + desc: 'Container, configuration, Artisan, processes, logging, testing', + facades: ['App', 'Artisan', 'Config', 'Process', 'Log', 'Testing'], + guides: ['Service Container', 'Artisan Console', 'Processes'] + }, + { + key: 'data', + name: 'Data', + desc: 'ORM, query builder, migrations, seeders, cache, storage', + facades: ['Orm', 'DB', 'Schema', 'Seeder', 'Cache', 'Storage'], + guides: ['ORM', 'Migrations', 'Cache'] + }, + { + key: 'async', + name: 'Async', + desc: 'Queues, events, task scheduling, mail, telemetry', + facades: ['Queue', 'Event', 'Schedule', 'Mail', 'Telemetry'], + guides: ['Queues', 'Events', 'Task Scheduling'] + } +] + +export const LAYER_NAME = Object.fromEntries(LAYERS.map((l) => [l.key, l.name])) as Record +export const LAYER_OF: Record = Object.fromEntries( + LAYERS.flatMap((l) => l.facades.map((f) => [f, l.key])) +) +export const LITE = ['App', 'Artisan', 'Config', 'Process'] + +export const FACADE_INFO: Record = { + Route: 'Routes, groups and middleware', + Http: 'HTTP client for outgoing requests', + Session: 'Session data across requests', + Grpc: 'gRPC servers and clients', + RateLimiter: 'Named rate limiters', + View: 'Templates rendered as responses', + Validation: 'Validate requests and data', + Auth: 'JWT and session guards', + Gate: 'Authorization gates and policies', + Hash: 'Password hashing', + Crypt: 'Encryption and decryption', + Lang: 'Localization', + AI: 'Agents and conversations with AI providers', + App: 'The service container', + Artisan: 'Console commands', + Config: 'Configuration values', + Process: 'Run system processes', + Log: 'Logging to channels', + Testing: 'Test helpers, like Docker databases', + Orm: 'Models, relationships and queries', + DB: 'Query builder', + Schema: 'Migrations', + Seeder: 'Database seeders', + Cache: 'Cache stores', + Storage: 'File storage', + Queue: 'Background jobs', + Event: 'Events and listeners', + Schedule: 'Task scheduling', + Mail: 'Sending mail', + Telemetry: 'Traces, metrics and logs' +} + +// ------------------------------------------------------------------ faces +const FACE_OF: Record = { + http: 'top', + app: 'top', + core: 'right', + data: 'right', + async: 'left' +} +const ROT_FACES = ['top', 'right', 'left'] as const +export const SHADE = { top: '#ffffff', right: '#f7f9fa', left: '#e5eaed' } as const + +/** Which face a piece shows after k thirds of a turn. */ +export function faceOf(key: LayerKey, k = 0): 'top' | 'right' | 'left' { + return ROT_FACES[(ROT_FACES.indexOf(FACE_OF[key]) + k) % 3] +} + +export const FACE_MATRIX = { + top: [0.866, 0.5, -0.866, 0.5], + right: [0.866, -0.5, 0, 1], + left: [0.866, 0.5, 0, 1] +} as const + +/** The transform that lays text flat on an isometric face. */ +export function faceTransform(face: 'top' | 'right' | 'left', x: number, y: number): string { + const [a, b, c, d] = FACE_MATRIX[face] + return `matrix(${a} ${b} ${c} ${d} ${x.toFixed(1)} ${y.toFixed(1)})` +} + +// ------------------------------------------------------------------ lattice +/** The triangular lattice the mark is cut from: verticals every U, diagonals every H. */ +export function latticePath(g: Iso, x0: number, x1: number, y0: number, y1: number): string { + const ux = U * g.s + const hy = H * g.s + const cx = g.ox + 133 * g.s + const cy = g.oy + 154 * g.s + const out: string[] = [] + + let k = Math.floor((x0 - cx) / ux) - 1 + while (cx + k * ux <= x1) { + const x = cx + k * ux + if (x >= x0) out.push(`M${x.toFixed(1)} ${y0.toFixed(1)}V${y1.toFixed(1)}`) + k += 1 + } + + const slope = V / U + const reach = (x1 - x0) * slope + for (const sign of [1, -1]) { + let m = Math.floor((y0 - reach - cy) / hy) - 1 + while (cy + m * hy <= y1 + reach) { + const base = cy + m * hy + out.push( + `M${x0.toFixed(1)} ${(base + sign * slope * (x0 - cx)).toFixed(1)}L${x1.toFixed(1)} ${( + base + + sign * slope * (x1 - cx) + ).toFixed(1)}` + ) + m += 1 + } + } + return out.join(' ') +} + +// ------------------------------------------------------------------ the request +/** + * Every segment runs along one lattice axis and lies in one plane of the mark: the top face + * (c = 3), then the right face (a = 3), then the left face (b = 3). + */ +export function requestPath(g: Iso) { + const p = (a: number, b: number, c: number) => g.p(a, b, c) + return { + entry: p(-1.2, 1.5, 3), + channel: p(3, 1.5, 3), + drop: p(3, 1.5, 2.5), + core: p(3, 2.5, 2.5), + data: p(3, 0.5, 2.5), + exit: p(3, -1.2, 2.5), + front: p(3, 3, 2.5), + fall: p(3, 3, 1.5), + async: p(0.5, 3, 1.5) + } +} + +/** A lattice cell, drawn as a rhombus. The only marker shape on the site. */ +export function cellPath(x: number, y: number, r: number): string { + const w = r * 0.866 * 2 + return `M${x.toFixed(1)} ${(y - r).toFixed(1)} L${(x + w / 2).toFixed(1)} ${y.toFixed(1)} L${x.toFixed(1)} ${( + y + r + ).toFixed(1)} L${(x - w / 2).toFixed(1)} ${y.toFixed(1)} Z` +} + +export function polyline(points: [number, number][]): string { + return 'M' + points.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(' L') +} + +/** The point a given distance along a polyline: where the request has reached. */ +export function pointAt(points: [number, number][], dist: number): [number, number] { + if (!points.length) return [0, 0] + let left = Math.max(0, dist) + for (let i = 1; i < points.length; i++) { + const [x0, y0] = points[i - 1] + const [x1, y1] = points[i] + const seg = Math.hypot(x1 - x0, y1 - y0) + if (left <= seg || i === points.length - 1) { + const k = seg === 0 ? 0 : Math.min(1, left / seg) + return [x0 + (x1 - x0) * k, y0 + (y1 - y0) * k] + } + left -= seg + } + return points[points.length - 1] +} + +/** Length of a polyline, so a track can be drawn on with stroke-dashoffset. */ +export function polylineLength(points: [number, number][]): number { + let total = 0 + for (let i = 1; i < points.length; i++) { + total += Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]) + } + return total +} diff --git a/.vitepress/theme/home/home.css b/.vitepress/theme/home/home.css new file mode 100644 index 000000000..fac9658e7 --- /dev/null +++ b/.vitepress/theme/home/home.css @@ -0,0 +1,1397 @@ +/* ================================================================== + The Goravel homepage + ------------------------------------------------------------------ + One mark, held on a sticky stage, and a stream of blocks scrolling + past it. Every piece of the mark is a single element that moves + between states, so the page is one continuous object rather than a + series of pictures. Five kinds of movement and no others: resolve, + route, connect, expand, assemble. + ================================================================== */ + +.g-home { + --g-margin: 96px; + --g-secpad: 56px; + --g-stage-gap: 80px; + color: var(--g-ink); + background: var(--g-white); +} + +.g-home .g-wrap { + width: 100%; + max-width: 1440px; + margin: 0 auto; + padding: 0 var(--g-margin); + box-sizing: border-box; +} + +/* ------------------------------------------------------------------ type */ +.g-home .g-display { + font-size: 84px; + line-height: 82px; + font-weight: 700; + letter-spacing: -0.04em; + margin: 0; + text-wrap: balance; +} + +.g-home .g-display .light { + font-weight: 500; +} + +.g-home .g-h2 { + font-size: 48px; + line-height: 52px; + font-weight: 600; + letter-spacing: -0.035em; + margin: 0; + text-wrap: balance; +} + +.g-home .g-h3 { + font-size: 28px; + line-height: 34px; + font-weight: 600; + letter-spacing: -0.025em; + margin: 0; +} + +.g-home .g-lead { + font-size: 18px; + line-height: 30px; + color: var(--g-grey); + margin: 0; + max-width: 560px; +} + +.g-home .g-body { + font-size: 16px; + line-height: 26px; + margin: 0; +} + +.g-home .g-small { + font-size: 15px; + line-height: 24px; + margin: 0; +} + +.g-home .g-label { + font-family: var(--vp-font-family-mono); + font-size: 11px; + line-height: 16px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--g-grey); +} + +.g-home .g-meta { + font-family: var(--vp-font-family-mono); + font-size: 12px; + line-height: 16px; + letter-spacing: 0.02em; + color: var(--g-grey); +} + +.g-home .muted { color: var(--g-grey); } +.g-home .cyan { color: var(--g-cyan-text); } +.g-home .ink { color: var(--g-ink); } + +.g-inline { + font-family: var(--vp-font-family-mono); + font-size: 0.92em; + color: var(--g-ink); + background: none; + padding: 0; +} + +/* ------------------------------------------------------------------ controls */ +/* ------------------------------------------------------------------ controls + One shape, two weights. A filled control is the single primary action on a screen; + an outlined control is everything else you can press, including the search field in + the bar. Same height, same corner, so the page has one control vocabulary. */ +.g-home .g-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 14px; + white-space: nowrap; + height: 50px; + padding: 0 24px; + border: 1px solid transparent; + border-radius: 3px; + background: var(--g-cyan); + color: var(--g-white); + font-size: 16px; + font-weight: 600; + text-decoration: none; + transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.g-home .g-button:hover { + background: var(--g-cyan-text); +} + +.g-home .g-button.is-outline { + background: var(--g-white); + border-color: var(--g-line); + color: var(--g-ink); + font-weight: 500; +} + +.g-home .g-button.is-outline:hover { + border-color: var(--g-ink); + background: var(--g-white); + color: var(--g-ink); +} + +.g-home .g-textlink { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 17px; + font-weight: 500; + color: var(--g-ink); + text-decoration: none; + transition: color 0.15s ease; +} + +.g-home .g-textlink:hover { + color: var(--g-cyan-text); +} + +.g-arrow { + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: square; + stroke-linejoin: miter; + flex-shrink: 0; + transition: transform 0.22s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.g-button:hover .g-arrow, +.g-textlink:hover .g-arrow { + transform: translateX(4px); +} + +/* a link in a list is a link, not an action: a hairline that colours in */ +.g-home .g-link { + font-size: 15px; + font-weight: 500; + color: var(--g-ink); + text-decoration: none; + padding-bottom: 3px; + border-bottom: 1px solid var(--g-line); + transition: color 0.15s ease, border-color 0.15s ease; +} + +.g-home .g-link:hover { + color: var(--g-cyan-text); + border-color: var(--g-cyan); +} + +/* ------------------------------------------------------------------ sections + Each section is about a screen, and one rule runs the width of the page + between them. Inside a section: a heading, a line, and something to work. */ +/* One section, one screen. Each holds a single idea and is wholly visible when you land on + it; it grows past a screen only if the content genuinely needs more room. */ +.g-sec > .g-wrap:not(.g-hero-grid) { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + +.g-sec > .g-hero-grid { + flex: 1; +} + +.g-sec { + display: flex; + flex-direction: column; + justify-content: center; + min-height: calc(100vh - var(--vp-nav-height)); + padding: var(--g-secpad) 0; + box-sizing: border-box; + border-top: 1px solid var(--g-line); + scroll-snap-align: start; + scroll-snap-stop: normal; +} + +.g-sec.is-hero { + border-top: 0; +} + +.g-hero-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 560px; + column-gap: 80px; + align-items: center; +} + +/* the proof: the same route, written twice. The reason a Laravel developer reads on. */ +.g-proof { + min-width: 0; + border-left: 1px solid var(--g-line); + padding: var(--g-secpad) 0 var(--g-secpad) 56px; + margin: calc(var(--g-secpad) * -1) 0; + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: center; +} + +.g-proof-pane .g-label { + display: block; + margin-left: -56px; + margin-right: calc(var(--g-margin) * -1); + padding: 0 var(--g-margin) 12px 56px; + border-bottom: 1px solid var(--g-line); +} + +/* the join is one rule from the column edge to the screen edge, with the node sitting on it */ +.g-proof-join { + position: relative; + height: 1px; + margin: 26px calc(var(--g-margin) * -1) 26px -56px; + padding: 0; + background: var(--g-line); +} + +.g-proof-join svg { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + padding: 0 10px; + background: var(--g-white); + box-sizing: content-box; +} + +.g-proof-pane .g-code { + padding-top: 16px; + font-size: 13.5px; + line-height: 25px; + color: var(--g-grey); +} + +.g-proof-pane.is-go .g-code { + color: var(--g-ink); +} + +.g-proof-join { + color: var(--g-cyan); +} + +.g-proof-join svg { + fill: var(--g-cyan); +} + +.g-hero-grid .g-lead { + margin-top: 28px; + font-size: 19px; + line-height: 32px; +} + +.g-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 20px 36px; + margin-top: 40px; +} + +.g-sec-head { + max-width: 720px; +} + +.g-sec-head .g-lead { + margin-top: 16px; +} + +.g-figure { + display: flex; + justify-content: center; +} + +/* ------------------------------------------------------------------ tabs + The one control on the page. Text, a rule under the row, and the accent + on the one you are reading. */ +.g-tabs { + display: flex; + flex-wrap: wrap; + gap: 2px 28px; + margin-top: 36px; + border-bottom: 1px solid var(--g-line); +} + +.g-tabs.is-tight { + margin-top: 0; + gap: 2px 20px; +} + +.g-tab { + position: relative; + padding: 0 0 14px; + border: 0; + background: none; + font-family: inherit; + font-size: 16px; + font-weight: 500; + color: var(--g-grey); + cursor: pointer; + transition: color 0.15s ease; +} + +.g-tab:hover { + color: var(--g-ink); +} + +.g-tab.is-active { + color: var(--g-ink); +} + +.g-tab::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 2px; + background: var(--g-cyan); + transform: scaleX(0); + transform-origin: left; + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.g-tab.is-active::after { + transform: scaleX(1); +} + +/* ------------------------------------------------------------------ panels */ +.g-panel { + display: grid; + grid-template-columns: 420px minmax(0, 1fr); + column-gap: 0; + align-items: center; + margin-top: 40px; + flex: 1; +} + +/* The drawing and the text are two columns divided by one rule, and every rule inside the + text column runs from that rule to the edge of the screen. Nothing ends in mid air. */ +.g-panel-body { + border-left: 1px solid var(--g-line); + padding: 40px 0 var(--g-secpad) 56px; + margin: -40px 0 calc(var(--g-secpad) * -1); + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: center; +} + +.g-panel-body .g-file-head, +.g-panel-body .g-tabs, +.g-panel-body .g-layer-code, +.g-panel-body .g-count { + margin-left: -56px; + margin-right: calc(var(--g-margin) * -1); + padding-left: 56px; + padding-right: var(--g-margin); +} + +/* the heading shares its row with the mark, so the two files get the full width */ +.g-sec-split { + display: grid; + grid-template-columns: minmax(0, 1fr) 260px; + column-gap: 72px; + align-items: center; +} + + + +.g-panel.is-layers { + grid-template-columns: 460px minmax(0, 1fr); +} + +.g-panel-body .g-facades { + padding-left: 0; +} + +.g-panel-body { + min-width: 0; +} + +.g-figure.is-small .g-mark { + margin: 0 auto; +} + +.g-compare { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: 0; + align-items: stretch; + margin-top: 40px; + flex: 1; +} + +/* the two files are two columns divided by one rule, same as every other panel */ +.g-compare .g-col:last-child { + border-left: 1px solid var(--g-line); + padding: 40px 0 var(--g-secpad) 56px; + margin: -40px 0 calc(var(--g-secpad) * -1); +} + +.g-compare .g-col:first-child { + padding-right: 56px; +} + +.g-col { + min-width: 0; +} + +/* The two lines that become each other light at the same moment, in both columns. No line is + drawn between them: they are the only two lit rows on the screen, so the eye pairs them. */ +.g-code .ln.is-pair { + background: var(--g-cyan-tint); + color: var(--g-ink); +} + +.g-code.is-plain .ln { + padding: 0 14px; + margin: 0 -14px; +} + +.g-code .ln.is-pair::before { + content: ''; + position: absolute; + left: 0; + top: 2px; + bottom: 2px; + width: 2px; + background: var(--g-cyan); +} + +/* content that swaps under a steady heading fades in rather than cutting */ +.g-swap { + animation: g-swap 0.32s ease both; +} + +@keyframes g-swap { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* the tab that is running shows how long it has left */ +.g-tab.is-active::after { + transform: scaleX(1); +} + +/* how many facades are in so far */ +.g-count { + display: flex; + align-items: baseline; + gap: 14px; + margin-top: 32px; +} + +.g-count .n { + font-size: 56px; + line-height: 52px; + font-weight: 600; + letter-spacing: -0.045em; + font-variant-numeric: tabular-nums; +} + +.g-count .muted { + font-size: 28px; + letter-spacing: -0.03em; +} + +/* ------------------------------------------------------------------ code */ +.g-file { + min-width: 0; + margin-top: 28px; +} + +.g-file-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--g-line); +} + +.g-code { + margin: 0; + padding: 18px 0 0; + max-width: 100%; + overflow-x: auto; + font-family: var(--vp-font-family-mono); + font-size: 13px; + line-height: 26px; + tab-size: 2; +} + +.g-code .ln { + display: block; + position: relative; + padding: 0 14px; + margin: 0 -14px; + white-space: pre; + color: var(--g-grey); + transition: background-color 0.4s ease, color 0.4s ease; +} + +.g-code .ln .no { + display: inline-block; + width: 24px; + text-align: right; + margin-right: 18px; + color: var(--g-line); + user-select: none; +} + +.g-code .ln.is-lit { + background: var(--g-cyan-tint); + color: var(--g-ink); +} + +.g-code .ln.is-lit .no { + color: var(--g-cyan); +} + +.g-code .call { + color: var(--g-cyan-text); +} + +.g-code.is-plain .ln { + padding: 0; + margin: 0; +} + +.g-code.is-shell { + margin-top: 24px; + padding: 14px 16px; + background: var(--g-soft); + border-radius: 2px; + font-size: 14px; +} + +.g-code.is-shell .prompt { + margin-right: 10px; + color: var(--g-cyan-text); +} + +.g-code.is-shell .src { + color: var(--g-ink); +} + +/* the two languages, and the lines that match */ +.g-parity-col { + min-width: 0; +} + +.g-ties { + align-self: start; + margin-top: 47px; + overflow: visible; +} + +.g-tie { + fill: none; + stroke: var(--g-cyan); + stroke-width: 1.25; + stroke-dasharray: 160; + stroke-dashoffset: 160; + opacity: 0; +} + +.g-block.is-live .g-tie { + animation: g-connect 0.55s ease forwards; + animation-delay: calc(var(--i) * 90ms + 150ms); +} + +@keyframes g-connect { + to { + opacity: 1; + stroke-dashoffset: 0; + } +} + +/* ------------------------------------------------------------------ facades */ +.g-facades { + list-style: none; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px 20px; + margin: 28px 0 0; + padding: 0; +} + +.g-facades li { + font-family: var(--vp-font-family-mono); + font-size: 14px; + color: var(--g-ink); +} + +.g-layer-code { + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid var(--g-line); +} + +/* ------------------------------------------------------------------ the mark + Every state is one element with a class, so the mark moves between states + instead of being redrawn in them. */ +.g-mark { + display: block; + max-width: 100%; + height: auto; + overflow: visible; +} + +.g-mark-lattice { + opacity: 0; + transition: opacity 0.6s ease; +} + +.g-mark-lattice.is-on { + opacity: 0.9; +} + +.g-mark-spin { + transition: transform 0.75s cubic-bezier(0.33, 0.1, 0.2, 1); +} + +.g-piece { + transition: transform 0.6s cubic-bezier(0.33, 0.1, 0.2, 1); +} + +.g-face { + stroke-linejoin: miter; + transition: fill 0.45s ease, stroke 0.45s ease, stroke-width 0.35s ease; +} + +.g-face.is-solid { + fill: var(--face); + stroke: var(--g-ink); + stroke-width: 1.25; +} + +.g-face.is-active { + fill: var(--g-cyan-tint); + stroke: var(--g-cyan); + stroke-width: 1.4; +} + +.g-face.is-faint { + fill: var(--g-white); + stroke: var(--g-construct); + stroke-width: 1.25; +} + +.g-face.is-ghost { + fill: transparent; + stroke: var(--g-grey); + stroke-width: 1; + stroke-dasharray: 3 4; + opacity: 0.55; +} + +.g-piece-text { + opacity: 0; + transition: opacity 0.35s ease; +} + +.g-piece-text.is-on { + opacity: 1; +} + +.g-piece-name { + font-family: var(--vp-font-family-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + fill: var(--g-ink); + transition: fill 0.4s ease; +} + +.g-piece-meta { + font-family: var(--vp-font-family-mono); + font-size: 11px; + letter-spacing: 0.02em; + fill: var(--g-grey); + transition: fill 0.4s ease; +} + +.g-piece.is-active .g-piece-name, +.g-piece.is-active .g-piece-meta { + fill: var(--g-cyan-text); +} + +.g-piece.is-ghost .g-piece-name, +.g-piece.is-ghost .g-piece-meta, +.g-piece.is-faint .g-piece-name, +.g-piece.is-faint .g-piece-meta { + fill: var(--g-construct); +} + +.g-mark.is-interactive .g-piece.is-hit { + cursor: pointer; +} + +.g-mark.is-interactive .g-piece.is-hit:hover .g-face { + stroke: var(--g-cyan); +} + +.g-piece.is-hit:focus-visible { + outline: none; +} + +.g-piece.is-hit:focus-visible .g-face { + stroke: var(--g-cyan); + stroke-width: 2; +} + +/* the request: one path, revealed as far as the stop in view */ +.g-request, +.g-leaders { + opacity: 0; + transition: opacity 0.4s ease; +} + +.g-request.is-on, +.g-leaders.is-on { + opacity: 1; +} + +.g-track { + fill: none; + stroke-linejoin: miter; +} + +.g-track.is-next { + stroke: var(--g-grey); + stroke-width: 1.5; + stroke-dasharray: 4 4; + opacity: 0.7; +} + +.g-track.is-done { + stroke: var(--g-cyan); + stroke-width: 2; +} + +.g-run { + stroke-dasharray: calc(var(--len) * 1px); + stroke-dashoffset: calc(var(--off) * 1px); + transition: stroke-dashoffset 0.75s cubic-bezier(0.4, 0, 0.2, 1); +} + +.g-head { + transition: transform 0.75s cubic-bezier(0.4, 0, 0.2, 1); +} + +.g-node.is-done { + fill: var(--g-cyan); +} + +.g-signal-halo { + fill: var(--g-cyan-tint); +} + +.g-signal-core { + fill: var(--g-cyan); +} + +.g-leader { + fill: none; + stroke: var(--g-construct); + stroke-width: 0.75; + stroke-dasharray: 2 3; +} + +.g-mono-ink, +.g-mono-grey { + font-family: var(--vp-font-family-mono); + font-size: 13px; + font-weight: 500; +} + +.g-mono-ink { + fill: var(--g-ink); +} + +.g-mono-grey { + fill: var(--g-grey); + transition: fill 0.4s ease; +} + +.g-mono-grey.is-lit { + fill: var(--g-cyan-text); +} + +/* ------------------------------------------------------------------ open source */ +.g-home section.g-community { + display: flex; + flex-direction: column; + justify-content: center; + min-height: calc(100vh - var(--vp-nav-height)); + box-sizing: border-box; + scroll-snap-align: start; + padding: 56px 0; + margin-left: calc(var(--g-margin) * -1); + margin-right: calc(var(--g-margin) * -1); + padding-left: var(--g-margin); + padding-right: var(--g-margin); + border-top: 1px solid var(--g-line); +} + +.g-home .g-footer { + margin-left: calc(var(--g-margin) * -1); + margin-right: calc(var(--g-margin) * -1); + padding-left: var(--g-margin); + padding-right: var(--g-margin); +} + +.g-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 24px; + margin-top: 40px; +} + +.g-stat { + display: flex; + flex-direction: column; + gap: 10px; +} + +.g-stat .value { + font-size: 60px; + line-height: 58px; + font-weight: 600; + letter-spacing: -0.05em; + font-variant-numeric: tabular-nums; +} + +.g-people { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 40px; + padding-top: 32px; + border-top: 1px solid var(--g-line); +} + +.g-person img { + display: block; + width: 34px; + height: 34px; + border-radius: 50%; + filter: grayscale(1); + opacity: 0.75; + transition: filter 0.2s ease, opacity 0.2s ease; +} + +.g-person:hover img { + filter: none; + opacity: 1; +} + +.g-community-foot { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 32px; + margin-top: 32px; +} + +.g-community-foot .links { + display: flex; + gap: 24px; +} + +/* where to find the project: four places, one row, one line of type each */ +.g-places { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 24px; + margin-top: 40px; + margin-left: calc(var(--g-margin) * -1); + margin-right: calc(var(--g-margin) * -1); + padding: 32px var(--g-margin) 0; + border-top: 1px solid var(--g-line); +} + +.g-places figure { + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; +} + +.g-places img { + display: block; + width: 112px; + height: 112px; + object-fit: cover; +} + +.g-places .discord { + display: flex; + align-items: center; + justify-content: center; + width: 112px; + height: 112px; + background: var(--g-soft); + color: var(--g-grey); + transition: color 0.15s ease, background-color 0.15s ease; +} + +.g-places .discord:hover { + background: var(--g-cyan-tint); + color: var(--g-cyan-text); +} + +.g-places figcaption { + white-space: nowrap; +} + +/* ------------------------------------------------------------------ footer */ +.g-footer { + padding: 64px 0 32px; + border-top: 1px solid var(--g-line); +} + +.g-footer-cols { + display: grid; + grid-template-columns: minmax(0, 6fr) repeat(3, minmax(0, 2fr)); + column-gap: 24px; +} + +.g-footer-brand { + display: flex; + flex-direction: column; + gap: 18px; + align-items: flex-start; +} + +.g-footer-brand img { + height: 24px; + width: auto; +} + +.g-footer-brand p { + max-width: 300px; +} + +.g-footer-col { + display: flex; + flex-direction: column; + gap: 10px; +} + +.g-footer-col a { + color: var(--g-grey); + text-decoration: none; + transition: color 0.15s ease; +} + +.g-footer-col a:hover { + color: var(--g-cyan-text); +} + +.g-footer-bar { + display: flex; + justify-content: space-between; + gap: 24px; + margin-top: 56px; + padding-top: 20px; + border-top: 1px solid var(--g-line); +} + +/* ------------------------------------------------------------------ narrow */ +@media (max-width: 1280px) { + .g-home { + --g-margin: 48px; + } + + .g-hero-grid { + grid-template-columns: minmax(0, 1fr) 420px; + column-gap: 48px; + } + + .g-panel, + .g-panel.is-layers { + grid-template-columns: 320px minmax(0, 1fr); + column-gap: 48px; + } + + .g-home .g-display { + font-size: 64px; + line-height: 64px; + } + + .g-home .g-h2 { + font-size: 38px; + line-height: 42px; + } + + .g-stat .value { + font-size: 56px; + line-height: 54px; + } +} + +@media (max-width: 900px) { + .g-home { + --g-margin: 24px; + } + + .g-sec { + padding: 64px 0; + } + + .g-sec.is-hero { + padding: 40px 0 64px; + } + + .g-hero-grid, + .g-sec-split, + .g-panel, + .g-panel.is-parity, + .g-panel.is-layers, + .g-compare, + .g-footer-cols { + grid-template-columns: minmax(0, 1fr); + row-gap: 32px; + } + + /* the mark beside a heading has no room of its own on a phone */ + .g-sec-split .g-figure { + display: none; + } + + .g-figure .g-mark { + width: 100%; + max-width: 360px; + } + + .g-ties { + display: none; + } + + .g-facades { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .g-home .g-display { + font-size: 44px; + line-height: 46px; + } + + .g-home .g-h2 { + font-size: 30px; + line-height: 34px; + } + + .g-tabs { + gap: 2px 20px; + margin-top: 32px; + } + + /* two actions, one width: stacked buttons match instead of stepping */ + .g-actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + } + + .g-home .g-button { + width: 100%; + } + + .g-tab { + font-size: 15px; + } + + .g-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + row-gap: 36px; + } + + .g-community-foot, + .g-footer-bar { + flex-direction: column; + gap: 14px; + } +} + +/* ------------------------------------------------------------------ less motion */ +@media (prefers-reduced-motion: reduce) { + .g-mark-spin, + .g-piece, + .g-face, + .g-run, + .g-head, + .g-block, + .g-mark-lattice, + .g-request, + .g-leaders, + .g-piece-text { + transition: none !important; + } + + .g-block.is-live .g-tie { + animation: none; + opacity: 1; + stroke-dashoffset: 0; + } + + .g-block { + opacity: 1; + } +} + +/* ================================================================== + The bar, on the homepage + ------------------------------------------------------------------ + The docs bar is built around the sidebar column. The homepage has no + sidebar, so the bar takes the homepage's own grid instead: the + wordmark stands on the same vertical as the headline, and the last + utility ends on the same vertical as the text. At the top of the page + the bar draws nothing; its rule arrives when the page passes under it. + ================================================================== */ +/* The bar always draws its rule here: the column rules below it have to terminate on + something, and a rule that only appears once you scroll leaves them hanging. */ +.Layout:has(.g-home) .VPNavBar { + border-bottom: 1px solid var(--g-line) !important; +} + +.Layout:has(.g-home) .VPNavBar .wrapper { + padding: 0 !important; +} + +.Layout:has(.g-home) .VPNavBar .container { + width: 100% !important; + max-width: 1440px !important; + margin: 0 auto !important; + padding: 0 var(--g-home-margin, 96px) !important; + box-sizing: border-box !important; +} + +/* the wordmark is not a cell here: no width, no rule, no section label */ +.Layout:has(.g-home) .VPNavBar .container > .title { + width: auto !important; + padding: 0 !important; + border: 0 !important; +} + +.Layout:has(.g-home) .VPNavBar .content { + padding-left: 0 !important; + padding-right: 0 !important; +} + +/* the bar's own rule lives on its last pixel, so the body must stop one short of it */ +.Layout:has(.g-home) .VPNavBar .content-body { + padding: 0 !important; + border-bottom: 0 !important; + height: calc(100% - 1px) !important; +} + +.Layout:has(.g-home) .VPNavBar .container > .title { + height: calc(100% - 1px) !important; +} + +.Layout:has(.g-home) .VPNavBarMenu { + padding-left: 56px !important; +} + +@media (min-width: 961px) { + .Layout:has(.g-home) .VPNavBarSearch { + width: 280px !important; + } +} + +/* on a phone the bar is the docs bar: mark, search, menu */ +@media (max-width: 960px) { + .Layout:has(.g-home) .VPNavBar .wrapper { + padding: 0 !important; + } + + .Layout:has(.g-home) .VPNavBar .container { + padding: 0 24px !important; + } + + .Layout:has(.g-home) .VPNavBarSearch { + width: auto !important; + margin-left: auto !important; + } +} + +@media (max-width: 1280px) { + .g-home, + .Layout:has(.g-home) { + --g-home-margin: 48px; + } + + .Layout:has(.g-home) .VPNavBarMenu { + padding-left: 40px !important; + } +} + +@media (max-width: 960px) { + .g-home, + .Layout:has(.g-home) { + --g-home-margin: 24px; + } +} + +/* a long line of code scrolls inside its own block; it never widens the page */ +.g-panel > *, +.g-compare > *, +.g-hero-grid > * { + min-width: 0; +} + +.g-home pre.g-code { + max-width: none; + overflow-x: auto; + box-sizing: border-box; +} + +/* a code block that carries a bleeding rule sizes itself from its margins, not from 100% */ +.g-panel-body .g-layer-code { + width: auto; +} + +/* ------------------------------------------------------------------ rules that bleed + A rule inside a section runs the width of the screen, the same as the rule between + sections. Nothing on this page draws a line that stops in the middle of the page. */ +.g-tabs, +.g-file-head, +.g-layer-code, +.g-people, +.g-groups, +.g-footer-bar, +.g-count { + margin-left: calc(var(--g-margin) * -1); + margin-right: calc(var(--g-margin) * -1); + padding-left: var(--g-margin); + padding-right: var(--g-margin); +} + +/* a file head in the compare runs from the screen edge to the rule between the columns */ +.g-compare .g-col:first-child .g-file-head { + margin-left: calc(var(--g-margin) * -1); + margin-right: -56px; + padding-left: var(--g-margin); + padding-right: 56px; +} + +.g-compare .g-col:last-child .g-file-head { + margin-left: -56px; + margin-right: calc(var(--g-margin) * -1); + padding-left: 56px; + padding-right: var(--g-margin); +} + + +.g-count { + padding-top: 24px; + border-top: 1px solid var(--g-line); +} + +/* the page settles on a section rather than stopping halfway between two */ +html.g-home-snap { + scroll-snap-type: y proximity; + scroll-padding-top: var(--vp-nav-height); +} + +@media (prefers-reduced-motion: reduce) { + html.g-home-snap { + scroll-snap-type: none; + } +} + +/* a short screen keeps its content: the section grows instead of clipping */ +@media (max-height: 760px), (max-width: 900px) { + html.g-home-snap { + scroll-snap-type: none; + } + + .g-sec, + .g-home section.g-community { + min-height: 0; + } +} + +/* the bar's search is the same outlined control as the secondary button */ +@media (min-width: 961px) { + .Layout:has(.g-home) .VPNavBarSearch .DocSearch-Button { + height: 38px !important; + border: 1px solid var(--g-line) !important; + border-radius: 3px !important; + background: var(--g-white) !important; + } + + .Layout:has(.g-home) .VPNavBarSearch .DocSearch-Button:hover { + border-color: var(--g-grey) !important; + background: var(--g-white) !important; + } +} + +/* the page carries its own footer, so the theme's is a second copy of the same line */ +.Layout:has(.g-home) .VPFooter { + display: none !important; +} + +/* ------------------------------------------------------------------ narrow: one column + The rules that divide two columns have nothing to divide when there is one column, so + they go, and everything that bled to them bleeds to the page edge instead. */ +@media (max-width: 900px) { + .g-proof, + .g-panel-body, + .g-compare .g-col:last-child { + border-left: 0; + padding-left: 0; + } + + .g-compare .g-col:first-child { + padding-right: 0; + } + + .g-proof-pane .g-label, + .g-proof-join, + .g-panel-body .g-file-head, + .g-panel-body .g-tabs, + .g-panel-body .g-layer-code, + .g-panel-body .g-count, + .g-compare .g-col:first-child .g-file-head, + .g-compare .g-col:last-child .g-file-head { + margin-left: calc(var(--g-margin) * -1); + margin-right: calc(var(--g-margin) * -1); + padding-left: var(--g-margin); + padding-right: var(--g-margin); + } + + .g-places { + grid-template-columns: repeat(2, minmax(0, 1fr)); + row-gap: 28px; + } + + .g-footer-cols { + row-gap: 32px; + } +} diff --git a/.vitepress/theme/home/useCycle.ts b/.vitepress/theme/home/useCycle.ts new file mode 100644 index 000000000..b7037c5fa --- /dev/null +++ b/.vitepress/theme/home/useCycle.ts @@ -0,0 +1,116 @@ +import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue' + +/** + * A section that walks through its own examples while it is on screen. + * + * The homepage should not need to be clicked to be read. Each section advances itself while it is + * in view, stops the moment the reader takes over, and never runs when it is off screen, when the + * tab is hidden, or when the reader has asked for less motion. + */ +export function useCycle(count: number, ms: number) { + const index = ref(0) + const root = ref(null) + const held = ref(false) // the reader has taken over + + let timer: ReturnType | null = null + let inView = false + + const reduced = () => + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches + + function stop() { + if (timer) { + clearInterval(timer) + timer = null + } + } + + function start() { + if (timer || held.value || !inView || reduced() || document.hidden) return + timer = setInterval(() => { + index.value = (index.value + 1) % count + }, ms) + } + + /** The reader chose one: stop moving and stay where they put it. */ + function set(i: number) { + held.value = true + stop() + index.value = i + } + + function onVisibility() { + document.hidden ? stop() : start() + } + + let observer: IntersectionObserver | null = null + + onMounted(() => { + observer = new IntersectionObserver( + ([entry]) => { + inView = entry.isIntersecting + inView ? start() : stop() + }, + { threshold: 0.35 } + ) + if (root.value) observer.observe(root.value) + document.addEventListener('visibilitychange', onVisibility) + }) + + onBeforeUnmount(() => { + stop() + observer?.disconnect() + document.removeEventListener('visibilitychange', onVisibility) + }) + + return { index, set, root, held } +} + +/** + * Walks the pairs of matching lines between two files, one pair at a time, so the reader is shown + * which line becomes which instead of being asked to trace a curve across a gutter. + */ +export function usePairs(pairsFor: () => [number, number][], ms: number) { + const step = ref(0) + let timer: ReturnType | null = null + let inView = false + const root = ref(null) + + const reduced = () => + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches + + function stop() { + if (timer) { + clearInterval(timer) + timer = null + } + } + + function start() { + if (timer || !inView || reduced() || document.hidden) return + timer = setInterval(() => { + const n = pairsFor().length || 1 + step.value = (step.value + 1) % n + }, ms) + } + + let observer: IntersectionObserver | null = null + + onMounted(() => { + observer = new IntersectionObserver( + ([entry]) => { + inView = entry.isIntersecting + inView ? start() : stop() + }, + { threshold: 0.3 } + ) + if (root.value) observer.observe(root.value) + }) + + onBeforeUnmount(() => { + stop() + observer?.disconnect() + }) + + return { step, root } +} diff --git a/.vitepress/theme/index.ts b/.vitepress/theme/index.ts index 977614ece..82c468967 100644 --- a/.vitepress/theme/index.ts +++ b/.vitepress/theme/index.ts @@ -1,5 +1,7 @@ import TwoslashFloatingVue from '@shikijs/vitepress-twoslash/client' import Theme from 'vitepress/theme' +import Layout from './Layout.vue' +import GoravelHome from './home/GoravelHome.vue' import giscusTalk from 'vitepress-plugin-comment-with-giscus' import { useData, useRoute } from 'vitepress' import { toRefs } from 'vue' @@ -8,11 +10,17 @@ import 'virtual:group-icons.css' import 'vitepress-markdown-timeline/dist/theme/index.css' import type { EnhanceAppContext } from 'vitepress' import './styles.css' +import './goravel.css' +import './shell.css' +import './home/home.css' export default { extends: Theme, + Layout, enhanceApp({ app }: EnhanceAppContext) { app.use(TwoslashFloatingVue) + // `layout: goravel-home` in a page's frontmatter renders this inside the normal shell. + app.component('goravel-home', GoravelHome) }, setup() { // Get frontmatter and route diff --git a/.vitepress/theme/shell.css b/.vitepress/theme/shell.css new file mode 100644 index 000000000..2f95dd46d --- /dev/null +++ b/.vitepress/theme/shell.css @@ -0,0 +1,1653 @@ +/* ================================================================== + The Goravel documentation shell + ------------------------------------------------------------------ + Three columns and one rule per column edge. The sidebar column runs + unbroken from the top of the screen; everything to its right is + capped by a single hairline under the header. Inside the columns + there are no rules at all: spacing, weight and one accent do the + work. Three controls share one vocabulary (version, search, filter): + a soft surface, a 2px radius, an inset glyph. + ================================================================== */ + +:root { + --g-hover: #eef2f5; /* one step past --g-soft, for control hover */ + --g-shell-inset: 40px; /* the page's outer inset, left and right */ + --g-control: 34px; /* the height every shell control shares */ +} + +/* ------------------------------------------------------------------ 1. the frame */ +@media (min-width: 960px) { + .VPContent.has-sidebar { + padding-left: var(--g-sidebar) !important; + padding-right: 0 !important; + } + + .VPDoc { + padding: 0 !important; + } + + .VPDoc .container { + max-width: none !important; + margin: 0 !important; + justify-content: flex-start !important; + } + + .VPDoc .container > .content { + order: 1; + flex-grow: 0; + flex-shrink: 0; + width: var(--g-col) !important; + max-width: none !important; + padding: 0 var(--g-rail) 0 var(--g-gutter) !important; + border-right: 1px solid var(--g-line); + } + + .VPDoc .container > .content > .content-container { + max-width: none !important; + margin: 0 !important; + padding: 36px 0 72px !important; + } + + .VPDoc .container > .aside { + order: 2; + flex-grow: 0; + flex-shrink: 0; + min-width: 0 !important; + max-width: none !important; + width: calc(100% - var(--g-col)) !important; + padding: 0 !important; + } + + .VPDoc .aside-curtain { + display: none !important; + } +} + +/* the column the article lives in never changes width; the outline takes the slack */ +@media (min-width: 1600px) { + .VPDoc .container > .content { + width: var(--g-col) !important; + } +} + +@media (min-width: 1280px) and (max-width: 1599px) { + .VPDoc .container > .content { + width: calc(100% - var(--g-aside)) !important; + } + + .VPDoc .container > .aside { + width: var(--g-aside) !important; + } +} + +@media (min-width: 960px) and (max-width: 1279px) { + .VPDoc .container > .content { + width: 100% !important; + border-right: 0; + } + + .VPDoc .container > .aside { + display: none !important; + } +} + +@media (max-width: 959px) { + .VPDoc .container, + .VPDoc .container > .content { + max-width: none !important; + width: 100% !important; + margin: 0 !important; + } + + .VPDoc .container > .content > .content-container, + .VPDoc.has-aside .content-container { + max-width: none !important; + margin: 0 !important; + padding: 24px 0 64px !important; + } +} + +/* ------------------------------------------------------------------ 2. the header */ +.VPNav, +.VPNavBar { + background: var(--g-white); +} + +.VPNav { + border-bottom: 0; +} + +.VPNavBar, +.VPNavBar .divider, +.VPNavBar .divider-line, +.VPNav .divider, +.VPNav .divider-line { + border: 0; +} + +.VPNav .divider, +.VPNav .divider-line, +.VPNavBar .curtain, +.VPNav .curtain { + display: none !important; +} + +@media (min-width: 960px) { + .VPNavBar .container { + max-width: none !important; + padding: 0 !important; + } + + /* the wordmark column is the sidebar column: one rule, full height */ + .VPNavBar.has-sidebar .container > .title { + width: var(--vp-sidebar-width) !important; + padding: 0 24px 0 var(--g-shell-inset) !important; + box-sizing: border-box; + display: flex; + align-items: center; + border-right: 1px solid var(--g-line); + border-bottom: 0 !important; + } + + .VPNavBar .container > .title .VPNavBarTitle { + display: flex !important; + align-items: center; + flex: 1; + min-width: 0; + border: 0 !important; + } + + .VPNavBarTitle > .title { + width: auto !important; + padding: 0 !important; + border: 0 !important; + } + + .VPNavBar .VPNavBarTitle .logo, + .VPNavBar .VPNavBarTitle img { + height: 26px !important; + width: auto !important; + } + + /* the section the reader is in, said quietly. The homepage is not in a section. */ + .VPNavBar.has-sidebar .container > .title .VPNavBarTitle::after { + content: 'Docs'; + margin-left: auto; + padding-left: 16px; + font-family: var(--vp-font-family-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--g-grey); + white-space: nowrap; + } + + /* everything right of the sidebar rule is one row, closed by one hairline */ + .VPNavBar.has-sidebar .content { + padding-left: var(--vp-sidebar-width) !important; + padding-right: 0 !important; + } + + .VPNavBar.has-sidebar .content-body { + align-items: center !important; + background: var(--g-white) !important; + padding: 0 var(--g-shell-inset) 0 0 !important; + border-bottom: 1px solid var(--g-line); + box-sizing: border-box; + height: 100%; + } + + /* A page with no sidebar has no column to start from, so the hairline runs the width. + The body and the title stop one pixel short of it, or their white ground paints over + the bar's own rule and the verticals below have nothing to meet. */ + .VPNavBar:not(.has-sidebar) .content-body { + align-items: center !important; + background: var(--g-white) !important; + padding: 0 var(--g-shell-inset) 0 0 !important; + box-sizing: border-box; + height: calc(100% - 1px); + } + + .VPNavBar:not(.has-sidebar) .container > .title { + height: calc(100% - 1px); + } + + .VPNavBar:not(.has-sidebar) { + border-bottom: 1px solid var(--g-line); + } + + /* the default theme underlines the wordmark cell; the bar's own rule does that job */ + .VPNavBar:not(.has-sidebar) .container > .title, + .VPNavBarTitle .title { + border-bottom: 0 !important; + } + + /* the first link stands on the same vertical as the article's first character */ + .VPNavBarMenu { + order: 1; + display: flex !important; + align-items: center; + min-width: 0; + padding: 0 0 0 var(--g-gutter) !important; + } + + .VPNavBarMenuLink, + .VPNavBarMenuGroup .VPFlyout .button { + height: auto !important; + padding: 0 14px !important; + font-size: 15px !important; + font-weight: 500 !important; + line-height: 20px !important; + color: var(--g-ink) !important; + transition: color 0.15s ease; + } + + .VPNavBarMenu > *:first-child .VPNavBarMenuLink, + .VPNavBarMenu > *:first-child .button { + padding-left: 0 !important; + } + + .VPNavBarMenuLink:hover, + .VPNavBarMenuGroup .VPFlyout:hover .button, + .VPNavBarMenuLink.active { + color: var(--g-cyan-text) !important; + } + + /* the label and its chevron are one object, centred on the same baseline */ + .VPNavBarMenuLink, + .VPNavBarMenuGroup .VPFlyout .button, + .VPNavBar .VPFlyout .button .text { + display: inline-flex !important; + align-items: center; + } + + .VPNavBar .vpi-chevron-down { + width: 14px !important; + height: 14px !important; + margin: 1px 0 0 5px; + color: var(--g-grey); + opacity: 1; + transition: color 0.15s ease, transform 0.2s ease; + } + + /* the chevron answers when its menu is open, and only then */ + .VPNavBarMenuGroup:hover .vpi-chevron-down, + .VPNavBarTranslations:hover .vpi-chevron-down { + color: var(--g-cyan-text); + } + + /* search first, utilities after it, both hard against the page's right inset */ + .VPNavBarSearch { + order: 2; + display: flex !important; + align-items: center; + flex-grow: 0 !important; + flex-shrink: 1 !important; + width: 280px !important; + min-width: 0; + margin-left: auto !important; + padding: 0 !important; + } + + .VPNavBarTranslations, + .VPNavBarSocialLinks, + .VPNavBarExtra, + .VPNavBar .VPSocialLinks { + order: 3; + } + + /* three groups, three distances: the search, then the language, then the project. + Nothing is divided by a line; the gaps do the grouping. */ + .VPNavBarTranslations { + display: flex !important; + align-items: center; + margin: 0 0 0 20px !important; + } + + .VPNavBar .VPNavBarSocialLinks { + display: flex !important; + align-items: center; + gap: 2px; + margin: 0 0 0 20px !important; + } + + /* one theme is designed, so the switch has nothing to offer yet */ + .VPNavBarAppearance, + .VPNavBar .VPSwitchAppearance { + display: none !important; + } + + /* the default theme marks its groups off with short pipes */ + .VPNav .VPNavBarTranslations::before, + .VPNav .VPSocialLinks::before, + .VPNav .VPNavBarExtra::before, + .VPNav .VPNavBarHamburger::before { + content: none !important; + } +} + +/* the utilities are 34px targets. They sit at one weight, in one grey, and take the page's + accent when reached for, the same as every link in the bar. */ +.VPNavBar .VPSocialLink, +.VPNavBar .VPNavBarTranslations .button, +.VPNavBar .VPNavBarTranslations .button .text, +.VPNavBar .VPNavBarExtra .button { + width: var(--g-control) !important; + height: var(--g-control) !important; + border-radius: 2px; + color: var(--g-grey) !important; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.VPNavBar .VPNavBarTranslations .button .text { + width: auto !important; + height: auto !important; + display: flex; + align-items: center; +} + +.VPNavBar .VPSocialLink:hover, +.VPNavBar .VPNavBarTranslations:hover .button, +.VPNavBar .VPNavBarTranslations:hover .button .text, +.VPNavBar .VPNavBarExtra:hover .button { + background: var(--g-soft); + color: var(--g-cyan-text) !important; +} + +.VPNavBar .VPNavBarTranslations:hover .button .text { + background: none; +} + +.VPNavBar .VPSocialLink > svg, +.VPNavBar .VPSocialLink > [class*='vpi-social'], +.VPNavBar .VPNavBarTranslations .vpi-languages { + width: 17px !important; + height: 17px !important; +} + +/* ------------------------------------------------------------------ 3. search */ +.VPNavBarSearch > #docsearch, +.VPNavBarSearch > div { + width: 100% !important; +} + +.VPNavBarSearch .DocSearch-Button { + width: 100% !important; + height: var(--g-control) !important; + margin: 0 !important; + padding: 0 10px 0 11px !important; + border: 0 !important; + border-radius: 2px !important; + background: var(--g-soft) !important; + justify-content: flex-start; + gap: 9px; + transition: background-color 0.15s ease, box-shadow 0.15s ease; +} + +.VPNavBarSearch .DocSearch-Button:hover { + background: var(--g-hover) !important; + box-shadow: none !important; +} + +.VPNavBarSearch .DocSearch-Button:focus-visible { + outline: none; + box-shadow: inset 0 0 0 1px var(--g-cyan) !important; +} + +.VPNavBarSearch .DocSearch-Button-Container { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} + +.VPNavBarSearch .DocSearch-Search-Icon { + width: 15px; + height: 15px; + color: var(--g-grey); +} + +.VPNavBarSearch .DocSearch-Button-Placeholder { + padding: 0; + font-size: 14px; + font-weight: 400 !important; + color: var(--g-grey) !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.VPNavBarSearch .DocSearch-Button-Keys { + display: flex; + align-items: center; + gap: 1px; + min-width: 0; + margin-left: auto !important; + padding-left: 8px; +} + +.VPNavBarSearch .DocSearch-Button-Key, +.VPNavBarSearch .DocSearch-Button-Key * { + width: auto; + height: auto; + margin: 0; + padding: 0; + top: 0; + border: 0 !important; + border-radius: 0 !important; + background: none !important; + box-shadow: none !important; + font-family: var(--vp-font-family-mono) !important; + font-size: 11px !important; + font-weight: 500 !important; + line-height: 16px !important; + color: var(--g-grey) !important; +} + +/* the modal: a sheet, not a card */ +.DocSearch-Container { + background: rgba(16, 24, 32, 0.32) !important; +} + +.DocSearch-Modal { + margin-top: 88px !important; + border-radius: 2px !important; + border: 1px solid var(--g-line) !important; + box-shadow: 0 16px 48px rgba(16, 24, 32, 0.12) !important; +} + +.DocSearch-SearchBar { + padding: 0 !important; + border-bottom: 1px solid var(--g-line); +} + +.DocSearch-Form { + height: 60px; + padding: 0 20px !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; + background: var(--g-white) !important; +} + +.DocSearch-MagnifierLabel { + color: var(--g-grey); +} + +.DocSearch-Input { + font-size: 17px !important; + font-weight: 400 !important; + padding: 0 0 0 12px !important; +} + +.DocSearch-Reset { + border-radius: 2px; +} + +.DocSearch-Cancel { + font-size: 13px; + color: var(--g-grey); +} + +.DocSearch-Dropdown { + padding: 8px 0 !important; +} + +.DocSearch-Hit-source { + padding: 12px 20px 6px !important; + font-family: var(--vp-font-family-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--g-grey) !important; + background: var(--g-white) !important; +} + +.DocSearch-Hit a { + border-radius: 0 !important; + padding: 0 20px !important; + box-shadow: none !important; + background: transparent !important; +} + +.DocSearch-Hit[aria-selected='true'] a { + background: var(--g-soft) !important; +} + +.DocSearch-Hit-title { + font-size: 14px; +} + +.DocSearch-Hit-path { + font-size: 12px; + color: var(--g-grey); +} + +.DocSearch-Hit-icon, +.DocSearch-Hit-action { + color: var(--g-grey); +} + +.DocSearch-Footer { + height: 48px; + border-radius: 0 !important; + box-shadow: inset 0 1px 0 var(--g-line) !important; +} + +.DocSearch-Commands, +.DocSearch-Logo { + opacity: 0.7; +} + +/* ------------------------------------------------------------------ 4. the sidebar */ +@media (min-width: 960px) { + .VPSidebar { + width: var(--vp-sidebar-width) !important; + padding: calc(var(--vp-nav-height) + 28px) 24px 96px var(--g-shell-inset) !important; + background: var(--g-white) !important; + border-right: 1px solid var(--g-line); + } +} + +.VPSidebar > .curtain { + display: none !important; +} + +.VPSidebar .group { + padding-top: 0 !important; + padding-bottom: 0 !important; + border-top: 0 !important; +} + +.VPSidebar .group + .group { + margin-top: 4px; +} + +/* a section: no rule, no fill, just weight and a caret that answers */ +.VPSidebarItem.level-0 { + padding-bottom: 0 !important; +} + +.VPSidebarItem.level-0 > .item { + width: auto !important; + margin: 0 -24px 0 calc(var(--g-shell-inset) * -1) !important; + padding: 8px 24px 8px var(--g-shell-inset) !important; + border: 0 !important; + border-radius: 0; + line-height: 20px; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.VPSidebarItem.level-0 > .item:hover { + background: var(--g-soft); +} + +.VPSidebarItem.level-0 > .item > .indicator { + display: none !important; +} + +.VPSidebarItem.level-0 .text { + padding: 0 !important; + font-size: 14px; + font-weight: 600; + line-height: 20px; + color: var(--g-ink); +} + +.VPSidebarItem .caret { + width: 20px !important; + height: 20px !important; + margin-right: -4px; + color: var(--g-grey); + opacity: 0.6; + transition: opacity 0.15s ease; +} + +.VPSidebarItem.level-0 > .item:hover .caret { + opacity: 1; +} + +.VPSidebarItem .caret-icon { + width: 16px; + height: 16px; + transition: transform 0.2s ease; +} + +/* the pages inside a section */ +.VPSidebarItem.level-1 .items, +.VPSidebarItem.level-0 > .items { + border-left: 0 !important; + padding-left: 0 !important; + margin: 2px 0 12px !important; +} + +.VPSidebarItem.level-1 > .item { + position: relative; + width: auto !important; + margin: 0 -24px 0 calc(var(--g-shell-inset) * -1) !important; + padding: 6px 24px 6px var(--g-shell-inset) !important; + border: 0 !important; + transition: background-color 0.15s ease; +} + +.VPSidebarItem.level-1 > .item:hover { + background: var(--g-soft); +} + +.VPSidebarItem.level-1 .link .text, +.VPSidebarItem.level-1 .text { + padding: 0 !important; + font-size: 14px; + font-weight: 400; + line-height: 20px; + color: var(--g-grey); + transition: color 0.15s ease; +} + +.VPSidebarItem.level-1 > .item:hover .text { + color: var(--g-ink); +} + +/* you are here: the accent is a 2px mark on the column edge, never a fill */ +.VPSidebarItem.is-active > .item .text, +.VPSidebarItem.is-link.is-active .text { + color: var(--g-cyan-text) !important; + font-weight: 500; +} + +.VPSidebarItem.level-1.is-active > .item::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: var(--g-cyan); +} + +.VPSidebarItem.is-active > .item .link { + background: transparent; +} + +/* ------------------------------------------------------------------ 5. the version control */ +.goravel-version { + position: relative; + display: flex; + margin: 0 0 24px; +} + +.goravel-version .trigger { + display: inline-flex; + align-items: center; + gap: 7px; + width: auto; + height: 30px; + padding: 0 9px 0 10px; + border: 0; + border-radius: 2px; + background: var(--g-soft); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.goravel-version.open .trigger, +.goravel-version .trigger:hover { + background: var(--g-hover); +} + +.goravel-version .v { + font-family: var(--vp-font-family-mono); + font-size: 12px; + font-weight: 500; + color: var(--g-ink); +} + +.goravel-version .note { + font-family: var(--vp-font-family-mono); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--g-cyan-text); +} + +.goravel-version .caret { + width: 7px; + height: 7px; + margin: -2px 0 0 1px; + border-right: 1.3px solid var(--g-grey); + border-bottom: 1.3px solid var(--g-grey); + transform: rotate(45deg); + transition: transform 0.2s ease; +} + +.goravel-version.open .caret { + transform: rotate(-135deg); + margin-top: 2px; +} + +.goravel-version .menu { + position: absolute; + left: 0; + top: 36px; + z-index: 40; + min-width: 208px; + padding: 4px; + border: 1px solid var(--g-line); + border-radius: 2px; + background: var(--g-white); + box-shadow: 0 10px 28px rgba(16, 24, 32, 0.1); +} + +.goravel-version .option { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border: 0; + border-radius: 2px; + font-family: var(--vp-font-family-mono); + font-size: 12px; + color: var(--g-ink); + text-decoration: none; + white-space: nowrap; +} + +.goravel-version .option .host { + margin-left: auto; + color: var(--g-grey); +} + +.goravel-version .option:hover { + background: var(--g-soft); +} + +.goravel-version .option.selected { + color: var(--g-cyan-text); +} + +/* ------------------------------------------------------------------ 6. the outline rail */ +@media (min-width: 1280px) { + .VPDoc .aside-container { + position: sticky !important; + top: calc(var(--vp-nav-height) + 36px) !important; + left: auto !important; + right: auto !important; + width: auto !important; + height: auto !important; + max-height: calc(100vh - var(--vp-nav-height) - 64px); + padding: 0 !important; + overflow-y: auto; + box-sizing: border-box; + } + + .VPDoc .aside-content { + min-height: 0; + padding-bottom: 24px; + } +} + +/* a label in the shell's quiet voice */ +.VPDocAsideOutline .outline-title, +.goravel-methods .label { + font-family: var(--vp-font-family-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--g-grey); +} + +.VPDocAsideOutline, +.goravel-methods { + margin: 0 0 28px !important; + padding: 0 var(--g-shell-inset) 0 var(--g-rail) !important; + border: 0 !important; +} + +.VPDocAsideOutline .content { + border-left: 0; + padding-left: 0; + position: relative; +} + +.VPDocAsideOutline .outline-marker { + display: none; +} + +.VPDocAsideOutline .outline-title { + margin-bottom: 8px; +} + +.VPDocOutlineItem .outline-link { + position: relative; + display: block; + padding: 5px 0 5px 12px; + font-size: 13px; + line-height: 20px; + font-weight: 400; + color: var(--g-grey); + transition: color 0.15s ease; +} + +.VPDocOutlineItem .outline-link:hover { + color: var(--g-ink); +} + +.VPDocOutlineItem .outline-link.active { + color: var(--g-cyan-text); + font-weight: 500; +} + +.VPDocOutlineItem .outline-link.active::before { + content: ''; + position: absolute; + left: 0; + top: 6px; + bottom: 6px; + width: 2px; + background: var(--g-cyan); +} + +.VPDocOutlineItem.nested { + padding-left: 12px; +} + +/* the method index: secondary to the outline, so it is quieter still */ +.goravel-methods { + display: flex; + flex-direction: column; +} + +.goravel-methods .head { + display: flex; + align-items: center; + margin-bottom: 8px; +} + +.goravel-methods .filter { + width: 100%; + height: 30px; + margin: 0 0 6px; + padding: 0 10px; + border: 0; + border-radius: 2px; + background: var(--g-soft); + font-family: var(--vp-font-family-base); + font-size: 13px; + color: var(--g-ink); + transition: background-color 0.15s ease, box-shadow 0.15s ease; +} + +.goravel-methods .filter::placeholder { + color: var(--g-grey); +} + +.goravel-methods .filter:hover { + background: var(--g-hover); +} + +.goravel-methods .filter:focus { + outline: none; + background: var(--g-white); + box-shadow: inset 0 0 0 1px var(--g-cyan); +} + +.goravel-methods .method { + position: relative; + padding: 3px 0 3px 12px; + font-family: var(--vp-font-family-mono); + font-size: 12px; + line-height: 20px; + color: var(--g-grey); + text-decoration: none; + overflow-wrap: anywhere; + transition: color 0.15s ease; +} + +.goravel-methods .method:hover { + color: var(--g-ink); +} + +.goravel-methods .method.active { + color: var(--g-cyan-text); +} + +.goravel-methods .method.active::before { + content: ''; + position: absolute; + left: 0; + top: 5px; + bottom: 5px; + width: 2px; + background: var(--g-cyan); +} + +/* the outline carries the page's headings at every width that has room for it, so the + article does not print them a second time */ +@media (min-width: 960px) { + .VPDoc.has-aside .table-of-contents { + display: none; + } +} + +/* on a phone it is the page's own index: quiet type, not a wall of links */ +@media (max-width: 959px) { + .vp-doc .table-of-contents a { + color: var(--g-ink); + text-decoration: none; + border-bottom: 0; + font-weight: 500; + } + + .vp-doc .table-of-contents ul a { + color: var(--g-grey); + font-weight: 400; + } + + .vp-doc .table-of-contents a:hover { + color: var(--g-cyan-text); + } +} + +/* ------------------------------------------------------------------ 7. the breadcrumb */ +.goravel-doc-meta { + display: flex; + align-items: baseline; + gap: 16px; + margin-bottom: 20px; + font-family: var(--vp-font-family-mono); + font-size: 12px; + line-height: 16px; + letter-spacing: 0.02em; + color: var(--g-grey); +} + +.goravel-doc-meta .crumbs { + display: flex; + gap: 8px; + min-width: 0; +} + +.goravel-doc-meta .crumbs > span { + white-space: nowrap; +} + +.goravel-doc-meta .current { + color: var(--g-ink); + overflow: hidden; + text-overflow: ellipsis; +} + +.goravel-doc-meta .sep { + opacity: 0.55; +} + +.goravel-doc-meta .layer { + margin-left: auto; + color: var(--g-cyan-text); + white-space: nowrap; +} + +@media (max-width: 599px) { + .goravel-doc-meta .crumbs > span:not(.current) { + display: none; + } +} + +/* ------------------------------------------------------------------ 8. tablet: 960 to 1279 */ +@media (min-width: 960px) and (max-width: 1279px) { + .VPNavBarMenu { + padding-left: 32px !important; + } + + .VPNavBarMenuLink, + .VPNavBarMenuGroup .VPFlyout .button { + padding: 0 10px !important; + font-size: 14px !important; + } + + /* search keeps its prominence; the project links fold into the overflow menu */ + .VPNavBarSearch { + width: 216px !important; + } + + .VPNavBar .VPNavBarSocialLinks { + display: none !important; + } + + .VPNavBarSearch .DocSearch-Button-Keys { + display: none !important; + } +} + +/* the narrowest desktop: the language picker folds into the overflow menu too */ +@media (min-width: 960px) and (max-width: 1099px) { + .VPNavBarMenu { + padding-left: 24px !important; + } + + .VPNavBar .VPNavBarTranslations { + display: none !important; + } + + .VPNavBarSearch { + width: 180px !important; + } +} + +/* the local outline bar only exists where the rail does not */ +.VPLocalNav { + background: var(--g-white) !important; + border-top: 0 !important; + border-bottom: 1px solid var(--g-line) !important; +} + +@media (min-width: 960px) { + .VPLocalNav { + padding-left: calc(var(--vp-sidebar-width) + var(--g-gutter)) !important; + } +} + +.VPLocalNav .menu, +.VPLocalNav .VPLocalNavOutlineDropdown button { + font-size: 13px; + color: var(--g-grey); +} + +.VPLocalNavOutlineDropdown .items { + border-radius: 2px !important; + border: 1px solid var(--g-line) !important; + box-shadow: 0 10px 28px rgba(16, 24, 32, 0.1) !important; +} + +/* ------------------------------------------------------------------ 9. phones */ +@media (max-width: 959px) { + /* the hairline belongs to the whole bar here, not to the right of the wordmark */ + .VPNav { + border-bottom: 1px solid var(--g-line); + } + + .VPNavBar .container { + padding: 0 20px !important; + } + + .VPNavBar .container > .title { + padding: 0 !important; + border: 0 !important; + } + + .VPNavBar .content-body { + padding: 0 !important; + border-bottom: 0; + background: var(--g-white) !important; + } + + .VPNavBar .VPNavBarTitle .logo, + .VPNavBar .VPNavBarTitle img { + height: 24px !important; + width: auto !important; + } + + /* the bar carries what a phone needs: the mark, search, and the way in */ + .VPNavBarSearch { + order: 1; + margin-left: auto; + } + + .VPNavBarSearch .DocSearch-Button { + width: var(--g-control) !important; + height: var(--g-control) !important; + padding: 0 !important; + background: transparent !important; + justify-content: center; + } + + .VPNavBarSearch .DocSearch-Button:hover { + background: var(--g-soft) !important; + } + + .VPNavBarSearch .DocSearch-Button-Placeholder, + .VPNavBarSearch .DocSearch-Button-Keys { + display: none !important; + } + + /* the default theme slides the middle bar aside to make the cross; with a square + target it has nowhere to slide to, so it leaves instead */ + .VPNavBarHamburger.active .middle { + opacity: 0; + } + + .VPNav { + border-bottom: 1px solid var(--g-line); + } + + .VPNavBar { + border-bottom: 0 !important; + } + + /* search then the way in, both at the right edge */ + .VPNavBarHamburger { + order: 2; + width: var(--g-control) !important; + height: var(--g-control) !important; + margin-left: 2px; + border-radius: 2px; + } + + .VPNavBarHamburger:hover { + background: var(--g-soft); + } + + .VPSidebar { + padding: 24px 20px 96px !important; + background: var(--g-white) !important; + border-right: 1px solid var(--g-line); + } + + .VPSidebarItem.level-0 > .item, + .VPSidebarItem.level-1 > .item { + margin-left: -20px !important; + margin-right: -20px !important; + padding-left: 20px !important; + padding-right: 20px !important; + } + + .DocSearch-Modal { + margin-top: 0 !important; + border: 0 !important; + } +} + +/* ------------------------------------------------------------------ 10. buttons */ +.VPButton { + border: 0 !important; + border-radius: 2px !important; + font-weight: 600 !important; + letter-spacing: 0 !important; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.VPButton.brand { + background: var(--g-cyan) !important; + color: var(--g-white) !important; +} + +.VPButton.brand:hover { + background: var(--g-cyan-text) !important; +} + +.VPButton.alt { + background: var(--g-soft) !important; + color: var(--g-ink) !important; +} + +.VPButton.alt:hover { + background: var(--g-hover) !important; +} + +.VPButton.medium { + height: 46px; + line-height: 46px; + padding: 0 22px; +} + +/* ------------------------------------------------------------------ 11. not found */ +.goravel-404 { + display: grid; + grid-template-columns: minmax(0, 1fr) 400px; + min-height: calc(100vh - var(--vp-nav-height)); + margin: 0; + padding: 0; +} + +.goravel-404 .left { + padding: 112px var(--g-gutter) 160px; + border-right: 1px solid var(--g-line); +} + +.goravel-404 .code { + font-family: var(--vp-font-family-mono); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.14em; + color: var(--g-grey); +} + +.goravel-404 .title { + margin-top: 18px; + font-size: 40px; + line-height: 46px; + font-weight: 600; + letter-spacing: -0.03em; +} + +.goravel-404 .lead { + margin-top: 14px; + max-width: 540px; + font-size: 17px; + line-height: 28px; + color: var(--g-grey); +} + +.goravel-404 .home { + display: inline-flex; + align-items: center; + height: 46px; + margin-top: 28px; + padding: 0 22px; + border-radius: 2px; + background: var(--g-cyan); + color: var(--g-white); + font-size: 15px; + font-weight: 600; + text-decoration: none; +} + +.goravel-404 .home:hover { + background: var(--g-cyan-text); +} + +.goravel-404 .right { + display: flex; + flex-direction: column; + padding: 112px var(--g-shell-inset) 0 var(--g-rail); +} + +.goravel-404 .label { + font-family: var(--vp-font-family-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--g-grey); + margin-bottom: 10px; +} + +.goravel-404 .suggestion { + position: relative; + padding: 8px 0 8px 12px; + border: 0; + font-size: 15px; + font-weight: 400; + color: var(--g-grey); + text-decoration: none; + transition: color 0.15s ease; +} + +.goravel-404 .suggestion:hover { + color: var(--g-cyan-text); +} + +.goravel-404 .suggestion:hover::before { + content: ''; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 2px; + background: var(--g-cyan); +} + +@media (max-width: 959px) { + .goravel-404 { + grid-template-columns: minmax(0, 1fr); + } + + .goravel-404 .left { + padding: 56px 24px 64px; + border-right: 0; + } + + .goravel-404 .title { + font-size: 32px; + line-height: 38px; + } + + .goravel-404 .right { + padding: 40px 24px 72px; + } +} + +/* ------------------------------------------------------------------ 12. the phone menu + The drawer follows the sidebar's rules: no separators, weight and spacing instead. */ +.VPNavScreen { + background: var(--g-white) !important; + border-top: 0; +} + +.VPNavScreen { + padding: 0 !important; +} + +.VPNavScreen .container { + max-width: none !important; + margin: 0 !important; + padding: 24px 24px 96px !important; +} + +/* the rows start on the same vertical as the wordmark above them */ +.VPNavScreenMenu, +.VPNavScreenTranslations, +.VPNavScreen .VPSocialLinks { + padding-left: 0 !important; + margin-left: 0 !important; +} + +.VPNavScreenMenuLink, +.VPNavScreenMenuGroupLink { + padding: 10px 0 !important; + border-bottom: 0 !important; + font-size: 16px !important; + font-weight: 500 !important; + color: var(--g-ink) !important; +} + +.VPNavScreenMenuGroup { + border-bottom: 0 !important; +} + +.VPNavScreenMenuGroupSection .title, +.VPNavScreenMenuGroup .button { + padding: 10px 0 !important; + border-bottom: 0 !important; + font-size: 16px !important; + font-weight: 500 !important; + color: var(--g-ink) !important; +} + +.VPNavScreenMenuGroup .button-icon { + color: var(--g-grey); +} + +.VPNavScreenMenuGroup .items { + padding-left: 14px; +} + +/* one theme is designed, so the switch has nothing to offer yet */ +.VPNavScreenAppearance { + display: none !important; +} + +/* the locale switch is a fixed height collapsible: it takes margin, never padding */ +.VPNavScreenTranslations { + margin-top: 28px !important; + padding: 0 !important; + border: 0 !important; +} + +.VPNavScreenTranslations .title { + font-size: 15px; + font-weight: 500; + color: var(--g-ink); +} + +.VPNavScreenTranslations .icon.lang { + color: var(--g-grey); +} + +.VPNavScreenTranslations-link { + font-size: 15px; + color: var(--g-grey); +} + +.VPNavScreen .VPSocialLinks { + margin-top: 28px; + justify-content: flex-start; + gap: 4px; +} + +.VPNavScreen .VPSocialLink { + width: var(--g-control) !important; + height: var(--g-control) !important; + border-radius: 2px; + color: var(--g-grey) !important; +} + +.VPNavScreen .VPSocialLink:hover { + background: var(--g-soft); + color: var(--g-ink) !important; +} + +/* a page with no headings has nothing for the local outline bar to say */ +.VPLocalNav.empty { + display: none !important; +} + +/* ------------------------------------------------------------------ nav dropdowns + A sheet, not a floating card: the same 3px corner and hairline as every other control, + one soft shadow, and rows that highlight the way the sidebar's rows do. */ +.VPNavBar .VPMenu { + min-width: 184px; + padding: 6px !important; + border: 1px solid var(--g-line) !important; + border-radius: 3px !important; + background: var(--g-white) !important; + box-shadow: 0 10px 28px rgba(16, 24, 32, 0.1) !important; +} + +.VPNavBar .VPMenu .VPMenuLink + .VPMenuLink, +.VPNavBar .VPMenu .VPMenuGroup + .VPMenuGroup { + border-top: 0 !important; +} + +.VPNavBar .VPMenu .link { + padding: 8px 12px !important; + border-radius: 2px !important; + font-size: 15px !important; + font-weight: 500 !important; + line-height: 22px !important; + color: var(--g-ink) !important; + transition: background-color 0.12s ease, color 0.12s ease; +} + +.VPNavBar .VPMenu .link:hover { + background: var(--g-soft) !important; + color: var(--g-cyan-text) !important; +} + +.VPNavBar .VPMenu .link.active { + color: var(--g-cyan-text) !important; +} + +/* an icon a row may carry sits back with the type */ +.VPNavBar .VPMenu .link img, +.VPNavBar .VPMenu .link svg { + width: 16px !important; + height: 16px !important; + border-radius: 2px; +} + +/* The icon's own direction is right, and the theme turns it a quarter to point down. Both + states are set here so the open state is a half turn from the closed one, not a guess. */ +.VPNavBar .vpi-chevron-down { + transform: rotate(90deg) !important; +} + +.VPNavBar .VPNavBarMenuGroup:hover .vpi-chevron-down, +.VPNavBar .VPNavBarTranslations:hover .vpi-chevron-down { + transform: rotate(270deg) !important; + color: var(--g-cyan-text); +} + +/* a little more air between the links than the default */ +@media (min-width: 960px) { + .VPNavBarMenuLink, + .VPNavBarMenuGroup .VPFlyout .button { + padding: 0 16px !important; + } +} + +/* ------------------------------------------------------------------ search on a phone + The sheet is the screen: the field takes the width it has, and Cancel gets the room the + word actually needs instead of being clipped by the edge. */ +@media (max-width: 959px) { + .DocSearch-Modal { + margin: 0 !important; + width: 100% !important; + max-width: 100% !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; + } + + .DocSearch-SearchBar { + padding: 0 16px !important; + } + + .DocSearch-Form { + height: 56px; + padding: 0 !important; + } + + .DocSearch-MagnifierLabel svg { + width: 18px; + height: 18px; + } + + .DocSearch-Input { + font-size: 16px !important; + padding-left: 10px !important; + } + + .DocSearch-Cancel { + flex-shrink: 0; + width: auto !important; + margin-left: 12px !important; + padding: 0 !important; + background: none !important; + font-size: 15px !important; + font-weight: 500; + color: var(--g-cyan-text) !important; + } + + .DocSearch-Dropdown { + background: var(--g-white) !important; + } + + .DocSearch-StartScreen, + .DocSearch-NoResults { + background: var(--g-white); + } + + .DocSearch-Footer { + background: var(--g-white) !important; + } +} + +/* ------------------------------------------------------------------ the search sheet + One surface. The sheet is white, a row tints only when it is the one you are on, and + the actions beside a row sit back until the row is selected. */ +.DocSearch-Modal, +.DocSearch-Dropdown, +.DocSearch-StartScreen, +.DocSearch-NoResults, +.DocSearch-Footer { + background: var(--g-white) !important; +} + +.DocSearch-Hit a { + background: var(--g-white) !important; + padding: 0 20px !important; +} + +.DocSearch-Hit[aria-selected='true'] a { + background: var(--g-soft) !important; +} + +.DocSearch-Hit-title { + font-size: 14.5px; + font-weight: 500; +} + +.DocSearch-Hit-path { + font-size: 12.5px; +} + +.DocSearch-Hit-icon, +.DocSearch-Hit-action { + color: var(--g-grey); +} + +.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-action, +.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-icon { + color: var(--g-cyan-text); +} + +.DocSearch-Hit-action svg { + width: 16px; + height: 16px; +} + +/* the source label speaks in the same voice as every other label on the site */ +.DocSearch-Hit-source { + font-family: var(--vp-font-family-mono); + font-size: 11px !important; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--g-grey) !important; + background: var(--g-white) !important; +} + +.DocSearch-Commands, +.DocSearch-Logo, +.DocSearch-Label { + font-size: 12px; + color: var(--g-grey); +} + +.DocSearch-Commands-Key { + background: var(--g-soft) !important; + box-shadow: none !important; + border-radius: 2px; + color: var(--g-grey); +} + +/* ------------------------------------------------------------------ the page footer + One line, in the same quiet mono as every other piece of meta, sitting on the page's + own inset rather than centred in the middle of nowhere. */ +.VPFooter { + padding: 20px var(--g-shell-inset) !important; + border-top: 1px solid var(--g-line) !important; + background: var(--g-white) !important; +} + +.VPFooter .container { + max-width: none !important; + margin: 0 !important; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + text-align: left !important; +} + +.VPFooter .message, +.VPFooter .copyright { + margin: 0 !important; + font-family: var(--vp-font-family-mono); + font-size: 12px !important; + line-height: 18px !important; + letter-spacing: 0.02em; + color: var(--g-grey) !important; + text-align: left !important; +} + +.VPFooter .copyright::before { + content: '·'; + margin-right: 10px; + color: var(--g-line); +} + +@media (max-width: 959px) { + .VPFooter { + padding: 20px 24px !important; + } + + .VPFooter .copyright::before { + content: none; + } +} diff --git a/en/index.md b/en/index.md index 684cf767a..8da9d4492 100644 --- a/en/index.md +++ b/en/index.md @@ -1,5 +1,4 @@ --- -layout: home title: Goravel - Full-featured Golang Development Framework head: @@ -46,32 +45,9 @@ head: ] - ['meta', { name: twitter:image, content: https://www.goravel.dev/meta.png }] -hero: - name: Goravel - text: Full-featured Golang Development Framework - tagline: Componentization, High-performance, easy-to-extend, PHPers' first choice. - # image: /logo.svg - actions: - - theme: brand - text: Quick Start - link: /getting-started/installation - - theme: alt - text: 🌟 Github Star - link: https://github.com/goravel/goravel - target: _blank -features: - - title: 🧰 Lite Or Full-featured - details: You can choose between a lightweight version with essential features or a full-featured version that includes ORM, HTTP, logging, caching, and other basic tools for all development. - - title: 💻 Componentization - details: Modular design, each module is independent and can be used separately, making development and maintenance more convenient. - - title: 🚀 High-performance - details: Built with Golang, integrating multiple excellent extensions to create fast and responsive applications. - - title: 📈 Easy-to-extend - details: The module provides multiple drivers, and can develop extension packages according to needs, suitable for applications of any scale, from small projects to large systems. - - title: 👥 Active community - details: Supported by an active community, they contribute to its development, documentation, and continuous support. - - title: 🔄 PHPers' first choice - details: The framework style is consistent with Laravel, allowing PHPer to create powerful Golang applications without learning a new framework. Tribute to Laravel! +layout: goravel-home +sidebar: false +aside: false ---
Contributors
From a661d154fc53d6e77de05c898851132cb6ae07e7 Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Wed, 16 Sep 2026 21:03:17 +0530 Subject: [PATCH 02/32] Add Testing to the Laravel comparison and deepen the examples The comparison carried one line per concept in places, which showed the naming matches but not that the shape of the code matches. Each concept now shows enough of the real call to be judged: - Validation shows the rules map and what happens when it fails - Queues shows job arguments and the queue it goes on - Cache shows Remember with the closure that fills it - ORM adds the limit so the chain reads as a chain - Scheduling wraps the way it is actually written Testing is added as a concept, since it is one of the reasons to pick a framework at all: a feature test hitting /tasks and asserting 200, in PHPUnit and in Goravel's suite. Every Go snippet uses the API as documented: facades.Cache().Remember, facades.Queue().Job().OnQueue().Dispatch, s.Http(s.T()).Get and response.AssertStatus. Also ignore design/, which holds the design sources rather than code that ships with the site. --- .gitignore | 4 ++- .vitepress/theme/home/content.ts | 44 ++++++++++++++++++-------------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 21df78e4e..902a56144 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ node_modules .vitepress/dist .idea .vscode -.DS_Store \ No newline at end of file +.DS_Store +# design sources are not shipped with the site +design/ diff --git a/.vitepress/theme/home/content.ts b/.vitepress/theme/home/content.ts index 4136877dc..aead441d9 100644 --- a/.vitepress/theme/home/content.ts +++ b/.vitepress/theme/home/content.ts @@ -100,44 +100,50 @@ export const CONCEPTS: Concept[] = [ }, { name: 'ORM', layer: 'data', phpFile: 'app/Services/TaskService.php', goFile: 'app/services/task_service.go', - php: "$tasks = Task::{{where}}('done', false)\n ->{{orderByDesc}}('created_at')\n ->{{get}}();", - go: 'var tasks []models.Task\n[[facades.Orm]]().[[Query]]().\n\t[[Where]]("done", false).\n\t[[OrderByDesc]]("created_at").\n\t[[Get]](&tasks)', - ties: [[1, 3], [2, 4], [3, 5]] + php: "$tasks = Task::{{where}}('done', false)\n ->{{orderByDesc}}('created_at')\n ->{{limit}}(20)\n ->{{get}}();", + go: 'var tasks []models.Task\n[[facades.Orm]]().[[Query]]().\n\t[[Where]]("done", false).\n\t[[OrderByDesc]]("created_at").\n\t[[Limit]](20).\n\t[[Get]](&tasks)', + ties: [[1, 3], [2, 4], [3, 5], [4, 6]] }, { name: 'Validation', layer: 'app', phpFile: 'app/Http/Controllers/TaskController.php', goFile: 'app/http/controllers/task_controller.go', - php: "$request->{{validate}}([\n 'title' => 'required|max:255',\n]);", - go: 'validator, err := ctx.[[Request]]().[[Validate]](map[string]any{\n\t"title": "required|max:255",\n})', - ties: [[1, 1], [2, 2]] + php: "$validated = $request->{{validate}}([\n 'title' => 'required|max:255',\n 'due_at' => 'required|date',\n]);", + go: 'validator, err := ctx.[[Request]]().[[Validate]](map[string]any{\n\t"title": "required|max:255",\n\t"due_at": "required|date",\n})\nif validator.[[Fails]]() {\n\treturn ctx.[[Response]]().[[Status]](422).\n\t\t[[Json]](validator.Errors().All())\n}', + ties: [[1, 1], [2, 2], [3, 3]] }, { name: 'Queues', layer: 'async', phpFile: 'app/Services/TaskService.php', goFile: 'app/services/task_service.go', - php: 'SyncTasks::{{dispatch}}();', - go: '[[facades.Queue]]().\n\t[[Job]](&jobs.SyncTasks{}, []queue.Arg{}).\n\t[[Dispatch]]()', - ties: [[1, 3]] + php: "SyncTasks::{{dispatch}}($task)\n ->{{onQueue}}('sync');", + go: '[[facades.Queue]]().\n\t[[Job]](&jobs.SyncTasks{}, []queue.Arg{\n\t\t{Type: "int", Value: task.ID},\n\t}).\n\t[[OnQueue]]("sync").\n\t[[Dispatch]]()', + ties: [[1, 2], [2, 5]] }, { name: 'Events', layer: 'async', phpFile: 'app/Services/TaskService.php', goFile: 'app/services/task_service.go', - php: 'TasksViewed::{{dispatch}}();', - go: '[[facades.Event]]().\n\t[[Job]](&events.TasksViewed{}, []event.Arg{}).\n\t[[Dispatch]]()', - ties: [[1, 3]] + php: 'TasksViewed::{{dispatch}}($tasks);', + go: '[[facades.Event]]().\n\t[[Job]](&events.TasksViewed{}, []event.Arg{\n\t\t{Type: "int", Value: len(tasks)},\n\t}).\n\t[[Dispatch]]()', + ties: [[1, 2]] }, { name: 'Scheduling', layer: 'async', phpFile: 'routes/console.php', goFile: 'bootstrap/app.go', - php: "Schedule::{{command}}('emails:send')->{{daily}}();", - go: '[[WithSchedule]](func() []schedule.Event {\n\treturn []schedule.Event{\n\t\t[[facades.Schedule]]().[[Command]]("emails:send").[[Daily]](),\n\t}\n})', - ties: [[1, 3]] + php: "Schedule::{{command}}('emails:send')\n ->{{daily}}();", + go: '[[WithSchedule]](func() []schedule.Event {\n\treturn []schedule.Event{\n\t\t[[facades.Schedule]]().[[Command]]("emails:send").\n\t\t\t[[Daily]](),\n\t}\n})', + ties: [[1, 3], [2, 4]] }, { name: 'Cache', layer: 'data', phpFile: 'app/Services/TaskService.php', goFile: 'app/services/task_service.go', - php: "Cache::{{put}}('key', 'value', 60);", - go: '[[facades.Cache]]().[[Put]]("key", "value", time.Minute)', - ties: [[1, 1]] + php: "$tasks = Cache::{{remember}}('tasks.open', 600, function () {\n return Task::{{where}}('done', false)->{{get}}();\n});", + go: '[[facades.Cache]]().[[Remember]]("tasks.open", 10*time.Minute,\n\tfunc() (any, error) {\n\t\tvar tasks []models.Task\n\t\terr := [[facades.Orm]]().[[Query]]().\n\t\t\t[[Where]]("done", false).[[Get]](&tasks)\n\t\treturn tasks, err\n\t})', + ties: [[1, 1], [2, 4]] + }, + { + name: 'Testing', layer: 'core', phpFile: 'tests/Feature/TaskTest.php', goFile: 'tests/feature/task_test.go', + php: "public function {{test_it_lists_open_tasks}}(): void\n{\n $this->{{get}}('/tasks')\n ->{{assertStatus}}(200);\n}", + go: 'func (s *TaskTestSuite) [[TestIndex]]() {\n\tresponse, err := s.[[Http]](s.T()).[[Get]]("/tasks")\n\ts.[[Nil]](err)\n\tresponse.[[AssertStatus]](200)\n}', + ties: [[1, 1], [3, 2], [4, 4]] }, { name: 'Artisan', layer: 'core', phpFile: 'Terminal', goFile: 'Terminal', php: 'php artisan {{make:controller}} TaskController', - go: './artisan make:controller TaskController', + go: './artisan [[make:controller]] TaskController', ties: [[1, 1]] } ] From 2cddc99f59fdd81c97b4186ba03a172ea53228c1 Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Wed, 16 Sep 2026 21:21:35 +0530 Subject: [PATCH 03/32] Carry the homepage's label voice into the docs shell The homepage speaks in small mono labels marked with the lattice cell the mark is cut from. The docs used the same mono but not the same marker, so the two read as separate products. - The layer tag becomes a label: uppercase, tracked, with a cyan cell - On this page and Methods take the same cell in construct grey - The method index drops to 11.5px on an 18px line and gains air above it, so it reads as an aid to the outline rather than a second outline A page now says which layer of the framework it belongs to in the same voice the homepage used to teach the five layers. --- .vitepress/theme/shell.css | 43 ++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/.vitepress/theme/shell.css b/.vitepress/theme/shell.css index 2f95dd46d..b9ed060db 100644 --- a/.vitepress/theme/shell.css +++ b/.vitepress/theme/shell.css @@ -800,7 +800,12 @@ color: var(--g-grey); } -.VPDocAsideOutline, +.VPDocAsideOutline { + margin: 0 0 36px !important; + padding: 0 var(--g-shell-inset) 0 var(--g-rail) !important; + border: 0 !important; +} + .goravel-methods { margin: 0 0 28px !important; padding: 0 var(--g-shell-inset) 0 var(--g-rail) !important; @@ -897,10 +902,10 @@ .goravel-methods .method { position: relative; - padding: 3px 0 3px 12px; + padding: 2px 0 2px 12px; font-family: var(--vp-font-family-mono); - font-size: 12px; - line-height: 20px; + font-size: 11.5px; + line-height: 18px; color: var(--g-grey); text-decoration: none; overflow-wrap: anywhere; @@ -986,11 +991,41 @@ } .goravel-doc-meta .layer { + display: inline-flex; + align-items: center; + gap: 8px; margin-left: auto; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; color: var(--g-cyan-text); white-space: nowrap; } +/* the lattice cell, the one marker shape the whole site uses */ +.goravel-doc-meta .layer::before, +.VPDocAsideOutline .outline-title::before, +.goravel-methods .label::before { + content: ''; + width: 6px; + height: 6px; + flex-shrink: 0; + background: currentColor; + transform: rotate(45deg); +} + +.VPDocAsideOutline .outline-title, +.goravel-methods .label { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.VPDocAsideOutline .outline-title::before, +.goravel-methods .label::before { + background: var(--g-construct); +} + @media (max-width: 599px) { .goravel-doc-meta .crumbs > span:not(.current) { display: none; From 3d0502fb7638a97dc4c7623f1e18088760c13a5d Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Wed, 16 Sep 2026 21:32:35 +0530 Subject: [PATCH 04/32] Rebuild the primary nav around four destinations The bar mixed destinations with controls: Quickstart and Translate were content links, Versions and Video Tutorials were menus, and none of them described the shape of the documentation. Four destinations now do: - Docs, the way in: installation, configuration, structure - Guides, the how to: the basics, digging deeper, database, ORM, testing, security, AI - Framework, the internals: lifecycle, container, providers, facades - Community, a menu: GitHub, Discord, the video series, the contribution guide and how to add a language Versions leaves the bar because the sidebar already carries a version control, and Translate leaves it because the locale switcher already sits beside the search. Each destination sets an activeMatch so the bar shows where the reader is. Applied to all three locales. --- .vitepress/config/en.ts | 47 ++++++++++++----------- .vitepress/config/uz_UZ.ts | 76 +++++++++++++++++++++++--------------- .vitepress/config/zh_CN.ts | 76 +++++++++++++++++++++++--------------- 3 files changed, 120 insertions(+), 79 deletions(-) diff --git a/.vitepress/config/en.ts b/.vitepress/config/en.ts index ff0423070..4d3799c9c 100644 --- a/.vitepress/config/en.ts +++ b/.vitepress/config/en.ts @@ -100,43 +100,48 @@ export const config = defineConfig({ function nav(): DefaultTheme.NavItem[] { return [ { - text: 'Quickstart', + text: 'Docs', link: '/getting-started/installation', activeMatch: '/getting-started/' }, { - text: 'Video Tutorials', + text: 'Guides', + link: '/the-basics/routing', + activeMatch: '/(the-basics|digging-deeper|database|orm|testing|security|ai)/' + }, + { + text: 'Framework', + link: '/architecture-concepts/request-lifecycle', + activeMatch: '/architecture-concepts/' + }, + { + text: 'Community', items: [ { - text: 'YouTube', - link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' + text: 'GitHub', + link: 'https://github.com/goravel/goravel' }, { - text: 'DevChalk', - link: 'https://devchalk.com/goravel' + text: 'Discord', + link: 'https://discord.gg/cFc5csczzS' }, - ] - }, - { - text: 'Versions', - items: [ { - text: 'v1.18 (Latest)', - link: 'https://www.goravel.dev/' + text: 'Video Tutorials', + link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' }, { - text: 'v1.17', - link: 'https://v117.goravel.dev/' + text: 'DevChalk', + link: 'https://devchalk.com/goravel' }, { - text: 'v1.16', - link: 'https://v116.goravel.dev/' + text: 'Contribution Guide', + link: '/prologue/contributions' }, + { + text: 'Add a Language', + link: '/prologue/contributions#add-a-new-language' + } ] - }, - { - text: 'Translate', - link: '/prologue/contributions#add-a-new-language' } ] } diff --git a/.vitepress/config/uz_UZ.ts b/.vitepress/config/uz_UZ.ts index 0201370ae..3957f0a25 100644 --- a/.vitepress/config/uz_UZ.ts +++ b/.vitepress/config/uz_UZ.ts @@ -94,36 +94,54 @@ export const config = defineConfig({ } }); function nav(): DefaultTheme.NavItem[] { - return [{ - text: "Tez boshlash", - link: "/uz_UZ/getting-started/installation", - activeMatch: "/uz_UZ/getting-started/" - }, { - text: "Video darsliklar", - items: [{ - text: 'YouTube', - link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' - }, { - text: 'DevChalk', - link: 'https://devchalk.com/goravel' - }] - }, { - text: "Versiyalar", - items: [{ - text: "v1.18 (Eng so'nggi)", - link: 'https://www.goravel.dev/' - }, { - text: "v1.17", - link: 'https://v117.goravel.dev/' - }, { - text: 'v1.16', - link: 'https://v116.goravel.dev/' - }] - }, { - text: "Tarjima qilish", - link: "/uz_UZ/prologue/contributions#add-a-new-language" - }]; + return [ + { + text: 'Hujjatlar', + link: '/uz_UZ/getting-started/installation', + activeMatch: '/uz_UZ/getting-started/' + }, + { + text: "Qo'llanmalar", + link: '/uz_UZ/the-basics/routing', + activeMatch: '/uz_UZ/(the-basics|digging-deeper|database|orm|testing|security|ai)/' + }, + { + text: 'Freymvork', + link: '/uz_UZ/architecture-concepts/request-lifecycle', + activeMatch: '/uz_UZ/architecture-concepts/' + }, + { + text: 'Hamjamiyat', + items: [ + { + text: 'GitHub', + link: 'https://github.com/goravel/goravel' + }, + { + text: 'Discord', + link: 'https://discord.gg/cFc5csczzS' + }, + { + text: 'Video darsliklar', + link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' + }, + { + text: 'DevChalk', + link: 'https://devchalk.com/goravel' + }, + { + text: "Hissa qo'shish", + link: '/uz_UZ/prologue/contributions' + }, + { + text: "Til qo'shish", + link: '/uz_UZ/prologue/contributions#add-a-new-language' + } + ] + } + ] } + function sidebarGettingStarted(): DefaultTheme.SidebarItem[] { return [{ text: "O'rnatish", diff --git a/.vitepress/config/zh_CN.ts b/.vitepress/config/zh_CN.ts index 084897d37..4db78ae23 100644 --- a/.vitepress/config/zh_CN.ts +++ b/.vitepress/config/zh_CN.ts @@ -94,36 +94,54 @@ export const config = defineConfig({ } }); function nav(): DefaultTheme.NavItem[] { - return [{ - text: "快速开始", - link: "/zh_CN/getting-started/installation", - activeMatch: "/zh_CN/getting-started/" - }, { - text: "视频教程", - items: [{ - text: 'YouTube', - link: "https://space.bilibili.com/1886603340/channel/seriesdetail?sid=4302621&ctype=0" - }, { - text: 'DevChalk', - link: 'https://devchalk.com/goravel' - }] - }, { - text: "版本", - items: [{ - text: "v1.18 (最新)", - link: 'https://www.goravel.dev/' - }, { - text: "v1.17", - link: 'https://v117.goravel.dev/' - }, { - text: 'v1.16', - link: 'https://v116.goravel.dev/' - }] - }, { - text: "翻译", - link: "/zh_CN/prologue/contributions#新增语言" - }]; + return [ + { + text: '文档', + link: '/zh_CN/getting-started/installation', + activeMatch: '/zh_CN/getting-started/' + }, + { + text: '指南', + link: '/zh_CN/the-basics/routing', + activeMatch: '/zh_CN/(the-basics|digging-deeper|database|orm|testing|security|ai)/' + }, + { + text: '框架', + link: '/zh_CN/architecture-concepts/request-lifecycle', + activeMatch: '/zh_CN/architecture-concepts/' + }, + { + text: '社区', + items: [ + { + text: 'GitHub', + link: 'https://github.com/goravel/goravel' + }, + { + text: 'Discord', + link: 'https://discord.gg/cFc5csczzS' + }, + { + text: '视频教程', + link: 'https://space.bilibili.com/1886603340/channel/seriesdetail?sid=4302621&ctype=0' + }, + { + text: 'DevChalk', + link: 'https://devchalk.com/goravel' + }, + { + text: '贡献指南', + link: '/zh_CN/prologue/contributions' + }, + { + text: '新增语言', + link: '/zh_CN/prologue/contributions#新增语言' + } + ] + } + ] } + function sidebarGettingStarted(): DefaultTheme.SidebarItem[] { return [{ text: "安装", From c6281d0d50e303060e286c299d6d648fc34a8aea Mon Sep 17 00:00:00 2001 From: kkumar-gcc Date: Wed, 16 Sep 2026 21:42:37 +0530 Subject: [PATCH 05/32] Link the facade list, group the Community menu, complete the Lite build Three gaps found while reading the page rather than measuring it. The thirty facades on the homepage were plain text. They are the quickest way into the reference, so each one now links to the page that documents it, from Route through to Telemetry. The Community menu pulled every link into one list. It now carries three sections laid side by side, each with a label in the shell's voice: Connect, Learn and Contribute. A menu with sections reads as sections. The Lite build skipped a layer. Four steps added one piece each and then Install everything filled in the Application layer along with the other twenty four facades, so one of the five pieces never arrived on its own. Adding Validation gives every layer its own moment: core, HTTP, data, async, application, then the whole set. --- .vitepress/config/en.ts | 41 +++++++++++---------- .vitepress/config/uz_UZ.ts | 39 +++++++++++--------- .vitepress/config/zh_CN.ts | 41 +++++++++++---------- .vitepress/theme/home/GoravelHome.vue | 6 ++-- .vitepress/theme/home/content.ts | 1 + .vitepress/theme/home/geometry.ts | 34 ++++++++++++++++++ .vitepress/theme/home/home.css | 14 ++++++++ .vitepress/theme/shell.css | 51 +++++++++++++++++++++++++++ 8 files changed, 172 insertions(+), 55 deletions(-) diff --git a/.vitepress/config/en.ts b/.vitepress/config/en.ts index 4d3799c9c..f238ad802 100644 --- a/.vitepress/config/en.ts +++ b/.vitepress/config/en.ts @@ -118,28 +118,33 @@ function nav(): DefaultTheme.NavItem[] { text: 'Community', items: [ { - text: 'GitHub', - link: 'https://github.com/goravel/goravel' + text: 'Connect', + items: [ + { text: 'GitHub', link: 'https://github.com/goravel/goravel' }, + { text: 'Discord', link: 'https://discord.gg/cFc5csczzS' }, + { text: 'X', link: 'https://x.com/goravel_dev' } + ] }, { - text: 'Discord', - link: 'https://discord.gg/cFc5csczzS' + text: 'Learn', + items: [ + { + text: 'Video Tutorials', + link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' + }, + { + text: 'DevChalk', + link: 'https://devchalk.com/goravel' + } + ] }, { - text: 'Video Tutorials', - link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' - }, - { - text: 'DevChalk', - link: 'https://devchalk.com/goravel' - }, - { - text: 'Contribution Guide', - link: '/prologue/contributions' - }, - { - text: 'Add a Language', - link: '/prologue/contributions#add-a-new-language' + text: 'Contribute', + items: [ + { text: 'Contribution Guide', link: '/prologue/contributions' }, + { text: 'Add a Language', link: '/prologue/contributions#add-a-new-language' }, + { text: 'Open Collective', link: 'https://opencollective.com/goravel' } + ] } ] } diff --git a/.vitepress/config/uz_UZ.ts b/.vitepress/config/uz_UZ.ts index 3957f0a25..d83b06035 100644 --- a/.vitepress/config/uz_UZ.ts +++ b/.vitepress/config/uz_UZ.ts @@ -114,28 +114,33 @@ function nav(): DefaultTheme.NavItem[] { text: 'Hamjamiyat', items: [ { - text: 'GitHub', - link: 'https://github.com/goravel/goravel' + text: 'Aloqa', + items: [ + { text: 'GitHub', link: 'https://github.com/goravel/goravel' }, + { text: 'Discord', link: 'https://discord.gg/cFc5csczzS' }, + { text: 'X', link: 'https://x.com/goravel_dev' } + ] }, { - text: 'Discord', - link: 'https://discord.gg/cFc5csczzS' - }, - { - text: 'Video darsliklar', - link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' - }, - { - text: 'DevChalk', - link: 'https://devchalk.com/goravel' + text: "O'rganish", + items: [ + { + text: 'Video darsliklar', + link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' + }, + { + text: 'DevChalk', + link: 'https://devchalk.com/goravel' + } + ] }, { text: "Hissa qo'shish", - link: '/uz_UZ/prologue/contributions' - }, - { - text: "Til qo'shish", - link: '/uz_UZ/prologue/contributions#add-a-new-language' + items: [ + { text: "Hissa qo'shish bo'yicha qo'llanma", link: '/uz_UZ/prologue/contributions' }, + { text: "Til qo'shish", link: '/uz_UZ/prologue/contributions#add-a-new-language' }, + { text: 'Open Collective', link: 'https://opencollective.com/goravel' } + ] } ] } diff --git a/.vitepress/config/zh_CN.ts b/.vitepress/config/zh_CN.ts index 4db78ae23..2a23b1c73 100644 --- a/.vitepress/config/zh_CN.ts +++ b/.vitepress/config/zh_CN.ts @@ -114,28 +114,33 @@ function nav(): DefaultTheme.NavItem[] { text: '社区', items: [ { - text: 'GitHub', - link: 'https://github.com/goravel/goravel' + text: '联系', + items: [ + { text: 'GitHub', link: 'https://github.com/goravel/goravel' }, + { text: 'Discord', link: 'https://discord.gg/cFc5csczzS' }, + { text: 'X', link: 'https://x.com/goravel_dev' } + ] }, { - text: 'Discord', - link: 'https://discord.gg/cFc5csczzS' + text: '学习', + items: [ + { + text: '视频教程', + link: 'https://space.bilibili.com/1886603340/channel/seriesdetail?sid=4302621&ctype=0' + }, + { + text: 'DevChalk', + link: 'https://devchalk.com/goravel' + } + ] }, { - text: '视频教程', - link: 'https://space.bilibili.com/1886603340/channel/seriesdetail?sid=4302621&ctype=0' - }, - { - text: 'DevChalk', - link: 'https://devchalk.com/goravel' - }, - { - text: '贡献指南', - link: '/zh_CN/prologue/contributions' - }, - { - text: '新增语言', - link: '/zh_CN/prologue/contributions#新增语言' + text: '参与贡献', + items: [ + { text: '贡献指南', link: '/zh_CN/prologue/contributions' }, + { text: '新增语言', link: '/zh_CN/prologue/contributions#新增语言' }, + { text: 'Open Collective', link: 'https://opencollective.com/goravel' } + ] } ] } diff --git a/.vitepress/theme/home/GoravelHome.vue b/.vitepress/theme/home/GoravelHome.vue index 4202f276b..cd3f3cef1 100644 --- a/.vitepress/theme/home/GoravelHome.vue +++ b/.vitepress/theme/home/GoravelHome.vue @@ -12,7 +12,7 @@ import Stage from './Stage.vue' import CommunitySection from './CommunitySection.vue' import HomeFooter from './HomeFooter.vue' import { CONCEPTS, FILES, LAYER_CODE, LITE_STEPS, MOVES, STOPS, tokenize, type View } from './content' -import { LAYERS, LAYER_ORDER, LITE, type LayerKey, type PieceState } from './geometry' +import { FACADE_INFO, FACADE_LINK, LAYERS, LAYER_ORDER, LITE, type LayerKey, type PieceState } from './geometry' /* The snap is on the document, so it has to be put on and taken off with the page. */ onMounted(() => document.documentElement.classList.add('g-home-snap')) @@ -261,7 +261,9 @@ const pair = computed(() => concept.value.ties[tieStep.value % concept.value.tie
 = Object.fromEntries(
 )
 export const LITE = ['App', 'Artisan', 'Config', 'Process']
 
+/** Where each facade is documented, so the list on the homepage goes somewhere. */
+export const FACADE_LINK: Record = {
+  Route: '/the-basics/routing.html',
+  Http: '/digging-deeper/http-client.html',
+  Session: '/the-basics/session.html',
+  Grpc: '/the-basics/grpc.html',
+  RateLimiter: '/the-basics/routing.html#rate-limiting',
+  View: '/the-basics/views.html',
+  Validation: '/the-basics/validation.html',
+  Auth: '/security/authentication.html',
+  Gate: '/security/authorization.html',
+  Hash: '/security/hashing.html',
+  Crypt: '/security/encryption.html',
+  Lang: '/digging-deeper/localization.html',
+  AI: '/ai/sdk.html',
+  App: '/architecture-concepts/service-container.html',
+  Artisan: '/digging-deeper/artisan-console.html',
+  Config: '/getting-started/configuration.html',
+  Process: '/digging-deeper/processes.html',
+  Log: '/the-basics/logging.html',
+  Testing: '/testing/getting-started.html',
+  Orm: '/orm/getting-started.html',
+  DB: '/database/queries.html',
+  Schema: '/database/migrations.html',
+  Seeder: '/database/seeding.html',
+  Cache: '/digging-deeper/cache.html',
+  Storage: '/digging-deeper/filesystem.html',
+  Queue: '/digging-deeper/queues.html',
+  Event: '/digging-deeper/event.html',
+  Schedule: '/digging-deeper/task-scheduling.html',
+  Mail: '/digging-deeper/mail.html',
+  Telemetry: '/digging-deeper/telemetry.html'
+}
+
 export const FACADE_INFO: Record = {
   Route: 'Routes, groups and middleware',
   Http: 'HTTP client for outgoing requests',
diff --git a/.vitepress/theme/home/home.css b/.vitepress/theme/home/home.css
index fac9658e7..a0615716f 100644
--- a/.vitepress/theme/home/home.css
+++ b/.vitepress/theme/home/home.css
@@ -1395,3 +1395,17 @@ html.g-home-snap {
     row-gap: 32px;
   }
 }
+
+/* a facade is a destination: the list is the quickest way into the reference */
+.g-facades li a {
+  color: var(--g-ink);
+  text-decoration: none;
+  border-bottom: 1px solid transparent;
+  padding-bottom: 2px;
+  transition: color 0.15s ease, border-color 0.15s ease;
+}
+
+.g-facades li a:hover {
+  color: var(--g-cyan-text);
+  border-bottom-color: var(--g-cyan);
+}
diff --git a/.vitepress/theme/shell.css b/.vitepress/theme/shell.css
index b9ed060db..01f423157 100644
--- a/.vitepress/theme/shell.css
+++ b/.vitepress/theme/shell.css
@@ -1686,3 +1686,54 @@
     content: none;
   }
 }
+
+/* ------------------------------------------------------------------ grouped menus
+   A menu with sections lays them side by side, the way a reader scans them, rather
+   than stacking one list under another. */
+.VPNavBar .VPMenu:has(.VPMenuGroup) {
+  min-width: 0;
+  padding: 10px !important;
+}
+
+.VPNavBar .VPMenu:has(.VPMenuGroup) .items {
+  display: grid;
+  grid-auto-flow: column;
+  grid-auto-columns: minmax(150px, max-content);
+  gap: 0 8px;
+  align-items: start;
+}
+
+.VPNavBar .VPMenu .VPMenuGroup {
+  padding: 0 !important;
+  margin: 0 !important;
+  border: 0 !important;
+}
+
+.VPNavBar .VPMenu .VPMenuGroup + .VPMenuGroup {
+  border-left: 1px solid var(--g-line) !important;
+  padding-left: 8px !important;
+}
+
+.VPNavBar .VPMenu .VPMenuGroup .title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 0 !important;
+  padding: 6px 12px 10px !important;
+  border: 0 !important;
+  font-family: var(--vp-font-family-mono);
+  font-size: 11px !important;
+  font-weight: 500 !important;
+  letter-spacing: 0.14em;
+  text-transform: uppercase;
+  color: var(--g-grey) !important;
+}
+
+.VPNavBar .VPMenu .VPMenuGroup .title::before {
+  content: '';
+  width: 5px;
+  height: 5px;
+  flex-shrink: 0;
+  background: var(--g-construct);
+  transform: rotate(45deg);
+}

From 2f3c2acd012e3c765934ea7fe12ac7724c0a3710 Mon Sep 17 00:00:00 2001
From: kkumar-gcc 
Date: Wed, 16 Sep 2026 22:07:01 +0530
Subject: [PATCH 06/32] Make the nav menu a panel that drops out of the bar

The menu was a rounded card floating over the page on a shadow, sitting
on top of the bar's own rule. That is the one thing this design does not
do anywhere else, and no amount of adjusting its padding was going to
fix it, which is what the last few passes were doing.

It is now a panel. Its top edge is the bar's rule. Its columns are
divided by verticals that run the panel's full height, from that rule
down to the panel's own rule, which bleeds the width of the screen. Its
content stands on the same grid as the bar above it, 96px in on the
homepage and 40px in the docs.

Rows carry an icon: a brand mark where the destination has one people
already know, a line drawn at the shell's own weight where it does not.
Both sit back in grey and follow the label to the accent. The theme's
external arrow goes, since the icon already says where a row leads.

The locale switch keeps the small sheet. It is a control with three
options, not a section of the site.
---
 .vitepress/config/en.ts        |  38 +++++---
 .vitepress/config/nav-icons.ts |  52 +++++++++++
 .vitepress/config/uz_UZ.ts     |  38 +++++---
 .vitepress/config/zh_CN.ts     |  38 +++++---
 .vitepress/theme/home/home.css |   5 ++
 .vitepress/theme/shell.css     | 155 +++++++++++++++++++++++++++------
 6 files changed, 257 insertions(+), 69 deletions(-)
 create mode 100644 .vitepress/config/nav-icons.ts

diff --git a/.vitepress/config/en.ts b/.vitepress/config/en.ts
index f238ad802..53275017e 100644
--- a/.vitepress/config/en.ts
+++ b/.vitepress/config/en.ts
@@ -1,4 +1,15 @@
 import { defineConfig, type DefaultTheme } from 'vitepress'
+import {
+  ICON_CONTRIBUTE,
+  ICON_DEVCHALK,
+  ICON_DISCORD,
+  ICON_GITHUB,
+  ICON_SUPPORT,
+  ICON_TRANSLATE,
+  ICON_X,
+  ICON_YOUTUBE,
+  menuRow
+} from './nav-icons'
 
 export const config = defineConfig({
   lang: 'en-US',
@@ -120,30 +131,24 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: 'Connect',
           items: [
-            { text: 'GitHub', link: 'https://github.com/goravel/goravel' },
-            { text: 'Discord', link: 'https://discord.gg/cFc5csczzS' },
-            { text: 'X', link: 'https://x.com/goravel_dev' }
+            { text: menuRow('GitHub', ICON_GITHUB), link: 'https://github.com/goravel/goravel' },
+            { text: menuRow('Discord', ICON_DISCORD), link: 'https://discord.gg/cFc5csczzS' },
+            { text: menuRow('X', ICON_X), link: 'https://x.com/goravel_dev' }
           ]
         },
         {
           text: 'Learn',
           items: [
-            {
-              text: 'Video Tutorials',
-              link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf'
-            },
-            {
-              text: 'DevChalk',
-              link: 'https://devchalk.com/goravel'
-            }
+            { text: menuRow('Video Tutorials', ICON_YOUTUBE), link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' },
+            { text: menuRow('DevChalk', ICON_DEVCHALK), link: 'https://devchalk.com/goravel' }
           ]
         },
         {
           text: 'Contribute',
           items: [
-            { text: 'Contribution Guide', link: '/prologue/contributions' },
-            { text: 'Add a Language', link: '/prologue/contributions#add-a-new-language' },
-            { text: 'Open Collective', link: 'https://opencollective.com/goravel' }
+            { text: menuRow('Contribution Guide', ICON_CONTRIBUTE), link: '/prologue/contributions' },
+            { text: menuRow('Add a Language', ICON_TRANSLATE), link: '/prologue/contributions#add-a-new-language' },
+            { text: menuRow('Open Collective', ICON_SUPPORT), link: 'https://opencollective.com/goravel' }
           ]
         }
       ]
@@ -151,6 +156,11 @@ function nav(): DefaultTheme.NavItem[] {
   ]
 }
 
+const ICON_YOUTUBE =
+  ''
+const ICON_DEVCHALK =
+  ''
+
 function sidebarGettingStarted(): DefaultTheme.SidebarItem[] {
   return [
     {
diff --git a/.vitepress/config/nav-icons.ts b/.vitepress/config/nav-icons.ts
new file mode 100644
index 000000000..d7a6c77d9
--- /dev/null
+++ b/.vitepress/config/nav-icons.ts
@@ -0,0 +1,52 @@
+/**
+ * Icons for the nav menu rows.
+ *
+ * Two kinds only: a brand mark where the destination has one people already recognise, and a
+ * line drawn at the same weight as the rest of the shell where it does not. Both are 16px and
+ * take their colour from the row, so they sit back in grey and follow the label to cyan.
+ */
+const wrap = (body: string, filled = false) =>
+  ``
+
+export const ICON_GITHUB = wrap(
+  '',
+  true
+)
+
+export const ICON_DISCORD = wrap(
+  '',
+  true
+)
+
+export const ICON_X = wrap(
+  '',
+  true
+)
+
+/** A pull request: the shape of sending a change. */
+export const ICON_CONTRIBUTE = wrap(
+  ''
+)
+
+/** The translate mark: one script becoming another. */
+export const ICON_TRANSLATE = wrap(
+  ''
+)
+
+/** Support: the only place a heart belongs on this site. */
+export const ICON_SUPPORT = wrap(
+  ''
+)
+
+export const ICON_YOUTUBE =
+  ''
+
+export const ICON_DEVCHALK =
+  ''
+
+/** One row of a nav menu: an icon, then what the destination is called. */
+export function menuRow(title: string, icon = ''): string {
+  return `${icon}${title}`
+}
diff --git a/.vitepress/config/uz_UZ.ts b/.vitepress/config/uz_UZ.ts
index d83b06035..7e8570581 100644
--- a/.vitepress/config/uz_UZ.ts
+++ b/.vitepress/config/uz_UZ.ts
@@ -1,4 +1,15 @@
 import { defineConfig, type DefaultTheme } from 'vitepress';
+import {
+  ICON_CONTRIBUTE,
+  ICON_DEVCHALK,
+  ICON_DISCORD,
+  ICON_GITHUB,
+  ICON_SUPPORT,
+  ICON_TRANSLATE,
+  ICON_X,
+  ICON_YOUTUBE,
+  menuRow
+} from './nav-icons'
 export const config = defineConfig({
   lang: "uz-UZ",
   description: "Goravel — bu to‘liq funksiyalarga ega va yuqori darajada kengaytiriladigan web-ilovalar frameworki hisoblanadi. U Go dasturchilari (gopherlar) uchun boshlang‘ich platforma (scaffolding) sifatida xizmat qiladi va ularga o‘z ilovalarini tez, tartibli va professional tarzda yaratishga yordam beradi.",
@@ -116,30 +127,24 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: 'Aloqa',
           items: [
-            { text: 'GitHub', link: 'https://github.com/goravel/goravel' },
-            { text: 'Discord', link: 'https://discord.gg/cFc5csczzS' },
-            { text: 'X', link: 'https://x.com/goravel_dev' }
+            { text: menuRow('GitHub', ICON_GITHUB), link: 'https://github.com/goravel/goravel' },
+            { text: menuRow('Discord', ICON_DISCORD), link: 'https://discord.gg/cFc5csczzS' },
+            { text: menuRow('X', ICON_X), link: 'https://x.com/goravel_dev' }
           ]
         },
         {
           text: "O'rganish",
           items: [
-            {
-              text: 'Video darsliklar',
-              link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf'
-            },
-            {
-              text: 'DevChalk',
-              link: 'https://devchalk.com/goravel'
-            }
+            { text: menuRow('Video darsliklar', ICON_YOUTUBE), link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' },
+            { text: menuRow('DevChalk', ICON_DEVCHALK), link: 'https://devchalk.com/goravel' }
           ]
         },
         {
           text: "Hissa qo'shish",
           items: [
-            { text: "Hissa qo'shish bo'yicha qo'llanma", link: '/uz_UZ/prologue/contributions' },
-            { text: "Til qo'shish", link: '/uz_UZ/prologue/contributions#add-a-new-language' },
-            { text: 'Open Collective', link: 'https://opencollective.com/goravel' }
+            { text: menuRow("Hissa qo'shish bo'yicha qo'llanma", ICON_CONTRIBUTE), link: '/uz_UZ/prologue/contributions' },
+            { text: menuRow("Til qo'shish", ICON_TRANSLATE), link: '/uz_UZ/prologue/contributions#add-a-new-language' },
+            { text: menuRow('Open Collective', ICON_SUPPORT), link: 'https://opencollective.com/goravel' }
           ]
         }
       ]
@@ -147,6 +152,11 @@ function nav(): DefaultTheme.NavItem[] {
   ]
 }
 
+const ICON_YOUTUBE =
+  ''
+const ICON_DEVCHALK =
+  ''
+
 function sidebarGettingStarted(): DefaultTheme.SidebarItem[] {
   return [{
     text: "O'rnatish",
diff --git a/.vitepress/config/zh_CN.ts b/.vitepress/config/zh_CN.ts
index 2a23b1c73..bec0587bf 100644
--- a/.vitepress/config/zh_CN.ts
+++ b/.vitepress/config/zh_CN.ts
@@ -1,4 +1,15 @@
 import { defineConfig, type DefaultTheme } from 'vitepress';
+import {
+  ICON_CONTRIBUTE,
+  ICON_DEVCHALK,
+  ICON_DISCORD,
+  ICON_GITHUB,
+  ICON_SUPPORT,
+  ICON_TRANSLATE,
+  ICON_X,
+  ICON_YOUTUBE,
+  menuRow
+} from './nav-icons'
 export const config = defineConfig({
   lang: "zh-CN",
   description: "Goravel 是一个功能完整且可扩展性极强的 Web 应用程序框架。作为一个起始脚手架,帮助 Gopher 快速构建自己的应用程序。",
@@ -116,30 +127,24 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: '联系',
           items: [
-            { text: 'GitHub', link: 'https://github.com/goravel/goravel' },
-            { text: 'Discord', link: 'https://discord.gg/cFc5csczzS' },
-            { text: 'X', link: 'https://x.com/goravel_dev' }
+            { text: menuRow('GitHub', ICON_GITHUB), link: 'https://github.com/goravel/goravel' },
+            { text: menuRow('Discord', ICON_DISCORD), link: 'https://discord.gg/cFc5csczzS' },
+            { text: menuRow('X', ICON_X), link: 'https://x.com/goravel_dev' }
           ]
         },
         {
           text: '学习',
           items: [
-            {
-              text: '视频教程',
-              link: 'https://space.bilibili.com/1886603340/channel/seriesdetail?sid=4302621&ctype=0'
-            },
-            {
-              text: 'DevChalk',
-              link: 'https://devchalk.com/goravel'
-            }
+            { text: menuRow('视频教程', ICON_YOUTUBE), link: 'https://space.bilibili.com/1886603340/channel/seriesdetail?sid=4302621&ctype=0' },
+            { text: menuRow('DevChalk', ICON_DEVCHALK), link: 'https://devchalk.com/goravel' }
           ]
         },
         {
           text: '参与贡献',
           items: [
-            { text: '贡献指南', link: '/zh_CN/prologue/contributions' },
-            { text: '新增语言', link: '/zh_CN/prologue/contributions#新增语言' },
-            { text: 'Open Collective', link: 'https://opencollective.com/goravel' }
+            { text: menuRow('贡献指南', ICON_CONTRIBUTE), link: '/zh_CN/prologue/contributions' },
+            { text: menuRow('新增语言', ICON_TRANSLATE), link: '/zh_CN/prologue/contributions#新增语言' },
+            { text: menuRow('Open Collective', ICON_SUPPORT), link: 'https://opencollective.com/goravel' }
           ]
         }
       ]
@@ -147,6 +152,11 @@ function nav(): DefaultTheme.NavItem[] {
   ]
 }
 
+const ICON_YOUTUBE =
+  ''
+const ICON_DEVCHALK =
+  ''
+
 function sidebarGettingStarted(): DefaultTheme.SidebarItem[] {
   return [{
     text: "安装",
diff --git a/.vitepress/theme/home/home.css b/.vitepress/theme/home/home.css
index a0615716f..3b05b5f3b 100644
--- a/.vitepress/theme/home/home.css
+++ b/.vitepress/theme/home/home.css
@@ -1179,6 +1179,11 @@
   border-bottom: 1px solid var(--g-line) !important;
 }
 
+/* the drop panel stands on the homepage's grid, the same as the bar above it */
+.Layout:has(.g-home) {
+  --g-menu-inset: var(--g-home-margin, 96px);
+}
+
 .Layout:has(.g-home) .VPNavBar .wrapper {
   padding: 0 !important;
 }
diff --git a/.vitepress/theme/shell.css b/.vitepress/theme/shell.css
index 01f423157..7b4eeeca0 100644
--- a/.vitepress/theme/shell.css
+++ b/.vitepress/theme/shell.css
@@ -1687,46 +1687,79 @@
   }
 }
 
-/* ------------------------------------------------------------------ grouped menus
-   A menu with sections lays them side by side, the way a reader scans them, rather
-   than stacking one list under another. */
-.VPNavBar .VPMenu:has(.VPMenuGroup) {
-  min-width: 0;
-  padding: 10px !important;
-}
+/* ==================================================================
+   Nav menus
+   ------------------------------------------------------------------
+   A menu is not a card that floats over the page. It is a panel that
+   drops out of the bar: its top edge is the bar's own rule, its columns
+   are divided by verticals that run its full height, and it closes with
+   a rule that bleeds the width of the screen. Its content stands on the
+   same grid as the bar above it.
+   ================================================================== */
+@media (min-width: 960px) {
+  .VPNavBar .VPNavBarMenuGroup .menu {
+    position: fixed !important;
+    top: var(--vp-nav-height) !important;
+    left: 0 !important;
+    right: 0 !important;
+    width: 100vw !important;
+    transform-origin: top center;
+  }
 
-.VPNavBar .VPMenu:has(.VPMenuGroup) .items {
-  display: grid;
-  grid-auto-flow: column;
-  grid-auto-columns: minmax(150px, max-content);
-  gap: 0 8px;
-  align-items: start;
-}
+  .VPNavBar .VPNavBarMenuGroup .VPMenu {
+    width: 100% !important;
+    max-width: none !important;
+    min-width: 0 !important;
+    padding: 0 !important;
+    border: 0 !important;
+    border-bottom: 1px solid var(--g-line) !important;
+    border-radius: 0 !important;
+    background: var(--g-white) !important;
+    box-shadow: 0 24px 40px -24px rgba(16, 24, 32, 0.12) !important;
+  }
+
+  /* the panel's content stands on the page's grid, like the bar's */
+  .VPNavBar .VPNavBarMenuGroup .VPMenu .items {
+    display: grid;
+    grid-auto-flow: column;
+    grid-auto-columns: minmax(184px, max-content);
+    justify-content: start;
+    max-width: 1440px;
+    margin: 0 auto;
+    padding: 0 var(--g-menu-inset, var(--g-shell-inset));
+    box-sizing: border-box;
+    align-items: stretch;
+  }
 
-.VPNavBar .VPMenu .VPMenuGroup {
-  padding: 0 !important;
-  margin: 0 !important;
-  border: 0 !important;
-}
+  /* the padding lives on the columns, so each divider runs from the bar's rule at the
+     top of the panel to the panel's own rule at the bottom. Nothing stops in mid air. */
+  .VPNavBar .VPNavBarMenuGroup .VPMenu .VPMenuGroup {
+    padding: 36px 32px 40px !important;
+    margin: 0 !important;
+    border: 0 !important;
+    border-left: 1px solid var(--g-line) !important;
+  }
 
-.VPNavBar .VPMenu .VPMenuGroup + .VPMenuGroup {
-  border-left: 1px solid var(--g-line) !important;
-  padding-left: 8px !important;
+  .VPNavBar .VPNavBarMenuGroup .VPMenu .VPMenuGroup:first-child {
+    padding-left: 0 !important;
+    border-left: 0 !important;
+  }
 }
 
+/* the section title takes the accent, the way the page's labels do */
 .VPNavBar .VPMenu .VPMenuGroup .title {
   display: flex;
   align-items: center;
-  gap: 8px;
-  margin: 0 !important;
-  padding: 6px 12px 10px !important;
+  gap: 9px;
+  margin: 0 0 14px !important;
+  padding: 0 !important;
   border: 0 !important;
   font-family: var(--vp-font-family-mono);
   font-size: 11px !important;
   font-weight: 500 !important;
   letter-spacing: 0.14em;
   text-transform: uppercase;
-  color: var(--g-grey) !important;
+  color: var(--g-cyan-text) !important;
 }
 
 .VPNavBar .VPMenu .VPMenuGroup .title::before {
@@ -1734,6 +1767,74 @@
   width: 5px;
   height: 5px;
   flex-shrink: 0;
-  background: var(--g-construct);
+  background: var(--g-cyan);
   transform: rotate(45deg);
 }
+
+/* the row: label, and the external mark beside it rather than under it */
+.VPNavBar .VPMenu .link {
+  display: flex !important;
+  align-items: center;
+  padding: 7px 0 !important;
+  border-radius: 0 !important;
+  background: none !important;
+  font-size: 15px !important;
+  font-weight: 400 !important;
+  line-height: 24px !important;
+  color: var(--g-ink) !important;
+  transition: color 0.12s ease;
+}
+
+.VPNavBar .VPMenu .link:hover,
+.VPNavBar .VPMenu .link.active {
+  background: none !important;
+  color: var(--g-cyan-text) !important;
+}
+
+.g-menu-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+/* the icon sits back in grey and follows the label to the accent */
+.g-menu-icon {
+  width: 16px;
+  height: 16px;
+  border-radius: 3px;
+  flex-shrink: 0;
+  display: block;
+  color: var(--g-grey);
+  transition: color 0.12s ease;
+}
+
+.VPNavBar .VPMenu .link:hover .g-menu-icon {
+  color: var(--g-cyan-text);
+}
+
+.g-menu-title {
+  white-space: nowrap;
+}
+
+/* the icon already says where a row goes, so the theme's external arrow is one mark too many */
+.VPNavBar .VPNavBarMenuGroup .VPMenu .link.vp-external-link-icon::after {
+  display: none !important;
+}
+
+/* the locale switch keeps the small sheet: it is a control, not a section */
+.VPNavBar .VPNavBarTranslations .VPMenu {
+  min-width: 184px;
+  padding: 8px !important;
+  border: 1px solid var(--g-line) !important;
+  border-radius: 3px !important;
+  box-shadow: 0 10px 28px rgba(16, 24, 32, 0.1) !important;
+}
+
+.VPNavBar .VPNavBarTranslations .VPMenu .link {
+  padding: 7px 12px !important;
+  border-radius: 2px !important;
+}
+
+.VPNavBar .VPNavBarTranslations .VPMenu .link:hover {
+  background: var(--g-soft) !important;
+}

From 8349ec9c258827d8dbd181c56ee1405b2f851c05 Mon Sep 17 00:00:00 2001
From: kkumar-gcc 
Date: Wed, 16 Sep 2026 22:12:50 +0530
Subject: [PATCH 07/32] Draw the last two menu icons, and fix a heading that
 was a callout

The video series and the written guides were the only two rows still
carrying a favicon fetched from youtube.com and devchalk.com: a third
party request from the navigation, a raster mark that cannot take the
row's colour, and a weight that did not match the icons beside it. Both
are drawn now. The play mark is deliberately generic, because that row
points at YouTube in English and Uzbek and at Bilibili in Chinese.

On the installation page, "Tip" was a level four heading wearing a
callout's clothes. It put an entry in the page outline that is not a
section, and gave the page an anchor of %E2%9C%85-tip. It is a tip
container now, which is what it always was. Three sibling headings lose
the emoji they were carrying, and a horizontal rule that sat between two
headings goes, since the headings already do that work.

Applied to all three locales.
---
 .vitepress/config/en.ts               | 12 ++++--------
 .vitepress/config/nav-icons.ts        | 19 +++++++++++++++----
 .vitepress/config/uz_UZ.ts            | 12 ++++--------
 .vitepress/config/zh_CN.ts            | 12 ++++--------
 en/getting-started/installation.md    | 12 +++++-------
 uz_UZ/getting-started/installation.md | 12 +++++-------
 zh_CN/getting-started/installation.md | 12 +++++-------
 7 files changed, 42 insertions(+), 49 deletions(-)

diff --git a/.vitepress/config/en.ts b/.vitepress/config/en.ts
index 53275017e..590269945 100644
--- a/.vitepress/config/en.ts
+++ b/.vitepress/config/en.ts
@@ -1,13 +1,13 @@
 import { defineConfig, type DefaultTheme } from 'vitepress'
 import {
   ICON_CONTRIBUTE,
-  ICON_DEVCHALK,
   ICON_DISCORD,
   ICON_GITHUB,
+  ICON_GUIDE,
   ICON_SUPPORT,
   ICON_TRANSLATE,
+  ICON_VIDEO,
   ICON_X,
-  ICON_YOUTUBE,
   menuRow
 } from './nav-icons'
 
@@ -139,8 +139,8 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: 'Learn',
           items: [
-            { text: menuRow('Video Tutorials', ICON_YOUTUBE), link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' },
-            { text: menuRow('DevChalk', ICON_DEVCHALK), link: 'https://devchalk.com/goravel' }
+            { text: menuRow('Video Tutorials', ICON_VIDEO), link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' },
+            { text: menuRow('DevChalk', ICON_GUIDE), link: 'https://devchalk.com/goravel' }
           ]
         },
         {
@@ -156,10 +156,6 @@ function nav(): DefaultTheme.NavItem[] {
   ]
 }
 
-const ICON_YOUTUBE =
-  ''
-const ICON_DEVCHALK =
-  ''
 
 function sidebarGettingStarted(): DefaultTheme.SidebarItem[] {
   return [
diff --git a/.vitepress/config/nav-icons.ts b/.vitepress/config/nav-icons.ts
index d7a6c77d9..2d4953e23 100644
--- a/.vitepress/config/nav-icons.ts
+++ b/.vitepress/config/nav-icons.ts
@@ -40,11 +40,22 @@ export const ICON_SUPPORT = wrap(
   ''
 )
 
-export const ICON_YOUTUBE =
-  ''
+/**
+ * The video series and the written guides.
+ *
+ * These were two favicons fetched from youtube.com and devchalk.com every time the bar rendered:
+ * a third party request from the navigation, a raster mark that cannot take the row's colour, and
+ * a weight that did not match the icons beside it. They are drawn here instead. The play mark is
+ * deliberately generic, because this row points at YouTube in English and Uzbek and at Bilibili in
+ * Chinese, and a YouTube mark would be wrong on one of them.
+ */
+export const ICON_VIDEO = wrap(
+  ''
+)
 
-export const ICON_DEVCHALK =
-  ''
+export const ICON_GUIDE = wrap(
+  ''
+)
 
 /** One row of a nav menu: an icon, then what the destination is called. */
 export function menuRow(title: string, icon = ''): string {
diff --git a/.vitepress/config/uz_UZ.ts b/.vitepress/config/uz_UZ.ts
index 7e8570581..e8046f87c 100644
--- a/.vitepress/config/uz_UZ.ts
+++ b/.vitepress/config/uz_UZ.ts
@@ -1,13 +1,13 @@
 import { defineConfig, type DefaultTheme } from 'vitepress';
 import {
   ICON_CONTRIBUTE,
-  ICON_DEVCHALK,
   ICON_DISCORD,
   ICON_GITHUB,
+  ICON_GUIDE,
   ICON_SUPPORT,
   ICON_TRANSLATE,
+  ICON_VIDEO,
   ICON_X,
-  ICON_YOUTUBE,
   menuRow
 } from './nav-icons'
 export const config = defineConfig({
@@ -135,8 +135,8 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: "O'rganish",
           items: [
-            { text: menuRow('Video darsliklar', ICON_YOUTUBE), link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' },
-            { text: menuRow('DevChalk', ICON_DEVCHALK), link: 'https://devchalk.com/goravel' }
+            { text: menuRow('Video darsliklar', ICON_VIDEO), link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' },
+            { text: menuRow('DevChalk', ICON_GUIDE), link: 'https://devchalk.com/goravel' }
           ]
         },
         {
@@ -152,10 +152,6 @@ function nav(): DefaultTheme.NavItem[] {
   ]
 }
 
-const ICON_YOUTUBE =
-  ''
-const ICON_DEVCHALK =
-  ''
 
 function sidebarGettingStarted(): DefaultTheme.SidebarItem[] {
   return [{
diff --git a/.vitepress/config/zh_CN.ts b/.vitepress/config/zh_CN.ts
index bec0587bf..95398b701 100644
--- a/.vitepress/config/zh_CN.ts
+++ b/.vitepress/config/zh_CN.ts
@@ -1,13 +1,13 @@
 import { defineConfig, type DefaultTheme } from 'vitepress';
 import {
   ICON_CONTRIBUTE,
-  ICON_DEVCHALK,
   ICON_DISCORD,
   ICON_GITHUB,
+  ICON_GUIDE,
   ICON_SUPPORT,
   ICON_TRANSLATE,
+  ICON_VIDEO,
   ICON_X,
-  ICON_YOUTUBE,
   menuRow
 } from './nav-icons'
 export const config = defineConfig({
@@ -135,8 +135,8 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: '学习',
           items: [
-            { text: menuRow('视频教程', ICON_YOUTUBE), link: 'https://space.bilibili.com/1886603340/channel/seriesdetail?sid=4302621&ctype=0' },
-            { text: menuRow('DevChalk', ICON_DEVCHALK), link: 'https://devchalk.com/goravel' }
+            { text: menuRow('视频教程', ICON_VIDEO), link: 'https://space.bilibili.com/1886603340/channel/seriesdetail?sid=4302621&ctype=0' },
+            { text: menuRow('DevChalk', ICON_GUIDE), link: 'https://devchalk.com/goravel' }
           ]
         },
         {
@@ -152,10 +152,6 @@ function nav(): DefaultTheme.NavItem[] {
   ]
 }
 
-const ICON_YOUTUBE =
-  ''
-const ICON_DEVCHALK =
-  ''
 
 function sidebarGettingStarted(): DefaultTheme.SidebarItem[] {
   return [{
diff --git a/en/getting-started/installation.md b/en/getting-started/installation.md
index d97aee581..117561bac 100644
--- a/en/getting-started/installation.md
+++ b/en/getting-started/installation.md
@@ -91,15 +91,13 @@ Install [air-verse/air](https://github.com/air-verse/air), Goravel has a built-i
 air
 ```
 
-#### 🧰 After Installing Air
+#### After Installing Air
 
 Once you have successfully installed Air, you need to make sure it can be executed properly within your environment.  
 Depending on your setup, Air might not be automatically available as a command.  
 Here are two simple ways to ensure it runs correctly:
 
----
-
-#### 🪄 Option 1: Using a Helper Script (`air.sh`)
+#### Option 1: Using a Helper Script (`air.sh`)
 
 If Air is installed but not recognized as a terminal command, you can create a small helper script that locates and runs it automatically.
 
@@ -134,7 +132,7 @@ $GO_BIN
 
 This ensures Air runs even if your `$PATH` does not include Go binaries.
 
-#### 💡 Option 2: Add Go Bin To PATH (Mac/Linux)
+#### Option 2: Add Go Bin To PATH (Mac/Linux)
 
 If you prefer to run Air directly without a script, you can add Go bin directory to your PATH.
 
@@ -151,8 +149,7 @@ After this setup, you can start your project simply by running:
 air
 ```
 
-#### ✅ Tip
-
+:::tip
 To verify that Air is installed and accessible, run:
 
 ```bash
@@ -160,6 +157,7 @@ which air
 ```
 
 If it doesn't return a valid path (for example `/Users/yourname/go/bin/air`), it means the helper script or the path hasn't been configured yet.
+:::
 
 ## Configuration
 
diff --git a/uz_UZ/getting-started/installation.md b/uz_UZ/getting-started/installation.md
index b7f44a7ed..86ec996b3 100644
--- a/uz_UZ/getting-started/installation.md
+++ b/uz_UZ/getting-started/installation.md
@@ -91,15 +91,13 @@ APP_ENV=production APP_DEBUG=true go run .
 air
 ```
 
-#### 🧰 Air O'rnatilgandan So'ng
+#### Air O'rnatilgandan So'ng
 
 Air muvaffaqiyatli o'rnatilgandan so'ng, u muhitingizda to'g'ri bajarilishiga ishonch hosil qilishingiz kerak.  
 Sozlashingizga qarab, Air avtomatik ravishda buyruq sifatida mavjud bo'lmasligi mumkin.  
 Uning to'g'ri ishlashini ta'minlashning ikkita oddiy usuli:
 
----
-
-#### 🪄 Variant 1: Yordamchi Skriptdan (`air.sh`) Foydalanish
+#### Variant 1: Yordamchi Skriptdan (`air.sh`) Foydalanish
 
 Agar Air o'rnatilgan bo'lsa, lekin terminal buyrug'i sifatida tan olinmasa, uni avtomatik ravishda topadigan va ishga tushiradigan kichik yordamchi skript yaratishingiz mumkin.
 
@@ -135,7 +133,7 @@ $GO_BIN
 
 Bu, `$PATH` Go binary fayllarini o'z ichiga olmasa ham, Air ishlashini ta'minlaydi.
 
-#### 💡 Variant 2: Go Bin Papkasini PATH ga Qo'shish (Mac/Linux)
+#### Variant 2: Go Bin Papkasini PATH ga Qo'shish (Mac/Linux)
 
 Agar siz skriptsiz Air-ni to'g'ridan-to'g'ri ishga tushirishni afzal ko'rsangiz, Go bin papkasini PATH ga qo'shishingiz mumkin.
 
@@ -152,8 +150,7 @@ Ushbu sozlashdan so'ng, loyihangizni shunchaki ishga tushirish orqali boshlashin
 air
 ```
 
-#### ✅ Maslahat
-
+:::tip
 Air o'rnatilganligi va kirish mumkinligini tekshirish uchun ishga tushiring:
 
 ```bash
@@ -161,6 +158,7 @@ which air
 ```
 
 Agar u to'g'ri yo'lni qaytarmasa (masalan, `/Users/yourname/go/bin/air`), bu yordamchi skript yoki yo'l hali sozlanmaganligini anglatadi.
+:::
 
 ## Konfiguratsiya
 
diff --git a/zh_CN/getting-started/installation.md b/zh_CN/getting-started/installation.md
index a4b011f97..e61588e7c 100644
--- a/zh_CN/getting-started/installation.md
+++ b/zh_CN/getting-started/installation.md
@@ -91,15 +91,13 @@ APP_ENV=production APP_DEBUG=true go run .
 air
 ```
 
-#### 🧰 安装 Air 后
+#### 安装 Air 后
 
 安装了 Air 成功后,你需要确保它能够在你的环境中正确执行。  
 根据你的环境设置,Air 可能不能自动成为一个有效的命令。  
 以下是确保正确运行的两种简单方式:
 
----
-
-#### 🪄 选项 1: 使用一个 Helper 脚本 (`air.sh`)
+#### 选项 1: 使用一个 Helper 脚本 (`air.sh`)
 
 如果 Air 已安装但无法被识别为一个终端命令,你可以创建一个脚本,自动定位和运行它。
 
@@ -135,7 +133,7 @@ $GO_BIN
 
 这样会确保 Air 正常运行即使你的 `$PATH` 中没有包含 Go 软件库。
 
-#### 💡 选项2:将Go Bin 添加到PATH (Mac/Linux)
+#### 选项2:将Go Bin 添加到PATH (Mac/Linux)
 
 如果你喜欢在没有脚本的情况下直接运行 air,你可以添加 Go bin 目录到你的 PATH。
 
@@ -152,8 +150,7 @@ source ~/.zshrc
 air
 ```
 
-#### ✅ 提示
-
+:::tip
 要验证  air 已安装并可被执行,可以运行:
 
 ```bash
@@ -161,6 +158,7 @@ which air
 ```
 
 如果它没有返回一个有效的路径(例如`/Users/yourname/go/bin/air`),它意味着帮助脚本或路径尚未配置。
+:::
 
 ## 配置
 

From b97a60bf69fed9265a4ca843cf753e860e08e0b8 Mon Sep 17 00:00:00 2001
From: kkumar-gcc 
Date: Wed, 16 Sep 2026 22:16:32 +0530
Subject: [PATCH 08/32] Put back the heading emoji on the installation page

Three headings lost their emoji in the last change. That was my call and
it was the wrong one: an emoji in a heading an author chose is a voice,
not noise, and stripping it is not mine to decide.

The Tip container stays, because that was a different problem: a heading
that was not a section, putting an entry in the outline and giving the
page an anchor of %E2%9C%85-tip.
---
 en/getting-started/installation.md    | 6 +++---
 uz_UZ/getting-started/installation.md | 6 +++---
 zh_CN/getting-started/installation.md | 6 +++---
 3 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/en/getting-started/installation.md b/en/getting-started/installation.md
index 117561bac..341ef94b8 100644
--- a/en/getting-started/installation.md
+++ b/en/getting-started/installation.md
@@ -91,13 +91,13 @@ Install [air-verse/air](https://github.com/air-verse/air), Goravel has a built-i
 air
 ```
 
-#### After Installing Air
+#### 🧰 After Installing Air
 
 Once you have successfully installed Air, you need to make sure it can be executed properly within your environment.  
 Depending on your setup, Air might not be automatically available as a command.  
 Here are two simple ways to ensure it runs correctly:
 
-#### Option 1: Using a Helper Script (`air.sh`)
+#### 🪄 Option 1: Using a Helper Script (`air.sh`)
 
 If Air is installed but not recognized as a terminal command, you can create a small helper script that locates and runs it automatically.
 
@@ -132,7 +132,7 @@ $GO_BIN
 
 This ensures Air runs even if your `$PATH` does not include Go binaries.
 
-#### Option 2: Add Go Bin To PATH (Mac/Linux)
+#### 💡 Option 2: Add Go Bin To PATH (Mac/Linux)
 
 If you prefer to run Air directly without a script, you can add Go bin directory to your PATH.
 
diff --git a/uz_UZ/getting-started/installation.md b/uz_UZ/getting-started/installation.md
index 86ec996b3..321bf1bb6 100644
--- a/uz_UZ/getting-started/installation.md
+++ b/uz_UZ/getting-started/installation.md
@@ -91,13 +91,13 @@ APP_ENV=production APP_DEBUG=true go run .
 air
 ```
 
-#### Air O'rnatilgandan So'ng
+#### 🧰 Air O'rnatilgandan So'ng
 
 Air muvaffaqiyatli o'rnatilgandan so'ng, u muhitingizda to'g'ri bajarilishiga ishonch hosil qilishingiz kerak.  
 Sozlashingizga qarab, Air avtomatik ravishda buyruq sifatida mavjud bo'lmasligi mumkin.  
 Uning to'g'ri ishlashini ta'minlashning ikkita oddiy usuli:
 
-#### Variant 1: Yordamchi Skriptdan (`air.sh`) Foydalanish
+#### 🪄 Variant 1: Yordamchi Skriptdan (`air.sh`) Foydalanish
 
 Agar Air o'rnatilgan bo'lsa, lekin terminal buyrug'i sifatida tan olinmasa, uni avtomatik ravishda topadigan va ishga tushiradigan kichik yordamchi skript yaratishingiz mumkin.
 
@@ -133,7 +133,7 @@ $GO_BIN
 
 Bu, `$PATH` Go binary fayllarini o'z ichiga olmasa ham, Air ishlashini ta'minlaydi.
 
-#### Variant 2: Go Bin Papkasini PATH ga Qo'shish (Mac/Linux)
+#### 💡 Variant 2: Go Bin Papkasini PATH ga Qo'shish (Mac/Linux)
 
 Agar siz skriptsiz Air-ni to'g'ridan-to'g'ri ishga tushirishni afzal ko'rsangiz, Go bin papkasini PATH ga qo'shishingiz mumkin.
 
diff --git a/zh_CN/getting-started/installation.md b/zh_CN/getting-started/installation.md
index e61588e7c..5217eb065 100644
--- a/zh_CN/getting-started/installation.md
+++ b/zh_CN/getting-started/installation.md
@@ -91,13 +91,13 @@ APP_ENV=production APP_DEBUG=true go run .
 air
 ```
 
-#### 安装 Air 后
+#### 🧰 安装 Air 后
 
 安装了 Air 成功后,你需要确保它能够在你的环境中正确执行。  
 根据你的环境设置,Air 可能不能自动成为一个有效的命令。  
 以下是确保正确运行的两种简单方式:
 
-#### 选项 1: 使用一个 Helper 脚本 (`air.sh`)
+#### 🪄 选项 1: 使用一个 Helper 脚本 (`air.sh`)
 
 如果 Air 已安装但无法被识别为一个终端命令,你可以创建一个脚本,自动定位和运行它。
 
@@ -133,7 +133,7 @@ $GO_BIN
 
 这样会确保 Air 正常运行即使你的 `$PATH` 中没有包含 Go 软件库。
 
-#### 选项2:将Go Bin 添加到PATH (Mac/Linux)
+#### 💡 选项2:将Go Bin 添加到PATH (Mac/Linux)
 
 如果你喜欢在没有脚本的情况下直接运行 air,你可以添加 Go bin 目录到你的 PATH。
 

From 45e5e3c3e66b6c3b7e85f45a775d438d0a6fa111 Mon Sep 17 00:00:00 2001
From: kkumar-gcc 
Date: Thu, 17 Sep 2026 01:14:03 +0530
Subject: [PATCH 09/32] Rework the docs shell, typography and markdown
 rendering

Typography
- Mona Sans for text and headings, IBM Plex Mono kept for code and labels.
  Width carries the display voice, so the homepage and page titles take a
  wider cut and the docs stay at normal width.
- Body moves to 17px/29px. Measured across 318 rendered lines: median 68
  characters, which is what the measure token always claimed.
- Name a CJK fallback per platform, since Mona Sans covers latin only.

Callouts
- info, tip, warning and danger were two looks for four names. Each now has
  its own ground, rule weight and mark. Severity is the one place the palette
  gives way: warning takes amber, danger red, both confined to a near-white
  tint, a 2px rule and an 8px mark.
- A title an author typed is set as text, not as a type label. The markup
  VitePress emits is identical either way, so the container renderer is
  wrapped to mark the difference.
- details reads as a disclosure: the lattice mark leads, a chevron closes the
  row and turns when it opens.

The shell
- A docs page carries no site navigation, because the sidebar is the
  navigation there. The bar keeps the wordmark, search, version, language and
  the project. The homepage keeps the destinations.
- Goravel / Docs is one lockup, so the word no longer appears in the bar
  twice. Discord and X leave the bar and live in Community only.
- Version and language are the same control built the same way, and the
  version chooser lists versions, not the hostnames they are served from.
- Search sits over the article column and is exactly as wide as the measure.
- The bar is one object across the site: one inset at every page and width.
- Community links move to a docs footer, which the hidden nav would otherwise
  have stranded.

Markdown
- Add footnotes, task lists, sub, sup and definition lists, none of which
  VitePress ships, and style each in the existing vocabulary.
- Enable Shiki's word-highlight transformers, so both the comment and the
  fence syntax work instead of rendering as text.
- Convert 15 blockquotes that were doing a callout's job into typed
  containers, classified by what each does to the reader rather than by the
  word Note, and mirror them into the other two locales where the count
  matches. Two are left for someone who reads the language.
- Drop 180 dead toc directives: the outline carries the headings beside the
  article and the local nav carries them on a phone.

Code blocks
- The line-number gutter had no line-height of its own and inherited the
  body's, so every number drifted four pixels further from its line. Across
  105 blocks the worst drift is now one pixel.
- A block that scrolls inside itself cannot keep a gutter in sync, so the
  upgrade guide drops its numbers and wraps rather than clipping lines.
- Diff blocks get room for their markers.
- Code inside a callout is the same block on a lighter ground, and that
  ground is painted to the band's edges rather than laid out, so the gutter
  and the text keep a normal block's geometry.
- Keep the language label visible on hover.

Elsewhere
- Brand marks in the community menu carry their owner's colour and the video
  row follows its own platform per locale.
- Serve the wordmark from a resampled asset: the original is an indexed PNG
  the browser had to take down elevenfold.
- The primary button was white on cyan at 2.95:1. Ink on the same cyan
  measures 6.07:1.
- Consolidate the stylesheets: no selector is now declared twice, verified by
  comparing 116,348 computed properties across 3,422 elements before and
  after.
---
 .vitepress/config/en.ts                       |   11 +-
 .vitepress/config/goravel-code.ts             |    2 +-
 .vitepress/config/nav-icons.ts                |   76 +-
 .vitepress/config/shared.ts                   |   66 +-
 .vitepress/config/uz_UZ.ts                    |   13 +-
 .vitepress/config/zh_CN.ts                    |   13 +-
 .vitepress/theme/Layout.vue                   |    5 +-
 .../theme/components/DocsFooterLinks.vue      |   55 +
 .../theme/components/LanguageSelect.vue       |   62 +
 .vitepress/theme/components/VersionSelect.vue |   38 +-
 .vitepress/theme/goravel.css                  |  957 +++++---
 .vitepress/theme/home/HomeFooter.vue          |    2 +-
 .vitepress/theme/home/home.css                |   79 +-
 .vitepress/theme/shell.css                    |  408 +++-
 en/ai/sdk.md                                  |    2 -
 en/architecture-concepts/facades.md           |    2 -
 en/architecture-concepts/request-lifecycle.md |    2 -
 en/architecture-concepts/service-container.md |    2 -
 en/architecture-concepts/service-providers.md |    2 -
 en/database/getting-started.md                |    6 +-
 en/database/migrations.md                     |    2 -
 en/database/queries.md                        |    6 +-
 en/database/seeding.md                        |    2 -
 en/digging-deeper/artisan-console.md          |    6 +-
 en/digging-deeper/cache.md                    |    2 -
 en/digging-deeper/collections.md              |    2 -
 en/digging-deeper/color.md                    |    2 -
 en/digging-deeper/event.md                    |    2 -
 en/digging-deeper/filesystem.md               |    6 +-
 en/digging-deeper/helpers.md                  |    2 -
 en/digging-deeper/http-client.md              |    2 -
 en/digging-deeper/localization.md             |    2 -
 en/digging-deeper/mail.md                     |    2 -
 en/digging-deeper/package-development.md      |    2 -
 en/digging-deeper/pluralization.md            |    2 -
 en/digging-deeper/processes.md                |    2 -
 en/digging-deeper/queues.md                   |    2 -
 en/digging-deeper/strings.md                  |    2 -
 en/digging-deeper/task-scheduling.md          |    2 -
 en/digging-deeper/telemetry.md                |    2 -
 en/getting-started/compile.md                 |    6 +-
 en/getting-started/configuration.md           |    2 -
 en/getting-started/directory-structure.md     |    2 -
 en/getting-started/installation.md            |    2 -
 en/orm/factories.md                           |    2 -
 en/orm/getting-started.md                     |   10 +-
 en/orm/relationships.md                       |    2 -
 en/prologue/contributions.md                  |    6 +-
 en/security/authentication.md                 |    2 -
 en/security/authorization.md                  |    2 -
 en/security/encryption.md                     |    2 -
 en/security/hashing.md                        |    2 -
 en/testing/getting-started.md                 |    2 -
 en/testing/http-tests.md                      |    2 -
 en/testing/mock.md                            |    2 -
 en/the-basics/controllers.md                  |    2 -
 en/the-basics/grpc.md                         |    2 -
 en/the-basics/logging.md                      |    2 -
 en/the-basics/middleware.md                   |    6 +-
 en/the-basics/request.md                      |    6 +-
 en/the-basics/response.md                     |    2 -
 en/the-basics/routing.md                      |    6 +-
 en/the-basics/session.md                      |    2 -
 en/the-basics/validation.md                   |    6 +-
 en/the-basics/views.md                        |    2 -
 en/upgrade/v1.1.md                            |    2 -
 en/upgrade/v1.10.md                           |    2 -
 en/upgrade/v1.11.md                           |    2 -
 en/upgrade/v1.18.md                           |   12 +-
 en/upgrade/v1.4.md                            |    2 -
 en/upgrade/v1.5.md                            |    2 -
 en/upgrade/v1.6.md                            |    2 -
 en/upgrade/v1.7.md                            |    2 -
 en/upgrade/v1.8.md                            |    2 -
 en/upgrade/v1.9.md                            |    2 -
 package.json                                  |    6 +
 pnpm-lock.yaml                                | 2145 +++++------------
 public/devchalk-mark.png                      |  Bin 0 -> 5491 bytes
 public/logo@2x.png                            |  Bin 0 -> 5826 bytes
 uz_UZ/ai/sdk.md                               |    2 -
 uz_UZ/architecture-concepts/facades.md        |    2 -
 .../request-lifecycle.md                      |    2 -
 .../service-container.md                      |    2 -
 .../service-providers.md                      |    2 -
 uz_UZ/database/getting-started.md             |    6 +-
 uz_UZ/database/migrations.md                  |    2 -
 uz_UZ/database/queries.md                     |    6 +-
 uz_UZ/database/seeding.md                     |    2 -
 uz_UZ/digging-deeper/artisan-console.md       |    6 +-
 uz_UZ/digging-deeper/cache.md                 |    2 -
 uz_UZ/digging-deeper/collections.md           |    2 -
 uz_UZ/digging-deeper/color.md                 |    2 -
 uz_UZ/digging-deeper/event.md                 |    2 -
 uz_UZ/digging-deeper/filesystem.md            |    6 +-
 uz_UZ/digging-deeper/helpers.md               |    2 -
 uz_UZ/digging-deeper/http-client.md           |    2 -
 uz_UZ/digging-deeper/localization.md          |    2 -
 uz_UZ/digging-deeper/mail.md                  |    2 -
 uz_UZ/digging-deeper/package-development.md   |    2 -
 uz_UZ/digging-deeper/pluralization.md         |    2 -
 uz_UZ/digging-deeper/processes.md             |    2 -
 uz_UZ/digging-deeper/queues.md                |    2 -
 uz_UZ/digging-deeper/strings.md               |    2 -
 uz_UZ/digging-deeper/task-scheduling.md       |    2 -
 uz_UZ/digging-deeper/telemetry.md             |    2 -
 uz_UZ/getting-started/compile.md              |    6 +-
 uz_UZ/getting-started/configuration.md        |    2 -
 uz_UZ/getting-started/directory-structure.md  |    2 -
 uz_UZ/getting-started/installation.md         |    2 -
 uz_UZ/orm/factories.md                        |    2 -
 uz_UZ/orm/getting-started.md                  |   10 +-
 uz_UZ/orm/relationships.md                    |    2 -
 uz_UZ/prologue/contributions.md               |    6 +-
 uz_UZ/security/authentication.md              |    2 -
 uz_UZ/security/authorization.md               |    2 -
 uz_UZ/security/encryption.md                  |    2 -
 uz_UZ/security/hashing.md                     |    2 -
 uz_UZ/testing/getting-started.md              |    2 -
 uz_UZ/testing/http-tests.md                   |    2 -
 uz_UZ/testing/mock.md                         |    2 -
 uz_UZ/the-basics/controllers.md               |    2 -
 uz_UZ/the-basics/grpc.md                      |    2 -
 uz_UZ/the-basics/logging.md                   |    2 -
 uz_UZ/the-basics/middleware.md                |    6 +-
 uz_UZ/the-basics/request.md                   |    6 +-
 uz_UZ/the-basics/response.md                  |    2 -
 uz_UZ/the-basics/routing.md                   |    6 +-
 uz_UZ/the-basics/session.md                   |    2 -
 uz_UZ/the-basics/validation.md                |    2 -
 uz_UZ/the-basics/views.md                     |    2 -
 uz_UZ/upgrade/v1.1.md                         |    2 -
 uz_UZ/upgrade/v1.10.md                        |    2 -
 uz_UZ/upgrade/v1.11.md                        |    2 -
 uz_UZ/upgrade/v1.18.md                        |   12 +-
 uz_UZ/upgrade/v1.4.md                         |    2 -
 uz_UZ/upgrade/v1.5.md                         |    2 -
 uz_UZ/upgrade/v1.6.md                         |    2 -
 uz_UZ/upgrade/v1.7.md                         |    2 -
 uz_UZ/upgrade/v1.8.md                         |    2 -
 uz_UZ/upgrade/v1.9.md                         |    2 -
 zh_CN/ai/sdk.md                               |    2 -
 zh_CN/architecture-concepts/facades.md        |    2 -
 .../request-lifecycle.md                      |    2 -
 .../service-container.md                      |    2 -
 .../service-providers.md                      |    2 -
 zh_CN/database/getting-started.md             |    6 +-
 zh_CN/database/migrations.md                  |    2 -
 zh_CN/database/queries.md                     |    6 +-
 zh_CN/database/seeding.md                     |    2 -
 zh_CN/digging-deeper/artisan-console.md       |    6 +-
 zh_CN/digging-deeper/cache.md                 |    2 -
 zh_CN/digging-deeper/collections.md           |    2 -
 zh_CN/digging-deeper/color.md                 |    2 -
 zh_CN/digging-deeper/event.md                 |    2 -
 zh_CN/digging-deeper/filesystem.md            |    2 -
 zh_CN/digging-deeper/helpers.md               |    2 -
 zh_CN/digging-deeper/http-client.md           |    2 -
 zh_CN/digging-deeper/localization.md          |    2 -
 zh_CN/digging-deeper/mail.md                  |    2 -
 zh_CN/digging-deeper/package-development.md   |    2 -
 zh_CN/digging-deeper/pluralization.md         |    2 -
 zh_CN/digging-deeper/processes.md             |    2 -
 zh_CN/digging-deeper/queues.md                |    2 -
 zh_CN/digging-deeper/strings.md               |    2 -
 zh_CN/digging-deeper/task-scheduling.md       |    2 -
 zh_CN/digging-deeper/telemetry.md             |    2 -
 zh_CN/getting-started/compile.md              |    6 +-
 zh_CN/getting-started/configuration.md        |    2 -
 zh_CN/getting-started/directory-structure.md  |    2 -
 zh_CN/getting-started/installation.md         |    2 -
 zh_CN/orm/factories.md                        |    2 -
 zh_CN/orm/getting-started.md                  |   10 +-
 zh_CN/orm/relationships.md                    |    2 -
 zh_CN/prologue/contributions.md               |    6 +-
 zh_CN/security/authentication.md              |    2 -
 zh_CN/security/authorization.md               |    2 -
 zh_CN/security/encryption.md                  |    2 -
 zh_CN/security/hashing.md                     |    2 -
 zh_CN/testing/getting-started.md              |    2 -
 zh_CN/testing/http-tests.md                   |    2 -
 zh_CN/testing/mock.md                         |    2 -
 zh_CN/the-basics/controllers.md               |    2 -
 zh_CN/the-basics/grpc.md                      |    2 -
 zh_CN/the-basics/logging.md                   |    2 -
 zh_CN/the-basics/middleware.md                |    6 +-
 zh_CN/the-basics/request.md                   |    6 +-
 zh_CN/the-basics/response.md                  |    2 -
 zh_CN/the-basics/routing.md                   |    6 +-
 zh_CN/the-basics/session.md                   |    2 -
 zh_CN/the-basics/validation.md                |    6 +-
 zh_CN/the-basics/views.md                     |    2 -
 zh_CN/upgrade/v1.1.md                         |    2 -
 zh_CN/upgrade/v1.10.md                        |    2 -
 zh_CN/upgrade/v1.11.md                        |    2 -
 zh_CN/upgrade/v1.18.md                        |   12 +-
 zh_CN/upgrade/v1.4.md                         |    2 -
 zh_CN/upgrade/v1.5.md                         |    2 -
 zh_CN/upgrade/v1.6.md                         |    2 -
 zh_CN/upgrade/v1.7.md                         |    2 -
 zh_CN/upgrade/v1.8.md                         |    2 -
 zh_CN/upgrade/v1.9.md                         |    2 -
 201 files changed, 1891 insertions(+), 2579 deletions(-)
 create mode 100644 .vitepress/theme/components/DocsFooterLinks.vue
 create mode 100644 .vitepress/theme/components/LanguageSelect.vue
 create mode 100644 public/devchalk-mark.png
 create mode 100644 public/logo@2x.png

diff --git a/.vitepress/config/en.ts b/.vitepress/config/en.ts
index 590269945..84bdf0e3a 100644
--- a/.vitepress/config/en.ts
+++ b/.vitepress/config/en.ts
@@ -1,13 +1,13 @@
 import { defineConfig, type DefaultTheme } from 'vitepress'
 import {
   ICON_CONTRIBUTE,
+  ICON_DEVCHALK,
   ICON_DISCORD,
   ICON_GITHUB,
-  ICON_GUIDE,
   ICON_SUPPORT,
   ICON_TRANSLATE,
-  ICON_VIDEO,
   ICON_X,
+  ICON_YOUTUBE,
   menuRow
 } from './nav-icons'
 
@@ -111,6 +111,8 @@ export const config = defineConfig({
 function nav(): DefaultTheme.NavItem[] {
   return [
     {
+      // On a docs page the wordmark already reads Goravel / Docs, so the shell hides this
+      // row there. It exists for the homepage, which has no such lockup.
       text: 'Docs',
       link: '/getting-started/installation',
       activeMatch: '/getting-started/'
@@ -131,7 +133,6 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: 'Connect',
           items: [
-            { text: menuRow('GitHub', ICON_GITHUB), link: 'https://github.com/goravel/goravel' },
             { text: menuRow('Discord', ICON_DISCORD), link: 'https://discord.gg/cFc5csczzS' },
             { text: menuRow('X', ICON_X), link: 'https://x.com/goravel_dev' }
           ]
@@ -139,8 +140,8 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: 'Learn',
           items: [
-            { text: menuRow('Video Tutorials', ICON_VIDEO), link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' },
-            { text: menuRow('DevChalk', ICON_GUIDE), link: 'https://devchalk.com/goravel' }
+            { text: menuRow('Video Tutorials', ICON_YOUTUBE), link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' },
+            { text: menuRow('DevChalk', ICON_DEVCHALK), link: 'https://devchalk.com/goravel' }
           ]
         },
         {
diff --git a/.vitepress/config/goravel-code.ts b/.vitepress/config/goravel-code.ts
index 8f24745f2..761cb64f9 100644
--- a/.vitepress/config/goravel-code.ts
+++ b/.vitepress/config/goravel-code.ts
@@ -124,7 +124,7 @@ export const goravelLight = theme('goravel-light', 'light', {
   punctuation: '#68747d',
   variable: '#101820',
   inserted: '#0077b3',
-  deleted: '#68747d'
+  deleted: '#b02b2b'
 })
 
 export const goravelDark = theme('goravel-dark', 'dark', {
diff --git a/.vitepress/config/nav-icons.ts b/.vitepress/config/nav-icons.ts
index 2d4953e23..1fc39eda2 100644
--- a/.vitepress/config/nav-icons.ts
+++ b/.vitepress/config/nav-icons.ts
@@ -1,63 +1,71 @@
 /**
  * Icons for the nav menu rows.
  *
- * Two kinds only: a brand mark where the destination has one people already recognise, and a
- * line drawn at the same weight as the rest of the shell where it does not. Both are 16px and
- * take their colour from the row, so they sit back in grey and follow the label to cyan.
+ * Two kinds. A destination that belongs to somebody else gets that somebody's real mark in that
+ * somebody's own colour, because a brand mark recoloured is no longer the mark people recognise:
+ * this is the one place the site's palette gives way. A destination that belongs to Goravel gets
+ * a line drawn at the shell's own weight, in the shell's grey, following the row's colour.
+ *
+ * Brand colours used, each the published primary of its owner:
+ *   GitHub   #181717    Discord  #5865F2    X       #000000
+ *   YouTube  #FF0000    Bilibili #00A1D6
  */
-const wrap = (body: string, filled = false) =>
-  ``
+const brand = (body: string, color: string, viewBox = '0 0 24 24') =>
+  ``
+
+const line = (body: string) =>
+  ``
 
-export const ICON_GITHUB = wrap(
+export const ICON_GITHUB = brand(
   '',
-  true
+  '#181717'
 )
 
-export const ICON_DISCORD = wrap(
+export const ICON_DISCORD = brand(
   '',
-  true
+  '#5865F2'
 )
 
-export const ICON_X = wrap(
+export const ICON_X = brand(
   '',
-  true
+  '#000000'
+)
+
+/** The English and Uzbek video series live on YouTube. */
+export const ICON_YOUTUBE = brand(
+  '',
+  '#FF0000'
 )
 
+/** The Chinese series lives on Bilibili, so that row carries Bilibili's mark, not YouTube's. */
+export const ICON_BILIBILI = brand(
+  '',
+  '#00A1D6'
+)
+
+/**
+ * DevChalk's mark is a painted illustration rather than a flat logo, so it is set as a small
+ * rounded avatar. Recolouring it is not possible and shrinking it to icon size turns it to mush.
+ */
+export const ICON_DEVCHALK =
+  ''
+
 /** A pull request: the shape of sending a change. */
-export const ICON_CONTRIBUTE = wrap(
+export const ICON_CONTRIBUTE = line(
   ''
 )
 
 /** The translate mark: one script becoming another. */
-export const ICON_TRANSLATE = wrap(
+export const ICON_TRANSLATE = line(
   ''
 )
 
 /** Support: the only place a heart belongs on this site. */
-export const ICON_SUPPORT = wrap(
+export const ICON_SUPPORT = line(
   ''
 )
 
-/**
- * The video series and the written guides.
- *
- * These were two favicons fetched from youtube.com and devchalk.com every time the bar rendered:
- * a third party request from the navigation, a raster mark that cannot take the row's colour, and
- * a weight that did not match the icons beside it. They are drawn here instead. The play mark is
- * deliberately generic, because this row points at YouTube in English and Uzbek and at Bilibili in
- * Chinese, and a YouTube mark would be wrong on one of them.
- */
-export const ICON_VIDEO = wrap(
-  ''
-)
-
-export const ICON_GUIDE = wrap(
-  ''
-)
-
-/** One row of a nav menu: an icon, then what the destination is called. */
+/** One row of a nav menu: a mark, then what the destination is called. */
 export function menuRow(title: string, icon = ''): string {
   return `${icon}${title}`
 }
diff --git a/.vitepress/config/shared.ts b/.vitepress/config/shared.ts
index 62aabed8b..6f359b8ec 100644
--- a/.vitepress/config/shared.ts
+++ b/.vitepress/config/shared.ts
@@ -1,8 +1,17 @@
+import {
+  transformerMetaWordHighlight,
+  transformerNotationWordHighlight
+} from '@shikijs/transformers'
 import { transformerTwoslash } from '@shikijs/vitepress-twoslash'
 import { createFileSystemTypesCache } from '@shikijs/vitepress-twoslash/cache-fs'
 import tailwindcss from '@tailwindcss/vite'
-import { defineConfig } from 'vitepress'
+import { defineConfig, type MarkdownRenderer } from 'vitepress'
 import timeline from 'vitepress-markdown-timeline'
+import deflist from 'markdown-it-deflist'
+import footnote from 'markdown-it-footnote'
+import sub from 'markdown-it-sub'
+import sup from 'markdown-it-sup'
+import taskLists from 'markdown-it-task-lists'
 import { goravelDark, goravelLight } from './goravel-code'
 import {
   groupIconMdPlugin,
@@ -11,6 +20,31 @@ import {
 
 // import {enSearch, zh_CNSearch} from './search'
 
+
+/**
+ * Tell a container's type name apart from a title its author wrote.
+ *
+ * `::: warning` renders the word WARNING, which is a label and is set like one: small, mono,
+ * tracked. `::: warning Read this before upgrading` renders a sentence, and setting a sentence
+ * as a label stretches it into something unreadable. The markup VitePress emits is identical in
+ * both cases, so the difference cannot be reached from CSS. This wraps VitePress's own renderer
+ * for each container and adds `has-custom-title` when the opening fence carried words of its
+ * own, which the stylesheet then sets as text rather than as a label.
+ */
+function markCustomContainerTitles(md: MarkdownRenderer) {
+  for (const name of ['info', 'tip', 'warning', 'danger', 'details']) {
+    const key = `container_${name}_open`
+    const original = md.renderer.rules[key]
+    if (!original) continue
+    md.renderer.rules[key] = (tokens, idx, options, env, self) => {
+      const html = original(tokens, idx, options, env, self)
+      // anything after the type name on the fence is a title the author typed
+      const custom = /^[a-z-]+[ \t]+\S/i.test((tokens[idx].info || '').trim())
+      return custom ? html.replace('custom-block', 'custom-block has-custom-title') : html
+    }
+  }
+}
+
 export const shared = defineConfig({
   title: 'Goravel',
 
@@ -41,6 +75,11 @@ export const shared = defineConfig({
     theme: { light: goravelLight, dark: goravelDark },
     lineNumbers: true,
     codeTransformers: [
+      /* VitePress enables four notation transformers (diff, focus, highlight, error level) but
+         not the word ones, so `[!code word:Foo]` in a comment, and `/Foo/` on the fence, both
+         rendered as literal text. */
+      transformerNotationWordHighlight(),
+      transformerMetaWordHighlight(),
       transformerTwoslash({
         typesCache: createFileSystemTypesCache()
       })
@@ -48,18 +87,25 @@ export const shared = defineConfig({
     config(md) {
       md.use(groupIconMdPlugin)
       md.use(timeline)
+      // markdown VitePress does not ship: footnotes, task lists, sub/sup and definition lists
+      md.use(footnote)
+      md.use(taskLists, { label: true })
+      md.use(sub)
+      md.use(sup)
+      md.use(deflist)
+      markCustomContainerTitles(md)
     },
     languages: ['go']
   },
 
   themeConfig: {
-    logo: '/logo.png',
+    /* a 4x RGBA cut of the wordmark: the 1000px original is an indexed PNG and the browser
+       had to take it down eleven-fold, which is what made the mark look soft */
+    logo: '/logo@2x.png',
     siteTitle: false,
-    socialLinks: [
-      { icon: 'github', link: 'https://github.com/goravel/goravel' },
-      { icon: 'discord', link: 'https://discord.gg/cFc5csczzS' },
-      { icon: 'x', link: 'https://x.com/goravelgolang' }
-    ],
+    /* Only GitHub. Discord and X live in Community, and listing them in both places made the
+       bar read as a toolbar of every link we have rather than a set of decisions. */
+    socialLinks: [{ icon: 'github', link: 'https://github.com/goravel/goravel' }],
     search: {
       provider: 'algolia',
       options: {
@@ -68,10 +114,10 @@ export const shared = defineConfig({
         indexName: 'goravel',
         locales: {
           root: {
-            placeholder: 'Search documentation',
+            placeholder: 'Search docs',
             translations: {
               button: {
-                buttonText: 'Search documentation'
+                buttonText: 'Search docs'
               }
             }
           },
@@ -87,7 +133,7 @@ export const shared = defineConfig({
             placeholder: 'Fayllarni qidirish',
             translations: {
               button: {
-                buttonText: 'Fayllarni qidirish'
+                buttonText: 'Hujjatlarda qidirish'
               }
             }
           }
diff --git a/.vitepress/config/uz_UZ.ts b/.vitepress/config/uz_UZ.ts
index e8046f87c..bf98addbd 100644
--- a/.vitepress/config/uz_UZ.ts
+++ b/.vitepress/config/uz_UZ.ts
@@ -1,13 +1,13 @@
 import { defineConfig, type DefaultTheme } from 'vitepress';
 import {
   ICON_CONTRIBUTE,
+  ICON_DEVCHALK,
   ICON_DISCORD,
   ICON_GITHUB,
-  ICON_GUIDE,
   ICON_SUPPORT,
   ICON_TRANSLATE,
-  ICON_VIDEO,
   ICON_X,
+  ICON_YOUTUBE,
   menuRow
 } from './nav-icons'
 export const config = defineConfig({
@@ -107,7 +107,9 @@ export const config = defineConfig({
 function nav(): DefaultTheme.NavItem[] {
   return [
     {
-      text: 'Hujjatlar',
+      // On a docs page the wordmark already reads Goravel / Docs, so the shell hides this
+      // row there. It exists for the homepage, which has no such lockup.
+      text: "Hujjatlar",
       link: '/uz_UZ/getting-started/installation',
       activeMatch: '/uz_UZ/getting-started/'
     },
@@ -127,7 +129,6 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: 'Aloqa',
           items: [
-            { text: menuRow('GitHub', ICON_GITHUB), link: 'https://github.com/goravel/goravel' },
             { text: menuRow('Discord', ICON_DISCORD), link: 'https://discord.gg/cFc5csczzS' },
             { text: menuRow('X', ICON_X), link: 'https://x.com/goravel_dev' }
           ]
@@ -135,8 +136,8 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: "O'rganish",
           items: [
-            { text: menuRow('Video darsliklar', ICON_VIDEO), link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' },
-            { text: menuRow('DevChalk', ICON_GUIDE), link: 'https://devchalk.com/goravel' }
+            { text: menuRow('Video darsliklar', ICON_YOUTUBE), link: 'https://www.youtube.com/playlist?list=PL40Xne4u-oXJ0Z5uFiPWHqIMvzZaG_BDf' },
+            { text: menuRow('DevChalk', ICON_DEVCHALK), link: 'https://devchalk.com/goravel' }
           ]
         },
         {
diff --git a/.vitepress/config/zh_CN.ts b/.vitepress/config/zh_CN.ts
index 95398b701..a80a2d924 100644
--- a/.vitepress/config/zh_CN.ts
+++ b/.vitepress/config/zh_CN.ts
@@ -1,12 +1,12 @@
 import { defineConfig, type DefaultTheme } from 'vitepress';
 import {
+  ICON_BILIBILI,
   ICON_CONTRIBUTE,
+  ICON_DEVCHALK,
   ICON_DISCORD,
   ICON_GITHUB,
-  ICON_GUIDE,
   ICON_SUPPORT,
   ICON_TRANSLATE,
-  ICON_VIDEO,
   ICON_X,
   menuRow
 } from './nav-icons'
@@ -107,7 +107,9 @@ export const config = defineConfig({
 function nav(): DefaultTheme.NavItem[] {
   return [
     {
-      text: '文档',
+      // On a docs page the wordmark already reads Goravel / Docs, so the shell hides this
+      // row there. It exists for the homepage, which has no such lockup.
+      text: "文档",
       link: '/zh_CN/getting-started/installation',
       activeMatch: '/zh_CN/getting-started/'
     },
@@ -127,7 +129,6 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: '联系',
           items: [
-            { text: menuRow('GitHub', ICON_GITHUB), link: 'https://github.com/goravel/goravel' },
             { text: menuRow('Discord', ICON_DISCORD), link: 'https://discord.gg/cFc5csczzS' },
             { text: menuRow('X', ICON_X), link: 'https://x.com/goravel_dev' }
           ]
@@ -135,8 +136,8 @@ function nav(): DefaultTheme.NavItem[] {
         {
           text: '学习',
           items: [
-            { text: menuRow('视频教程', ICON_VIDEO), link: 'https://space.bilibili.com/1886603340/channel/seriesdetail?sid=4302621&ctype=0' },
-            { text: menuRow('DevChalk', ICON_GUIDE), link: 'https://devchalk.com/goravel' }
+            { text: menuRow('视频教程', ICON_BILIBILI), link: 'https://space.bilibili.com/1886603340/channel/seriesdetail?sid=4302621&ctype=0' },
+            { text: menuRow('DevChalk', ICON_DEVCHALK), link: 'https://devchalk.com/goravel' }
           ]
         },
         {
diff --git a/.vitepress/theme/Layout.vue b/.vitepress/theme/Layout.vue
index f93c6d935..88846e8fa 100644
--- a/.vitepress/theme/Layout.vue
+++ b/.vitepress/theme/Layout.vue
@@ -6,6 +6,8 @@ import DocMeta from './components/DocMeta.vue'
 import MethodIndex from './components/MethodIndex.vue'
 import NotFound from './components/NotFound.vue'
 import VersionSelect from './components/VersionSelect.vue'
+import DocsFooterLinks from './components/DocsFooterLinks.vue'
+import LanguageSelect from './components/LanguageSelect.vue'
 
 const { Layout } = DefaultTheme
 const route = useRoute()
@@ -35,9 +37,10 @@ watch(() => route.path, () => nextTick(drawCoverage))
 
 
diff --git a/.vitepress/theme/components/DocsFooterLinks.vue b/.vitepress/theme/components/DocsFooterLinks.vue
new file mode 100644
index 000000000..142b5aa48
--- /dev/null
+++ b/.vitepress/theme/components/DocsFooterLinks.vue
@@ -0,0 +1,55 @@
+
+
+
diff --git a/.vitepress/theme/components/LanguageSelect.vue b/.vitepress/theme/components/LanguageSelect.vue
new file mode 100644
index 000000000..3a330a13a
--- /dev/null
+++ b/.vitepress/theme/components/LanguageSelect.vue
@@ -0,0 +1,62 @@
+
+
+
diff --git a/.vitepress/theme/components/VersionSelect.vue b/.vitepress/theme/components/VersionSelect.vue
index 10b976b84..6db90bc60 100644
--- a/.vitepress/theme/components/VersionSelect.vue
+++ b/.vitepress/theme/components/VersionSelect.vue
@@ -1,28 +1,48 @@