@devslab/site-kit 0.12.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -23,11 +23,25 @@ export interface SiteLink {
23
23
  href: string;
24
24
  label: string;
25
25
  external?: boolean;
26
+ /**
27
+ * Drawn heavier than its neighbours. For a link the product must make
28
+ * easy to find — a Korean site's 개인정보처리방침 (privacy policy), which
29
+ * the law asks to stand out from the other footer links.
30
+ */
31
+ emphasis?: boolean;
26
32
  }
27
33
  export interface SiteBrand {
28
34
  name: string;
29
35
  href: string;
30
36
  logo?: JSX.Element;
37
+ /**
38
+ * The header brand link's accessible name, when the visible name is not
39
+ * enough ("Acme home"). Start it with the visible name so voice users can
40
+ * say what they see. A product whose wordmark is its logo passes the mark
41
+ * as `logo` and `name: ""` — the name would otherwise print twice — and
42
+ * names the link here.
43
+ */
44
+ label?: string;
31
45
  }
32
46
  export interface LocaleState {
33
47
  locale: SiteLocale;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/solid/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AACpC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEtD,MAAM,WAAW,YAAY;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,QAAQ;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE;AAC7E,MAAM,WAAW,SAAS;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,GAAG,CAAC,OAAO,CAAA;CAAE;AAC7E,MAAM,WAAW,WAAW;IAAG,MAAM,EAAE,UAAU,CAAC;IAAC,aAAa,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,MAAM,CAAA;CAAE;AAClG,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/solid/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AACpC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEtD,MAAM,WAAW,YAAY;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AACD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,WAAW;IAAG,MAAM,EAAE,UAAU,CAAC;IAAC,aAAa,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,MAAM,CAAA;CAAE;AAClG,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devslab/site-kit",
3
- "version": "0.12.2",
3
+ "version": "0.14.0",
4
4
  "description": "Strict i18n, SEO/GEO, and Solid public-site shells for DevsLab products",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -60,7 +60,7 @@
60
60
  "./site-sections.css": "./site-sections.css"
61
61
  },
62
62
  "dependencies": {
63
- "@devslab/dds-solid": "0.12.2"
63
+ "@devslab/dds-solid": "0.14.0"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@tanstack/solid-router": "1.170.30",
package/site-sections.css CHANGED
@@ -4,6 +4,10 @@
4
4
  tokens carry light and dark. */
5
5
 
6
6
  .site-section { padding-block: var(--dds-space-64); border-block-start: 1px solid var(--dds-color-border-default); }
7
+ /* The site header sticks to the top; an in-page link ("#how") stops the section below it, not under it.
8
+ A product that changes --site-header-block-size sets it on :root or .site-shell (an ancestor of both
9
+ the header and <main>) — set on .site-header it resizes the header but never reaches the sections. */
10
+ .site-hero, .site-section { scroll-margin-block-start: calc(var(--site-header-block-size, 64px) + var(--dds-space-8)); }
7
11
  .site-section[data-tone="band"] { background: var(--dds-color-bg-subtle); }
8
12
  .site-section__shell { inline-size: min(100% - var(--dds-space-32), 1120px); margin-inline: auto; }
9
13
 
@@ -53,7 +57,9 @@ html:lang(ko) .site-rows__body { word-break: keep-all; overflow-wrap: break-word
53
57
  height rule — a swapping scene fixes its own height in the consumer. */
54
58
  .site-hero { padding-block: var(--dds-space-64); }
55
59
  .site-hero__shell { inline-size: min(100% - var(--dds-space-32), 1120px); margin-inline: auto; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--dds-space-48); align-items: start; }
56
- .site-hero__eyebrow { margin: 0; margin-block-end: var(--dds-space-12); color: var(--dds-color-text-brand); font-family: var(--dds-font-family-mono); font-size: var(--dds-typo-caption-font-size); letter-spacing: .18em; }
60
+ /* Tracking and weight are hooks: wide mono tracking suits Latin caps, not Hangul — a product whose
61
+ eyebrow is Korean sets --site-hero-eyebrow-tracking: 0 (and a weight) on its landing root. */
62
+ .site-hero__eyebrow { margin: 0; margin-block-end: var(--dds-space-12); color: var(--dds-color-text-brand); font-family: var(--dds-font-family-mono); font-size: var(--dds-typo-caption-font-size); font-weight: var(--site-hero-eyebrow-weight, normal); letter-spacing: var(--site-hero-eyebrow-tracking, .18em); }
57
63
  .site-hero__copy h1 { margin: 0; font-size: var(--dds-typo-display-font-size); line-height: var(--dds-typo-display-line-height); letter-spacing: -.025em; text-wrap: balance; white-space: pre-line; }
58
64
  .site-hero__lede { margin: 0; margin-block-start: var(--dds-space-24); max-inline-size: 46ch; color: var(--dds-color-text-secondary); font-size: var(--dds-typo-body-1-font-size); line-height: 1.7; }
59
65
  .site-hero__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--dds-space-16) var(--dds-space-24); margin-block-start: var(--dds-space-32); }
@@ -0,0 +1,12 @@
1
+ /** `GTM-` followed by uppercase letters and digits. Anything else is refused before it reaches a script string. */
2
+ export declare const GTM_CONTAINER_ID_PATTERN: RegExp;
3
+ /**
4
+ * Body of Google's nonce-aware Tag Manager head loader for `containerId`, without the `<script>` tag or a nonce —
5
+ * the renderer owns the element and its per-request nonce. Throws RangeError for an id that does not match GTM_CONTAINER_ID_PATTERN.
6
+ */
7
+ export declare function gtmHeadScript(containerId: string): string;
8
+ /** The `<iframe>` HTML Google puts inside `<noscript>` right after `<body>` opens. Throws RangeError for a malformed id. */
9
+ export declare function gtmNoscriptIframe(containerId: string): string;
10
+ export type GtmCspDirective = "script-src" | "connect-src" | "img-src" | "frame-src";
11
+ /** CSP sources for Tag Manager + GA4 without Ads features (Google's CSP guide). Excludes preview mode, Custom JavaScript variables and Ads hosts. */
12
+ export declare const GTM_CSP_SOURCES: Readonly<Record<GtmCspDirective, readonly string[]>>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Google Tag Manager, written once for the family sites (D-031).
3
+ *
4
+ * The strings below are Google's, character for character, with only the
5
+ * container id substituted. The loader is the nonce-aware variant Google
6
+ * publishes for pages under a Content Security Policy
7
+ * (https://developers.google.com/tag-platform/security/guides/csp): it is the
8
+ * standard snippet plus one statement that copies the page's nonce onto the
9
+ * gtm.js element it creates, so Tag Manager can pass that nonce on to the
10
+ * scripts it adds. Every family site runs a nonce CSP, so this is the variant
11
+ * that fits all of them; on a page without nonces the extra statement finds no
12
+ * `[nonce]` element and does nothing.
13
+ *
14
+ * The id is validated before it is placed inside a script string: a value
15
+ * that is not `GTM-` plus uppercase letters and digits never reaches the
16
+ * output. The pattern admits no quote, backslash, `<` or whitespace, so the
17
+ * id cannot close the string literal or the element it sits in.
18
+ */
19
+
20
+ export const GTM_CONTAINER_ID_PATTERN = /^GTM-[A-Z0-9]+$/;
21
+
22
+ const containerId = (value) => {
23
+ if (typeof value !== "string" || !GTM_CONTAINER_ID_PATTERN.test(value)) {
24
+ throw new RangeError(`Google Tag Manager container id must match ${GTM_CONTAINER_ID_PATTERN} (for example "GTM-AB12CD3"); got ${JSON.stringify(value)}`);
25
+ }
26
+ return value;
27
+ };
28
+
29
+ /**
30
+ * The body of the head loader `<script>` — no `<script>` tag and no nonce:
31
+ * the page's renderer owns the element and puts the per-request nonce on it
32
+ * (in TanStack Start the router does, from `ssr.nonce`).
33
+ */
34
+ export function gtmHeadScript(id) {
35
+ return `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
36
+ new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
37
+ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
38
+ 'https://www.googletagmanager.com/gtm.js?id='+i+dl;var n=d.querySelector('[nonce]');
39
+ n&&j.setAttribute('nonce',n.nonce||n.getAttribute('nonce'));f.parentNode.insertBefore(j,f);
40
+ })(window,document,'script','dataLayer','${containerId(id)}');`;
41
+ }
42
+
43
+ /** The contents of the `<noscript>` Google places right after `<body>` opens. */
44
+ export function gtmNoscriptIframe(id) {
45
+ return `<iframe src="https://www.googletagmanager.com/ns.html?id=${containerId(id)}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`;
46
+ }
47
+
48
+ const sources = (...list) => Object.freeze(list);
49
+
50
+ /**
51
+ * CSP sources a page needs for Tag Manager plus Google Analytics 4 without
52
+ * Ads features, per Google's "Use Tag Manager with a Content Security
53
+ * Policy" guide. Add each list to the directive of the same name, next to
54
+ * the page's own nonce.
55
+ *
56
+ * - script-src: Google lists it under `script-src-elem`; a policy without
57
+ * that directive falls back to `script-src`, which is where the family
58
+ * sites keep their nonce.
59
+ * - connect-src: `*.google.com` is Google's own entry and also covers
60
+ * `www.google.com` (the container's) and `*.analytics.google.com` (GA4's
61
+ * regional collection hosts) — a CSP wildcard matches any depth of
62
+ * subdomain.
63
+ * - frame-src: the `<noscript>` iframe.
64
+ *
65
+ * Not included, by design: Tag Manager preview mode (tagmanager.google.com,
66
+ * gstatic, Google Fonts), Custom JavaScript variables (`'unsafe-eval'`), and
67
+ * the Google Ads / Google signals hosts (doubleclick, googlesyndication,
68
+ * googleadservices, `*.google.<TLD>`). A product that turns one of those on
69
+ * adds its sources from the same guide.
70
+ */
71
+ export const GTM_CSP_SOURCES = Object.freeze({
72
+ "script-src": sources("https://www.googletagmanager.com"),
73
+ "connect-src": sources("https://www.googletagmanager.com", "https://*.google-analytics.com", "https://*.google.com"),
74
+ "img-src": sources("https://www.googletagmanager.com", "https://*.google-analytics.com"),
75
+ "frame-src": sources("https://www.googletagmanager.com"),
76
+ });
@@ -3,3 +3,4 @@ export * from "./catalog.mjs";
3
3
  export * from "./seo.mjs";
4
4
  export * from "./geo.mjs";
5
5
  export * from "./publisher.mjs";
6
+ export * from "./gtm.mjs";
@@ -3,3 +3,4 @@ export { CatalogValidationError, createTranslator, validateCatalogs } from "./ca
3
3
  export { BRAND_ICON_FILES, ROBOTS_USER_AGENTS, brandIconLinks, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs";
4
4
  export { VerifiedFactRegistry, buildVerifiedJsonLd, renderLlmsTxt } from "./geo.mjs";
5
5
  export { definePublisher, buildPublisher, serializeJsonLd, renderPublisherHtml } from "./publisher.mjs";
6
+ export { GTM_CONTAINER_ID_PATTERN, GTM_CSP_SOURCES, gtmHeadScript, gtmNoscriptIframe } from "./gtm.mjs";
@@ -6,7 +6,13 @@ import type { SiteLocale } from "./core/locales.mjs";
6
6
  * registry (D-018) yields `SiteMetadata<string>`; the adapter reads the
7
7
  * same fields whatever the code type is, so it must not refuse that.
8
8
  */
9
- /** `icons: true` appends brandIconLinks() (the /brand default); an object passes its options through. Omitted, no icon links are emitted. */
10
- export interface TanStackHeadOptions { icons?: boolean | { basePath?: string } }
11
- export declare function toTanStackHead<Code extends string = SiteLocale>(metadata: SiteMetadata<Code>, options?: TanStackHeadOptions): { meta: Array<Record<string, string>>; links: Array<Record<string, string>> };
9
+ /**
10
+ * `icons: true` appends brandIconLinks() (the /brand default); an object passes its options through. Omitted, no icon links are emitted.
11
+ * `gtm` adds Google Tag Manager's head loader for that container id as `scripts[0]`; omitted or `undefined` (a route without Tag Manager), the result has no `scripts` key.
12
+ */
13
+ export interface TanStackHeadOptions { icons?: boolean | { basePath?: string }; gtm?: string | undefined }
14
+ /** A route `head().scripts` entry: the loader body only. The router adds `ssr.nonce` when it renders the tag. Throws RangeError for a malformed id. */
15
+ export interface GtmHeadEntry { children: string }
16
+ export declare function gtmHeadEntry(containerId: string): GtmHeadEntry;
17
+ export declare function toTanStackHead<Code extends string = SiteLocale>(metadata: SiteMetadata<Code>, options?: TanStackHeadOptions): { meta: Array<Record<string, string>>; links: Array<Record<string, string>>; scripts?: GtmHeadEntry[] };
12
18
  export declare const toHtmlAttributes: <Code extends string = SiteLocale>(metadata: SiteMetadata<Code>) => { lang: Code; dir: string };
@@ -1,11 +1,29 @@
1
1
  import { brandIconLinks } from "./core/seo.mjs";
2
+ import { gtmHeadScript } from "./core/gtm.mjs";
3
+
4
+ /**
5
+ * A route `head().scripts` entry that loads Google Tag Manager (D-031).
6
+ *
7
+ * Only `children`: no `nonce`, on purpose. The router's HeadContent stamps
8
+ * `router.options.ssr.nonce` on every head script it renders, after the
9
+ * entry's own attributes, so the per-request nonce reaches this script the
10
+ * same way it reaches the meta and link tags. A nonce written here would be
11
+ * overwritten anyway. The product sets `ssr.nonce` when it creates the
12
+ * router (asklinq#427); without it the loader renders with no nonce and the
13
+ * page's CSP blocks it.
14
+ */
15
+ export function gtmHeadEntry(containerId) {
16
+ return { children: gtmHeadScript(containerId) };
17
+ }
2
18
 
3
19
  // `icons` is opt-in: the adapter cannot know where (or whether) a product
4
20
  // serves the linq-brand files, and a head that links icons the server 404s
5
21
  // is worse than one that links none. `true` takes the /brand default.
22
+ // `gtm` is opt-in the same way, and per route: the product decides which
23
+ // pages carry Tag Manager (public marketing and legal pages, not consoles).
6
24
  export function toTanStackHead(metadata, options = {}) {
7
25
  const icons = options.icons === true ? brandIconLinks() : options.icons ? brandIconLinks(options.icons) : [];
8
- return {
26
+ const head = {
9
27
  meta: [
10
28
  { title: metadata.title },
11
29
  { name: "description", content: metadata.description },
@@ -27,6 +45,8 @@ export function toTanStackHead(metadata, options = {}) {
27
45
  ...icons,
28
46
  ],
29
47
  };
48
+ if (options.gtm !== undefined) head.scripts = [gtmHeadEntry(options.gtm)];
49
+ return head;
30
50
  }
31
51
 
32
52
  export const toHtmlAttributes = (metadata) => ({ lang: metadata.html.lang, dir: metadata.html.dir });
package/styles.css CHANGED
@@ -8,12 +8,14 @@
8
8
  .site-main { flex: 1; inline-size: min(100% - var(--dds-space-32), 1120px); margin-inline: auto; }
9
9
  .site-main--bleed { inline-size: 100%; }
10
10
  .site-header { position: sticky; inset-block-start: 0; z-index: 100; border-block-end: 1px solid var(--dds-color-border-default); background: var(--dds-color-bg-default); }
11
- .site-header__inner { min-block-size: 64px; inline-size: min(100% - var(--dds-space-32), 1120px); margin-inline: auto; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: var(--dds-space-16); }
11
+ .site-header__inner { min-block-size: var(--site-header-block-size, 64px); inline-size: min(100% - var(--dds-space-32), 1120px); margin-inline: auto; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: var(--dds-space-16); }
12
12
  .site-brand { justify-self: start; color: var(--dds-color-text-primary); font-weight: 700; text-decoration: none; }
13
13
  .site-nav { grid-column: 2; justify-self: center; min-inline-size: 0; }
14
14
  .site-nav__list, .site-footer__links { display: flex; flex-wrap: wrap; gap: var(--dds-space-16); padding: 0; margin: 0; list-style: none; }
15
15
  .site-nav__list a, .site-footer__links a { color: var(--dds-color-text-secondary); text-decoration: none; }
16
16
  .site-nav__list a:hover, .site-footer__links a:hover { color: var(--dds-color-text-primary); text-decoration: underline; }
17
+ /* SiteLink.emphasis — a link the product must make easy to find (a Korean privacy policy). */
18
+ :is(.site-nav__list, .site-footer__links, .site-footer__brand) a.site-link--emphasis { color: var(--dds-color-text-primary); font-weight: 700; }
17
19
  .site-header__controls { grid-column: 3; justify-self: end; display: flex; align-items: center; gap: var(--dds-space-8); }
18
20
  .site-menu-button { display: none; }
19
21
  .site-locale { inline-size: auto; min-inline-size: 9rem; }
@@ -22,7 +24,8 @@
22
24
  /* The kit gave these rows layout and colour but no size, so the footer
23
25
  inherited the 16px body and read a step larger than the same row on a
24
26
  sibling product's page. Every consumer that noticed fixed it locally. */
25
- .site-footer__links { font-size: var(--dds-typo-body-2-font-size); }
27
+ /* Baseline, so a bare copyright line sits level with links that are 44px targets on touch. */
28
+ .site-footer__links { font-size: var(--dds-typo-body-2-font-size); align-items: baseline; }
26
29
  .site-footer__langs { font-size: var(--dds-typo-caption-font-size); }
27
30
  .site-footer__langs-menu { position: relative; display: inline-block; }
28
31
  .site-footer__langs-trigger { display: inline-flex; align-items: center; gap: var(--dds-space-4); color: var(--dds-color-text-muted); cursor: pointer; list-style: none; }
@@ -38,9 +41,17 @@
38
41
  .site-footer__langs-list a[aria-current="page"] { color: var(--dds-color-text-primary); font-weight: 600; }
39
42
  .site-footer__row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--dds-space-16) var(--dds-space-32); }
40
43
  .site-footer__brand { display: inline-flex; align-items: center; gap: var(--dds-space-8); margin: 0; color: var(--dds-color-text-secondary); }
41
- .site-footer__brand img, .site-footer__brand svg { block-size: 16px; inline-size: 16px; }
44
+ /* The icon mark beside a printed name. A wordmark passed as the logo (name "") keeps its own size. */
45
+ .site-footer__brand:has(> strong) :is(img, svg) { block-size: 16px; inline-size: 16px; }
42
46
  .site-footer__brand a { color: inherit; text-decoration: none; }
43
47
  .site-footer__brand a:hover { color: var(--dds-color-text-primary); text-decoration: underline; }
48
+ /* SiteFooter.details — a block under the brand line (business registration, address). */
49
+ .site-footer__identity { display: grid; gap: var(--dds-space-8); min-inline-size: 0; }
50
+ .site-footer__details { display: grid; gap: var(--dds-space-4); color: var(--dds-color-text-secondary); font-size: var(--dds-typo-body-2-font-size); }
51
+ .site-footer__details > * { margin: 0; }
52
+ .site-footer__details address { font-style: normal; }
53
+ /* With details the left column runs several lines; the links sit level with its first line, not its middle. */
54
+ .site-footer__row:has(> .site-footer__identity) { align-items: first baseline; }
44
55
  .site-legal { max-inline-size: 760px; margin-inline: auto; padding-block: var(--dds-space-40); }
45
56
  .site-status { border: 1px solid var(--dds-color-border-default); border-inline-start-width: var(--dds-space-4); border-radius: var(--dds-radius-md); padding: var(--dds-space-12) var(--dds-space-16); }
46
57
  .site-status--danger { border-inline-start-color: var(--dds-color-status-danger); }
@@ -54,14 +65,25 @@
54
65
  .oss-product-mark--md { --oss-product-mark-size: 32px; }
55
66
  .oss-product-mark--lg { --oss-product-mark-size: 48px; }
56
67
 
68
+ /* Touch: every link in the chrome is a 44px target (spec §6), as every button already is
69
+ (dds-css button.css). Placed before the narrow block so its menu rows can override the display. */
70
+ @media (pointer: coarse) {
71
+ .site-brand, .site-nav__list a, .site-footer__links a, .site-footer__brand a, .site-footer__langs-trigger { display: inline-flex; align-items: center; min-block-size: 44px; }
72
+ .site-footer__langs-list a { display: flex; align-items: center; min-block-size: 44px; }
73
+ }
74
+
57
75
  @media (max-width: 720px) {
58
- .site-header__inner { grid-template-columns: minmax(0, 1fr) auto; padding-block: var(--dds-space-12); }
76
+ /* The first row is the header's own height whatever the button's size (44px on touch), so the
77
+ closed header is the same 64px everywhere and a section's scroll offset holds. */
78
+ .site-header__inner { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: var(--site-header-block-size, 64px); padding-block: 0; }
59
79
  .site-menu-button { display: inline-flex; grid-column: 2; grid-row: 1; }
60
80
  .site-nav { display: none; grid-column: 1 / -1; grid-row: 2; justify-self: stretch; }
61
81
  .site-nav[data-open="true"] { display: grid; }
62
- .site-nav__list { display: grid; }
82
+ /* The open menu is a list of full-width 44px rows (about the pitch the 16px gap gave text lines). */
83
+ .site-nav__list { display: grid; gap: 0; }
84
+ .site-nav__list a { display: flex; align-items: center; min-block-size: 44px; }
63
85
  .site-header__controls { display: none; grid-column: 1 / -1; grid-row: 3; flex-wrap: wrap; }
64
- .site-header__controls[data-open="true"] { display: flex; }
86
+ .site-header__controls[data-open="true"] { display: flex; padding-block-end: var(--dds-space-12); }
65
87
  }
66
88
 
67
89
  @media (prefers-reduced-motion: reduce) { .site-header { scroll-behavior: auto; } }