From 140a2874650c2f8a32a4586191257124c1f605a0 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Tue, 25 Aug 2026 16:58:29 +0300 Subject: [PATCH 01/29] Prefix the pricing comparison table's classes to clear a name collision Pricing/ComparisonTable and Landing/ComparisonTable both claimed .comparison-table and .comparison-title. Pricing declares its block is:global, so its table rules (width, border-collapse, table-layout, min-width) would apply to Landing's
as soon as both render on one page. Renames Pricing's 19 classes to pc-comparison-*, including the four selectors in pricing/index.astro and PlanFeatureItem that reach into them. No markup or style changes. --- src/components/Pricing/ComparisonTable.astro | 82 ++++++++++---------- src/components/Pricing/PlanFeatureItem.astro | 2 +- src/pages/pricing/index.astro | 10 +-- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/components/Pricing/ComparisonTable.astro b/src/components/Pricing/ComparisonTable.astro index d28c065071..d174a3a9c0 100644 --- a/src/components/Pricing/ComparisonTable.astro +++ b/src/components/Pricing/ComparisonTable.astro @@ -17,12 +17,12 @@ const { } = Astro.props; --- -
-

{title}

- {subtitle &&

{subtitle}

} +
+

{title}

+ {subtitle &&

{subtitle}

} -
- +
+
{/* Authoritative column widths for `table-layout: fixed`. Setting widths via is honored by every browser and survives any specificity drift on the per-class width rules @@ -35,9 +35,9 @@ const { - + {headers.map((h) => ( - + ))} @@ -54,17 +54,17 @@ const { const tail = lastSpace !== -1 ? row.feature.slice(lastSpace + 1) : row.feature; return ( - {row.values.map((val) => ( - ))} @@ -98,28 +98,28 @@ const { diff --git a/src/components/DataTableValue.astro b/src/components/DataTableValue.astro new file mode 100644 index 0000000000..599ccc74d8 --- /dev/null +++ b/src/components/DataTableValue.astro @@ -0,0 +1,49 @@ +--- +/** + * DataTableValue — renders one comparison cell's value as text, always. + * + * Icons are decorative (`aria-hidden`); the value itself is a text node, visible when + * the design shows text and visually-hidden when the design shows only an icon. That is + * what makes a cell readable by screen readers and by plain-text extraction. + * + * Never put `aria-label` on an ``: astro-icon spreads props onto a bare `` + * with no `role="img"`, so the name is announced unreliably and extraction sees nothing. + */ +import { Icon } from 'astro-icon/components'; + +interface Props { + value: boolean | string; + /** Icon for `true`. */ + yesIcon?: string; + yesClass?: string; + /** How `false` renders: `'blank'` shows nothing, or give an icon to draw. */ + no?: 'blank' | { icon: string; class?: string }; + yesText?: string; + noText?: string; +} + +const { + value, + yesIcon = 'tabler:check', + yesClass, + no = 'blank', + yesText = 'Yes', + noText = 'No', +} = Astro.props; +--- + +{ + value === true ? ( + <> + + {yesText} + + ) : value === false ? ( + <> + {no !== 'blank' && `, emitted inline. Inline is the only form that + * survives a scoped-style hash change, so prefer it over CSS where the width is + * static. Use `colClass` instead when the width varies by breakpoint — a `calc()` + * containing a percentage is silently dropped on ``. + */ + width?: string; + /** Class on the ``, for skins that key widths or column paint off it. */ + colClass?: string; + /** Class on the ` diff --git a/src/components/Landing/ComparisonTable.astro b/src/components/Landing/ComparisonTable.astro index c3ae19cf41..8ef72c22a0 100644 --- a/src/components/Landing/ComparisonTable.astro +++ b/src/components/Landing/ComparisonTable.astro @@ -19,6 +19,9 @@ interface ComparisonGroup { interface Props { title?: string; + /** Appended to each group's caption, visually hidden, so a table lifted out of + * the page still says what is being compared. */ + captionContext?: string; groups?: ComparisonGroup[]; peColor?: string; centerTitle?: boolean; @@ -29,6 +32,7 @@ interface Props { const { title = 'Feature comparison', + captionContext = 'ThingsBoard Community Edition compared with Professional Edition', groups: propGroups, peColor = '#038f7e', centerTitle = false, @@ -142,7 +146,10 @@ const groups = propGroups ?? defaultGroups; const hasCellLink = group.rows.some((row) => row.ceLinkHref || row.peLinkHref); return (
{h}{h}
+ {hasTip ? ( <> - {head}{tail} - + {head}{tail} + {row.faqTooltip && ( - - - + + + - {row.autoTooltip && row.faqId && read more} + {row.autoTooltip && row.faqId && read more} )} @@ -75,15 +75,15 @@ const { )} + {val === true ? ( - + ) : val === false ? ( ) : val.toLowerCase() === 'contact us' ? ( - Contact us + Contact us ) : ( - {val} + {val} )}
`, e.g. to neutralise an inherited `thead th` skin. */ + thClass?: string; + /** Inline declarations on the ``, e.g. per-column custom properties. */ + thStyle?: string; +} From 2029ec891d65a71b5844b6712b6215ab846b3002 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Tue, 25 Aug 2026 17:27:44 +0300 Subject: [PATCH 03/29] Add captions, header scopes and row headers to four sound tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These four already emit a real ; they were missing an accessible name, column and row header semantics, and a keyboard-reachable scroll region. Content is untouched. Two cells needed care rather than a straight td-to-th swap: UseCase/ComparisonTable styled th with the dark header row's white text, so a body row header would have rendered white on a light background. Scoped that rule to thead th and extended the td rule to tbody th. development-services had a 1279px media query keyed on td alone, so the converted row header would have lost its wider padding between 960 and 1279px. Extended that selector too. The hardware spec table gets a caption and scope="row" but no thead: it is a key/value table with no column headers to name. LegalLayout had the opposite defect from the rest of this work — no main landmark at all on its ten-plus pages, since they do not route through Starlight. Its container is now a main. Adds a visually-hidden mixin so the recipe has one home. --- src/components/DataTable.astro | 14 ++---- src/components/IotHub/SectionTable.astro | 17 ++++++- src/components/UseCase/ComparisonTable.astro | 38 ++++++++++++--- src/layouts/LegalLayout.astro | 6 ++- src/pages/partners/hardware/[slug].astro | 22 +++++++-- .../services/development-services/index.astro | 47 +++++++++++++++---- src/styles/_variables.scss | 14 ++++++ 7 files changed, 125 insertions(+), 33 deletions(-) diff --git a/src/components/DataTable.astro b/src/components/DataTable.astro index f5ae4f5716..fde4d9b898 100644 --- a/src/components/DataTable.astro +++ b/src/components/DataTable.astro @@ -101,20 +101,14 @@ const labelLines = (label: string) => label.split('\n');
- From 0d457eb6b9d2fc8eca9309f4751812c047f7bdf0 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Wed, 26 Aug 2026 15:54:15 +0300 Subject: [PATCH 10/29] Fix four regressions from the table conversions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four were introduced by promoting a cell from td to th, or by the change of containing block that came with it. Each is measured. services/development-services: the Fast-delivery label column is styled by tbody td:first-child, which stopped matching once the labels became th. The column lost its blue background, its left alignment and its square corners, rendering identically to a value cell beneath a header that still painted blue — visible at any viewport above 960px, which is the only width where that table renders at all. tr:last-child td was orphaned the same way, leaving the last label cell with a border its neighbours drop. Both selectors now accept either cell type. Landing/ComparisonTable, hover target: tr:has(a[href]) also matches a row whose only link is a text link in a value cell, so three rows across two pages showed the full row-hover affordance — background, label growth and the left bar — while being inert. Previously those rows were divs, which [href] never matched. The selector now requires the link to be in the row header. Landing/ComparisonTable, hover bar: the bar was offset by a constant chosen for the row's resting height, but the same hover rule grows the label's line height, so every row grew and the bar sat 3px high on all 27 of them. It is centred now, which also holds when a label wraps. ce-vs-pe-diff: the new scroll wrapper is a block formatting context, so the table's own bottom margin can no longer collapse out of it and was added to the wrapper's. The gap to the following rule went from 24px to 40px. The table's margin is zeroed inside the wrapper. Also collapses a comment that had been left stacked on top of an earlier draft of itself. --- src/components/Landing/ComparisonTable.astro | 18 +++++++----------- src/pages/ce-vs-pe-diff/index.astro | 6 ++++++ .../services/development-services/index.astro | 6 ++++-- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/components/Landing/ComparisonTable.astro b/src/components/Landing/ComparisonTable.astro index 7f6105c4d7..c3ae19cf41 100644 --- a/src/components/Landing/ComparisonTable.astro +++ b/src/components/Landing/ComparisonTable.astro @@ -522,7 +522,7 @@ const groups = propGroups ?? defaultGroups; // Chrome dev-tools' responsive mode (where the primary input // stays "mouse" and the hover query still resolves to true). @media (hover: hover) and (min-width: 901px) { - .comparison-group tbody tr:has(a[href]):hover { + .comparison-group tbody tr:has(> th.col-feature a[href]):hover { background-color: #f9f9f9; th.col-feature { @@ -535,8 +535,8 @@ const groups = propGroups ?? defaultGroups; } } - // `top` is explicit: the bar used to be centred by the flex row's - // align-items, and a block container would drop it to the cell's top. + // Centred rather than offset by a constant: the row grows on hover, and + // grows again when the label wraps, so a fixed `top` drifts. th.col-feature::before { content: ''; height: 44px; @@ -544,7 +544,8 @@ const groups = propGroups ?? defaultGroups; border: 1px solid #00695c; position: absolute; left: 0; - top: 2px; + top: 50%; + transform: translateY(-50%); } td.col-ce, @@ -577,13 +578,8 @@ const groups = propGroups ?? defaultGroups; } } - // Column widths are table-wide, so a group containing any text-link cell widens - // its value columns for every row in that group. Accepted: the alternative is - // per-row widths, which a table cannot express. - // A group containing any text-link cell gets wider value columns at mobile, - // matching the wider flex share those cells used to claim for themselves. - // Column widths are table-wide, so every row in that group widens with them — - // harmless where the group is a single row, and an accepted shift where it is not. + // Widths are table-wide, so a group with any text-link cell widens every row in + // that group — exact for a single-row group, an accepted shift otherwise. @media screen and (max-width: 500px) { .comparison-group--link-cells { .c-feature { width: 52.3987%; } diff --git a/src/pages/ce-vs-pe-diff/index.astro b/src/pages/ce-vs-pe-diff/index.astro index 5f89b63bf6..c76964deca 100644 --- a/src/pages/ce-vs-pe-diff/index.astro +++ b/src/pages/ce-vs-pe-diff/index.astro @@ -406,6 +406,12 @@ import LegalLayout from '../../layouts/LegalLayout.astro'; .table-scroll { overflow-x: auto; margin-bottom: 16px; + + // The wrapper is a block formatting context, so the table's own bottom + // margin can no longer collapse out of it and would be added twice. + table { + margin-bottom: 0; + } } // Zeroed rather than positioned: `position: absolute` would blockify the diff --git a/src/pages/services/development-services/index.astro b/src/pages/services/development-services/index.astro index 0eebe126e8..1615f4de72 100644 --- a/src/pages/services/development-services/index.astro +++ b/src/pages/services/development-services/index.astro @@ -4555,7 +4555,9 @@ const customerLogos = [ width: 35%; } - tbody td:first-child { + // :is(td, th) — the row labels are
, so a td-only selector would + // leave the label column unstyled. + tbody :is(td, th):first-child { font-weight: 400; color: #212529; width: 30%; @@ -4568,7 +4570,7 @@ const customerLogos = [ width: 35%; } - tr:last-child td { + tr:last-child :is(td, th) { border-bottom: none; } } From 4b04588388df3cf93a31ef6a6c73180086d5d1c6 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Wed, 26 Aug 2026 16:10:27 +0300 Subject: [PATCH 11/29] Name what each table compares in its caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every table had a caption, which satisfied the rule, but most captions said nothing on their own. A consumer that lifts one table out of the page got "Additional features", "Feature comparison", "Specifications", or — on the two product pages that render seven tables each — a bare group name like "Platform Core", with no mention of the product or the editions being compared. The cells were machine-readable; the table had no subject. Each caption now names it: Additional features -> ThingsBoard Private Cloud: additional features by plan Feature Comparison Matrix -> ThingsBoard Community Edition, Professional Edition and Cloud Feature comparison -> Google IoT Core compared with ThingsBoard Professional Edition Feature Comparison -> ThingsBoard Community Edition compared with Professional Edition Specifications -> hardware specifications Fast delivery -> why each capability matters and what it gives overview -> high-performance compared with traditional The pricing and matrix components take an optional caption so the page, which is where the product context lives, can supply it. On the two comparison-grid pages the caption is the visible group header bar, so its text could not change. The context is appended there as a visually-hidden suffix instead: the bar still reads "Platform Core" while the table's accessible name reads "Platform Core — ThingsBoard Community Edition compared with Professional Edition". TBMQ passes its own wording. Verified the visible text, the header bar's painted box and the mark positions are all unchanged. --- src/components/Landing/ComparisonMatrix.astro | 6 ++++-- src/components/Landing/ComparisonTable.astro | 9 ++++++++- src/components/Landing/PaasComparisonMatrix.astro | 7 ++++++- src/components/Pricing/ComparisonTable.astro | 5 ++++- src/components/UseCase/ComparisonTable.astro | 2 +- src/pages/ce-vs-pe-diff/index.astro | 4 +++- src/pages/google-iot-core-alternative/index.astro | 7 ++++++- src/pages/partners/hardware/[slug].astro | 2 +- src/pages/pricing/index.astro | 6 +++++- src/pages/products/mqtt-broker/index.astro | 1 + src/pages/services/development-services/index.astro | 4 +++- 11 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/components/Landing/ComparisonMatrix.astro b/src/components/Landing/ComparisonMatrix.astro index 4f0d5b682d..5f47fb39a7 100644 --- a/src/components/Landing/ComparisonMatrix.astro +++ b/src/components/Landing/ComparisonMatrix.astro @@ -25,13 +25,15 @@ interface Row { interface Props { title?: string; + /** Table's accessible name. Defaults to `title`, which rarely names what is compared. */ + caption?: string; columns: Column[]; rows: Row[]; /** Remove the default 60px top margin (use when matrix is directly below another section). */ noTopMargin?: boolean; } -const { title, columns, rows, noTopMargin = false } = Astro.props; +const { title, caption, columns, rows, noTopMargin = false } = Astro.props; // Values are read positionally against `columns`, so a length mismatch would // otherwise fail deep in the render with an opaque error. @@ -97,7 +99,7 @@ const dtColumns: DataTableColumn[] = [ {/* Caption hidden: the decorative column backgrounds are positioned off hardcoded row metrics, so the table must add no height of its own. */} - + { rows.map((row) => (
- + diff --git a/src/components/Landing/PaasComparisonMatrix.astro b/src/components/Landing/PaasComparisonMatrix.astro index aa7377fe2a..64d2e1d10e 100644 --- a/src/components/Landing/PaasComparisonMatrix.astro +++ b/src/components/Landing/PaasComparisonMatrix.astro @@ -62,4 +62,9 @@ const rows = [ ]; --- - + diff --git a/src/components/Pricing/ComparisonTable.astro b/src/components/Pricing/ComparisonTable.astro index 511a5676b7..a689a22447 100644 --- a/src/components/Pricing/ComparisonTable.astro +++ b/src/components/Pricing/ComparisonTable.astro @@ -8,6 +8,8 @@ import type { ComparisonRow } from '@data/pricing/types.ts'; interface Props { title?: string; subtitle?: string; + /** Table's accessible name. Defaults to `title`, which rarely names the product. */ + caption?: string; headers: string[]; rows: ComparisonRow[]; } @@ -15,6 +17,7 @@ interface Props { const { title = 'Additional features', subtitle = 'Extra details and upgrade options for all subscription plans.', + caption, headers, rows, } = Astro.props; @@ -33,7 +36,7 @@ const columns: DataTableColumn[] = [ {/* Caption hidden: the

above already shows this text. */} {/* Hidden: the

above already shows this text. */} -

+
{group.category} + {group.category} + — {captionContext} + {title}{title}: high-performance compared with traditional
diff --git a/src/pages/ce-vs-pe-diff/index.astro b/src/pages/ce-vs-pe-diff/index.astro index c76964deca..c900998e88 100644 --- a/src/pages/ce-vs-pe-diff/index.astro +++ b/src/pages/ce-vs-pe-diff/index.astro @@ -65,7 +65,9 @@ import LegalLayout from '../../layouts/LegalLayout.astro';
{/* Hidden: the

above already shows this text. */} -

+ diff --git a/src/pages/google-iot-core-alternative/index.astro b/src/pages/google-iot-core-alternative/index.astro index a60298686a..bf0a3941e7 100644 --- a/src/pages/google-iot-core-alternative/index.astro +++ b/src/pages/google-iot-core-alternative/index.astro @@ -133,7 +133,12 @@ const concepts = [ - +

Learn more about ThingsBoard features in the ThingsBoard overview. diff --git a/src/pages/partners/hardware/[slug].astro b/src/pages/partners/hardware/[slug].astro index a40b567c10..06f08dfbe7 100644 --- a/src/pages/partners/hardware/[slug].astro +++ b/src/pages/partners/hardware/[slug].astro @@ -72,7 +72,7 @@ const linkCategories = Object.entries(partner.links).filter(([, links]) => links

Feature ComparisonThingsBoard Community Edition compared with Professional Edition: feature comparison
Feature Category
{/* Hidden: the

above already shows this text. No

— the table is key/value, so there are no column headers to name. */} - + { partner.connectivity.length > 0 && ( diff --git a/src/pages/pricing/index.astro b/src/pages/pricing/index.astro index c78262af6e..afab0418ec 100644 --- a/src/pages/pricing/index.astro +++ b/src/pages/pricing/index.astro @@ -262,7 +262,11 @@ const tbmqSubTabs = enrichTabs([ /> - + diff --git a/src/pages/products/mqtt-broker/index.astro b/src/pages/products/mqtt-broker/index.astro index bf22cd2434..7cb79b2656 100644 --- a/src/pages/products/mqtt-broker/index.astro +++ b/src/pages/products/mqtt-broker/index.astro @@ -215,6 +215,7 @@ const tbmqGroups = [
Specifications{partner.name} hardware specifications
{/* Hidden: the

above already shows this text. */} -

+ From 902ad95746fbf5cd15177204874b120a23a8132f Mon Sep 17 00:00:00 2001 From: Ruslan Date: Wed, 26 Aug 2026 16:40:11 +0300 Subject: [PATCH 12/29] Say what the comments meant instead of coining a term MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Skin" and "skin-owned" appear nowhere else in this repo — they were invented in these comments and never defined, so a reader had to work out that they meant the calling component's own CSS. The comments now say that, and name the properties DataTable deliberately leaves alone rather than gesturing at "box metrics". Comments only; no rendered output changes. --- src/components/DataTable.astro | 18 +++++++++++------- src/components/Landing/ComparisonMatrix.astro | 2 +- .../Landing/EdgeComparisonMatrix.astro | 2 +- src/models/data-table.ts | 4 ++-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/DataTable.astro b/src/components/DataTable.astro index fde4d9b898..07cc176889 100644 --- a/src/components/DataTable.astro +++ b/src/components/DataTable.astro @@ -3,12 +3,16 @@ * DataTable — semantic shell for the site's comparison tables. * * Emits caption → colgroup → thead (th scope="col") → tbody slot. Callers pass only - * rows whose first cell is a rows whose first cell is a
Fast deliveryThingsBoard fast delivery: why each capability matters and what it gives
Capability
. All box metrics are skin-owned; - * the existing skins disagree on every one of them. + *
. * - * Skinning contract: a caller moving its shell in here must switch its From 487fd9e8aca81e23a3fdbcd8110d3370a48655e8 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Thu, 27 Aug 2026 16:08:43 +0300 Subject: [PATCH 17/29] Derive caption ids in one guarded place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The id a caption carries, so its scroll region can name itself, was derived by the same unguarded slug expression in two components, and had three ways to produce an unusable value. Slugifying keeps Latin alphanumerics only, so a caption in any of the site's non-Latin locales — or one made of punctuation — reduced to nothing and emitted a bare prefix. Two such tables on a page collided. There is now a hash fallback, so an id is never bare. An override of the empty string was accepted, because ?? guards only null and undefined. A caller computing the override from data with a missing field would emit id="" and leave the region unnamed. Both components now treat a blank override as absent. SectionTable repeated the derivation and offered no override at all, so a collision there could not be worked around. It takes the same optional prop as DataTable now, and both call the shared helper. Verified against English, Chinese, Arabic, punctuation-only and a whitespace override: five distinct ids, none bare, no duplicate or empty id on the page, and every aria-labelledby resolving to its caption. The ids on the four live pages are unchanged. What this does not fix: two captions with identical text still derive the same id. A component cannot see its siblings, so that stays the caller's job — which is what the override is for, and why SectionTable needed one. --- src/components/DataTable.astro | 11 +++++----- src/components/IotHub/SectionTable.astro | 12 ++++++---- src/util/caption-id.ts | 28 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 src/util/caption-id.ts diff --git a/src/components/DataTable.astro b/src/components/DataTable.astro index 07cc176889..7a6e63c5f5 100644 --- a/src/components/DataTable.astro +++ b/src/components/DataTable.astro @@ -15,6 +15,7 @@ * carries this component's hash instead of the caller's. */ import type { DataTableColumn } from '@models/data-table'; +import { captionId as buildCaptionId } from '@util/caption-id'; interface Props { /** Emitted as `
`. Required — every table needs an accessible name. */ @@ -32,7 +33,7 @@ interface Props { /** Wrap in a keyboard-scrollable region, named from the caption. */ scrollable?: boolean; scrollClass?: string; - /** Overrides the caption id. Only needed when one page has two identical captions. */ + /** Overrides the derived id. Needed only when one page has two identical captions. */ captionId?: string; } @@ -49,11 +50,9 @@ const { captionId, } = Astro.props; -const slug = caption - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); -const id = captionId ?? `dt-caption-${slug}`; +// `||` not `??`: a caller computing the override from data can hand us an empty +// string, which would emit id="" and leave the scroll region unnamed. +const id = captionId?.trim() || buildCaptionId(caption, 'dt-caption'); // Fragment collapses to nothing, so the table is unwrapped unless `scrollable`. const Wrapper = scrollable ? 'div' : Fragment; diff --git a/src/components/IotHub/SectionTable.astro b/src/components/IotHub/SectionTable.astro index 0103457f7a..a9d267b7e5 100644 --- a/src/components/IotHub/SectionTable.astro +++ b/src/components/IotHub/SectionTable.astro @@ -1,23 +1,27 @@ --- +import { captionId as buildCaptionId } from '@util/caption-id'; + interface Props { heading: string; intro?: string; columns: string[]; rows: string[][]; + /** Overrides the derived id. Needed only when one page has two identical headings. */ + captionId?: string; } -const { heading, intro, columns, rows } = Astro.props; +const { heading, intro, columns, rows, captionId } = Astro.props; -const captionId = `ih-table-${heading.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`; +const id = captionId?.trim() || buildCaptionId(heading, 'ih-table'); ---

{heading}

{intro &&

{intro}

} -
+
{/* Hidden: the

above already shows this text. */} -

+ {columns.map((col) => )} diff --git a/src/util/caption-id.ts b/src/util/caption-id.ts new file mode 100644 index 0000000000..0de690e0a4 --- /dev/null +++ b/src/util/caption-id.ts @@ -0,0 +1,28 @@ +/** + * Builds the id a table's ` a containing block; the overlay would re-anchor to .comparison-inner and one row's link would cover the whole comparison section. The label cell is the containing block now — positionable in every engine — at the cost of the click target shrinking from the row to the label column. Cell links get their own stacking level so a row carrying both an href and cell links keeps all of them clickable, and the hover selector keys off a data-row-link hook stamped where the row renders instead of encoding the DOM shape in :has(). --- src/components/Landing/ComparisonTable.astro | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/Landing/ComparisonTable.astro b/src/components/Landing/ComparisonTable.astro index 6da842822a..801558b9bf 100644 --- a/src/components/Landing/ComparisonTable.astro +++ b/src/components/Landing/ComparisonTable.astro @@ -165,7 +165,7 @@ const groups = propGroups ?? defaultGroups; {group.rows.map((row) => ( - + now. Padding and the divider live on the cells: a table row // ignores both padding and border in the separated model. .comparison-group tbody tr { - position: relative; transition: background $transition-base; } @@ -479,9 +478,13 @@ const groups = propGroups ?? defaultGroups; border-bottom: none; } - // The row link lives in the row header and is stretched over the row, so the - // accessible name is the feature label rather than the whole row's text. + // The row link lives in the row header and is stretched over the label cell, so + // the accessible name is the feature label rather than the whole row's text. + // The cell, not the row, is the containing block: WebKit does not reliably + // honour `position` on a , and the fallback ancestor would be + // .comparison-inner — one row's overlay would then cover every table. .comparison-group tbody th.col-feature { + position: relative; font-size: $font-size-sm; font-weight: $font-weight-normal; line-height: 1.5rem; @@ -526,7 +529,7 @@ const groups = propGroups ?? defaultGroups; // Chrome dev-tools' responsive mode (where the primary input // stays "mouse" and the hover query still resolves to true). @media (hover: hover) and (min-width: 901px) { - .comparison-group tbody tr:has(> th.col-feature a[href]):hover { + .comparison-group tbody tr[data-row-link]:hover { background-color: #f9f9f9; // Font grows but the line box does not: bumping line-height too made the @@ -570,6 +573,10 @@ const groups = propGroups ?? defaultGroups; } .comparison-group .cell-link { + // Above any stretched row-link overlay, so both stay clickable on a row + // that has an href as well as cell links. + position: relative; + z-index: 1; font-size: $font-size-sm; color: $color-primary; text-decoration: none; From 3892b79dc8fb1137e064eed3e412f5e107c5a694 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Mon, 31 Aug 2026 14:00:48 +0300 Subject: [PATCH 19/29] Render the comparison group cards through DataTable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The largest table work in the branch hand-rolled the exact shell the new primitive emits — caption, colgroup, zero-height announced header — so the codebase carried two competing ways to build the same thing, and the discoverable one was the non-shared one. Each group card is now a DataTable call: the category as a visible caption (captionClass), the announced headers as headerHidden columns, the column classes on the colgroup. Rows stay slot content, so the stretched row link and the value cells are untouched. This also gives captionVisible, captionClass and headerHidden their first callers. DataTable grows one prop for it: captionSr, visually hidden text appended to the caption, so a visible short caption can still carry the what-is-compared context in its accessible name. The Yes/No spans switch to the .dt-vh the primitive already ships, replacing the local copy. The style block follows the documented contract — is:global, nested under .comparison-table — and the caption class is group-caption now, since it is no longer a header row. Geometry measured before and after at 1440/900/500/375 px: zero differences, worst deviation 0.000 px. --- src/components/DataTable.astro | 5 + src/components/Landing/ComparisonTable.astro | 602 +++++++++---------- 2 files changed, 298 insertions(+), 309 deletions(-) diff --git a/src/components/DataTable.astro b/src/components/DataTable.astro index 7a6e63c5f5..5585750e32 100644 --- a/src/components/DataTable.astro +++ b/src/components/DataTable.astro @@ -22,6 +22,9 @@ interface Props { caption: string; /** Show the caption. When false it stays in the a11y tree at zero layout cost. */ captionVisible?: boolean; + /** Visually hidden text appended to the caption — context the visible caption + * doesn't show, e.g. what is being compared. Include any separator. */ + captionSr?: string; captionClass?: string; columns: DataTableColumn[]; /** Class on the `
{heading}{heading}
{col}
` carries so its scroll region can point at it + * with `aria-labelledby`. + * + * Two captions with the same text still produce the same id. That cannot be resolved + * here — a component cannot see its siblings — so a caller rendering two tables with + * identical captions on one page must pass an explicit id for at least one of them. + */ +export function captionId(text: string, prefix: string): string { + const slug = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + + // Slugifying keeps Latin alphanumerics only, so a caption in any of the site's + // non-Latin locales reduces to nothing. Fall back to a hash of the original + // rather than emitting a bare prefix, which would collide with every other one. + return `${prefix}-${slug || hash(text)}`; +} + +/** Short, stable, non-cryptographic digest — only needs to differ per caption. */ +function hash(text: string): string { + let h = 0; + for (let i = 0; i < text.length; i += 1) { + h = (h * 31 + text.charCodeAt(i)) | 0; + } + return Math.abs(h).toString(36); +} From d704766936dc0a56512bcb9768b766870ac8a70a Mon Sep 17 00:00:00 2001 From: Ruslan Date: Mon, 31 Aug 2026 13:39:43 +0300 Subject: [PATCH 18/29] Anchor the row link to the label cell, not the row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebKit does not reliably make a positioned
{row.href ? ( {row.label} @@ -459,7 +459,6 @@ const groups = propGroups ?? defaultGroups; // Rows are
` element — where the caller's CSS attaches. */ @@ -40,6 +43,7 @@ interface Props { const { caption, captionVisible = false, + captionSr, captionClass, columns, class: className, @@ -68,6 +72,7 @@ const labelLines = (label: string) => label.split('\n');
{colgroup && ( diff --git a/src/components/Landing/ComparisonTable.astro b/src/components/Landing/ComparisonTable.astro index 801558b9bf..b9b4ea252f 100644 --- a/src/components/Landing/ComparisonTable.astro +++ b/src/components/Landing/ComparisonTable.astro @@ -1,5 +1,7 @@ --- +import DataTable from '@components/DataTable.astro'; import SmartImage from '@components/SmartImage.astro'; +import type { DataTableColumn } from '@models/data-table'; import { externalLinkAttrs } from '@util/external-links'; interface ComparisonRow { @@ -115,6 +117,14 @@ const defaultGroups: ComparisonGroup[] = [ ]; const groups = propGroups ?? defaultGroups; + +// Announced column headers. The visible labels are the header card above the +// tables, so each table's own header row renders at zero height (headerHidden). +const dtColumns: DataTableColumn[] = [ + { label: 'Features', colClass: 'c-feature' }, + { label: 'Community Edition', colClass: 'c-ce' }, + { label: 'Professional Edition', colClass: 'c-pe' }, +]; ---
row.ceLinkHref || row.peLinkHref); return ( -
{captionVisible ? caption : {caption}} + {captionSr && {captionSr}}
- - - - - - - - - - - - - - + {group.rows.map((row) => ( ))} - -
- {group.category} - — {captionContext} -
FeaturesCommunity EditionProfessional Edition
@@ -183,10 +184,10 @@ const groups = propGroups ?? defaultGroups; ) : row.ce ? ( <> - Yes + Yes ) : ( - No + No )} @@ -199,24 +200,26 @@ const groups = propGroups ?? defaultGroups; ) : row.pe ? ( <> - Yes + Yes ) : ( - No + No )}
+ ); })}
- From 7148420df2ce6a05703bf3bbc245e081d0882373 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Mon, 31 Aug 2026 14:05:36 +0300 Subject: [PATCH 20/29] Fold the nine zero-height hiders into one caption-hidden mixin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five-declaration trick that hides a caption while keeping its role was written out nine times, six of them carrying the same two-line comment. It is one mixin now, documented once next to visually-hidden, so a future change — or a switch to content-visibility — happens in one place. The header-row variant is the mixin plus border: 0. The four class names this branch coined for hidden value text — dt-vh, comparison-sr, uc-comparison__sr, fast-delivery-sr — converge on visually-hidden, matching the mixin. development-services stops hand-maintaining the mixin body: a namespaced @use gets the mixin without dumping global names into the page's stylesheet, which was the stated reason for inlining it. DataTable's own style block also records the real reason it is global: tbody rows are caller-authored slot content, so a scoped rule could never match a span a caller writes into its rows. --- src/components/DataTable.astro | 28 ++++++--------- src/components/DataTableValue.astro | 6 ++-- src/components/IotHub/SectionTable.astro | 9 +---- src/components/Landing/ComparisonTable.astro | 8 ++--- src/components/UseCase/ComparisonTable.astro | 13 ++----- src/pages/ce-vs-pe-diff/index.astro | 11 ++---- src/pages/partners/hardware/[slug].astro | 9 +---- .../services/development-services/index.astro | 35 ++++--------------- src/styles/_variables.scss | 13 +++++++ 9 files changed, 45 insertions(+), 87 deletions(-) diff --git a/src/components/DataTable.astro b/src/components/DataTable.astro index 5585750e32..cd637011dd 100644 --- a/src/components/DataTable.astro +++ b/src/components/DataTable.astro @@ -71,8 +71,8 @@ const labelLines = (label: string) => label.split('\n'); {colgroup && ( @@ -97,7 +97,7 @@ const labelLines = (label: string) => label.split('\n'); ) ) ) : ( - {col.srLabel ?? 'Feature'} + {col.srLabel ?? 'Feature'} )} ))} @@ -112,28 +112,20 @@ const labelLines = (label: string) => label.split('\n'); diff --git a/src/components/DataTableValue.astro b/src/components/DataTableValue.astro index 554d5de04a..6ab9c7b5ea 100644 --- a/src/components/DataTableValue.astro +++ b/src/components/DataTableValue.astro @@ -46,12 +46,12 @@ if (typeof value === 'string' && !value.trim()) { value === true ? ( <> diff --git a/src/components/UseCase/ComparisonTable.astro b/src/components/UseCase/ComparisonTable.astro index 6931e721c0..d67b015dfd 100644 --- a/src/components/UseCase/ComparisonTable.astro +++ b/src/components/UseCase/ComparisonTable.astro @@ -19,7 +19,7 @@ const { title, rows } = Astro.props; @@ -69,19 +69,12 @@ const { title, rows } = Astro.props; } } - .uc-comparison__sr { + .visually-hidden { @include visually-hidden; } - // Zeroed rather than positioned: `position: absolute` would blockify the - // caption and strip the table's accessible name. .uc-comparison__caption { - padding: 0; - height: 0; - line-height: 0; - font-size: 0; - overflow: hidden; - color: transparent; + @include caption-hidden; } // ── Desktop table ── diff --git a/src/pages/ce-vs-pe-diff/index.astro b/src/pages/ce-vs-pe-diff/index.astro index 24f94d3e1f..ac4055026a 100644 --- a/src/pages/ce-vs-pe-diff/index.astro +++ b/src/pages/ce-vs-pe-diff/index.astro @@ -288,6 +288,8 @@ import LegalLayout from '../../layouts/LegalLayout.astro';
- {captionVisible ? caption : {caption}} - {captionSr && {captionSr}} + {captionVisible ? caption : {caption}} + {captionSr && {captionSr}}
@@ -200,10 +200,10 @@ const dtColumns: DataTableColumn[] = [ ) : row.pe ? ( <> - Yes + Yes ) : ( - No + No )}
- Criteria + Criteria High-performance Traditional