diff --git a/astro.config.mjs b/astro.config.mjs index 541c7c43be..ef84fe924e 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,6 +2,7 @@ import remarkDirective from 'remark-directive'; import remarkHeading from 'remark-heading-id'; import { defineConfig } from 'astro/config'; import mdx from '@astrojs/mdx'; +import tailwindcss from '@tailwindcss/vite'; import { attributeMarkdown, wrapTables } from '/src/themes/octopus/utilities/custom-markdown.mjs'; import llmMdEmitter from './src/integrations/llm-md-emitter.ts'; @@ -12,6 +13,11 @@ export default defineConfig({ mdx(), llmMdEmitter() ], + // Tailwind v4 only lands on pages that import the Ink design system entry + // (src/ink/theme.css), so the live site stays untouched. + vite: { + plugins: [tailwindcss()] + }, markdown: { shikiConfig: { theme: 'light-plus' diff --git a/dictionary-octopus.txt b/dictionary-octopus.txt index 67dc758fef..0696cb4ab8 100644 --- a/dictionary-octopus.txt +++ b/dictionary-octopus.txt @@ -3,6 +3,7 @@ actionid actiontemplates Adfs advfirewall +affordances agentic allatclaims ALLUSERSPROFILE @@ -44,6 +45,7 @@ azureaccount azureactivedirectory azurefile bacpac +behaviour Bento Blkio bootstrap @@ -170,6 +172,7 @@ figcaption filedata filestore FIPS +focusables fontawesome frontmatter functionapp @@ -191,6 +194,7 @@ gruntfile guestloginenabled gulpfile GZRS +Hanken hasattr Hashtable hastscript @@ -282,6 +286,7 @@ maxage mdast Metabase microsite +microsites milli minidump minifier @@ -310,6 +315,7 @@ mytransform.config namespacing Nartac navigationid +navscroll NETBIOS netcore netcoreapp @@ -341,6 +347,7 @@ npmjs NTDS NTFS NTLM +nums nupkg NVARCHAR O_WRONLY @@ -373,6 +380,7 @@ Octostache octostorage octoterra OIDC +oklab oldcert oldguid oldthumbprint @@ -492,6 +500,7 @@ serviceaccount Sesna setspn sfproj +shadcn shiki showcerts Showplan @@ -529,6 +538,7 @@ struct stylesheet subcomponent subcontext +sublist sunsetting swaggerui systemprofile @@ -575,6 +585,7 @@ umoci undeployed undeploying uniquestring +unlayered Unmarshal updateprogress upgradeavailability @@ -592,6 +603,7 @@ VARCHAR variablename variableset vhdx +viewports violationreason VMSS vnet @@ -614,6 +626,7 @@ windir windowsfeatures Wireshark WIXUI +wordmark workerpool workerpools workertools diff --git a/package.json b/package.json index cffd73757e..a8c7bd2b0d 100644 --- a/package.json +++ b/package.json @@ -44,13 +44,16 @@ "devDependencies": { "@playwright/test": "^1.59.1", "@pnpm/exe": "^11.0.9", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.3.0", "cspell": "^10.0.0", "csv-parse": "^6.2.1", "linkinator": "7.0.0", "npm-run-all": "^4.1.5", "onchange": "^7.1.0", "prettier": "^3.8.3", - "prettier-plugin-astro": "^0.14.1" + "prettier-plugin-astro": "^0.14.1", + "tailwindcss": "^4.3.0" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 939299a054..a022fedb80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,10 +20,10 @@ importers: dependencies: '@astrojs/mdx': specifier: ^5.0.4 - version: 5.0.4(astro@6.3.1(@types/node@24.10.1)(rollup@4.60.3)(yaml@2.8.4)) + version: 5.0.4(astro@6.3.1(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.3)(yaml@2.8.4)) astro: specifier: ^6.3.1 - version: 6.3.1(@types/node@24.10.1)(rollup@4.60.3)(yaml@2.8.4) + version: 6.3.1(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.3)(yaml@2.8.4) astro-accelerator-utils: specifier: ^0.3.84 version: 0.3.84 @@ -61,6 +61,12 @@ importers: '@pnpm/exe': specifier: ^11.0.9 version: 11.0.9 + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.3.0) + '@tailwindcss/vite': + specifier: ^4.3.0 + version: 4.3.0(vite@7.3.3(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) cspell: specifier: ^10.0.0 version: 10.0.0 @@ -82,6 +88,9 @@ importers: prettier-plugin-astro: specifier: ^0.14.1 version: 0.14.1 + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 packages: @@ -680,9 +689,22 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -959,6 +981,101 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -1272,6 +1389,11 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + csso@5.0.5: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} @@ -1369,6 +1491,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.22.0: + resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -1882,6 +2008,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -1907,6 +2037,76 @@ packages: leac@0.7.0: resolution: {integrity: sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + linkinator@7.0.0: resolution: {integrity: sha512-81sjQATza2eVL5Bqk96QrdVnYYI6fxWSrOHIC3/oWM6qG+5ARqwAVVwkrqSZ6RawsJcBRqOmOm4pkkj3r6Nrow==} engines: {node: '>=20'} @@ -2318,6 +2518,10 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -2661,6 +2865,13 @@ packages: engines: {node: '>=16'} hasBin: true + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -2832,6 +3043,9 @@ packages: uploadthing: optional: true + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -3000,12 +3214,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@5.0.4(astro@6.3.1(@types/node@24.10.1)(rollup@4.60.3)(yaml@2.8.4))': + '@astrojs/mdx@5.0.4(astro@6.3.1(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.3)(yaml@2.8.4))': dependencies: '@astrojs/markdown-remark': 7.1.1 '@mdx-js/mdx': 3.1.1 acorn: 8.16.0 - astro: 6.3.1(@types/node@24.10.1)(rollup@4.60.3)(yaml@2.8.4) + astro: 6.3.1(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.3)(yaml@2.8.4) es-module-lexer: 2.1.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -3474,8 +3688,25 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 @@ -3713,6 +3944,79 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.22.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/typography@0.5.19(tailwindcss@4.3.0)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@7.3.3(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 7.3.3(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -3810,7 +4114,7 @@ snapshots: astro-accelerator-utils@0.3.84: {} - astro@6.3.1(@types/node@24.10.1)(rollup@4.60.3)(yaml@2.8.4): + astro@6.3.1(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.3)(yaml@2.8.4): dependencies: '@astrojs/compiler': 4.0.0 '@astrojs/internal-helpers': 0.9.0 @@ -3862,8 +4166,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5 vfile: 6.0.3 - vite: 7.3.3(@types/node@24.10.1)(yaml@2.8.4) - vitefu: 1.1.3(vite@7.3.3(@types/node@24.10.1)(yaml@2.8.4)) + vite: 7.3.3(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vitefu: 1.1.3(vite@7.3.3(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 @@ -4147,6 +4451,8 @@ snapshots: css-what@6.2.2: {} + cssesc@3.0.0: {} + csso@5.0.5: dependencies: css-tree: 2.2.1 @@ -4241,6 +4547,11 @@ snapshots: emoji-regex@9.2.2: {} + enhanced-resolve@5.22.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@4.5.0: {} entities@6.0.1: {} @@ -4929,6 +5240,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jiti@2.7.0: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -4948,6 +5261,55 @@ snapshots: leac@0.7.0: {} + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + linkinator@7.0.0: dependencies: chalk: 5.6.2 @@ -5650,6 +6012,11 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss@8.5.14: dependencies: nanoid: 3.3.12 @@ -6173,6 +6540,10 @@ snapshots: picocolors: 1.1.1 sax: 1.6.0 + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + tiny-inflate@1.0.3: {} tinyclip@0.1.12: {} @@ -6329,6 +6700,8 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.4 + util-deprecate@1.0.2: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -6349,7 +6722,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.3(@types/node@24.10.1)(yaml@2.8.4): + vite@7.3.3(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): dependencies: esbuild: 0.28.0 fdir: 6.5.0(picomatch@4.0.4) @@ -6360,11 +6733,13 @@ snapshots: optionalDependencies: '@types/node': 24.10.1 fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 yaml: 2.8.4 - vitefu@1.1.3(vite@7.3.3(@types/node@24.10.1)(yaml@2.8.4)): + vitefu@1.1.3(vite@7.3.3(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): optionalDependencies: - vite: 7.3.3(@types/node@24.10.1)(yaml@2.8.4) + vite: 7.3.3(@types/node@24.10.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) vscode-languageserver-textdocument@1.0.12: {} diff --git a/src/ink/README.md b/src/ink/README.md new file mode 100644 index 0000000000..273965c1cd --- /dev/null +++ b/src/ink/README.md @@ -0,0 +1,297 @@ +# Ink — Octopus UI design system + +A self-contained, framework-free UI design system for every Octopus Astro site. Built so the +docs, blog, marketing pages, and integrations microsites can share **one** visual language, +**one** token contract, and **one** set of primitives — instead of each maintaining its own +CSS and drifting apart. The name is a nod to the octopus's namesake: every Octopus surface +gets *inked* the same way. + +Everything here is plain Astro + CSS custom properties + Tailwind v4 utilities — **no React, +Vue, or Svelte; no client framework runtime** — which keeps SEO and Core Web Vitals intact on +statically generated pages. + +> **Status:** MVP / preview — not yet production-complete. Proven in this repo on +> `/docs` and `/docs/ui-mvp`. Adopt it elsewhere by copying this one folder and wiring two +> things (see [§3 Adoption](#3-adopting-ink-in-another-repo)). + +--- + +## 1. Anatomy + +```text +src/ink/ +├── README.md ← this file +├── tokens.css ← brand + semantic CSS variables (THE customization surface) +├── theme.css ← Tailwind v4 entry: imports tokens, maps them into the @theme, +│ tunes the typography plugin. Import this once per page. +├── core/ ← universal primitives — any Octopus site (docs, blog, marketing…) +│ ├── Button.astro +│ ├── Badge.astro +│ ├── Card.astro +│ ├── Callout.astro +│ └── CodeBlock.astro +└── shells/ ← per-site outer wrappers (the page-level chrome) + └── docs/ ← documentation-site shell (one of several planned) + ├── Layout.astro ← page shell with hand-rolled (MVP entry point) + ├── ContentLayout.astro ← markdown-content layout: real nav, breadcrumbs, SEO head + ├── Header.astro ← top bar: OctopusLogo, real search, theme toggle, CTA + ├── Sidebar.astro ← left section nav (mobile menu) + ├── SidebarNav.astro ← recursive nav-tree renderer (lightweight collapsed style) + ├── SidebarBehavior.astro ← tiny inline script for sidebar interactions + ├── Toc.astro ← right "on this page" column w/ scroll-spy + ├── Breadcrumbs.astro ← RDFa BreadcrumbList breadcrumbs + └── Head.astro ← SEO head (HtmlHead minus main.css, for clean styling) +``` + +**Two layers, adopt independently:** + +- **`core/`** — universal primitives, no site bias. Any Octopus Astro site can use them. +- **`shells//`** — opinionated outer wrappers for a specific site type. Take a shell as-is + for that kind of site, or build a new one (`shells/blog/`, `shells/marketing/`, …) on top of + `core/` when the time comes. + +Future shells (not yet built) might be `shells/blog/`, `shells/marketing/`, `shells/integrations/`. +The folder structure already accommodates them; each new shell gets its own components and +imports from `core/`. + +--- + +## 2. The single customization surface: `tokens.css` + +`tokens.css` is the **only** file a site is expected to edit. It defines three layers: + +1. **Brand primitives** — the literal Octopus palette (`--octo-blue`, `--octo-green`, the navy + scale) and font stacks (`--font-display` / `--font-sans` / `--font-mono`). The brand source + of truth, mirrored from `public/docs/css/vars.css`. +2. **Semantic tokens** (shadcn-shape) — `--background`, `--foreground`, `--primary`, `--accent`, + `--muted`, `--border`, `--ring`, callout intents, surfaces, elevation, etc., defined for + light (`:root`) and dark (`[data-theme='dark']`). +3. **Bioluminescence layer** (the "Abyssal" look) — decorative palette: cyan → purple → pink + (`#1FC0FF` / `#6950FF` / `#CC3CFF`). Green is reserved for semantic success only. Glow tokens + (`--glow-primary`, `--glow-brand`), brand gradients (`--gradient-brand`, `--gradient-ink`), + atmosphere stops (`--aurora-1..3`, `--grid-line`, `--grain-opacity`), and `--shadow-glow`. + Dial the whole mood from here: drop the glow alphas and grain to near-zero for a flatter + skin, or push them up for more drama. No component edits required. + +Components and `theme.css` reference **only the semantic tokens**, never raw hex. So: + +- Re-skinning a site = edit `tokens.css`, nothing else. +- Because every microsite is the *same* Octopus brand, `tokens.css` ports **1:1 unchanged** — + that is the whole point: copy it verbatim and every site converges on one look. Only fork it + if a site genuinely needs to diverge. + +Dark mode is driven by the `[data-theme='dark']` attribute on `` (matches the existing +Octopus site switch), not a Tailwind `dark:` class. + +--- + +## 3. Adopting Ink in another repo + +**Prerequisites:** Astro 5+ (built and tested on Astro 6.3) using Vite, and pnpm. + +### Step 1 — install dependencies + +```bash +pnpm add -D tailwindcss@^4 @tailwindcss/vite@^4 @tailwindcss/typography@^0.5 +``` + +### Step 2 — wire the Tailwind v4 Vite plugin + +Tailwind v4 is added as a **Vite plugin**, not an Astro integration. In `astro.config.mjs`: + +```js +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + // ...existing integrations stay as-is... + vite: { + plugins: [tailwindcss()], + }, +}); +``` + +This is global to the build, but Tailwind only emits CSS for pages that actually **import +`theme.css`** — so it does not leak onto pages that don't opt in. + +### Step 3 — copy the module + +Copy `src/ink/` into the target repo's `src/`. Keep the folder intact so the relative imports +inside it keep working (`theme.css` → `./tokens.css`, shell files → siblings). + +### Step 4 — satisfy the `shells/docs` external dependencies (docs-style sites only) + +`shells/docs/` deliberately reuses existing shared Octopus assets rather than reinventing them. +A docs-style adopter must provide: + +| Dependency | Used by | Path in this repo | Notes | +| --- | --- | --- | --- | +| `OctopusLogo.astro` | `Header.astro` | `public/docs/img/OctopusLogo.astro` | The genuine wordmark + glyph. Import path is `../../../../public/docs/img/OctopusLogo.astro` — adjust if your asset lives elsewhere. | +| `SharedFooter.astro` | `Layout.astro` / `ContentLayout.astro` | `src/components/SharedFooter.astro` | Imported as `../../../components/SharedFooter.astro`. Fetches the shared footer + Marketo form; swap for the site's own footer if needed. | +| Shared asset stylesheets | `Head.astro` (``) | `${SHARED_ASSETS_BASE_URL}/footer-pu-styles.css` etc. | Read from `SHARED_ASSETS_BASE_URL` / `SHARED_ASSETS_ORIGIN` env vars (set in `.env.production` / `.env.staging`). | + +The `core/` layer has **no** external dependencies. + +### Step 5 — use it + +For a docs-style page: + +```astro +--- +import Layout from '../ink/shells/docs/Layout.astro'; +import Callout from '../ink/core/Callout.astro'; +import Card from '../ink/core/Card.astro'; +import Button from '../ink/core/Button.astro'; +import Badge from '../ink/core/Badge.astro'; +import CodeBlock from '../ink/core/CodeBlock.astro'; +--- + + Body text… + + +``` + +For a non-docs site, import `theme.css` once in your own layout's frontmatter and use the +`core/` primitives: + +```astro +--- +import '../ink/theme.css'; +import Button from '../ink/core/Button.astro'; +--- +``` + +> Tip: add an alias (e.g. `@ink/*` → `src/ink/*`) in `tsconfig.json` / `astro.config` to avoid +> `../../` import chains. Optional but recommended once the package is widely consumed. + +--- + +## 3a. Finding what's what (developer experience) + +With Tailwind the styling lives **on the element, inside the component file** — there is no +semantic stylesheet like the old `main.css` to grep for `.article-nav__title`. The source of +truth is the `.astro` component, not a CSS file. To keep the DOM traceable we tag every +component's root element with a namespaced **`data-component`** attribute. + +### Convention + +```text +data-component="ink//" +``` + +- `` is `core` for universal primitives or the shell name for shell components + (currently only `docs`). +- `` is the PascalCase filename (without `.astro`). + +Examples in the wild: + +| Element | `data-component` value | File | +| --- | --- | --- | +| A button | `ink/core/Button` | `src/ink/core/Button.astro` | +| A callout | `ink/core/Callout` | `src/ink/core/Callout.astro` | +| Docs sidebar region | `ink/docs/Sidebar` | `src/ink/shells/docs/Sidebar.astro` (rendered by `ContentLayout`) | +| Nav item | `ink/docs/SidebarNav` | `src/ink/shells/docs/SidebarNav.astro` | +| TOC | `ink/docs/Toc` | `src/ink/shells/docs/Toc.astro` | + +### Workflow to locate and fix anything + +1. Inspect the element in browser devtools. +2. Walk up to the nearest `[data-component="ink/.../X"]`. +3. The path tells you exactly where: `ink//` → `src/ink//X.astro` + (with `core/` flat and `shells//` for shell components). +4. Open the file — the Tailwind classes you want to change are right there on the element + (component bodies are small and readable). + +### Why namespaced + +Once other microsites adopt Ink, multiple sites may render side-by-side or share components; +the `ink/` namespace makes provenance obvious and disambiguates when a future shell +(`ink/blog/Header`) coexists with `ink/docs/Header`. It also survives into production HTML and +is greppable across repos. + +For finer granularity inside a component (e.g. distinct slots), add +`data-slot=""` — the same idea shadcn/ui uses for component parts. + +Optional dev aid: Astro can emit `data-astro-source-file` / `data-astro-source-loc` (click an +element to open the exact file:line in your editor) — not currently enabled in this repo; can +be turned on for the dev build if wanted. + +--- + +## 4. Guarantees, and how not to break them + +These hold by construction; preserve them when extending the system. + +- **Zero framework runtime.** Components are `.astro` (compile to static HTML) + Tailwind + utilities. The only JavaScript is a handful of tiny `is:inline` vanilla scripts. Do **not** + introduce a UI framework or a client-side component library. +- **SEO surfaces preserved.** `Breadcrumbs` emits an RDFa `BreadcrumbList`; `ContentLayout` + wraps content in `
` with + `itemprop` headline/description/articleBody; heading hierarchy is semantic; JsonLd survives + through the reused HtmlHead (via `shells/docs/Head.astro`). Keep these when editing the shell. +- **Core Web Vitals.** No blocking JS, no layout-shifting hydration. A FOUC-prevention inline + script in `Layout`'s `` applies the stored theme before paint. Three web fonts + (Bricolage Grotesque / Hanken Grotesk / JetBrains Mono via Bunny Fonts, a privacy-friendly + Google Fonts mirror) are `preconnect`ed and `display=swap`, and every `--font-*` token keeps + a system-stack fallback, so first paint uses system fonts with no invisible-text delay. To + go font-free, point the `--font-*` tokens at system stacks and drop the ``s. +- **In-content images get the "glass" effect.** Images authored inside documentation content + (`.prose figure` / `.image` / `[data-image]` from `` and the `:::figure` / `:img` + directives, plus standalone `img.resp-img`) are wrapped in a frosted panel with a blurred + cyan/pink bloom behind them — ported 1:1 from the marketing site. **Scoped to `.prose`**, so + the logo and all UI/chrome imagery are never touched. Tokenized in `tokens.css` (`--glass-*`): + dark mode uses the marketing values; light mode is retuned for the pale background. +- **Atmosphere is decorative and pointer-transparent.** The fixed `.ds-atmosphere` (aurora mesh + and engineering grid) and `.ds-grain` overlay sit at `z-index:-1` behind content and are + GPU-cheap. The page-load `[data-rise]` reveal, the aurora drift, and the status-dot pulse all + collapse under `prefers-reduced-motion: reduce`. +- **Typography plugin override technique.** The prose/table/code/status rules in `theme.css` + are intentionally **unlayered** (outside `@layer base`) so they beat `@tailwindcss/typography`'s + utility-layer defaults. Pages use `class="prose"` (not `prose-slate`). Don't move these into a + layer or the brand colors will lose to the plugin (and dark-mode body text will go dark). + +### Inline-script inventory (all vanilla, all `is:inline`) + +| Script | File | Purpose | +| --- | --- | --- | +| FOUC theme apply | `shells/docs/Layout.astro` / `ContentLayout.astro` (head) | Set `data-theme` from `localStorage` before paint | +| Theme toggle | `shells/docs/Header.astro` | Toggle + persist light/dark | +| Search | `shells/docs/Header.astro` renders the real `themes/octopus/components/Search.astro` | The real `search.js` engine (index + scoring + synonyms), re-skinned via `.ds-search` CSS. Not an Ink script. | +| Sidebar behavior | `shells/docs/SidebarBehavior.astro` | Tiny interaction helpers (`navscroll`, viewport-aware behavior) | +| TOC scroll-spy | `shells/docs/Toc.astro` | `IntersectionObserver` active-section highlight | +| Copy button | `core/CodeBlock.astro` | Delegated copy-to-clipboard with "Copied" state | +| Button glow | `core/Button.astro` | Tracks the pointer to set `--hover-x` / `--hover-y` for the cursor-following glow | + +--- + +## 5. Known scope notes + +- **Search uses the real engine.** The header renders the existing `Search.astro` + `search.js` + (full `search.json` index, scoring, synonyms), re-skinned to the Ink UI via `.ds-search` CSS. + The engine files are unmodified; the layout sets `window.site_url` / `window.site_features` + (which `search.js` depends on). +- **Code blocks: no syntax highlighting yet.** `CodeBlock` gives the surface + filename bar + + copy button, but not token highlighting. Adding a build-time highlighter (Shiki via + rehype/remark) is a planned step. Bare fenced code in MDX gets the styled surface but not the + filename bar / copy button until a rehype step wraps fenced code in the `CodeBlock` shape. +- **Mobile nav** is a simple `
` disclosure (no slide-over / scroll-lock), to honor the + zero-JS constraint. +- **Other shells (`blog`, `marketing`, `integrations`) don't exist yet.** The `shells/` + directory is structured to accept them when those microsites adopt Ink. + +--- + +## 6. Porting checklist + +- [ ] `pnpm add -D tailwindcss@^4 @tailwindcss/vite@^4 @tailwindcss/typography@^0.5` +- [ ] Add `vite.plugins: [tailwindcss()]` to `astro.config.mjs` +- [ ] Copy `src/ink/` into the target repo (keep folder structure intact) +- [ ] (`shells/docs` only) Provide `OctopusLogo.astro`, `SharedFooter.astro`, the shared-asset + env vars (`SHARED_ASSETS_BASE_URL`, `SHARED_ASSETS_ORIGIN`), and fix the relative paths + if your layout sits at a different depth +- [ ] Import `theme.css` once per page (directly, or transitively via a `Layout`) +- [ ] Confirm dark mode toggles via `[data-theme='dark']` +- [ ] Verify a built page: HTTP 200, no Vite error overlay, RDFa / TechArticle present, brand + colors render in both themes +- [ ] Decide whether `tokens.css` ports verbatim (recommended for brand unification) or forks +- [ ] When adding a new shell (`shells/blog/`, etc.), keep components flat-named (`Layout`, + `Header`, …) and namespace their `data-component` values as `ink//` diff --git a/src/ink/core/Badge.astro b/src/ink/core/Badge.astro new file mode 100644 index 0000000000..e2c31d5065 --- /dev/null +++ b/src/ink/core/Badge.astro @@ -0,0 +1,26 @@ +--- +type Variant = 'default' | 'brand' | 'success' | 'outline'; + +interface Props { + variant?: Variant; + class?: string; +} + +const { variant = 'default', class: extra = '' } = Astro.props; + +const variants: Record = { + default: 'bg-muted text-muted-foreground ring-1 ring-inset ring-border/60', + brand: 'bg-primary/10 text-primary ring-1 ring-inset ring-primary/20', + success: + 'bg-[color-mix(in_oklab,var(--octo-green)_16%,var(--background))] text-[color-mix(in_oklab,var(--octo-green)_60%,var(--foreground))] ring-1 ring-inset ring-[color-mix(in_oklab,var(--octo-green)_35%,transparent)]', + outline: 'border border-border text-muted-foreground', +}; + +const cls = [ + 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium tracking-tight', + variants[variant], + extra, +].join(' '); +--- + + diff --git a/src/ink/core/Button.astro b/src/ink/core/Button.astro new file mode 100644 index 0000000000..5bdf6ed719 --- /dev/null +++ b/src/ink/core/Button.astro @@ -0,0 +1,38 @@ +--- +type Variant = 'primary' | 'secondary' | 'ghost'; + +interface Props { + href?: string; + variant?: Variant; + class?: string; +} + +const { href, variant = 'primary', class: extra = '' } = Astro.props; +const cls = ['ds-btn', `ds-btn--${variant}`, extra].join(' '); +const Tag = href ? 'a' : 'button'; +--- + + + + diff --git a/src/ink/core/Callout.astro b/src/ink/core/Callout.astro new file mode 100644 index 0000000000..52eaec031d --- /dev/null +++ b/src/ink/core/Callout.astro @@ -0,0 +1,71 @@ +--- +type Intent = 'info' | 'success' | 'warning' | 'problem'; + +interface Props { + intent?: Intent; + title?: string; +} + +const { intent = 'info', title } = Astro.props; + +// Colors pull from the intent tokens defined in tokens.css. +const styles: Record = { + info: '[--c:var(--info)] [--bg:var(--info-bg)] [--bd:var(--info-border)]', + success: + '[--c:var(--success)] [--bg:var(--success-bg)] [--bd:var(--success-border)]', + warning: + '[--c:var(--warning)] [--bg:var(--warning-bg)] [--bd:var(--warning-border)]', + problem: + '[--c:var(--problem)] [--bg:var(--problem-bg)] [--bd:var(--problem-border)]', +}; + +const labels: Record = { + info: 'Note', + success: 'Success', + warning: 'Warning', + problem: 'Important', +}; + +// Per-intent inline SVG paths (24x24 viewBox), stroke-based to match the brand iconography. +const icons: Record = { + info: '', + success: + '', + warning: + '', + problem: + '', +}; +--- + +
+ + + +
+

{title ?? labels[intent]}

+
+ +
+
+
diff --git a/src/ink/core/Card.astro b/src/ink/core/Card.astro new file mode 100644 index 0000000000..1d57a796b1 --- /dev/null +++ b/src/ink/core/Card.astro @@ -0,0 +1,45 @@ +--- +interface Props { + href?: string; + title?: string; + class?: string; +} + +const { href, title, class: extra = '' } = Astro.props; +const interactive = href + ? 'group hover:-translate-y-1 hover:border-primary/45 hover:[box-shadow:var(--shadow-glow)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background before:absolute before:inset-x-5 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-primary/50 before:to-transparent before:opacity-0 before:transition-opacity before:duration-300 hover:before:opacity-100' + : ''; +const cls = [ + 'relative flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card/70 text-card-foreground p-5 backdrop-blur-sm transition-[transform,box-shadow,border-color] duration-300 ease-out', + interactive, + extra, +].join(' '); +const Tag = href ? 'a' : 'div'; +// Title may carry a trailing arrow glyph in content; strip it so we render our own. +const cleanTitle = title?.replace(/\s*→\s*$/, ''); +--- + + + { + cleanTitle && ( +

+ {cleanTitle} + {href && ( + + )} +

+ ) + } +
+
diff --git a/src/ink/core/CodeBlock.astro b/src/ink/core/CodeBlock.astro new file mode 100644 index 0000000000..63e6f5438f --- /dev/null +++ b/src/ink/core/CodeBlock.astro @@ -0,0 +1,101 @@ +--- +// Calm bordered code surface with a slim label bar (filename/language) and an +// optional copy affordance. The copy button is a tiny is:inline vanilla script +// (no framework runtime) - it reads textContent from the sibling
.
+type Lang = 'bash' | 'shell' | 'json' | 'yaml' | 'powershell' | 'text';
+
+interface Props {
+  label?: string;
+  lang?: Lang;
+  copy?: boolean;
+}
+
+const { label, lang = 'text', copy = true } = Astro.props;
+
+const icons: Record = {
+  bash: '',
+  shell: '',
+  powershell: '',
+  json: '',
+  yaml: '',
+  text: '',
+};
+---
+
+
+
+ + + {label ?? lang} + + { + copy && ( + + ) + } +
+ +
+ + diff --git a/src/ink/shells/docs/Breadcrumbs.astro b/src/ink/shells/docs/Breadcrumbs.astro new file mode 100644 index 0000000000..ea629fb6f0 --- /dev/null +++ b/src/ink/shells/docs/Breadcrumbs.astro @@ -0,0 +1,54 @@ +--- +interface Crumb { + label: string; + href?: string; +} +interface Props { + items: Crumb[]; +} +const { items } = Astro.props; +--- + + + diff --git a/src/ink/shells/docs/ContentLayout.astro b/src/ink/shells/docs/ContentLayout.astro new file mode 100644 index 0000000000..94d255e202 --- /dev/null +++ b/src/ink/shells/docs/ContentLayout.astro @@ -0,0 +1,300 @@ +--- +// Phase A adapter layout: markdown-compatible entrypoint that lets any real +// content page adopt the new design system by switching its `layout:` +// frontmatter line. Reuses the site's existing data sources (nav tree, +// breadcrumbs, SEO head via HtmlHead) and renders them through our chrome. +// +// ⚠️ MVP / PREVIEW — NOT production-complete. Versus DefaultLegacy.astro this +// omits essential plumbing (main.js, Plausible, Marketo, SkipLinks, real search, +// theme reconciliation, article-footer features) — complete these before any +// real rollout. +import '../../theme.css'; +import { Accelerator } from 'astro-accelerator-utils'; +import { SITE } from '@config'; +import { menu } from '@data/navigation'; +import Head from './Head.astro'; + +import Header from './Header.astro'; +import Toc from './Toc.astro'; +import Breadcrumbs from './Breadcrumbs.astro'; +import SidebarNav from './SidebarNav.astro'; +import SidebarBehavior from './SidebarBehavior.astro'; +import SharedFooter from '../../../components/SharedFooter.astro'; +import CopyMarkdown from '../../../components/CopyMarkdown.astro'; + +type Props = { + frontmatter: Record; + headings: { depth: number; slug: string; text: string }[]; + breadcrumbs?: { url: string; title: string; ariaCurrent?: string }[] | null; +}; +const { frontmatter, headings, breadcrumbs } = Astro.props satisfies Props; + +const accelerator = new Accelerator(SITE); +const lang = frontmatter.lang ?? SITE.default.lang; +const currentUrl = new URL(Astro.request.url); + +// Real sidebar nav tree (NavPage[]). +const pages = accelerator.navigation.menu(currentUrl, SITE.subfolder, menu); + +// Lightweight nav (mirrors src/themes/octopus/components/Navigation.astro from +// main): keep only the active section's subtree expanded and collapse every +// other top-level section to a single labelled link, so the rendered DOM stays +// small for AI-crawler token budgets. Collapsed sections render as links with a +// right-chevron 'navigate' affordance (see SidebarNav); the active section +// stays an expandable dropdown. +const currentPath = currentUrl.pathname.replace(/\/$/, ''); +const isOnActivePath = (page: any): boolean => { + const pUrl = (page.url ?? '').replace(/\/$/, ''); + if (!pUrl) return false; + return currentPath === pUrl || currentPath.startsWith(pUrl + '/'); +}; +const markActiveBranchOpen = (items: any[]): void => { + for (const item of items) { + if (item.children && item.children.length > 0 && isOnActivePath(item)) { + item.isOpen = true; + markActiveBranchOpen(item.children); + } + } +}; +for (const top of pages) { + if (top.children && top.children.length > 0) { + if (isOnActivePath(top)) { + top.isOpen = true; + markActiveBranchOpen(top.children); + } else { + if (top.section) top.title = top.section; + top.children = []; + } + } +} + +// Real breadcrumbs from accelerator nav data. +const crumbs = accelerator.navigation + .breadcrumbs(currentUrl, SITE.subfolder, breadcrumbs?.length ?? 0) + .map((crumb: any) => ({ + label: crumb.section || crumb.title, + href: accelerator.urlFormatter.formatAddress(crumb.url), + })); + +// TOC from the rendered markdown headings (h2/h3 only). +const toc = headings + .filter((h) => h.depth >= 2 && h.depth <= 3) + .map((h) => ({ + label: h.text, + href: '#' + h.slug, + depth: h.depth >= 3 ? 2 : 1, + })); + +const sortedTopLevel = pages + .slice() + .sort((a: any, b: any) => a.order - b.order); +--- + + + + + + +
+
+
+ +
+ + +
+
+
+
+ + + + Documentation menu + + + + +
+ +
+ +
+ +
+
+
+

+ + {crumbs[crumbs.length - 2]?.label ?? 'Documentation'} +

+ +
+

+ {frontmatter.title} +

+ { + frontmatter.description && ( +

+ {frontmatter.description} +

+ ) + } +
+
+
+ +
+ +
+
+
+
+ + +
+
+ + + + + + + diff --git a/src/ink/shells/docs/Head.astro b/src/ink/shells/docs/Head.astro new file mode 100644 index 0000000000..60536668d3 --- /dev/null +++ b/src/ink/shells/docs/Head.astro @@ -0,0 +1,146 @@ +--- +// HtmlHead minus main.css, for Ink pages (prevents legacy CSS bleed). +import { Accelerator } from 'astro-accelerator-utils'; +import type { Frontmatter } from 'astro-accelerator-utils/types/Frontmatter'; +import { SITE, OPEN_GRAPH, HEADER_SCRIPTS } from '@config'; +import { getEligibleSlugs } from '@util/mdxContent'; +import JsonLd from '@components/JsonLd.astro'; +import { ASSETS, ASSETS_ORIGIN } from 'src/lib/sharedNavbarFooter'; + +const accelerator = new Accelerator(SITE); +const stats = new accelerator.statistics('octopus/components/HtmlHead.astro'); +stats.start(); + +// Properties +type Props = { + lang: string; + frontmatter: Frontmatter; + headings: { depth: number; slug: string; text: string }[]; +}; +const { frontmatter } = Astro.props; + +// Logic +const imageSrc = frontmatter.bannerImage?.src ?? OPEN_GRAPH.image.src; +const imageAlt = frontmatter.bannerImage?.alt ?? OPEN_GRAPH.image.alt; +const robots = frontmatter.robots ?? 'index, follow'; +const canonicalImageSrc = new URL(imageSrc, Astro.site); +const canonicalURL = accelerator.urlFormatter.formatUrl( + new URL(Astro.url.pathname, Astro.site + SITE.subfolder) +); +const socialTitle = await accelerator.markdown.getTextFrom(frontmatter?.title); +const title = `${accelerator.markdown.titleCase(socialTitle)} ${frontmatter.titleAdditional ? ` ${frontmatter.titleAdditional}` : ''} | ${SITE.title}`; +const pageMeta = + frontmatter?.meta && frontmatter?.meta?.length > 0 ? frontmatter.meta : []; + +const authorList = accelerator.authors.forPost(frontmatter); +const authorMeta = + authorList.mainAuthor?.frontmatter?.meta && + authorList.mainAuthor?.frontmatter?.meta?.length > 0 + ? authorList.mainAuthor.frontmatter.meta + : []; + +const autoMeta = (name: string) => { + return pageMeta.filter((m) => m.name.toLowerCase() === name).length === 0; +}; + +const docsPath = Astro.url.pathname.replace(/\/$/, ''); +const subfolderPrefix = SITE.subfolder.replace(/\/$/, '') + '/'; +const docsSlug = docsPath.startsWith(subfolderPrefix) + ? docsPath.slice(subfolderPrefix.length) + : null; +const eligibleSlugs = getEligibleSlugs(); +const markdownAlternateHref = + docsSlug && eligibleSlugs.has(docsSlug) ? SITE.url + docsPath + '.md' : null; + +stats.stop(); +--- + + + + {title} + + + + + + + + + + + + { + autoMeta('viewport') && ( + + ) + } + { + autoMeta('format-detection') && ( + + ) + } + { + autoMeta('theme-color') && ( + + ) + } + {autoMeta('canonical') && } + { + markdownAlternateHref && ( + + ) + } + { + SITE.feedUrl && ( + + ) + } + + + + + + + + + + + + + + + + {pageMeta.map((m) => )} + {authorMeta.map((m) => )} + + + diff --git a/src/ink/shells/docs/Header.astro b/src/ink/shells/docs/Header.astro new file mode 100644 index 0000000000..1b143ea4e3 --- /dev/null +++ b/src/ink/shells/docs/Header.astro @@ -0,0 +1,163 @@ +--- +// Top navigation bar. The only JS is a tiny theme toggle (vanilla, no framework +// runtime) to demonstrate dark mode - consistent with the zero-JS approach. +// Search renders the REAL site search component + engine (search.js), restyled +// via tokens in theme.css. The engine is unchanged; we only provide its markup. +import OctopusLogo from '../../../../public/docs/img/OctopusLogo.astro'; +import Button from '../../core/Button.astro'; +import Search from '../../../themes/octopus/components/Search.astro'; +import { SITE } from '@config'; + +type Props = { + lang?: string; +}; +const { lang = SITE.default.lang } = Astro.props satisfies Props; +--- + +
+ +
+ + + + + + + +
+ + +
+ + + + + + diff --git a/src/ink/shells/docs/Layout.astro b/src/ink/shells/docs/Layout.astro new file mode 100644 index 0000000000..b65d34a95b --- /dev/null +++ b/src/ink/shells/docs/Layout.astro @@ -0,0 +1,195 @@ +--- +import '../../theme.css'; +import { SITE } from '@config'; +import Header from './Header.astro'; +import Sidebar from './Sidebar.astro'; +import Toc from './Toc.astro'; +import Breadcrumbs from './Breadcrumbs.astro'; +import SharedFooter from '../../../components/SharedFooter.astro'; + +interface Props { + title: string; + description?: string; + eyebrow?: string; + breadcrumbs: { label: string; href?: string }[]; + sidebar: { + title: string; + items: { label: string; href: string; active?: boolean }[]; + }[]; + toc: { label: string; href: string; depth?: number }[]; +} + +const { title, description, eyebrow, breadcrumbs, sidebar, toc } = Astro.props; +const kicker = + eyebrow ?? breadcrumbs[breadcrumbs.length - 2]?.label ?? 'Documentation'; +--- + + + + + + + {title} · Octopus Docs + {description && } + + + + + + + + + + +
+
+ +
+ +
+ + +
+
+
+
+ + + + Documentation menu + + + + +
+ +
+ +
+ +
+
+

+ + {kicker} +

+

+ {title} +

+ { + description && ( +

+ {description} +

+ ) + } +
+
+
+ +
+ +
+
+
+
+ + +
+
+ + + + diff --git a/src/ink/shells/docs/Sidebar.astro b/src/ink/shells/docs/Sidebar.astro new file mode 100644 index 0000000000..31f2afaf9c --- /dev/null +++ b/src/ink/shells/docs/Sidebar.astro @@ -0,0 +1,72 @@ +--- +import SidebarBehavior from './SidebarBehavior.astro'; +interface NavItem { + label: string; + href: string; + active?: boolean; +} +interface NavSection { + title: string; + items: NavItem[]; +} +interface Props { + sections: NavSection[]; +} +const { sections } = Astro.props; +--- + + diff --git a/src/ink/shells/docs/SidebarBehavior.astro b/src/ink/shells/docs/SidebarBehavior.astro new file mode 100644 index 0000000000..cd05e4878e --- /dev/null +++ b/src/ink/shells/docs/SidebarBehavior.astro @@ -0,0 +1,86 @@ +--- +// Sidebar behaviour for [data-navscroll] viewports: edge fade shades, auto-reveal +// of the active item, and a tooltip for truncated labels. Vanilla, zero deps. +// Shared by Sidebar (showcase) and ContentLayout (real nav). +--- + + diff --git a/src/ink/shells/docs/SidebarNav.astro b/src/ink/shells/docs/SidebarNav.astro new file mode 100644 index 0000000000..6237d75bda --- /dev/null +++ b/src/ink/shells/docs/SidebarNav.astro @@ -0,0 +1,107 @@ +--- +// Recursive nav over the Octopus nav tree (NavPage[]). Three shapes: leaf link, +// collapsed top-level link (right-chevron), expandable native
. +// Zero-JS. Styling lives in theme.css (.ds-nav__*) so it paints on first load. +import { Accelerator } from 'astro-accelerator-utils'; +import type { NavPage } from 'astro-accelerator-utils/types/NavPage'; +import { SITE } from '@config'; + +const accelerator = new Accelerator(SITE); + +type Props = { + page: NavPage; + depth?: number; +}; +const { page, depth = 0 } = Astro.props satisfies Props; + +const displayLink = page.title != null; +const linkTitle = page.title === page.fullTitle ? null : page.fullTitle; +const children = page.children.slice().sort((a, b) => a.order - b.order); + +const href = accelerator.urlFormatter.formatAddress(page.url); + +// page.ariaCurrent is the source of truth; fall back to a normalized path +// compare because the upstream exact-match misses on trailing-slash diffs. +const normalize = (p: string) => p.replace(/\/+$/, '') || '/'; +const currentPath = normalize(new URL(Astro.url).pathname); +const isCurrent = Boolean(page.ariaCurrent) || normalize(href) === currentPath; +const ariaCurrent = isCurrent ? page.ariaCurrent || 'page' : undefined; + +// Open section on the active branch lights its rail (the trail to the page). +const isCurrentPage = (p: NavPage): boolean => + Boolean(p.ariaCurrent) || + normalize(accelerator.urlFormatter.formatAddress(p.url)) === currentPath; +const onActiveTrail = + page.children.length > 0 && + page.isOpen && + page.children.some(function hasCurrent(c: NavPage): boolean { + return isCurrentPage(c) || c.children.some(hasCurrent); + }); +--- + +{ + displayLink && page.children.length === 0 && ( +
  • + + {depth > 0 && +
  • + ) +} +{ + displayLink && page.children.length > 0 && ( +
  • +
    + + {depth > 0 && +
      + {children.map((child) => ( + + ))} +
    +
    +
  • + ) +} diff --git a/src/ink/shells/docs/Toc.astro b/src/ink/shells/docs/Toc.astro new file mode 100644 index 0000000000..c3b30bac7b --- /dev/null +++ b/src/ink/shells/docs/Toc.astro @@ -0,0 +1,416 @@ +--- +interface TocItem { + label: string; + href: string; + depth?: number; +} +interface Props { + items: TocItem[]; +} +const { items } = Astro.props; +--- + + + + + + diff --git a/src/ink/theme.css b/src/ink/theme.css new file mode 100644 index 0000000000..c279f8b7e3 --- /dev/null +++ b/src/ink/theme.css @@ -0,0 +1,1893 @@ +/* + Design-system entry. Importing this opts a page in: Tailwind v4 + typography + plugin + brand tokens mapped into the theme. Web fonts load in the layout + . Nothing here leaks onto pages that don't import it. +*/ + +@import 'tailwindcss'; +@import './tokens.css'; +@plugin '@tailwindcss/typography'; + +/* Dark mode is driven by the existing [data-theme="dark"] attribute, not a class. */ +@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-surface: var(--surface); + --color-surface-2: var(--surface-2); + + --font-display: var(--font-display); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + --radius-xl: calc(var(--radius) + 5px); + + --shadow-sm: var(--shadow-sm); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-glow: var(--shadow-glow); +} + +/* Reserve the scrollbar gutter so a late-appearing scrollbar can't shift the + centered layout sideways. Smooth in-page scrolling for the TOC anchors. */ +html { + scrollbar-gutter: stable; + scroll-behavior: smooth; + -webkit-text-size-adjust: 100%; +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} + +/* Tinted, thin scrollbars that match the abyss. */ +* { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklab, var(--muted-foreground) 35%, transparent) + transparent; +} +*::-webkit-scrollbar { + width: 9px; + height: 9px; +} +*::-webkit-scrollbar-thumb { + background: color-mix(in oklab, var(--muted-foreground) 32%, transparent); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: padding-box; +} +*::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklab, var(--muted-foreground) 55%, transparent); + background-clip: padding-box; +} + +::selection { + background: color-mix(in oklab, var(--primary) 24%, transparent); + color: var(--foreground); +} + +/* Atmosphere: fixed aurora mesh + grid + grain, behind everything (z-index:-1). + Layout renders the .ds-atmosphere / .ds-grain divs. */ +.ds-atmosphere { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(52rem 38rem at 12% -8%, var(--aurora-1), transparent 60%), + radial-gradient(46rem 40rem at 100% 0%, var(--aurora-2), transparent 58%); + background-color: var(--background); +} +.ds-atmosphere::before { + /* Engineering grid, masked to fade inward. */ + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, var(--grid-line) 1px, transparent 1px), + linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px); + background-size: 56px 56px; + background-position: center top; + -webkit-mask-image: radial-gradient( + 120% 80% at 50% 0%, + #000 0%, + transparent 75% + ); + mask-image: radial-gradient(120% 80% at 50% 0%, #000 0%, transparent 75%); +} +.ds-grain { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + opacity: var(--grain-opacity); + mix-blend-mode: soft-light; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +/* Staggered page-load reveal: [data-rise] fades/lifts, --rise sets the delay. */ +@keyframes ds-rise { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +[data-rise] { + animation: ds-rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both; + animation-delay: var(--rise, 0ms); +} +@media (prefers-reduced-motion: reduce) { + [data-rise] { + animation: none; + } +} + +/* Gradient brand text (hero H1 / accents). */ +.ds-gradient-text { + background: var(--gradient-ink); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* Prose: brand-tuned typography. Intentionally UNLAYERED to beat the typography + plugin's utility-layer defaults (keeps brand colors + dark mode). */ +.prose { + --tw-prose-body: var(--foreground); + --tw-prose-headings: var(--foreground); + --tw-prose-links: var(--primary); + --tw-prose-bold: var(--foreground); + --tw-prose-counters: var(--muted-foreground); + --tw-prose-bullets: var(--octo-navy-300); + --tw-prose-hr: var(--border); + --tw-prose-quotes: var(--foreground); + --tw-prose-quote-borders: var(--primary); + --tw-prose-captions: var(--muted-foreground); + --tw-prose-code: var(--foreground); + --tw-prose-th-borders: var(--border); + --tw-prose-td-borders: var(--border); + --tw-prose-pre-bg: var(--code-bg); + --tw-prose-pre-code: var(--code-foreground); + max-width: 46rem; + font-size: 1rem; + line-height: 1.75; + font-feature-settings: 'ss01', 'cv01'; +} + +/* Confident display headings with a luminous anchored accent. */ +.prose :where(h2):not(:where([class~='not-prose'] *)) { + position: relative; + margin-top: 3rem; + margin-bottom: 1rem; + font-family: var(--font-display); + font-size: 1.75rem; + line-height: 1.25; + font-weight: 700; + letter-spacing: -0.02em; + scroll-margin-top: 6rem; +} +/* Glowing tick to the left of every H2, centered on the first line. */ +.prose :where(h2):not(:where([class~='not-prose'] *))::before { + content: ''; + position: absolute; + left: -1.15rem; + top: 50%; + transform: translateY(-50%); + height: 0.82em; + width: 3px; + border-radius: 9999px; + background: var(--gradient-brand); + box-shadow: 0 0 12px -1px var(--glow-primary); +} +.prose :where(h3):not(:where([class~='not-prose'] *)) { + margin-top: 2.1rem; + margin-bottom: 0.6rem; + font-family: var(--font-display); + font-size: 1.25rem; + line-height: 1.35; + font-weight: 650; + letter-spacing: -0.014em; + scroll-margin-top: 6rem; +} +.prose :where(h2, h3):not(:where([class~='not-prose'] *)):first-child { + margin-top: 0; +} +.prose :where(p, ul, ol):not(:where([class~='not-prose'] *)) { + margin-top: 0; + margin-bottom: 1.25rem; +} +.prose :where(strong):not(:where([class~='not-prose'] *)) { + font-weight: 650; + color: var(--foreground); +} + +/* Links: no underline at rest; on hover it draws in from the left (matches the + marketing site). Uses background-size width (0 -> 100%) rather than an + absolute ::after, so it works on links that wrap across lines. Buttons opt out. */ +.prose :where(a):not(.button):not(:where([class~='not-prose'] *)) { + font-weight: 500; + text-decoration: none; + color: var(--primary); + background-image: linear-gradient(currentColor, currentColor); + background-position: 0 100%; + background-repeat: no-repeat; + background-size: 0% 1px; + padding-bottom: 1px; + transition: + background-size 0.2s ease-in-out, + color 0.2s ease-in-out; +} +.prose :where(a):not(.button):not(:where([class~='not-prose'] *)):hover, +.prose + :where(a):not(.button):not(:where([class~='not-prose'] *)):focus-visible { + background-size: 100% 1px; +} + +/* Lists: tidy markers and spacing. */ +.prose :where(ol > li, ul > li):not(:where([class~='not-prose'] *)) { + margin-top: 0.4rem; + margin-bottom: 0.4rem; + padding-left: 0.4em; +} +.prose :where(ol > li)::marker:not(:where([class~='not-prose'] *)) { + font-weight: 600; + color: var(--primary); +} +.prose :where(ul > li)::marker:not(:where([class~='not-prose'] *)) { + color: var(--primary); +} + +/* Blockquote: glassy panel with a luminous edge. */ +.prose :where(blockquote):not(:where([class~='not-prose'] *)) { + margin: 1.75rem 0; + padding: 0.9rem 1.25rem; + border: 0; + border-left: 3px solid transparent; + border-image: var(--gradient-brand) 1; + border-radius: 0 var(--radius) var(--radius) 0; + background: color-mix(in oklab, var(--primary) 6%, transparent); + font-style: normal; + font-weight: 450; + color: var(--foreground); +} +.prose :where(blockquote p:first-of-type)::before, +.prose :where(blockquote p:last-of-type)::after { + content: ''; +} + +/* Horizontal rule: a faded brand line. */ +.prose :where(hr):not(:where([class~='not-prose'] *)) { + margin-top: 3rem; + margin-bottom: 3rem; + border: 0; + height: 1px; + background: linear-gradient( + to right, + transparent, + var(--border) 18%, + var(--border) 82%, + transparent + ); +} + +/* Code blocks. The label bar + copy button live in a figure.code-block wrapper. */ +.prose :where(pre):not(:where([class~='not-prose'] *)) { + position: relative; + margin-top: 1.5rem; + margin-bottom: 1.5rem; + padding: 1.2rem 1.3rem; + background: var(--code-bg); + color: var(--code-foreground); + border: 1px solid var(--code-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.78; + overflow-x: auto; +} +.prose :where(pre code):not(:where([class~='not-prose'] *)) { + background: transparent; + padding: 0; + font-weight: 400; + font-size: inherit; + color: inherit; +} + +/* Code-block wrapper: top bar + body in one frame, with a lit top edge. */ +.prose :where(.code-block):not(:where([class~='not-prose'] *)) { + position: relative; + text-align: left; + margin-top: 1.75rem; + margin-bottom: 1.75rem; + border: 1px solid var(--code-border); + border-radius: var(--radius-lg); + background: var(--code-bg); + box-shadow: var(--shadow-md); + overflow: hidden; +} +.prose :where(.code-block):not(:where([class~='not-prose'] *))::before { + content: ''; + position: absolute; + inset: -1px -1px auto -1px; + height: 1px; + background: linear-gradient( + to right, + transparent, + color-mix(in oklab, var(--octo-blue-bright) 55%, transparent), + color-mix(in oklab, var(--pink-100) 45%, transparent), + transparent + ); +} +.code-block > .code-block__bar { + display: flex; + align-items: center; + justify-content: space-between; + height: 2.6rem; + padding: 0 0.5rem 0 1rem; + background: var(--code-chrome); + border-bottom: 1px solid var(--code-border); +} +.code-block__label { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 0.03em; + font-weight: 500; + color: color-mix(in oklab, var(--code-foreground) 64%, transparent); +} +.code-block__label svg { + width: 0.9rem; + height: 0.9rem; + color: color-mix(in oklab, var(--octo-blue-bright) 70%, transparent); +} +/* The inner
     (inside the .not-prose figure) re-states the code surface. */
    +.code-block > pre {
    +  margin: 0;
    +  text-align: left;
    +  padding: 1.2rem 1.3rem;
    +  border: 0;
    +  border-radius: 0;
    +  box-shadow: none;
    +  background: var(--code-bg);
    +  color: var(--code-foreground);
    +  font-family: var(--font-mono);
    +  font-size: 0.875rem;
    +  line-height: 1.78;
    +  overflow-x: auto;
    +}
    +.code-block > pre code {
    +  background: transparent;
    +  padding: 0;
    +  font-weight: 400;
    +  font-size: inherit;
    +  color: inherit;
    +}
    +/* Copy affordance: a quiet button that brightens to brand on interaction. */
    +.code-block__copy {
    +  display: inline-flex;
    +  align-items: center;
    +  gap: 0.4rem;
    +  height: 1.8rem;
    +  padding: 0 0.6rem;
    +  border: 1px solid transparent;
    +  border-radius: var(--radius-sm);
    +  background: transparent;
    +  color: color-mix(in oklab, var(--code-foreground) 55%, transparent);
    +  font-family: var(--font-sans);
    +  font-size: 0.75rem;
    +  font-weight: 500;
    +  cursor: pointer;
    +  transition:
    +    color 0.15s ease,
    +    background-color 0.15s ease,
    +    border-color 0.15s ease;
    +}
    +.code-block__copy svg {
    +  width: 0.85rem;
    +  height: 0.85rem;
    +}
    +.code-block__copy:hover {
    +  color: var(--code-foreground);
    +  background: color-mix(in oklab, var(--code-foreground) 10%, transparent);
    +}
    +.code-block__copy:focus-visible {
    +  outline: none;
    +  border-color: color-mix(in oklab, var(--primary) 55%, transparent);
    +  box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 55%, transparent);
    +}
    +.code-block__copy[data-copied] {
    +  color: var(--accent);
    +}
    +.code-block__copy-idle,
    +.code-block__copy-done {
    +  display: inline-flex;
    +  align-items: center;
    +  gap: 0.4rem;
    +}
    +.code-block__copy-done {
    +  display: none;
    +}
    +.code-block__copy[data-copied] .code-block__copy-idle {
    +  display: none;
    +}
    +.code-block__copy[data-copied] .code-block__copy-done {
    +  display: inline-flex;
    +}
    +
    +/* --- Tables: zebra + hover, token-aligned borders, refined header --- */
    +.prose :where(table):not(:where([class~='not-prose'] *)) {
    +  width: 100%;
    +  margin-top: 1.5rem;
    +  margin-bottom: 1.5rem;
    +  border: 1px solid var(--border);
    +  border-radius: var(--radius-lg);
    +  border-collapse: separate;
    +  border-spacing: 0;
    +  overflow: hidden;
    +  font-size: 0.875rem;
    +  background: color-mix(in oklab, var(--card) 60%, transparent);
    +}
    +.prose :where(thead):not(:where([class~='not-prose'] *)) {
    +  background: var(--muted);
    +  border-bottom: 1px solid var(--border);
    +}
    +.prose :where(thead th):not(:where([class~='not-prose'] *)) {
    +  padding: 0.72rem 1rem;
    +  font-size: 0.75rem;
    +  font-weight: 650;
    +  letter-spacing: 0.06em;
    +  text-transform: uppercase;
    +  color: var(--muted-foreground);
    +  border-bottom: 1px solid var(--border);
    +}
    +.prose :where(tbody td):not(:where([class~='not-prose'] *)) {
    +  padding: 0.72rem 1rem;
    +  border-bottom: 1px solid var(--border);
    +  vertical-align: middle;
    +}
    +.prose :where(tbody tr:last-child td):not(:where([class~='not-prose'] *)) {
    +  border-bottom: 0;
    +}
    +.prose :where(tbody tr):not(:where([class~='not-prose'] *)):nth-child(even) {
    +  background: color-mix(in oklab, var(--muted) 45%, transparent);
    +}
    +.prose :where(tbody tr):not(:where([class~='not-prose'] *)):hover {
    +  background: color-mix(in oklab, var(--primary) 7%, transparent);
    +}
    +
    +/* Inline code chips, brand-tinted */
    +.prose :not(pre) > code {
    +  background: color-mix(in oklab, var(--primary) 10%, var(--background));
    +  color: color-mix(in oklab, var(--primary) 72%, var(--foreground));
    +  border: 1px solid color-mix(in oklab, var(--primary) 22%, transparent);
    +  padding: 0.12em 0.42em;
    +  border-radius: var(--radius-sm);
    +  font-family: var(--font-mono);
    +  font-weight: 500;
    +  font-size: 0.875em;
    +  /* Long unbroken tokens (URLs, paths) must wrap inside the container, not overflow it. */
    +  overflow-wrap: anywhere;
    +  word-break: break-word;
    +}
    +.prose :not(pre) > code::before,
    +.prose :not(pre) > code::after {
    +  content: none;
    +}
    +
    +/* Status pills (used in tables to render state cells tastefully) */
    +.status-pill {
    +  display: inline-flex;
    +  align-items: center;
    +  gap: 0.4rem;
    +  padding: 0.15rem 0.6rem 0.15rem 0.5rem;
    +  border-radius: 9999px;
    +  font-size: 0.75rem;
    +  font-weight: 600;
    +  line-height: 1.4;
    +  white-space: nowrap;
    +  border: 1px solid transparent;
    +}
    +.status-dot {
    +  width: 0.45rem;
    +  height: 0.45rem;
    +  border-radius: 9999px;
    +  background: currentColor;
    +  box-shadow: 0 0 0 3px color-mix(in oklab, currentColor 22%, transparent);
    +  animation: ds-pulse 2.4s ease-in-out infinite;
    +}
    +@keyframes ds-pulse {
    +  0%,
    +  100% {
    +    box-shadow: 0 0 0 0 color-mix(in oklab, currentColor 35%, transparent);
    +  }
    +  50% {
    +    box-shadow: 0 0 0 4px color-mix(in oklab, currentColor 8%, transparent);
    +  }
    +}
    +@media (prefers-reduced-motion: reduce) {
    +  .status-dot {
    +    animation: none;
    +  }
    +}
    +.status-pill--ok {
    +  color: var(--success-border);
    +  background: color-mix(in oklab, var(--success-border) 12%, var(--background));
    +  border-color: color-mix(in oklab, var(--success-border) 30%, transparent);
    +}
    +.status-pill--wait {
    +  color: var(--warning-border);
    +  background: color-mix(in oklab, var(--warning-border) 12%, var(--background));
    +  border-color: color-mix(in oklab, var(--warning-border) 30%, transparent);
    +}
    +
    +/* --- Callout boxes (remark directive output) ---
    +   Authored as `:::div{.hint|.info|.success|.warning|.problem|.question}` and
    +   rendered as raw `
    ` inside .prose. Match ui/Callout.astro. */ +.prose + :where( + div.hint, + div.info, + div.success, + div.warning, + div.problem, + div.question + ):not(:where([class~='not-prose'] *)) { + position: relative; + margin-top: 1.6rem; + margin-bottom: 1.6rem; + padding: 1rem 1.15rem 1rem 1.2rem; + border: 1px solid color-mix(in oklab, var(--bd) 32%, transparent); + border-left: 3px solid var(--bd); + border-radius: var(--radius-lg); + background: var(--bg); + color: var(--c); + font-size: 1rem; + line-height: 1.66; + box-shadow: var(--shadow-sm); +} +.prose :where(div.hint, div.info):not(:where([class~='not-prose'] *)) { + --c: var(--info); + --bg: var(--info-bg); + --bd: var(--info-border); +} +.prose :where(div.success):not(:where([class~='not-prose'] *)) { + --c: var(--success); + --bg: var(--success-bg); + --bd: var(--success-border); +} +.prose :where(div.warning):not(:where([class~='not-prose'] *)) { + --c: var(--warning); + --bg: var(--warning-bg); + --bd: var(--warning-border); +} +.prose :where(div.problem):not(:where([class~='not-prose'] *)) { + --c: var(--problem); + --bg: var(--problem-bg); + --bd: var(--problem-border); +} +.prose :where(div.question):not(:where([class~='not-prose'] *)) { + --c: var(--question); + --bg: var(--question-bg); + --bd: var(--question-border); +} +.prose + :where( + div.hint, + div.info, + div.success, + div.warning, + div.problem, + div.question + ) + :where(p):not(:where([class~='not-prose'] *)) { + margin-top: 0.5rem; + margin-bottom: 0.5rem; + color: inherit; +} +.prose + :where( + div.hint, + div.info, + div.success, + div.warning, + div.problem, + div.question + ) + :where(p:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} +.prose + :where( + div.hint, + div.info, + div.success, + div.warning, + div.problem, + div.question + ) + :where(p:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 0; +} + +/* In-content image "glass" effect (from the marketing site), scoped to .prose + so chrome imagery is untouched. Frosted panel + blurred bloom (::before) + behind; image/caption above via z-index. Tokens: --glass-* in tokens.css. */ +.prose :where(figure, .image, [data-image]):not(:where([class~='not-prose'] *)), +.prose p:has(> img.resp-img:only-child):not(:where([class~='not-prose'] *)) { + position: relative; + isolation: isolate; + width: fit-content; + max-width: 100%; + margin: 2.25rem auto; + padding: var(--glass-pad); + border-radius: var(--radius-lg); + background: var(--glass-background); + border: 1px solid var(--glass-border-color); + box-shadow: var(--glass-shadow); + overflow: visible; + text-align: center; +} +.prose + :where(figure, .image, [data-image]):not( + :where([class~='not-prose'] *) + )::before, +.prose + p:has(> img.resp-img:only-child):not(:where([class~='not-prose'] *))::before { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 50%; + filter: blur(60px); + z-index: 0; + pointer-events: none; + background: var(--glass-blur-glow); +} +/* Inner image stacks above the bloom. */ +.prose + :where(figure, .image, [data-image]):not(:where([class~='not-prose'] *)) + :where(img, .image__img), +.prose + p:has(> img.resp-img:only-child):not(:where([class~='not-prose'] *)) + > img { + position: relative; + z-index: 1; + display: block; + max-width: 100%; + height: auto; + /* Zero the plugin's block margins so glass padding is uniform on all sides. */ + margin: 0 auto; + border-radius: calc(var(--radius-lg) - 0.15rem); +} +/* Strip prose margin from a wrapping

    inside the figure. */ +.prose + :where(figure, .image, [data-image]):not(:where([class~='not-prose'] *)) + :where(p):not(:where([class~='not-prose'] *)) { + margin: 0; +} +.prose :where(.image__caption, figcaption):not(:where([class~='not-prose'] *)) { + position: relative; + z-index: 1; + display: block; + margin-top: 0.7rem; + text-align: center; + color: var(--muted-foreground); + font-size: 0.875rem; + line-height: 1.5; +} + +/* --- Table wrapper (wrapTables ->

    ) --- */ +.prose :where(.table-wrap):not(:where([class~='not-prose'] *)) { + margin-top: 1.5rem; + margin-bottom: 1.5rem; + overflow-x: auto; +} +.prose :where(.table-wrap > table):not(:where([class~='not-prose'] *)) { + margin-top: 0; + margin-bottom: 0; +} + +/* --- Shiki code blocks (`
    `) --- */
    +.prose :where(pre.astro-code):not(:where([class~='not-prose'] *)) {
    +  margin-top: 1.5rem;
    +  margin-bottom: 1.5rem;
    +  padding: 1.2rem 1.3rem;
    +  border: 1px solid var(--code-border);
    +  border-radius: var(--radius-lg);
    +  box-shadow: var(--shadow-md);
    +  font-size: 0.875rem;
    +  line-height: 1.7;
    +  overflow-x: auto;
    +}
    +
    +/* Buttons (ported from the marketing site's pu-button mixins). Primary = purple
    +   with a cursor-tracked pink glow; secondary = gradient-border (::after mask).
    +   --hover-x/--hover-y are set by the inline script in Button.astro. In the
    +   components layer so utilities (h-9, hidden, px-*) can override base sizing. */
    +@layer components {
    +  .ds-btn {
    +    display: inline-flex;
    +    align-items: center;
    +    justify-content: center;
    +    gap: 0.5rem;
    +    max-width: fit-content;
    +    padding: var(--space-12) var(--space-24);
    +    border-radius: 0.5rem;
    +    border: 0.0625rem solid transparent;
    +    font-family: var(--font-sans);
    +    font-size: var(--button-font-size);
    +    font-weight: var(--button-font-weight);
    +    line-height: 1.2;
    +    white-space: nowrap;
    +    text-decoration: none;
    +    cursor: pointer;
    +    appearance: none;
    +    transition-property: background, box-shadow, border-color, color;
    +    transition-duration: 250ms;
    +    transition-timing-function: ease-in-out;
    +  }
    +
    +  .ds-btn--primary {
    +    position: relative;
    +    z-index: 0;
    +    overflow: hidden;
    +    color: var(--white);
    +    background: var(--purple-300);
    +    border: none;
    +  }
    +  .ds-btn--primary::before {
    +    content: '';
    +    position: absolute;
    +    left: var(--hover-x, 50%);
    +    top: var(--hover-y, 50%);
    +    width: 16rem;
    +    height: 16rem;
    +    background: radial-gradient(
    +      circle closest-side,
    +      var(--pink-100),
    +      transparent
    +    );
    +    transform: translate(-50%, -50%) scale(0);
    +    transition: transform 0.3s ease;
    +    z-index: -1;
    +    pointer-events: none;
    +  }
    +  .ds-btn--primary:hover {
    +    box-shadow: inset 0 0 0 0.0625rem rgba(255, 255, 255, 0.25);
    +  }
    +  .ds-btn--primary:hover::before {
    +    transform: translate(-50%, -50%) scale(1);
    +  }
    +  .ds-btn--primary:active {
    +    box-shadow: var(--button-shadow-inner);
    +  }
    +  .ds-btn--primary:focus-visible {
    +    outline: 0.25rem solid rgba(105, 80, 255, 0.5);
    +    box-shadow: none;
    +  }
    +  .ds-btn--primary:focus-visible::before {
    +    transform: translate(-50%, -50%) scale(0);
    +  }
    +
    +  .ds-btn--secondary {
    +    position: relative;
    +    z-index: 0;
    +    overflow: hidden;
    +    color: var(--secondary-button-text);
    +    background: var(--secondary-button-bg);
    +    border: none;
    +  }
    +  .ds-btn--secondary::before {
    +    content: '';
    +    position: absolute;
    +    left: var(--hover-x, 50%);
    +    top: var(--hover-y, 50%);
    +    width: 14rem;
    +    height: 14rem;
    +    background: radial-gradient(
    +      circle closest-side,
    +      var(--secondary-button-hover-glow),
    +      transparent
    +    );
    +    transform: translate(-50%, -50%) scale(0);
    +    transition: transform 0.3s ease;
    +    z-index: -1;
    +    pointer-events: none;
    +  }
    +  /* Gradient border ring via a masked padding box. */
    +  .ds-btn--secondary::after {
    +    content: '';
    +    position: absolute;
    +    inset: 0;
    +    border-radius: inherit;
    +    padding: 0.0625rem;
    +    background: var(--purple-300);
    +    -webkit-mask-image: linear-gradient(#fff 0 0), linear-gradient(#fff 0 0);
    +    -webkit-mask-clip: content-box, border-box;
    +    -webkit-mask-composite: xor;
    +    mask-image: linear-gradient(#fff 0 0), linear-gradient(#fff 0 0);
    +    mask-clip: content-box, border-box;
    +    mask-composite: exclude;
    +    pointer-events: none;
    +    z-index: 2;
    +  }
    +  .ds-btn--secondary svg path {
    +    transition: stroke 200ms ease-in-out;
    +  }
    +  .ds-btn--secondary:hover {
    +    color: var(--secondary-button-hover-text);
    +    background: var(--secondary-button-hover-bg);
    +  }
    +  .ds-btn--secondary:hover svg path {
    +    stroke: var(--secondary-button-hover-text);
    +  }
    +  .ds-btn--secondary:hover::before {
    +    transform: translate(-50%, -50%) scale(1);
    +  }
    +  .ds-btn--secondary:hover::after {
    +    background: radial-gradient(
    +      circle 7rem at var(--hover-x, 50%) var(--hover-y, 50%),
    +      var(--secondary-button-hover-border),
    +      var(--purple-300)
    +    );
    +  }
    +  .ds-btn--secondary:active {
    +    box-shadow: var(--button-shadow-inner);
    +  }
    +  .ds-btn--secondary:active::before {
    +    transform: translate(-50%, -50%) scale(0);
    +  }
    +  .ds-btn--secondary:active::after {
    +    background: var(--purple-400);
    +  }
    +  .ds-btn--secondary:focus-visible {
    +    background: var(--secondary-button-bg);
    +    outline: 0.25rem solid var(--purple-300);
    +    box-shadow: none;
    +  }
    +  .ds-btn--secondary:focus-visible::before {
    +    transform: translate(-50%, -50%) scale(0);
    +  }
    +
    +  /* Low-emphasis text button. */
    +  .ds-btn--ghost {
    +    color: var(--foreground);
    +    background: transparent;
    +    border-color: transparent;
    +  }
    +  .ds-btn--ghost:hover {
    +    background: var(--muted);
    +  }
    +
    +  @media (prefers-reduced-motion: reduce) {
    +    .ds-btn--primary::before,
    +    .ds-btn--secondary::before {
    +      transition: none;
    +    }
    +  }
    +}
    +
    +/* Sidebar nav (shells/docs/SidebarNav). Kept here (not a scoped