This is an automated email from the ASF dual-hosted git repository.

Yilialinn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-website.git


The following commit(s) were added to refs/heads/master by this push:
     new b90b115cb18 fix(docs): improve content width and table readability 
(#2112)
b90b115cb18 is described below

commit b90b115cb1856f690bd983edafcccb5b354c10e6
Author: Ming Wen <[email protected]>
AuthorDate: Mon Aug 31 10:12:48 2026 +0800

    fix(docs): improve content width and table readability (#2112)
---
 .github/workflows/deploy.yml               |  46 +-
 doc/src/css/customTheme.scss               | 196 ++++++-
 doc/src/theme/DocPage/index.tsx            | 280 ++++++++-
 doc/src/theme/DocPage/styles.module.css    |   3 +-
 next/astro.config.mjs                      |   2 +
 next/scripts/rehype-doc-tables.mjs         | 173 ++++++
 next/src/layouts/DocPage.astro             |  32 ++
 next/src/styles/global.css                 | 224 +++++++-
 next/tests/e2e/docs-mobile-layout.spec.mjs | 877 ++++++++++++++++++++++++++++-
 9 files changed, 1815 insertions(+), 18 deletions(-)

diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 3f950945b0a..f841419e29c 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -602,12 +602,56 @@ jobs:
             exit 1
           fi
 
+      # Production HTML points at the CDN, but a new content-hashed bundle is
+      # unavailable there until after this job succeeds and publishes it. Test
+      # an isolated copy with local asset URLs so hydration exercises the exact
+      # JS/CSS produced above without changing the artifact that gets 
published.
+      - name: Prepare the final site for local browser testing
+        id: e2e-site
+        run: |
+          set -euo pipefail
+          cdn_origin='https://apisix-website-static.apiseven.com'
+          cdn_origin_pattern='https://apisix-website-static\.apiseven\.com'
+          kapa_script='https://widget.kapa.ai/kapa-widget.bundle.js'
+          e2e_site=$(mktemp -d "$RUNNER_TEMP/apisix-website-e2e.XXXXXX")
+          cp -R website/build/. "$e2e_site/"
+          # Keep third-party Kapa/reCAPTCHA traffic from blocking layout tests;
+          # the checks below ensure only the disposable copy loses the widget.
+          find "$e2e_site" -type f -name '*.html' -exec \
+            sed -i \
+              -e "s|$cdn_origin_pattern/assets/|/assets/|g" \
+              -e "s|$cdn_origin_pattern/zh/assets/|/zh/assets/|g" \
+              -e 's|<script 
src="https://widget\.kapa\.ai/kapa-widget\.bundle\.js"[^>]*></script>||g' {} +
+          for asset_prefix in "$cdn_origin/assets/" "$cdn_origin/zh/assets/"; 
do
+            if grep -RqlF --include='*.html' "$asset_prefix" "$e2e_site"; then
+              echo "CDN asset URLs remain in the local E2E site: $asset_prefix"
+              exit 1
+            fi
+            if ! grep -RqlF --include='*.html' "$asset_prefix" website/build; 
then
+              echo "The production build lost its CDN asset URLs: 
$asset_prefix"
+              exit 1
+            fi
+          done
+          if grep -RqlF --include='*.html' "$kapa_script" "$e2e_site"; then
+            echo 'The third-party Kapa widget remains in the isolated E2E 
site.'
+            exit 1
+          fi
+          if ! grep -RqlF --include='*.html' "$kapa_script" website/build; then
+            echo 'The production build lost the Kapa widget.'
+            exit 1
+          fi
+          test -d "$e2e_site/assets/js"
+          test -d "$e2e_site/assets/css"
+          test -d "$e2e_site/zh/assets/js"
+          test -d "$e2e_site/zh/assets/css"
+          echo "site=$e2e_site" >> "$GITHUB_OUTPUT"
+
       - name: Test the final overlaid site on desktop and mobile
         working-directory: next
         run: npm run test:e2e
         env:
           PLAYWRIGHT_BASE_URL: http://127.0.0.1:4321
-          PLAYWRIGHT_WEB_SERVER_COMMAND: python3 -m http.server 4321 --bind 
127.0.0.1 --directory ../website/build
+          PLAYWRIGHT_WEB_SERVER_COMMAND: python3 -m http.server 4321 --bind 
127.0.0.1 --directory ${{ steps.e2e-site.outputs.site }}
           EXPECT_DOCUSARUS_ROUTES: 'true'
 
       - name: Upload final site for publishing
diff --git a/doc/src/css/customTheme.scss b/doc/src/css/customTheme.scss
index dfa1d0d3df3..9ab4691825b 100644
--- a/doc/src/css/customTheme.scss
+++ b/doc/src/css/customTheme.scss
@@ -237,6 +237,7 @@ a:hover {
 
 .markdown a {
   color: var(--color-primary);
+  overflow-wrap: anywhere;
 }
 
 .arrow-btn {
@@ -532,13 +533,202 @@ article img {
   height: auto;
 }
 
-.markdown table {
-  display: block;
+.markdown .lazy-load-image-background {
   max-width: 100%;
-  overflow-x: auto;
+}
+
+.markdown > p,
+.markdown > ul,
+.markdown > ol,
+.markdown > blockquote,
+.markdown > div[class*="admonition"] {
+  max-width: 85ch;
+}
+
+.table-shell {
+  position: relative;
+  width: 100%;
+  max-width: 100%;
+  margin-bottom: var(--ifm-spacing-vertical);
+}
+
+.table-scroll {
+  max-width: 100%;
+  overflow: auto;
+  overscroll-behavior-x: contain;
   -webkit-overflow-scrolling: touch;
 }
 
+.table-shell[data-overflow="false"] .table-scroll {
+  overflow: visible;
+}
+
+.table-scroll:focus-visible {
+  outline: 3px solid var(--ifm-color-primary);
+  outline-offset: 3px;
+  border-radius: 4px;
+}
+
+.table-shell::before,
+.table-shell::after {
+  content: "";
+  position: absolute;
+  z-index: 5;
+  top: 0;
+  bottom: 0;
+  width: 2.5rem;
+  pointer-events: none;
+  opacity: 0;
+  transition: opacity var(--ifm-transition-fast) ease;
+}
+
+.table-shell::before {
+  left: 0;
+  background: linear-gradient(90deg, var(--ifm-background-surface-color), 
transparent);
+}
+
+.table-shell::after {
+  right: 0;
+  background: linear-gradient(270deg, var(--ifm-background-surface-color), 
transparent);
+}
+
+.table-shell[data-overflow="true"][data-at-start="false"]::before,
+.table-shell[data-overflow="true"][data-at-end="false"]::after {
+  opacity: 1;
+}
+
+.table-shell--attributes[data-overflow="true"][data-at-start="false"]::before {
+  opacity: 0;
+}
+
+.markdown .docs-table {
+  display: table;
+  width: 100%;
+  min-width: 100%;
+  table-layout: auto;
+  margin: 0;
+  border-collapse: separate;
+  border-spacing: 0;
+  border-top: 1px solid var(--ifm-table-border-color, var(--color-border));
+  border-left: 1px solid var(--ifm-table-border-color, var(--color-border));
+  overflow: visible;
+}
+
+.markdown .docs-table th,
+.markdown .docs-table td {
+  padding: 0.5rem 0.75rem;
+  font-size: 0.9rem;
+  line-height: 1.5;
+  vertical-align: top;
+  border: 0;
+  border-right: 1px solid var(--ifm-table-border-color, var(--color-border));
+  border-bottom: 1px solid var(--ifm-table-border-color, var(--color-border));
+}
+
+.markdown .docs-table--attributes thead th {
+  white-space: nowrap;
+}
+
+.markdown .docs-table--attributes {
+  width: 100%;
+  min-width: 100%;
+  table-layout: auto;
+}
+
+.docs-table--attributes .docs-table__col--name {
+  width: 13rem;
+  min-width: 13rem;
+  overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--type {
+  width: 7.5rem;
+  min-width: 7.5rem;
+  white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--required {
+  width: 6rem;
+  min-width: 6rem;
+  white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--encrypted {
+  width: 7.5rem;
+  min-width: 7.5rem;
+  white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--default {
+  width: 9rem;
+  min-width: 9rem;
+  overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--valid-values {
+  width: 12rem;
+  min-width: 12rem;
+  overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--description {
+  width: 19rem;
+  min-width: 19rem;
+  overflow-wrap: anywhere;
+}
+
+.table-shell[data-overflow="true"] .docs-table--attributes 
.docs-table__col--name,
+.table-shell[data-overflow="unknown"] .docs-table--attributes 
.docs-table__col--name {
+  position: sticky;
+  z-index: 1;
+  left: 0;
+  background: var(--ifm-background-surface-color);
+  box-shadow: 1px 0 var(--ifm-table-border-color, var(--color-border));
+}
+
+.table-shell[data-overflow="true"] .docs-table--attributes thead 
.docs-table__col--name,
+.table-shell[data-overflow="unknown"] .docs-table--attributes thead 
.docs-table__col--name {
+  z-index: 2;
+  background: var(--ifm-color-emphasis-100);
+}
+
+@media screen and (max-width: 996px) {
+  .docs-table--attributes .docs-table__col--name {
+    width: 10rem;
+    min-width: 10rem;
+  }
+
+  .docs-table--attributes .docs-table__col--type {
+    width: 6.5rem;
+    min-width: 6.5rem;
+  }
+
+  .docs-table--attributes .docs-table__col--required {
+    width: 6rem;
+    min-width: 6rem;
+  }
+
+  .docs-table--attributes .docs-table__col--encrypted {
+    width: 6.5rem;
+    min-width: 6.5rem;
+  }
+
+  .docs-table--attributes .docs-table__col--default {
+    width: 8rem;
+    min-width: 8rem;
+  }
+
+  .docs-table--attributes .docs-table__col--valid-values {
+    width: 11rem;
+    min-width: 11rem;
+  }
+
+  .docs-table--attributes .docs-table__col--description {
+    width: 13rem;
+    min-width: 13rem;
+  }
+}
+
 .markdown pre {
   max-width: 100%;
   overflow-x: auto;
diff --git a/doc/src/theme/DocPage/index.tsx b/doc/src/theme/DocPage/index.tsx
index 99a6b9c97cb..0e79109c2fa 100644
--- a/doc/src/theme/DocPage/index.tsx
+++ b/doc/src/theme/DocPage/index.tsx
@@ -7,9 +7,11 @@
  * LICENSE file in the root directory of this source tree.
  */
 
-import type { ReactNode } from 'react';
+import type {
+  ReactElement, ReactNode, TableHTMLAttributes,
+} from 'react';
 import React, {
-  useState, useCallback, useEffect,
+  useState, useCallback, useEffect, useRef,
 } from 'react';
 // eslint-disable-next-line import/no-extraneous-dependencies
 import { MDXProvider } from '@mdx-js/react';
@@ -56,8 +58,281 @@ const navbarLinkMap = {
 
 const navbarLinkKeys = Object.keys(navbarLinkMap);
 
+type AttributeColumnKind =
+  | 'name'
+  | 'type'
+  | 'required'
+  | 'encrypted'
+  | 'default'
+  | 'valid-values'
+  | 'description';
+
+const attributeColumnPatterns: ReadonlyArray<{
+  kind: AttributeColumnKind;
+  pattern: RegExp;
+}> = [
+  { kind: 'name', pattern: /^(name|field|名称|字段|参数名|属性名称)$/i },
+  { kind: 'type', pattern: /^(type|类型)$/i },
+  {
+    kind: 'required',
+    pattern: /^(required|requirement|必选项|要求|是否必需|必需|必填)$/i,
+  },
+  { kind: 'encrypted', pattern: /^(encrypted|加密)$/i },
+  { kind: 'default', pattern: /^(default|default value|默认值|默认)$/i },
+  {
+    kind: 'valid-values',
+    pattern: /^(valid|valid values?|有效值|有效)$/i,
+  },
+  { kind: 'description', pattern: /^(description|描述)$/i },
+];
+
+const fieldTypePattern = 
/^(?:array|boolean|integer|null|number|object|string)(?:\s*[/|]\s*(?:array|boolean|integer|null|number|object|string))*$/i;
+
+type ElementWithChildren = ReactElement<{
+  children?: ReactNode;
+  className?: string;
+  mdxType?: string;
+  originalType?: string;
+}>;
+
+const elementType = (element: ElementWithChildren): string | undefined => (
+  typeof element.type === 'string'
+    ? element.type
+    : element.props.mdxType ?? element.props.originalType
+);
+
+const elementChildren = (children: ReactNode): ElementWithChildren[] => (
+  React.Children.toArray(children).filter(
+    (child): child is ElementWithChildren => React.isValidElement(child),
+  )
+);
+
+const findElement = (children: ReactNode, type: string): ElementWithChildren | 
undefined => {
+  const directMatch = elementChildren(children).find((child) => 
elementType(child) === type);
+  if (directMatch) return directMatch;
+
+  return elementChildren(children)
+    .map((child) => findElement(child.props.children, type))
+    .find(Boolean);
+};
+
+const reactTextContent = (children: ReactNode): string => (
+  React.Children.toArray(children).map((child) => {
+    if (typeof child === 'string' || typeof child === 'number') return 
String(child);
+    if (React.isValidElement<{ children?: ReactNode }>(child)) {
+      return reactTextContent(child.props.children);
+    }
+    return '';
+  }).join('')
+);
+
+const attributeColumnKind = (header: string): AttributeColumnKind | null => (
+  attributeColumnPatterns.find(({ pattern }) => pattern.test(header))?.kind ?? 
null
+);
+
+const hasFieldTypeValues = (children: ReactNode): boolean => {
+  const body = findElement(children, 'tbody');
+  const rows = elementChildren(body?.props.children)
+    .filter((row) => elementType(row) === 'tr');
+  const typeValues = rows.map((row) => {
+    const cells = elementChildren(row.props.children)
+      .filter((cell) => elementType(cell) === 'th' || elementType(cell) === 
'td');
+    return reactTextContent(cells[1]?.props.children)
+      .trim()
+      .replace(/[\s\u00a0]+/g, ' ');
+  });
+
+  return rows.length > 0
+    && typeValues.every((value) => fieldTypePattern.test(value));
+};
+
+// Header semantics are deliberate: request, metadata, and other field-schema
+// tables need the same readable widths even outside an "Attributes" section.
+const attributeColumns = (children: ReactNode): AttributeColumnKind[] | null 
=> {
+  const head = findElement(children, 'thead');
+  const row = findElement(head?.props.children, 'tr');
+  const headers = elementChildren(row?.props.children)
+    .filter((cell) => elementType(cell) === 'th')
+    .map((cell) => 
reactTextContent(cell.props.children).trim().replace(/[\s\u00a0]+/g, ' '));
+  const columns = headers.map(attributeColumnKind);
+
+  if (
+    columns.length < 3
+    || columns.length > 7
+    || columns.some((column) => column === null)
+  ) return null;
+
+  const semanticColumns = columns as AttributeColumnKind[];
+  const uniqueColumns = new Set(semanticColumns);
+
+  // Name | Type | Description is also used by metric catalogs. Only promote
+  // the ambiguous three-column form when its body contains field data types.
+  if (
+    semanticColumns[0] !== 'name'
+    || semanticColumns[semanticColumns.length - 1] !== 'description'
+    || uniqueColumns.size !== semanticColumns.length
+    || (!uniqueColumns.has('type') && !uniqueColumns.has('required'))
+    || (
+      semanticColumns.length === 3
+      && semanticColumns[1] === 'type'
+      && !hasFieldTypeValues(children)
+    )
+  ) return null;
+
+  return semanticColumns;
+};
+
+const decorateRowCells = (
+  children: ReactNode,
+  columns: AttributeColumnKind[],
+): ReactNode => {
+  let cellIndex = 0;
+
+  return React.Children.map(children, (child) => {
+    if (!React.isValidElement<{ className?: string }>(child)) return child;
+    if (elementType(child) !== 'th' && elementType(child) !== 'td') return 
child;
+
+    const column = columns[cellIndex];
+    cellIndex += 1;
+    if (!column) return child;
+
+    return React.cloneElement(child, {
+      className: clsx(child.props.className, `docs-table__col--${column}`),
+    });
+  });
+};
+
+const decorateTableRows = (
+  children: ReactNode,
+  columns: AttributeColumnKind[],
+): ReactNode => React.Children.map(children, (child) => {
+  if (!React.isValidElement<{ children?: ReactNode }>(child)) return child;
+
+  if (elementType(child) === 'tr') {
+    return React.cloneElement(child, {
+      children: decorateRowCells(child.props.children, columns),
+    });
+  }
+
+  if (child.props.children === undefined) return child;
+
+  return React.cloneElement(child, {
+    children: decorateTableRows(child.props.children, columns),
+  });
+});
+
+const tableLabel = (frame: HTMLElement): string => {
+  const markdown = frame.closest('.markdown');
+  if (!markdown) return 'Documentation table';
+
+  let label = 'Documentation table';
+  markdown.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((heading) => {
+    if (
+      !heading.closest('.admonition')
+      && heading.compareDocumentPosition(frame) === 
Node.DOCUMENT_POSITION_FOLLOWING
+    ) {
+      label = heading.textContent?.trim() || label;
+    }
+  });
+  return label;
+};
+
+const DocsTable = ({
+  className,
+  children,
+  ...props
+}: TableHTMLAttributes<HTMLTableElement>): JSX.Element => {
+  // Classify from React children so the SSR HTML has stable semantic widths
+  // before hydration, including when JavaScript is unavailable.
+  const columns = attributeColumns(children);
+  const attributes = columns !== null;
+  const tableChildren = columns ? decorateTableRows(children, columns) : 
children;
+  const frameRef = useRef<HTMLDivElement>(null);
+  const scrollerRef = useRef<HTMLDivElement>(null);
+  const tableRef = useRef<HTMLTableElement>(null);
+  const [state, setState] = useState<{
+    overflow: boolean | null;
+    atStart: boolean;
+    atEnd: boolean;
+    label: string;
+  }>({
+    overflow: null,
+    atStart: true,
+    atEnd: false,
+    label: 'Documentation table',
+  });
+
+  useEffect(() => {
+    const frame = frameRef.current;
+    const scroller = scrollerRef.current;
+    const table = tableRef.current;
+    if (!frame || !scroller || !table) return undefined;
+
+    const update = () => {
+      const next = {
+        overflow: scroller.scrollWidth > scroller.clientWidth + 1,
+        atStart: scroller.scrollLeft <= 1,
+        atEnd: scroller.scrollLeft + scroller.clientWidth >= 
scroller.scrollWidth - 1,
+        label: tableLabel(frame),
+      };
+      setState((current) => (
+        current.overflow === next.overflow
+        && current.atStart === next.atStart
+        && current.atEnd === next.atEnd
+        && current.label === next.label
+          ? current
+          : next
+      ));
+    };
+
+    scroller.addEventListener('scroll', update, { passive: true });
+    const observer = new ResizeObserver(update);
+    observer.observe(scroller);
+    observer.observe(table);
+    update();
+
+    return () => {
+      scroller.removeEventListener('scroll', update);
+      observer.disconnect();
+    };
+  }, []);
+
+  return (
+    <div
+      ref={frameRef}
+      className={clsx('table-shell', {
+        'table-shell--attributes': attributes,
+      })}
+      data-overflow={state.overflow ?? 'unknown'}
+      data-at-start={state.atStart}
+      data-at-end={state.atEnd}
+    >
+      <div
+        ref={scrollerRef}
+        className="table-scroll"
+        role={state.overflow === false ? undefined : 'region'}
+        aria-label={state.overflow === false ? undefined : state.label}
+        // A focusable region is the keyboard fallback for native horizontal 
scrolling.
+        // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
+        tabIndex={state.overflow === false ? -1 : 0}
+      >
+        <table
+          {...props}
+          ref={tableRef}
+          className={clsx('docs-table', {
+            'docs-table--attributes': attributes,
+          }, className)}
+        >
+          {tableChildren}
+        </table>
+      </div>
+    </div>
+  );
+};
+
 const components = (currentPage: string) => ({
   ...MDXComponents,
+  table: DocsTable,
   a: (props) => {
     const { children, ...others } = props;
     const inCurrent = props.href?.includes(currentPage) || 
props.href?.startsWith('#');
@@ -79,6 +354,7 @@ const components = (currentPage: string) => ({
           <div
             style={{
               width: 500,
+              maxWidth: '100%',
               height: 300,
               borderRadius: '1rem',
               backgroundColor: '#d2d2d7',
diff --git a/doc/src/theme/DocPage/styles.module.css 
b/doc/src/theme/DocPage/styles.module.css
index dd633d728f6..014d5939076 100644
--- a/doc/src/theme/DocPage/styles.module.css
+++ b/doc/src/theme/DocPage/styles.module.css
@@ -80,7 +80,8 @@
     background-color: var(--collapse-button-bg-color-dark);
   }
 
+  .docItemWrapper,
   .docItemWrapperEnhanced {
-    max-width: calc(var(--ifm-container-width) + var(--doc-sidebar-width)) 
!important;
+    max-width: 2000px !important;
   }
 }
diff --git a/next/astro.config.mjs b/next/astro.config.mjs
index 89ee08ba81c..e4c27c12a38 100644
--- a/next/astro.config.mjs
+++ b/next/astro.config.mjs
@@ -3,6 +3,7 @@ import remarkDirective from 'remark-directive';
 import { remarkAdmonitions } from './scripts/remark-admonitions.mjs';
 import { remarkHeadingIds } from './scripts/remark-heading-ids.mjs';
 import { remarkImageLoading } from './scripts/remark-image-loading.mjs';
+import rehypeDocTables from './scripts/rehype-doc-tables.mjs';
 
 // Static-only rebuild of apisix.apache.org.
 // URL contract: every public URL is identical to the current Docusaurus site
@@ -16,6 +17,7 @@ export default defineConfig({
   build: { format: 'directory', inlineStylesheets: 'never' },
   markdown: {
     remarkPlugins: [remarkDirective, remarkAdmonitions, remarkHeadingIds, 
remarkImageLoading],
+    rehypePlugins: [rehypeDocTables],
     shikiConfig: { theme: 'github-dark-default' },
   },
 });
diff --git a/next/scripts/rehype-doc-tables.mjs 
b/next/scripts/rehype-doc-tables.mjs
new file mode 100644
index 00000000000..2de5f3e72ee
--- /dev/null
+++ b/next/scripts/rehype-doc-tables.mjs
@@ -0,0 +1,173 @@
+import { SKIP, visit } from 'unist-util-visit';
+
+const ATTRIBUTE_COLUMN_KINDS = [
+  ['name', /^(name|field|名称|字段|参数名|属性名称)$/i],
+  ['type', /^(type|类型)$/i],
+  ['required', /^(required|requirement|必选项|必填|必需|是否必需|要求)$/i],
+  ['encrypted', /^(encrypted|加密)$/i],
+  ['default', /^(default( value)?|默认值|默认)$/i],
+  ['valid-values', /^(valid( values?)?|有效值|有效)$/i],
+  ['description', /^(description|描述)$/i],
+];
+
+const FIELD_TYPE_PATTERN = 
/^(?:array|boolean|integer|null|number|object|string)(?:\s*[/|]\s*(?:array|boolean|integer|null|number|object|string))*$/i;
+
+function textContent(node) {
+  if (typeof node.value === 'string') return node.value;
+  return node.children?.map(textContent).join('') ?? '';
+}
+
+function classNames(value) {
+  if (Array.isArray(value)) return value;
+  return typeof value === 'string' ? [value] : [];
+}
+
+// Header semantics are deliberate: request, metadata, and other field-schema
+// tables need the same readable widths even when the section is not literally
+// titled "Attributes".
+function attributeColumns(node) {
+  const headerRow = node.children
+    ?.find((child) => child.tagName === 'thead')
+    ?.children?.find((child) => child.tagName === 'tr');
+  const headers = headerRow?.children
+    ?.filter((child) => child.tagName === 'th')
+    .map((child) => textContent(child).trim().replace(/\s+/g, ' ')) ?? [];
+  const columns = headers.map((header) => (
+    ATTRIBUTE_COLUMN_KINDS.find(([, pattern]) => pattern.test(header))?.[0]
+  ));
+
+  if (columns.length < 3 || columns.length > 7) return null;
+  if (columns[0] !== 'name' || columns.at(-1) !== 'description') return null;
+  if (columns.some((column) => !column)) return null;
+  if (new Set(columns).size !== columns.length) return null;
+  if (!columns.includes('type') && !columns.includes('required')) return null;
+  // Name | Type | Description is also used by metric catalogs. Only promote
+  // the ambiguous three-column form when its body contains field data types.
+  if (
+    columns.length === 3
+    && columns[1] === 'type'
+    && !hasFieldTypeValues(node)
+  ) return null;
+
+  return columns;
+}
+
+function decorateRow(row, columns) {
+  let columnIndex = 0;
+
+  return {
+    ...row,
+    children: row.children?.map((cell) => {
+      if (cell.tagName !== 'th' && cell.tagName !== 'td') return cell;
+
+      const column = columns[columnIndex];
+      columnIndex += 1;
+      if (!column) return cell;
+
+      return {
+        ...cell,
+        properties: {
+          ...cell.properties,
+          className: [
+            ...classNames(cell.properties?.className),
+            `docs-table__col--${column}`,
+          ],
+        },
+      };
+    }),
+  };
+}
+
+function decorateAttributeColumns(node, columns) {
+  return {
+    ...node,
+    children: node.children?.map((section) => {
+      if (!['thead', 'tbody', 'tfoot'].includes(section.tagName)) return 
section;
+
+      return {
+        ...section,
+        children: section.children?.map((row) => (
+          row.tagName === 'tr' ? decorateRow(row, columns) : row
+        )),
+      };
+    }),
+  };
+}
+
+function hasFieldTypeValues(node) {
+  const bodyRows = node.children
+    ?.find((child) => child.tagName === 'tbody')
+    ?.children?.filter((child) => child.tagName === 'tr') ?? [];
+  const typeValues = bodyRows.map((row) => {
+    const cells = row.children?.filter((child) => (
+      child.tagName === 'th' || child.tagName === 'td'
+    )) ?? [];
+    return cells[1]
+      ? textContent(cells[1]).trim().replace(/[\s\u00a0]+/g, ' ')
+      : '';
+  });
+
+  return bodyRows.length > 0
+    && typeValues.every((value) => FIELD_TYPE_PATTERN.test(value));
+}
+
+export default function rehypeDocTables() {
+  return (tree, file) => {
+    if (!String(file.path ?? '').includes('/docs-')) return;
+
+    let sectionLabel = 'Documentation';
+
+    visit(tree, 'element', (node, index, parent) => {
+      if (/^h[1-6]$/.test(node.tagName)) {
+        sectionLabel = textContent(node).trim() || sectionLabel;
+        return;
+      }
+
+      if (node.tagName !== 'table' || !parent || typeof index !== 'number') 
return;
+
+      const classes = classNames(node.properties?.className);
+      const columns = attributeColumns(node);
+      const attributesTable = columns !== null;
+      const decoratedNode = attributesTable
+        ? decorateAttributeColumns(node, columns)
+        : node;
+      const table = {
+        ...decoratedNode,
+        properties: {
+          ...decoratedNode.properties,
+          className: [
+            ...classes,
+            'docs-table',
+            ...(attributesTable ? ['docs-table--attributes'] : []),
+          ],
+        },
+      };
+
+      const shell = {
+        type: 'element',
+        tagName: 'div',
+        properties: {
+          className: ['table-shell', ...(attributesTable ? 
['table-shell--attributes'] : [])],
+          'data-overflow': 'unknown',
+          'data-at-start': 'true',
+          'data-at-end': 'false',
+          'data-table-label': sectionLabel,
+        },
+        children: [{
+          type: 'element',
+          tagName: 'div',
+          properties: {
+            className: ['table-scroll'],
+            role: 'region',
+            tabIndex: 0,
+            ariaLabel: sectionLabel,
+          },
+          children: [table],
+        }],
+      };
+      parent.children.splice(index, 1, shell);
+
+      return SKIP;
+    });
+  };
+}
diff --git a/next/src/layouts/DocPage.astro b/next/src/layouts/DocPage.astro
index 18cc1f365f8..145d4f2dd01 100644
--- a/next/src/layouts/DocPage.astro
+++ b/next/src/layouts/DocPage.astro
@@ -90,4 +90,36 @@ const hasAlternate = entry.hasTranslation && 
!canonicalOverride;
       </div>
     </article>
   </div>
+  <script>
+    document.querySelectorAll<HTMLElement>('.table-shell').forEach((shell) => {
+      const scroller = shell.querySelector<HTMLElement>('.table-scroll');
+      const table = scroller?.querySelector('table');
+      if (!scroller || !table) return;
+
+      const update = () => {
+        const overflow = scroller.scrollWidth > scroller.clientWidth + 1;
+        const atStart = scroller.scrollLeft <= 1;
+        const atEnd = scroller.scrollLeft + scroller.clientWidth >= 
scroller.scrollWidth - 1;
+
+        shell.dataset.overflow = String(overflow);
+        shell.dataset.atStart = String(atStart);
+        shell.dataset.atEnd = String(atEnd);
+        scroller.tabIndex = overflow ? 0 : -1;
+
+        if (overflow) {
+          scroller.setAttribute('role', 'region');
+          scroller.setAttribute('aria-label', shell.dataset.tableLabel ?? 
'Documentation');
+        } else {
+          scroller.removeAttribute('role');
+          scroller.removeAttribute('aria-label');
+        }
+      };
+
+      scroller.addEventListener('scroll', update, { passive: true });
+      const observer = new ResizeObserver(update);
+      observer.observe(scroller);
+      observer.observe(table);
+      update();
+    });
+  </script>
 </Base>
diff --git a/next/src/styles/global.css b/next/src/styles/global.css
index 2850048bb6f..f04787654aa 100644
--- a/next/src/styles/global.css
+++ b/next/src/styles/global.css
@@ -10,6 +10,9 @@
   --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, 
Arial, sans-serif;
   --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, 
monospace;
   --max-width: 1140px;
+  --docs-reading-max: 85ch;
+  --docs-wide-max: 92rem;
+  --docs-shell-max: 114.5rem;
   --header-height: 60px;
   --radius-card: .75rem;
   --color-surface-warm: #faf7f7;
@@ -304,7 +307,11 @@ a.tag:focus-visible { outline: 2px solid 
var(--color-primary); outline-offset: 2
 .prose .code-title { background: #21262d; color: #e6edf3; font-family: 
var(--font-mono); font-size: .78rem; padding: .45rem 1.2rem; border-radius: 
10px 10px 0 0; margin-bottom: 0; }
 .prose .code-title + pre { border-radius: 0 0 10px 10px; margin-top: 0; }
 .prose pre code { background: none; padding: 0; font-size: inherit; }
-.prose table { border-collapse: collapse; display: block; overflow-x: auto; 
margin: 1.25rem 0; }
+
+.prose table {
+  border-collapse: collapse;
+  margin: 1.25rem 0;
+}
 .prose th, .prose td { border: 1px solid var(--color-border); padding: .5rem 
.8rem; font-size: .9rem; }
 .prose th { background: var(--color-surface-alt); }
 .prose blockquote { margin: 1.25rem 0; padding: .2rem 1.25rem; border-left: 
4px solid var(--color-primary); background: var(--color-surface-alt); 
border-radius: 0 8px 8px 0; }
@@ -321,7 +328,15 @@ a.tag:focus-visible { outline: 2px solid 
var(--color-primary); outline-offset: 2
 .admonition-important { border-color: #a25ddc; background: #f4eefb; }
 
 /* ---------- docs layout ---------- */
-.docs-layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); 
gap: 2.5rem; align-items: start; padding-block: 2rem; }
+.docs-layout {
+  display: grid;
+  grid-template-columns: 280px minmax(0, 1fr);
+  gap: 2.5rem;
+  align-items: start;
+  max-width: var(--docs-shell-max);
+  padding-block: 2rem;
+}
+
 .docs-sidebar {
   position: sticky; top: calc(var(--header-height) + 1rem);
   max-height: calc(100vh - var(--header-height) - 2rem);
@@ -353,9 +368,210 @@ a.tag:focus-visible { outline: 2px solid 
var(--color-primary); outline-offset: 2
 }
 .docs-sidebar .sidebar-category[open] > summary::before { transform: 
rotate(90deg); }
 .docs-sidebar .sidebar-category > summary:hover { color: var(--color-primary); 
}
-.docs-content { min-width: 0; padding-bottom: 3rem; }
+
+/* stylelint-disable no-descending-specificity */
+.docs-content {
+  width: 100%;
+  max-width: var(--docs-wide-max);
+  min-width: 0;
+  padding-bottom: 3rem;
+}
+
+.docs-content > p,
+.docs-content > ul,
+.docs-content > ol,
+.docs-content > blockquote,
+.docs-content > .admonition { max-width: var(--docs-reading-max); }
+
+.docs-content a { overflow-wrap: anywhere; }
+
+.table-shell {
+  position: relative;
+  width: 100%;
+  max-width: 100%;
+  margin: 1.25rem 0;
+}
+
+.table-scroll {
+  max-width: 100%;
+  overflow: auto;
+  overscroll-behavior-x: contain;
+  -webkit-overflow-scrolling: touch;
+}
+
+.table-scroll:focus-visible {
+  outline: 3px solid var(--color-primary-dark);
+  outline-offset: 3px;
+  border-radius: 4px;
+}
+
+.table-shell[data-overflow="false"] .table-scroll {
+  overflow: visible;
+}
+
+.table-shell::before,
+.table-shell::after {
+  content: "";
+  position: absolute;
+  z-index: 5;
+  top: 0;
+  bottom: 0;
+  width: 2.5rem;
+  pointer-events: none;
+  opacity: 0;
+  transition: opacity var(--dur) var(--ease);
+}
+
+.table-shell::before {
+  left: 0;
+  background: linear-gradient(90deg, rgb(255 255 255 / 92%), transparent);
+}
+
+.table-shell::after {
+  right: 0;
+  background: linear-gradient(270deg, rgb(255 255 255 / 92%), transparent);
+}
+
+.table-shell[data-overflow="true"][data-at-start="false"]::before,
+.table-shell[data-overflow="true"][data-at-end="false"]::after {
+  opacity: 1;
+}
+
+/* The sticky Name column is the left-edge cue for schema tables. A gradient
+   above it would wash out the text once the reader starts panning. */
+.table-shell--attributes[data-overflow="true"][data-at-start="false"]::before {
+  opacity: 0;
+}
+
+.table-shell .docs-table {
+  display: table;
+  width: 100%;
+  min-width: 100%;
+  table-layout: auto;
+  margin: 0;
+  border-collapse: separate;
+  border-spacing: 0;
+  border-top: 1px solid var(--color-border);
+  border-left: 1px solid var(--color-border);
+}
+
+.table-shell .docs-table th,
+.table-shell .docs-table td {
+  vertical-align: top;
+  line-height: 1.5;
+  border: 0;
+  border-right: 1px solid var(--color-border);
+  border-bottom: 1px solid var(--color-border);
+}
+
+.table-shell .docs-table--attributes thead th {
+  white-space: nowrap;
+}
+
+.table-shell .docs-table--attributes {
+  width: 100%;
+  min-width: 100%;
+  table-layout: auto;
+}
+
+.docs-table--attributes .docs-table__col--name {
+  width: 13rem;
+  min-width: 13rem;
+  overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--type {
+  width: 7.5rem;
+  min-width: 7.5rem;
+  white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--required {
+  width: 6rem;
+  min-width: 6rem;
+  white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--encrypted {
+  width: 7.5rem;
+  min-width: 7.5rem;
+  white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--default {
+  width: 9rem;
+  min-width: 9rem;
+  overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--valid-values {
+  width: 12rem;
+  min-width: 12rem;
+  overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--description {
+  width: 19rem;
+  min-width: 19rem;
+  overflow-wrap: anywhere;
+}
+
+.table-shell[data-overflow="true"] .docs-table--attributes 
.docs-table__col--name,
+.table-shell[data-overflow="unknown"] .docs-table--attributes 
.docs-table__col--name {
+  position: sticky;
+  z-index: 1;
+  left: 0;
+  background: var(--color-surface);
+  box-shadow: 1px 0 var(--color-border);
+}
+
+.table-shell[data-overflow="true"] .docs-table--attributes thead 
.docs-table__col--name,
+.table-shell[data-overflow="unknown"] .docs-table--attributes thead 
.docs-table__col--name {
+  z-index: 2;
+  background: var(--color-surface-alt);
+}
+
+@media (max-width: 996px) {
+  .docs-table--attributes .docs-table__col--name {
+    width: 10rem;
+    min-width: 10rem;
+  }
+
+  .docs-table--attributes .docs-table__col--type {
+    width: 6.5rem;
+    min-width: 6.5rem;
+  }
+
+  .docs-table--attributes .docs-table__col--required {
+    width: 6rem;
+    min-width: 6rem;
+  }
+
+  .docs-table--attributes .docs-table__col--encrypted {
+    width: 6.5rem;
+    min-width: 6.5rem;
+  }
+
+  .docs-table--attributes .docs-table__col--default {
+    width: 8rem;
+    min-width: 8rem;
+  }
+
+  .docs-table--attributes .docs-table__col--valid-values {
+    width: 11rem;
+    min-width: 11rem;
+  }
+
+  .docs-table--attributes .docs-table__col--description {
+    width: 13rem;
+    min-width: 13rem;
+  }
+}
+/* stylelint-enable no-descending-specificity */
+
 .docs-meta { border-top: 1px solid var(--color-border); margin-top: 2.5rem; 
padding-top: 1rem; font-size: .85rem; color: var(--color-text-soft); display: 
flex; gap: 1rem; flex-wrap: wrap; }
-@media (max-width: 960px) {
+
+@media (max-width: 1260px) {
   /* The nav stays in document order, above the article. It used to be pushed
      below it, which put the link tree — and the version picker inside it —
      roughly seven screens down, with only ~4% of the tree visible once you got
diff --git a/next/tests/e2e/docs-mobile-layout.spec.mjs 
b/next/tests/e2e/docs-mobile-layout.spec.mjs
index 5f44e204020..2e0ed9b1e7e 100644
--- a/next/tests/e2e/docs-mobile-layout.spec.mjs
+++ b/next/tests/e2e/docs-mobile-layout.spec.mjs
@@ -1,5 +1,10 @@
+// Playwright is a test-only dependency by design.
+// eslint-disable-next-line import/no-extraneous-dependencies
 import { expect, test } from '@playwright/test';
 
+/* Viewport and schema matrices intentionally reuse one page in sequence. */
+/* eslint-disable no-await-in-loop, no-restricted-syntax */
+
 /** Computed horizontal padding of the first element matching `selector`. */
 async function inlinePadding(page, selector) {
   return page.locator(selector).first().evaluate((el) => {
@@ -21,6 +26,37 @@ async function firstBlogPost(page) {
   return href;
 }
 
+const ATTRIBUTE_COLUMNS = {
+  'server-info': ['name', 'type', 'description'],
+  degraphql: ['name', 'type', 'required', 'description'],
+  'proxy-buffering': ['name', 'type', 'required', 'default', 'description'],
+  'openid-connect': ['name', 'type', 'required', 'default', 'valid-values', 
'description'],
+  'saml-auth': [
+    'name', 'type', 'required', 'encrypted', 'default', 'valid-values', 
'description',
+  ],
+};
+
+const ATTRIBUTE_COLUMN_MIN_WIDTHS = {
+  compact: {
+    name: 158,
+    type: 102,
+    required: 94,
+    encrypted: 102,
+    default: 126,
+    'valid-values': 174,
+    description: 206,
+  },
+  desktop: {
+    name: 206,
+    type: 118,
+    required: 94,
+    encrypted: 118,
+    default: 142,
+    'valid-values': 190,
+    description: 302,
+  },
+};
+
 /** Padding restored and nav ahead of the article, for any docs page. */
 async function assertDocsLayout(page, url) {
   await page.goto(url);
@@ -41,14 +77,16 @@ async function assertDocsLayout(page, url) {
 
   expect(geom.h1Left, `${url}: article text must not touch the viewport 
edge`).toBeGreaterThan(0);
 
-  // Both remaining checks are breakpoint-dependent, and 960px is the line
-  // where .docs-layout collapses to one column (the max-width: 960px media
+  // Both remaining checks are breakpoint-dependent, and 1260px is the line
+  // where .docs-layout collapses to one column (the max-width: 1260px media
   // query in global.css).
-  if (geom.viewport <= 960) {
+  if (geom.viewport <= 1260) {
     // Stacked: the article shares the container's inline padding with the
     // header, so their left edges line up.
-    expect(Math.abs(geom.h1Left - geom.brandLeft),
-      `${url}: article should line up with the header 
brand`).toBeLessThanOrEqual(1);
+    expect(
+      Math.abs(geom.h1Left - geom.brandLeft),
+      `${url}: article should line up with the header brand`,
+    ).toBeLessThanOrEqual(1);
     // Stacked: the nav must precede the article. This is the defect — `order: 
2`
     // used to push it below.
     expect(geom.navTop, `${url}: the docs nav must come before the article`)
@@ -64,12 +102,440 @@ async function assertDocsLayout(page, url) {
   }
 }
 
+async function assertAttributeTableSurfacePaint(shell, context, {
+  requireThemeSurface = false,
+} = {}) {
+  const metrics = await shell.evaluate((element) => {
+    const alpha = (color) => {
+      if (!color || color === 'transparent') return 0;
+      const channels = color.match(/rgba?\(([^)]+)\)/)?.[1]
+        .split(/[,/\s]+/)
+        .filter(Boolean) ?? [];
+      if (channels.length < 4) return 1;
+      const value = parseFloat(channels[3]);
+      return channels[3].endsWith('%') ? value / 100 : value;
+    };
+    const stickyCell = element.querySelector(
+      '.docs-table--attributes tbody tr > :first-child',
+    );
+    const surfaceProbe = document.createElement('span');
+    surfaceProbe.style.backgroundColor = 'var(--ifm-background-surface-color)';
+    element.append(surfaceProbe);
+    const surfaceColor = getComputedStyle(surfaceProbe).backgroundColor;
+    surfaceProbe.remove();
+    const stickyBackground = getComputedStyle(stickyCell).backgroundColor;
+    const cueBackgrounds = ['::before', '::after']
+      .map((pseudo) => getComputedStyle(element, pseudo).backgroundImage);
+    const cueEdgeColors = cueBackgrounds.map((background) => (
+      background.match(/rgba?\([^)]+\)/)?.[0] ?? null
+    ));
+
+    return {
+      cueEdgeAlphas: cueEdgeColors.map(alpha),
+      cueEdgeColors,
+      stickyBackground,
+      stickyBackgroundAlpha: alpha(stickyBackground),
+      surfaceAlpha: alpha(surfaceColor),
+      surfaceColor,
+    };
+  });
+
+  expect(metrics.stickyBackgroundAlpha, `${context}: sticky Name cells must 
mask scrolled text`)
+    .toBeGreaterThan(0);
+  expect(
+    metrics.cueEdgeAlphas.every((value) => value > 0),
+    `${context}: both overflow cues must include a visible edge stop`,
+  ).toBe(true);
+  if (requireThemeSurface) {
+    expect(metrics.surfaceAlpha, `${context}: the Docusaurus surface must be 
opaque`).toBe(1);
+    expect(metrics.stickyBackground, `${context}: sticky Name cells must use 
the theme surface`)
+      .toBe(metrics.surfaceColor);
+    expect(metrics.cueEdgeColors, `${context}: both cues must start with the 
theme surface`)
+      .toEqual([metrics.surfaceColor, metrics.surfaceColor]);
+  }
+}
+
+async function assertPluginTable(page, {
+  path,
+  shellSelector,
+  contentSelector,
+  expectedColumns,
+  shouldOverflow,
+  minimumShellWidth = 0,
+  verifyShortCells = false,
+  accessibleName = /Attributes/i,
+  requireThemeSurface = false,
+}) {
+  if (new URL(page.url()).pathname !== path) {
+    await page.goto(path, { waitUntil: 'domcontentloaded' });
+  }
+  const shell = page.locator(shellSelector);
+  const scroller = shell.locator('.table-scroll');
+  const expectedOverflow = String(shouldOverflow);
+  await expect(shell).toHaveAttribute('data-overflow', expectedOverflow);
+  await expect(shell.locator('.docs-table--attributes')).toHaveCount(1);
+  await scroller.evaluate((element) => {
+    element.scrollTo({ left: 0 });
+  });
+
+  const metrics = await page.evaluate(({ content, expectedColumnCount, 
tableShell }) => {
+    const contentElement = document.querySelector(content);
+    const shellElement = document.querySelector(tableShell);
+    const scrollerElement = shellElement.querySelector('.table-scroll');
+    const tableElement = shellElement.querySelector('.docs-table--attributes');
+    const headers = [...tableElement.querySelectorAll('thead th')];
+    const bodyRows = [...tableElement.querySelectorAll('tbody tr')];
+    const completeBodyRow = bodyRows
+      .find((row, index) => (
+        index > 0 && row.querySelectorAll(':scope > td').length === 
expectedColumnCount
+      ))
+      ?? bodyRows.find((row) => (
+        row.querySelectorAll(':scope > td').length === expectedColumnCount
+      ));
+    const bodyCells = completeBodyRow
+      ? [...completeBodyRow.querySelectorAll(':scope > td')]
+      : [];
+    const firstParagraph = [...contentElement.children]
+      .find((element) => element.matches('p') && 
element.getBoundingClientRect().width > 0);
+    const shortCells = [...tableElement.querySelectorAll('tbody td')]
+      .filter((cell) => ['string', 'True', 
'False'].includes(cell.textContent.trim()));
+    const readingProbe = document.createElement('span');
+    readingProbe.style.cssText = 
'position:absolute;visibility:hidden;width:85ch';
+    contentElement.append(readingProbe);
+    const readingMaxWidth = readingProbe.getBoundingClientRect().width;
+    readingProbe.remove();
+
+    return {
+      contentWidth: contentElement.getBoundingClientRect().width,
+      paragraphWidth: firstParagraph?.getBoundingClientRect().width ?? 0,
+      readingMaxWidth,
+      shellWidth: shellElement.getBoundingClientRect().width,
+      pageClientWidth: document.documentElement.clientWidth,
+      pageScrollWidth: document.documentElement.scrollWidth,
+      scrollerClientWidth: scrollerElement.clientWidth,
+      scrollerScrollWidth: scrollerElement.scrollWidth,
+      scrollerClientHeight: scrollerElement.clientHeight,
+      scrollerScrollHeight: scrollerElement.scrollHeight,
+      tableWidth: tableElement.getBoundingClientRect().width,
+      columnWidths: headers.map((cell) => cell.getBoundingClientRect().width),
+      columnKinds: headers.map((cell) => (
+        [...cell.classList]
+          .find((name) => name.startsWith('docs-table__col--'))
+          ?.replace('docs-table__col--', '') ?? null
+      )),
+      bodyColumnKinds: bodyCells.map((cell) => (
+        [...cell.classList]
+          .find((name) => name.startsWith('docs-table__col--'))
+          ?.replace('docs-table__col--', '') ?? null
+      )),
+      headerWhiteSpace: headers.map((cell) => 
getComputedStyle(cell).whiteSpace),
+      shortCellWhiteSpace: shortCells.slice(0, 3)
+        .map((cell) => getComputedStyle(cell).whiteSpace),
+    };
+  }, {
+    content: contentSelector,
+    expectedColumnCount: expectedColumns.length,
+    tableShell: shellSelector,
+  });
+
+  expect(metrics.pageScrollWidth, `${path}: the page itself must not scroll 
sideways`)
+    .toBeLessThanOrEqual(metrics.pageClientWidth);
+  expect(metrics.paragraphWidth, `${path}: prose must retain the configured 
85ch line length`)
+    .toBeLessThanOrEqual(metrics.readingMaxWidth + 1);
+  expect(metrics.shellWidth, `${path}: table should use the available content 
rail`)
+    .toBeGreaterThanOrEqual(minimumShellWidth);
+  const widthMode = page.viewportSize().width <= 996 ? 'compact' : 'desktop';
+  const minimumWidths = ATTRIBUTE_COLUMN_MIN_WIDTHS[widthMode];
+  const minimumTableWidth = expectedColumns
+    .reduce((total, column) => total + minimumWidths[column], 0);
+  expect(metrics.tableWidth, `${path}: preserve readable semantic column 
widths`)
+    .toBeGreaterThanOrEqual(minimumTableWidth);
+  expect(metrics.columnKinds, `${path}: every attribute column needs a 
semantic class`)
+    .toEqual(expectedColumns);
+  expect(
+    metrics.bodyColumnKinds,
+    `${path}: a complete body row must preserve the header's semantic column 
order`,
+  )
+    .toEqual(expectedColumns);
+  metrics.columnWidths.forEach((width, index) => {
+    const column = expectedColumns[index];
+    expect(width, `${path}: ${column} column must remain readable`)
+      .toBeGreaterThanOrEqual(minimumWidths[column]);
+    if (shouldOverflow && widthMode === 'compact' && column === 'name') {
+      expect(width, `${path}: compact Name must not consume the table 
viewport`)
+        .toBeLessThanOrEqual(162);
+    }
+    if (shouldOverflow && widthMode === 'compact' && column === 'description') 
{
+      expect(width, `${path}: compact Description must leave room for adjacent 
columns`)
+        .toBeLessThanOrEqual(210);
+    }
+  });
+  expect(metrics.headerWhiteSpace, `${path}: short headers must not break 
letter by letter`)
+    .toEqual(expectedColumns.map(() => 'nowrap'));
+  if (verifyShortCells) {
+    expect(metrics.shortCellWhiteSpace, `${path}: common short values must 
stay on one line`)
+      .toEqual(['nowrap', 'nowrap', 'nowrap']);
+  }
+  expect(metrics.scrollerScrollHeight, `${path}: table must not create nested 
vertical scrolling`)
+    .toBeLessThanOrEqual(metrics.scrollerClientHeight + 1);
+
+  if (shouldOverflow) {
+    
expect(metrics.scrollerScrollWidth).toBeGreaterThan(metrics.scrollerClientWidth);
+    await expect(scroller).toHaveAttribute('role', 'region');
+    await expect(scroller).toHaveAttribute('aria-label', accessibleName);
+    await expect(scroller).toHaveAttribute('tabindex', '0');
+    await expect(shell).toHaveAttribute('data-at-start', 'true');
+
+    await scroller.focus();
+    await expect(scroller).toBeFocused();
+    await page.keyboard.press('ArrowRight');
+    await expect.poll(() => scroller.evaluate((element) => element.scrollLeft))
+      .toBeGreaterThan(0);
+    await expect(shell).toHaveAttribute('data-at-start', 'false');
+
+    const stickyMetrics = await scroller.evaluate((element) => {
+      element.scrollTo({ left: Math.min(250, element.scrollWidth - 
element.clientWidth) });
+      const firstCell = element.querySelector('tbody tr > :first-child');
+      return {
+        position: getComputedStyle(firstCell).position,
+        cellLeft: firstCell.getBoundingClientRect().left,
+        scrollerLeft: element.getBoundingClientRect().left,
+      };
+    });
+    expect(stickyMetrics.position, `${path}: keep the attribute name visible 
while panning`)
+      .toBe('sticky');
+    await assertAttributeTableSurfacePaint(shell, path, { requireThemeSurface 
});
+    expect(Math.abs(stickyMetrics.cellLeft - stickyMetrics.scrollerLeft))
+      .toBeLessThanOrEqual(2);
+    const leftCueOpacity = await shell.evaluate((element) => (
+      getComputedStyle(element, '::before').opacity
+    ));
+    expect(leftCueOpacity, `${path}: the overflow cue must not wash out sticky 
Name text`)
+      .toBe('0');
+  } else {
+    
expect(metrics.scrollerScrollWidth).toBeLessThanOrEqual(metrics.scrollerClientWidth
 + 1);
+    await expect(scroller).not.toHaveAttribute('role', 'region');
+    await expect(scroller).toHaveAttribute('tabindex', '-1');
+    await expect(shell).toHaveAttribute('data-at-start', 'true');
+    await expect(shell).toHaveAttribute('data-at-end', 'true');
+  }
+
+  return metrics;
+}
+
+async function assertNoJsPluginTable(browser, {
+  path,
+  shellSelector,
+  expectedColumns,
+  requireThemeSurface = false,
+  theme = 'light',
+}) {
+  const noJsPage = await browser.newPage({
+    baseURL: test.info().project.use.baseURL,
+    javaScriptEnabled: false,
+    viewport: { width: 390, height: 844 },
+  });
+  try {
+    await noJsPage.goto(path);
+    if (theme === 'dark') {
+      await noJsPage.locator('html').evaluate((element) => {
+        element.setAttribute('data-theme', 'dark');
+      });
+      await expect(noJsPage.locator('html')).toHaveAttribute('data-theme', 
'dark');
+    }
+    const shell = noJsPage.locator(shellSelector);
+    const scroller = shell.locator('.table-scroll');
+    const table = scroller.locator('.docs-table--attributes');
+
+    await expect(shell).toHaveAttribute('data-overflow', 'unknown');
+    await expect(scroller).toHaveAttribute('role', 'region');
+    await expect(scroller).toHaveAttribute('tabindex', '0');
+    await expect(table).toHaveCount(1);
+    const metrics = await table.evaluate((element) => {
+      const scrollRegion = element.closest('.table-scroll');
+      const semanticColumns = [...element.querySelectorAll('thead 
th')].map((cell) => (
+        [...cell.classList]
+          .find((name) => name.startsWith('docs-table__col--'))
+          ?.replace('docs-table__col--', '') ?? null
+      ));
+      return {
+        firstBodyCellPosition: getComputedStyle(
+          element.querySelector('tbody tr > :first-child'),
+        ).position,
+        pageClientWidth: document.documentElement.clientWidth,
+        pageScrollWidth: document.documentElement.scrollWidth,
+        scrollerClientWidth: scrollRegion.clientWidth,
+        scrollerScrollWidth: scrollRegion.scrollWidth,
+        semanticColumns,
+      };
+    });
+    expect(metrics.semanticColumns).toEqual(expectedColumns);
+    expect(metrics.firstBodyCellPosition, 'no-JS schema tables must keep Name 
visible')
+      .toBe('sticky');
+    await assertAttributeTableSurfacePaint(
+      shell,
+      `${path} in ${theme} theme without JavaScript`,
+      { requireThemeSurface },
+    );
+    
expect(metrics.scrollerScrollWidth).toBeGreaterThan(metrics.scrollerClientWidth);
+    expect(metrics.pageScrollWidth, 'no-JS table overflow must remain inside 
its scroller')
+      .toBeLessThanOrEqual(metrics.pageClientWidth);
+  } finally {
+    await noJsPage.close();
+  }
+}
+
+async function assertGenericMetricsTable(page, { path, shellSelector }) {
+  await page.setViewportSize({ width: 390, height: 844 });
+  await page.goto(path, { waitUntil: 'domcontentloaded' });
+  const shell = page.locator(shellSelector);
+  const table = shell.locator('table');
+  await expect(shell).toHaveCount(1);
+  await expect(shell).not.toHaveClass(/table-shell--attributes/);
+  await expect(table).not.toHaveClass(/docs-table--attributes/);
+  await expect(table.locator('thead th')).toHaveText(['Name', 'Type', 
'Description']);
+
+  const metrics = await shell.evaluate((element) => ({
+    firstBodyCellPosition: getComputedStyle(element.querySelector('tbody 
td')).position,
+    headerWhiteSpace: [...element.querySelectorAll('thead th')]
+      .map((header) => getComputedStyle(header).whiteSpace),
+    pageClientWidth: document.documentElement.clientWidth,
+    pageScrollWidth: document.documentElement.scrollWidth,
+  }));
+  expect(metrics.headerWhiteSpace).toEqual(metrics.headerWhiteSpace.map(() => 
'normal'));
+  expect(metrics.firstBodyCellPosition).not.toBe('sticky');
+  expect(metrics.pageScrollWidth, `${path}: generic metric overflow must 
remain local`)
+    .toBeLessThanOrEqual(metrics.pageClientWidth);
+}
+
+async function assertLiveResizeTransitions(page, { path, shellSelector }) {
+  await page.setViewportSize({ width: 390, height: 844 });
+  await page.goto(path, { waitUntil: 'domcontentloaded' });
+  const shell = page.locator(shellSelector);
+  const scroller = shell.locator('.table-scroll');
+
+  await expect(shell).toHaveAttribute('data-overflow', 'true');
+  await expect(shell).toHaveAttribute('data-at-end', 'false');
+  await expect(scroller).toHaveAttribute('role', 'region');
+  await expect(scroller).toHaveAttribute('tabindex', '0');
+  await expect.poll(() => shell.evaluate((element) => (
+    getComputedStyle(element, '::after').opacity
+  ))).toBe('1');
+
+  await page.setViewportSize({ width: 2962, height: 1668 });
+  await expect(shell).toHaveAttribute('data-overflow', 'false');
+  await expect(shell).toHaveAttribute('data-at-end', 'true');
+  await expect(scroller).not.toHaveAttribute('role', 'region');
+  await expect(scroller).toHaveAttribute('tabindex', '-1');
+
+  await page.setViewportSize({ width: 390, height: 844 });
+  await expect(shell).toHaveAttribute('data-overflow', 'true');
+  await expect(shell).toHaveAttribute('data-at-end', 'false');
+  await expect(scroller).toHaveAttribute('role', 'region');
+  await expect(scroller).toHaveAttribute('tabindex', '0');
+  await expect.poll(() => shell.evaluate((element) => (
+    getComputedStyle(element, '::after').opacity
+  ))).toBe('1');
+
+  await scroller.evaluate((element) => element.scrollTo({ left: 
element.scrollWidth }));
+  await expect(shell).toHaveAttribute('data-at-end', 'true');
+  await expect.poll(() => shell.evaluate((element) => (
+    getComputedStyle(element, '::after').opacity
+  ))).toBe('0');
+}
+
 // docs/general/** ships from this repo, so it exists in the PR CI build too —
 // no gate, and the fix is verified before anything is deployed.
 test('general docs keep padding and put the nav above the article', async ({ 
page }) => {
   await assertDocsLayout(page, '/docs/general/contributor-guide/');
 });
 
+test('docs content rail stays wide across the sidebar breakpoint', async ({ 
page }) => {
+  test.skip(test.info().project.name !== 'desktop-chrome', 'one desktop run 
covers both widths');
+
+  let lastStackedContentWidth = 0;
+  for (const width of [960, 961, 1200, 1260]) {
+    await page.setViewportSize({ width, height: 900 });
+    await page.goto('/docs/general/contributor-guide/');
+    const metrics = await page.evaluate(() => ({
+      contentWidth: 
document.querySelector('.docs-content').getBoundingClientRect().width,
+      navTop: document.querySelector('.docs-sidebar').offsetTop,
+      articleTop: document.querySelector('.docs-content').offsetTop,
+    }));
+    expect(metrics.contentWidth, `${width}px: the docs rail must not collapse 
beside the sidebar`)
+      .toBeGreaterThanOrEqual(900);
+    expect(metrics.navTop, `${width}px: docs navigation should remain above 
the article`)
+      .toBeLessThan(metrics.articleTop);
+    lastStackedContentWidth = metrics.contentWidth;
+  }
+
+  await page.setViewportSize({ width: 1261, height: 900 });
+  await page.goto('/docs/general/contributor-guide/');
+  const desktopMetrics = await page.evaluate(() => ({
+    contentWidth: 
document.querySelector('.docs-content').getBoundingClientRect().width,
+    navTop: document.querySelector('.docs-sidebar').offsetTop,
+    articleTop: document.querySelector('.docs-content').offsetTop,
+  }));
+  expect(desktopMetrics.contentWidth, '1261px: keep a readable rail beside the 
sidebar')
+    .toBeGreaterThanOrEqual(900);
+  expect(
+    lastStackedContentWidth - desktopMetrics.contentWidth,
+    'the rail should not collapse excessively when the sidebar returns',
+  ).toBeLessThanOrEqual(330);
+  expect(desktopMetrics.navTop, '1261px: nav and article should switch to the 
same grid row')
+    .toBe(desktopMetrics.articleTop);
+});
+
+test('ordinary prose tables wrap within the available docs rail', async ({ 
page }) => {
+  await page.goto('/docs/general/how-to-contribute/');
+  const shell = page.locator('.docs-content .table-shell').first();
+  await expect(shell).toHaveAttribute('data-overflow', 'false');
+
+  const metrics = await shell.evaluate((element) => {
+    const scroller = element.querySelector('.table-scroll');
+    const table = element.querySelector('.docs-table');
+    const description = table.querySelector('tbody td:nth-child(2)');
+    return {
+      shellWidth: element.getBoundingClientRect().width,
+      tableWidth: table.getBoundingClientRect().width,
+      scrollWidth: scroller.scrollWidth,
+      clientWidth: scroller.clientWidth,
+      pageScrollWidth: document.documentElement.scrollWidth,
+      pageClientWidth: document.documentElement.clientWidth,
+      descriptionWhiteSpace: getComputedStyle(description).whiteSpace,
+    };
+  });
+
+  expect(metrics.pageScrollWidth).toBeLessThanOrEqual(metrics.pageClientWidth);
+  expect(metrics.tableWidth).toBeLessThanOrEqual(metrics.shellWidth + 1);
+  expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth + 1);
+  expect(metrics.descriptionWhiteSpace).toBe('normal');
+});
+
+test('ordinary docs table headers can wrap instead of forcing overflow', async 
({ page }) => {
+  test.skip(process.env.EXPECT_DOCUSARUS_ROUTES !== 'true', 'APISIX docs need 
the final tree');
+
+  await page.setViewportSize({ width: 390, height: 844 });
+  await page.goto('/docs/apisix/security-threat-model/');
+  const table = page.locator('.docs-content .table-shell').first();
+  await expect(table).toHaveCount(1);
+  await expect(table.locator('thead th').first()).toHaveText('Role');
+  await expect(table).not.toHaveClass(/table-shell--attributes/);
+  await 
expect(table.locator('table')).not.toHaveClass(/docs-table--attributes/);
+  const whiteSpace = await table.locator('thead th').evaluateAll((headers) => (
+    headers.map((header) => getComputedStyle(header).whiteSpace)
+  ));
+  expect(whiteSpace).toEqual(whiteSpace.map(() => 'normal'));
+  const metrics = await table.evaluate((element) => ({
+    firstBodyCellPosition: getComputedStyle(element.querySelector('tbody 
td')).position,
+    pageClientWidth: document.documentElement.clientWidth,
+    pageScrollWidth: document.documentElement.scrollWidth,
+  }));
+  expect(metrics.firstBodyCellPosition, 'ordinary table cells must not become 
sticky').not.toBe('sticky');
+  expect(metrics.pageScrollWidth, 'ordinary table overflow must remain local 
to its scroller')
+    .toBeLessThanOrEqual(metrics.pageClientWidth);
+});
+
 // Same assertions over the 200-link apisix tree that motivated the report.
 // Gated: apisix docs need .sync/ checkouts only the deploy pipeline has.
 test('apisix docs keep padding and put the nav above the article', async ({ 
page }) => {
@@ -80,6 +546,402 @@ test('apisix docs keep padding and put the nav above the 
article', async ({ page
   await assertDocsLayout(page, '/docs/apisix/getting-started/README/');
 });
 
+test('current APISIX plugin tables use wide screens without sacrificing 
readability', async ({ page }) => {
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'desktop-chrome',
+    'desktop APISIX docs only exist in the final overlaid tree',
+  );
+
+  const currentPath = '/docs/apisix/plugins/openid-connect/';
+  const table = {
+    path: currentPath,
+    shellSelector: '.docs-content h2#attributes + .table-shell',
+    contentSelector: '.docs-content',
+  };
+
+  await page.setViewportSize({ width: 961, height: 900 });
+  await assertPluginTable(page, {
+    ...table,
+    expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+    shouldOverflow: false,
+    minimumShellWidth: 900,
+    verifyShortCells: true,
+  });
+
+  await page.setViewportSize({ width: 1440, height: 900 });
+  await assertPluginTable(page, {
+    ...table,
+    expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+    shouldOverflow: false,
+    minimumShellWidth: 1070,
+    verifyShortCells: true,
+  });
+
+  await page.setViewportSize({ width: 1920, height: 1080 });
+  await assertPluginTable(page, {
+    ...table,
+    expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+    shouldOverflow: false,
+    minimumShellWidth: 1460,
+    verifyShortCells: true,
+  });
+
+  await page.setViewportSize({ width: 2962, height: 1668 });
+  await assertPluginTable(page, {
+    ...table,
+    expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+    shouldOverflow: false,
+    minimumShellWidth: 1460,
+    verifyShortCells: true,
+  });
+});
+
+test('current Attributes table remains usable without JavaScript', async ({ 
browser }) => {
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'desktop-chrome',
+    'current APISIX docs only exist in the final overlaid tree',
+  );
+
+  await assertNoJsPluginTable(browser, {
+    path: '/docs/apisix/plugins/openid-connect/',
+    shellSelector: '.docs-content h2#attributes + .table-shell',
+    expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+  });
+});
+
+test('current metrics catalog stays generic', async ({ page }) => {
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'desktop-chrome',
+    'current APISIX docs only exist in the final overlaid tree',
+  );
+
+  await assertGenericMetricsTable(page, {
+    path: '/docs/apisix/plugins/prometheus/',
+    shellSelector: '.docs-content .table-shell:has(> 
.table-scroll[aria-label="Metrics"])',
+  });
+});
+
+test('current Attributes table reacts to live viewport changes', async ({ page 
}) => {
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'desktop-chrome',
+    'current APISIX docs only exist in the final overlaid tree',
+  );
+
+  await assertLiveResizeTransitions(page, {
+    path: '/docs/apisix/plugins/openid-connect/',
+    shellSelector: '.docs-content h2#attributes + .table-shell',
+  });
+});
+
+test('current Attributes tables support four, five, and seven column schemas', 
async ({ page }) => {
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'desktop-chrome',
+    'desktop APISIX docs only exist in the final overlaid tree',
+  );
+
+  const cases = [
+    { slug: 'degraphql', overflowAt961: false, overflowAt1440: false },
+    { slug: 'proxy-buffering', overflowAt961: false, overflowAt1440: false },
+    { slug: 'saml-auth', overflowAt961: true, overflowAt1440: true },
+  ];
+
+  for (const item of cases) {
+    const table = {
+      path: `/docs/apisix/plugins/${item.slug}/`,
+      shellSelector: '.docs-content h2#attributes + .table-shell',
+      contentSelector: '.docs-content',
+      expectedColumns: ATTRIBUTE_COLUMNS[item.slug],
+    };
+    await page.setViewportSize({ width: 961, height: 900 });
+    await assertPluginTable(page, {
+      ...table, shouldOverflow: item.overflowAt961, minimumShellWidth: 900,
+    });
+    await page.setViewportSize({ width: 1440, height: 900 });
+    await assertPluginTable(page, {
+      ...table, shouldOverflow: item.overflowAt1440, minimumShellWidth: 1070,
+    });
+  }
+});
+
+test('archived APISIX plugin tables expand when space is available', async ({ 
page }) => {
+  test.setTimeout(120_000);
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'desktop-chrome',
+    'desktop APISIX docs only exist in the final overlaid tree',
+  );
+
+  const table = {
+    path: '/docs/apisix/3.18/plugins/openid-connect/',
+    shellSelector: '.markdown h2:has(#attributes) + .table-shell',
+    contentSelector: '.markdown',
+    requireThemeSurface: true,
+  };
+
+  const serverResponse = await page.request.get(table.path);
+  expect(serverResponse.ok(), `${table.path}: SSR document should 
load`).toBe(true);
+  const serverHtml = await serverResponse.text();
+  expect(
+    serverHtml,
+    `${table.path}: Attributes widths must be present before hydration`,
+  ).toMatch(/<table[^>]+class="[^"]*docs-table--attributes/);
+  expect(
+    serverHtml,
+    `${table.path}: overflow cues should wait for client measurement`,
+  ).toMatch(/class="table-shell[^"]*" data-overflow="unknown"/);
+
+  await page.setViewportSize({ width: 961, height: 900 });
+  await assertPluginTable(page, {
+    ...table,
+    expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+    shouldOverflow: false,
+    minimumShellWidth: 880,
+  });
+
+  await page.setViewportSize({ width: 1440, height: 900 });
+  await assertPluginTable(page, {
+    ...table,
+    expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+    shouldOverflow: true,
+    minimumShellWidth: 800,
+  });
+
+  await page.setViewportSize({ width: 1920, height: 1080 });
+  await assertPluginTable(page, {
+    ...table,
+    expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+    shouldOverflow: false,
+    minimumShellWidth: 1150,
+  });
+
+  await page.setViewportSize({ width: 2962, height: 1668 });
+  await assertPluginTable(page, {
+    ...table,
+    expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+    shouldOverflow: false,
+    minimumShellWidth: 1460,
+  });
+});
+
+test('archived Docusaurus tables cover variable schemas and generic wrapping', 
async ({ page }) => {
+  test.setTimeout(120_000);
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'desktop-chrome',
+    'archived Docusaurus docs only exist in the final overlaid tree',
+  );
+
+  await page.setViewportSize({ width: 390, height: 844 });
+  const schemaCases = [
+    {
+      slug: 'server-info',
+      shellSelector: '.markdown .table-shell:has(> 
.table-scroll[aria-label^="Description"])',
+      accessibleName: /Description/i,
+    },
+    { slug: 'degraphql' },
+    { slug: 'proxy-buffering' },
+    { slug: 'saml-auth' },
+  ];
+  for (const item of schemaCases) {
+    await assertPluginTable(page, {
+      path: `/docs/apisix/3.18/plugins/${item.slug}/`,
+      shellSelector: item.shellSelector
+        ?? '.markdown h2:has(#attributes) + .table-shell',
+      contentSelector: '.markdown',
+      expectedColumns: ATTRIBUTE_COLUMNS[item.slug],
+      shouldOverflow: true,
+      minimumShellWidth: 340,
+      accessibleName: item.accessibleName ?? /Attributes/i,
+      requireThemeSurface: true,
+    });
+  }
+
+  await assertGenericMetricsTable(page, {
+    path: '/docs/apisix/3.18/plugins/prometheus/',
+    shellSelector: '.markdown .table-shell:has(> 
.table-scroll[aria-label^="Metrics"])',
+  });
+});
+
+test('archived Docusaurus Attributes table works without JavaScript', async ({ 
browser }) => {
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'desktop-chrome',
+    'archived Docusaurus docs only exist in the final overlaid tree',
+  );
+
+  for (const theme of ['light', 'dark']) {
+    await assertNoJsPluginTable(browser, {
+      path: '/docs/apisix/3.18/plugins/openid-connect/',
+      shellSelector: '.markdown h2:has(#attributes) + .table-shell',
+      expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+      requireThemeSurface: true,
+      theme,
+    });
+  }
+});
+
+test('archived Docusaurus table focus stays visible in dark mode', async ({ 
browser }) => {
+  test.setTimeout(120_000);
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'desktop-chrome',
+    'archived Docusaurus docs only exist in the final overlaid tree',
+  );
+
+  const darkPage = await browser.newPage({
+    baseURL: test.info().project.use.baseURL,
+    viewport: { width: 1440, height: 900 },
+  });
+  try {
+    await darkPage.addInitScript(() => localStorage.setItem('theme', 'dark'));
+    await darkPage.goto('/docs/apisix/3.18/plugins/openid-connect/', {
+      waitUntil: 'domcontentloaded',
+    });
+    await expect(darkPage.locator('html')).toHaveAttribute('data-theme', 
'dark');
+    const shell = darkPage.locator('.markdown h2:has(#attributes) + 
.table-shell');
+    const scroller = shell.locator('.table-scroll');
+    await expect(shell).toHaveAttribute('data-overflow', 'true');
+    await assertAttributeTableSurfacePaint(shell, 'archived dark-mode table', {
+      requireThemeSurface: true,
+    });
+    await darkPage.locator('.markdown h2:has(#attributes) .hash-link').focus();
+    await darkPage.keyboard.press('Tab');
+    await expect(scroller).toBeFocused();
+
+    const focusMetrics = await scroller.evaluate((element) => {
+      const probe = document.createElement('span');
+      probe.style.color = 'var(--ifm-color-primary)';
+      probe.style.backgroundColor = 'var(--ifm-background-color)';
+      document.body.append(probe);
+      const { color: primaryColor, backgroundColor } = getComputedStyle(probe);
+      probe.remove();
+
+      const channels = (color) => (
+        (color.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number)
+      );
+      const luminance = (color) => {
+        const [red, green, blue] = channels(color).map((value) => {
+          const channel = value / 255;
+          return channel <= 0.04045
+            ? channel / 12.92
+            : ((channel + 0.055) / 1.055) ** 2.4;
+        });
+        return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
+      };
+      const foreground = luminance(primaryColor);
+      const background = luminance(backgroundColor);
+      const contrast = (Math.max(foreground, background) + 0.05)
+        / (Math.min(foreground, background) + 0.05);
+      const style = getComputedStyle(element);
+
+      return {
+        contrast,
+        outlineColor: style.outlineColor,
+        outlineStyle: style.outlineStyle,
+        outlineWidth: parseFloat(style.outlineWidth),
+        primaryColor,
+      };
+    });
+    expect(focusMetrics.outlineStyle).toBe('solid');
+    expect(focusMetrics.outlineWidth).toBeGreaterThanOrEqual(3);
+    expect(focusMetrics.outlineColor).toBe(focusMetrics.primaryColor);
+    expect(focusMetrics.contrast, 'focus outline needs at least 3:1 
contrast').toBeGreaterThanOrEqual(3);
+  } finally {
+    await darkPage.close();
+  }
+});
+
+test('archived Docusaurus table reacts to live viewport changes', async ({ 
page }) => {
+  test.setTimeout(120_000);
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'desktop-chrome',
+    'archived Docusaurus docs only exist in the final overlaid tree',
+  );
+
+  await assertLiveResizeTransitions(page, {
+    path: '/docs/apisix/3.18/plugins/openid-connect/',
+    shellSelector: '.markdown h2:has(#attributes) + .table-shell',
+  });
+});
+
+test('current plugin table overflow remains local and keyboard accessible on 
mobile', async ({ page }) => {
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'mobile-chrome',
+    'mobile APISIX docs only exist in the final overlaid tree',
+  );
+
+  await assertPluginTable(page, {
+    path: '/docs/apisix/plugins/openid-connect/',
+    shellSelector: '.docs-content h2#attributes + .table-shell',
+    contentSelector: '.docs-content',
+    expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+    shouldOverflow: true,
+    minimumShellWidth: 340,
+  });
+});
+
+test('variable Attributes schemas keep Name visible on mobile', async ({ page 
}) => {
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'mobile-chrome',
+    'mobile APISIX docs only exist in the final overlaid tree',
+  );
+
+  for (const slug of ['degraphql', 'proxy-buffering', 'saml-auth']) {
+    await assertPluginTable(page, {
+      path: `/docs/apisix/plugins/${slug}/`,
+      shellSelector: '.docs-content h2#attributes + .table-shell',
+      contentSelector: '.docs-content',
+      expectedColumns: ATTRIBUTE_COLUMNS[slug],
+      shouldOverflow: true,
+      minimumShellWidth: 340,
+    });
+  }
+});
+
+test('non-Attributes field schemas intentionally use semantic table behavior', 
async ({ page }) => {
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'mobile-chrome',
+    'mobile APISIX docs only exist in the final overlaid tree',
+  );
+
+  await assertPluginTable(page, {
+    path: '/docs/apisix/plugins/ai-proxy/',
+    shellSelector: '.docs-content h2#request-format + .table-shell',
+    contentSelector: '.docs-content',
+    expectedColumns: ATTRIBUTE_COLUMNS.degraphql,
+    shouldOverflow: true,
+    minimumShellWidth: 340,
+    accessibleName: /Request Format/i,
+  });
+});
+
+test('archived plugin table overflow remains local and keyboard accessible on 
mobile', async ({ page }) => {
+  test.skip(
+    process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+      || test.info().project.name !== 'mobile-chrome',
+    'mobile APISIX docs only exist in the final overlaid tree',
+  );
+
+  await assertPluginTable(page, {
+    path: '/docs/apisix/3.18/plugins/openid-connect/',
+    shellSelector: '.markdown h2:has(#attributes) + .table-shell',
+    contentSelector: '.markdown',
+    expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+    shouldOverflow: true,
+    minimumShellWidth: 340,
+    requireThemeSurface: true,
+  });
+});
+
 test('blog posts keep their horizontal padding', async ({ page }) => {
   await page.goto(await firstBlogPost(page));
   const pad = await inlinePadding(page, '.article-wrap');
@@ -97,8 +959,9 @@ test('desktop keeps the three-column article rails', async 
({ page }) => {
   const rails = page.locator('.article-wrap.with-rails');
   await expect(rails, 'the discovered post should render the rails 
layout').toHaveCount(1);
 
-  const tracks = await rails.evaluate((el) =>
-    
getComputedStyle(el).gridTemplateColumns.split(/\s+/).filter(Boolean).map(parseFloat));
+  const tracks = await rails.evaluate((el) => (
+    
getComputedStyle(el).gridTemplateColumns.split(/\s+/).filter(Boolean).map(parseFloat)
+  ));
 
   expect(tracks.length, 'the rails grid must stay three columns').toBe(3);
 

Reply via email to