From ae34e9fe3d67af3a97cfc4bddaceda9d473fc753 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Sun, 13 Sep 2026 08:00:50 +0100 Subject: [PATCH 001/142] Add Solutions helpers and keep Suggested labs off pages that recommend Solutions --- src/helpers/add-suggested-labs.js | 16 +++ src/helpers/format-duration.js | 18 ++++ src/helpers/get-solution-step.js | 22 ++++ src/helpers/get-solutions-catalog.js | 155 +++++++++++++++++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 src/helpers/format-duration.js create mode 100644 src/helpers/get-solution-step.js create mode 100644 src/helpers/get-solutions-catalog.js diff --git a/src/helpers/add-suggested-labs.js b/src/helpers/add-suggested-labs.js index a48d05a0..4ae3a906 100644 --- a/src/helpers/add-suggested-labs.js +++ b/src/helpers/add-suggested-labs.js @@ -1,9 +1,25 @@ 'use strict' +// A page that carries Solutions recommendations (page-related-solutions, from +// the solutions-catalog extension; rendered by solution-recommendations.hbs as +// "Build it in practice") never also gets "Suggested labs" appended: one +// recommendation section per page. Malformed JSON counts as "none". +function hasRelatedSolutions (attributes) { + const raw = attributes && attributes['related-solutions'] + if (!raw) return false + try { + const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw + return Array.isArray(parsed) && parsed.length > 0 + } catch (e) { + return false + } +} + module.exports = (attributes, content, { data: { root } }) => { const { contentCatalog } = root if (attributes['component-name'] === 'labs') return content if (attributes['exclude-related-labs'] === 'true') return content + if (hasRelatedSolutions(attributes)) return content if (!contentCatalog) return content // Extract related labs from attributes diff --git a/src/helpers/format-duration.js b/src/helpers/format-duration.js new file mode 100644 index 00000000..7075726a --- /dev/null +++ b/src/helpers/format-duration.js @@ -0,0 +1,18 @@ +'use strict' + +/** + * Formats a duration in minutes for display: 45 -> "45 min", 60 -> "1 hr", + * 75 -> "1 hr 15 min". Returns '' for missing or unusable input so templates + * can wrap the call in {{#if}}. + * + * Usage: {{format-duration solution.duration}} + */ +module.exports = (minutes) => { + const value = Number(minutes) + if (!isFinite(value) || value <= 0) return '' + const total = Math.round(value) + if (total < 60) return total + ' min' + const hours = Math.floor(total / 60) + const rest = total % 60 + return rest ? hours + ' hr ' + rest + ' min' : hours + ' hr' +} diff --git a/src/helpers/get-solution-step.js b/src/helpers/get-solution-step.js new file mode 100644 index 00000000..3c1c71dc --- /dev/null +++ b/src/helpers/get-solution-step.js @@ -0,0 +1,22 @@ +'use strict' + +/** + * Finds one step record in a solution's ordered `steps` array. + * + * Usage: + * {{#with (get-solution-step solution) as |step|}} current step (page-solution-step) + * {{#with (get-solution-step solution 'create-topics')}} a named step + * + * `solution` is the parsed `page-solution` record. Without an explicit id the + * helper uses the `solution-step` page attribute that the solutions-catalog + * extension sets on every step page. Returns null when there is no match so a + * `{{#with}}` block renders nothing. + */ +module.exports = function (solution, stepId) { + const options = arguments[arguments.length - 1] + if (stepId === options) stepId = undefined + const root = options && options.data && options.data.root + const id = stepId || (root && root.page && root.page.attributes && root.page.attributes['solution-step']) + if (!id || !solution || !Array.isArray(solution.steps)) return null + return solution.steps.find((step) => step && step.id === id) || null +} diff --git a/src/helpers/get-solutions-catalog.js b/src/helpers/get-solutions-catalog.js new file mode 100644 index 00000000..afce17c4 --- /dev/null +++ b/src/helpers/get-solutions-catalog.js @@ -0,0 +1,155 @@ +'use strict' + +/** + * Reads the solutions catalog that the solutions-catalog extension writes at + * build time as the `solutions-catalog` attribute on the `solutions` component + * version (the same JSON it publishes at /assets/data/solutions.json). + * + * Usage: + * {{#with (get-solutions-catalog) as |catalog|}} + * {{#each catalog.all}}{{> solution-card solution=this}}{{/each}} + * {{/with}} + * + * Returns null when no catalog is present so callers can render nothing. The + * lookup order is the `solutions` component (latest version first), then the + * current page's component version, then a `page-solutions-catalog` page + * attribute, which only the UI preview fixtures use. + * + * Shape of the result: + * { + * generatedAt, siteUrl, + * all: every record, featured first, then newest, then by title; + * deprecated records sort last + * featured: published records flagged featured + * recent: up to 6 published records by lastModified desc + * facets: { categories, technologies, difficulty, platforms } (arrays) + * count: all.length + * json: the catalog serialized for a + {{else}} +
+

No Solutions published yet

+

Solutions appear here as soon as the first one is published.

+
+ {{/with}} + {{> feedback-footer}} + {{> tracking-pixel}} + + + {{> footer}} + + +{{> chat-panel}} + + diff --git a/src/partials/article.hbs b/src/partials/article.hbs index 4ba852b9..3e79720d 100644 --- a/src/partials/article.hbs +++ b/src/partials/article.hbs @@ -285,6 +285,14 @@ {{{suggestedLabs}}} {{/with}} {{/if}} +{{!-- "Build it in practice": Solutions recommended for this Product Docs page + (page-related-solutions from the solutions-catalog extension). Only on + plain default/index layouts; the home and component landing pages and + the solutions component itself have their own treatment. The partial + renders nothing when the attribute is absent or the page opts out. --}} +{{#if (and (or (eq page.layout 'default') (eq page.layout 'index')) (ne page.attributes.role 'home') (ne page.attributes.role 'component-home-v3') (ne page.component.name 'solutions'))}} +{{> solution-recommendations}} +{{/if}} {{#if (and (ne page.attributes.role 'home') (ne page.attributes.role 'component-home-v3'))}} {{> back-to-top}} {{/if}} diff --git a/src/partials/component-home-v3.hbs b/src/partials/component-home-v3.hbs index cf62a4f8..6e7a72ba 100644 --- a/src/partials/component-home-v3.hbs +++ b/src/partials/component-home-v3.hbs @@ -716,6 +716,45 @@ {{/if}} + {{!-- Build it in practice: Solutions for this product. Hand-picked + page-solutions-* slots win when set; otherwise the automatic + page-related-solutions recommendations render (nothing when empty). --}} + {{#if (and page.attributes.solutions-title (or page.attributes.solutions-1-title page.attributes.solutions-2-title page.attributes.solutions-3-title))}} +
+
+

{{{page.attributes.solutions-title}}}

+ {{#if page.attributes.solutions-link}} + Browse all Solutions + {{/if}} +
+ +
+ {{else}} + {{> solution-recommendations}} + {{/if}} + {{!-- Pathway cards section (e.g., "Pick where to start") --}} {{#if page.attributes.pathways-title}}
diff --git a/src/partials/nav-menu-scroll.hbs b/src/partials/nav-menu-scroll.hbs index 299dbc63..ff1497c5 100644 --- a/src/partials/nav-menu-scroll.hbs +++ b/src/partials/nav-menu-scroll.hbs @@ -14,6 +14,8 @@ {{/each}} {{else if (or (eq page.layout 'lab') (eq page.layout 'labs-search'))}} {{> nav-tree-lab}} +{{else if (or (eq page.layout 'solution') (eq page.layout 'solution-step') (eq page.layout 'solutions-home'))}} +{{> nav-tree-solution}} {{else}} {{!-- Standard Antora navigation --}} {{> nav-tree navigation=page.navigation}} diff --git a/src/partials/nav-tree-solution.hbs b/src/partials/nav-tree-solution.hbs new file mode 100644 index 00000000..2867da97 --- /dev/null +++ b/src/partials/nav-tree-solution.hbs @@ -0,0 +1,111 @@ +{{!-- + Sidebar for the solutions component. Derived entirely from build-time + metadata, never from a nav.adoc: + - on a solution page: Solutions home, Overview, the ordered steps (with + tick slots that 28-solution-progress.js fills), Build-along files, and + Related docs, all from page-solution-nav and page-solution + - on the landing page (or any page without page-solution-nav): Solutions + home plus every solution in the catalog +--}} + diff --git a/src/partials/solution-access-gate.hbs b/src/partials/solution-access-gate.hbs new file mode 100644 index 00000000..d5116c3e --- /dev/null +++ b/src/partials/solution-access-gate.hbs @@ -0,0 +1,12 @@ +{{!-- + Sign-in nudge under the authenticated download. Context: `solution`. + 28-solution-progress.js hides it once the reader is signed in, swaps the text + for "Saved on this device" (and hides the download CTA) when the docs account + backend is unavailable, and opens the shared sign-in modal on click with a + download intent so the reader lands back here with the file starting. +--}} +
+ + Sign in to save progress and download the complete example. + +
diff --git a/src/partials/solution-card.hbs b/src/partials/solution-card.hbs new file mode 100644 index 00000000..fc91b9fa --- /dev/null +++ b/src/partials/solution-card.hbs @@ -0,0 +1,44 @@ +{{!-- + One catalog card. Context: `solution` (a normalized catalog record). + The data-* attributes are what 29-solutions-home.js filters on, so they hold + the raw facet values joined with "|" rather than display labels. +--}} + +
+ {{#if solution.featured}}Featured Solution{{else}}Solution{{/if}} + {{#if (eq solution.status 'deprecated')}}Deprecated{{/if}} +
+

{{solution.title}}

+ {{#if solution.description}}

{{solution.description}}

{{/if}} +
+
+ {{#if solution.difficulty}}{{solution.difficulty}}{{/if}} + {{#if solution.duration}} + + + {{format-duration solution.duration}} + + {{/if}} + {{#if solution.stepCount}}{{solution.stepCount}} steps{{/if}} +
+ {{#if solution.technologies.length}} +
+ {{#each solution.technologies}}{{#if (lt @index 3)}}{{this}}{{/if}}{{/each}} +
+ {{/if}} +
+ +
diff --git a/src/partials/solution-code-download.hbs b/src/partials/solution-code-download.hbs new file mode 100644 index 00000000..f30cc24b --- /dev/null +++ b/src/partials/solution-code-download.hbs @@ -0,0 +1,46 @@ +{{!-- + "Get the code" panel for the rail. Context: `solution`. + + Knowledge is never gated: the tutorial shows every snippet and the public + build-along files below are enough to complete every step. The download is a + shortcut to the complete bundle, served by the docs-site /solutions/download + function, which owns the sign-in redirect for `authenticated` solutions. The + repository itself is private and its URL is deliberately never rendered here. +--}} +{{#unless (eq solution.download 'none')}} +
+

Get the code

+

Build this Solution yourself using the tutorial, or download the complete working example.

+ + + Download the complete example + + {{#if solution.asset}} +

{{solution.asset}}

+ {{/if}} + {{#if (eq solution.download 'authenticated')}} + {{> solution-access-gate}} + {{/if}} +
+{{/unless}} +{{#if solution.attachments.length}} +
+

Build-along files

+

Everything you need to follow the steps without the download.

+ +
+{{/if}} diff --git a/src/partials/solution-hero.hbs b/src/partials/solution-hero.hbs new file mode 100644 index 00000000..770083a9 --- /dev/null +++ b/src/partials/solution-hero.hbs @@ -0,0 +1,46 @@ +{{!-- + Solution overview hero. Context: `solution` (parsed page-solution record). + The Start CTA points at the first step; 28-solution-progress.js turns it into + "Continue" (pointing at the reader's current step) when local progress exists. +--}} +
+
+ Solution + {{#if (eq solution.status 'deprecated')}} + Deprecated + {{else if (eq solution.status 'draft')}} + Draft + {{/if}} + {{#if solution.featured}} + Featured + {{/if}} +
+ {{#with @root.page.title}} +

{{{this}}}

+ {{/with}} + {{#if solution.description}} +

{{solution.description}}

+ {{/if}} + {{#if (eq solution.status 'deprecated')}} +
+ This Solution is no longer maintained. + {{#if solution.supersededBy}} + {{#if solution.supersededBy.url}} + See {{solution.supersededBy.title}} instead. + {{/if}} + {{/if}} +
+ {{/if}} +
+ {{#with solution.steps.[0]}} + + Start building + + + {{/with}} + {{#if solution.steps.length}} + See the steps + {{/if}} + +
+
diff --git a/src/partials/solution-meta.hbs b/src/partials/solution-meta.hbs new file mode 100644 index 00000000..453c773a --- /dev/null +++ b/src/partials/solution-meta.hbs @@ -0,0 +1,53 @@ +{{!-- + Solution metadata strip: difficulty, duration, step count, platforms, + technologies, version. Context: `solution`. +--}} +
+ {{#if solution.difficulty}} +
+
Difficulty
+
{{solution.difficulty}}
+
+ {{/if}} + {{#if solution.duration}} +
+
Time
+
+ + {{format-duration solution.duration}} +
+
+ {{/if}} + {{#if solution.steps.length}} +
+
Steps
+
{{solution.steps.length}}
+
+ {{/if}} + {{#if solution.platforms.length}} +
+
Runs on
+
+ {{#each solution.platforms}} + {{#if (eq this 'self-managed')}}Self-Managed{{else if (eq this 'cloud')}}Cloud{{else}}{{this}}{{/if}} + {{/each}} +
+
+ {{/if}} + {{#if solution.technologies.length}} +
+
Technologies
+
+ {{#each solution.technologies}} + {{this}} + {{/each}} +
+
+ {{/if}} + {{#if solution.version}} +
+
Version
+
{{solution.version}}
+
+ {{/if}} +
diff --git a/src/partials/solution-progress.hbs b/src/partials/solution-progress.hbs new file mode 100644 index 00000000..1abcddfa --- /dev/null +++ b/src/partials/solution-progress.hbs @@ -0,0 +1,51 @@ +{{!-- + Progress panel for the sticky rail. Context: `solution`. + Everything here is driven by 28-solution-progress.js: the bar, the "n of M" + count, the state line, the save control and its sync state, the + version-changed notice, and the done panel. Server-rendered text is the + anonymous, nothing-started baseline so the panel reads correctly without JS. +--}} +
+
+

Your progress

+ 0 of {{solution.steps.length}} +
+
+
+
+

Not started

+ {{!-- Compact step list: the id/url pairs 28-solution-progress.js needs on + every solution page (the overview's big list lives in solution-steps). + Shown in the rail on step pages only; see solutions.css. --}} +
    + {{#each solution.steps}} +
  1. + + + {{title}} + + +
  2. + {{/each}} +
+ +
+ + +
+ +
diff --git a/src/partials/solution-recommendations.hbs b/src/partials/solution-recommendations.hbs new file mode 100644 index 00000000..bb6dc712 --- /dev/null +++ b/src/partials/solution-recommendations.hbs @@ -0,0 +1,46 @@ +{{!-- + "Build it in practice": Solutions recommended for a Product Docs page. + + Data: page-related-solutions, a JSON array the solutions-catalog extension + writes only when there is at least one edge above threshold: + [{id, title, url, description, difficulty, duration, technologies, + provenance, score, reason}] + + Guards, in order: + - :page-exclude-related-solutions: on the page opts out (any value, even + empty, since Antora stores a valueless attribute as ''). + - parse-json returns [] for a missing or malformed attribute. + - An empty list renders nothing at all (no heading, no wrapper). + At most 3 cards render. Each carries data-provenance and its position for + analytics; the "Why this?" reason ships hidden and 28-solution-progress.js + reveals it when localStorage['docs-debug-recs']==='1' or ?debug=recs is set. +--}} +{{#if (eq page.attributes.exclude-related-solutions undefined)}} +{{#with (parse-json page.attributes.related-solutions) as |recs|}} +{{#if recs.length}} +
+
+

Build it in practice

+

Complete, runnable Solutions that use what this page covers.

+
+ +
+{{/if}} +{{/with}} +{{/if}} diff --git a/src/partials/solution-related-docs.hbs b/src/partials/solution-related-docs.hbs new file mode 100644 index 00000000..752d873b --- /dev/null +++ b/src/partials/solution-related-docs.hbs @@ -0,0 +1,35 @@ +{{!-- + Related Product Docs and related Solutions from the content graph. + Context: `solution`. Each doc link carries its provenance (explicit, + editor-approved, category) so analytics can tell curated from derived edges. +--}} +{{#if solution.relatedDocs.length}} + +{{/if}} +{{#if solution.relatedSolutions.length}} + +{{/if}} diff --git a/src/partials/solution-step-actions.hbs b/src/partials/solution-step-actions.hbs new file mode 100644 index 00000000..22d4a70e --- /dev/null +++ b/src/partials/solution-step-actions.hbs @@ -0,0 +1,40 @@ +{{!-- + Step page footer actions: explicit mark-complete (the only way a step is + ever marked), save, and prev/next from page-solution-prev/next-url/title. + Context: `solution`. +--}} + diff --git a/src/partials/solution-step-header.hbs b/src/partials/solution-step-header.hbs new file mode 100644 index 00000000..d447a06e --- /dev/null +++ b/src/partials/solution-step-header.hbs @@ -0,0 +1,45 @@ +{{!-- + Step page header. Context: `solution` plus the step-page attributes the + solutions-catalog extension sets (page-solution-step-index/count, and the + scalar mirrors page-solution-difficulty/duration). +--}} +{{#with (get-solution-step solution) as |step|}} +{{!-- Inside this block the context is the step; ../solution is the partial's own context. --}} +
+
+ + + {{../solution.title}} + + + Step {{@root.page.attributes.solution-step-index}} of {{@root.page.attributes.solution-step-count}} +
+ {{#with @root.page.title}} +

{{{this}}}

+ {{/with}} +
+ {{#if step.duration}} + + + About {{format-duration step.duration}} + + {{/if}} + {{#if @root.page.attributes.solution-difficulty}} + {{@root.page.attributes.solution-difficulty}} + {{/if}} + +
+
+{{else}} +
+ + {{#with @root.page.title}} +

{{{this}}}

+ {{/with}} +
+{{/with}} diff --git a/src/partials/solution-steps.hbs b/src/partials/solution-steps.hbs new file mode 100644 index 00000000..d750497b --- /dev/null +++ b/src/partials/solution-steps.hbs @@ -0,0 +1,27 @@ +{{!-- + Ordered step list on the overview. Context: `solution`. + Each item carries data-sol-step-id so 28-solution-progress.js can tick + completed steps and highlight the reader's current one. +--}} +{{#if solution.steps.length}} +
+

Steps

+
    + {{#each solution.steps}} +
  1. + + + + {{title}} + {{#if duration}}{{format-duration duration}}{{/if}} + + + + +
  2. + {{/each}} +
+
+{{/if}} diff --git a/src/partials/solutions-filters.hbs b/src/partials/solutions-filters.hbs new file mode 100644 index 00000000..eec601b3 --- /dev/null +++ b/src/partials/solutions-filters.hbs @@ -0,0 +1,53 @@ +{{!-- + Landing page filters. Context: `catalog` from get-solutions-catalog. + Rendered as
so it can collapse on narrow screens; on desktop the + summary is hidden and 29-solutions-home.js keeps it open. Filtering is + client-side over the embedded catalog JSON with URL state + (?q=&category=&difficulty=&tech=&platform=). +--}} +
+ + Filters + + + +
diff --git a/src/partials/solutions-grid.hbs b/src/partials/solutions-grid.hbs new file mode 100644 index 00000000..31ba2e68 --- /dev/null +++ b/src/partials/solutions-grid.hbs @@ -0,0 +1,21 @@ +{{!-- + Landing page results. Context: `catalog` from get-solutions-catalog. + Every card is rendered at build time (SEO-visible, works without Algolia); + 29-solutions-home.js only shows and hides them. +--}} +
+
+

{{catalog.count}} {{#if (eq catalog.count 1)}}Solution{{else}}Solutions{{/if}}

+
+
+ {{#each catalog.all}} + {{> solution-card solution=this}} + {{/each}} +
+ +
From 580f86e56fc4ddcc673720fd774f75e59e19b232 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Sun, 13 Sep 2026 08:00:52 +0100 Subject: [PATCH 003/142] Style Solutions pages, cards, recommendations and authoring roles --- src/css/site.css | 1 + src/css/solutions.css | 1605 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1606 insertions(+) create mode 100644 src/css/solutions.css diff --git a/src/css/site.css b/src/css/site.css index 06e8c60f..f2e7623e 100644 --- a/src/css/site.css +++ b/src/css/site.css @@ -55,4 +55,5 @@ @import "chat-panel.css"; @import "product-switcher.css"; @import "labs-home.css"; +@import "solutions.css"; @import "hero-light-mode.css"; diff --git a/src/css/solutions.css b/src/css/solutions.css new file mode 100644 index 00000000..dcd0e800 --- /dev/null +++ b/src/css/solutions.css @@ -0,0 +1,1605 @@ +/* ============================================================================= + Solutions (component `solutions`): landing page, overview, step pages, the + progress rail, and the "Build it in practice" recommendations that render + on Product Docs pages. + + Prefix: sol-. Tokens only, from vars.css: --component-* (the component's + signature hue, remapped per product by body[data-component-color]), + --grey-*-new, --radius-*-new, --shadow-*-new, --font-display. Dark mode via + html[data-theme=dark]. Narrow screens: the rail and the filters are +
that collapse, and every grid drops to one column. + ============================================================================= */ + +/* ---- shared primitives ---------------------------------------------------- */ + +.sol-doc .visually-hidden, +.sol-filters .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.sol-section-title { + font-family: var(--font-display); + font-size: calc(22 / var(--rem-base) * 1rem); + font-weight: 600; + letter-spacing: -0.01em; + margin: 0 0 0.75rem; +} + +.sol-eyebrow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + font-size: calc(12 / var(--rem-base) * 1rem); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--component-600); + margin-bottom: 0.75rem; +} + +.sol-eyebrow-link { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--component-600); + text-decoration: none; + text-transform: none; + letter-spacing: 0; + font-weight: 500; +} + +.sol-eyebrow-link:hover { + text-decoration: underline; +} + +.sol-eyebrow-sep { + color: var(--grey-300-new); +} + +.sol-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: calc(12 / var(--rem-base) * 1rem); + font-weight: 500; + line-height: 1.4; + padding: 2px 8px; + border-radius: var(--radius-full-new); + background: var(--grey-50-new); + color: var(--grey-700-new); + border: 1px solid var(--grey-100-new); + text-transform: none; + letter-spacing: 0; +} + +.sol-chip--tech { + background: var(--component-bg-tint); + border-color: transparent; + color: var(--component-700); +} + +.sol-chip--status.is-deprecated { + background: rgba(226, 67, 40, 0.1); + color: #b1351c; + border-color: transparent; +} + +.sol-chip--status.is-draft { + background: rgba(234, 179, 8, 0.12); + color: #a16207; + border-color: transparent; +} + +.sol-chip--status.is-featured { + background: var(--component-600); + color: #fff; + border-color: transparent; +} + +.sol-chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +/* Difficulty chip: the labs-card-diff recipe. */ +.sol-diff { + display: inline-block; + font-size: calc(12 / var(--rem-base) * 1rem); + font-weight: 500; + line-height: 1.4; + text-transform: capitalize; + padding: 3px 8px; + border-radius: 4px; + background: var(--grey-100-new); + color: var(--grey-600-new); +} + +.sol-diff.is-beginner { + background: rgba(34, 197, 94, 0.1); + color: #15803d; +} + +.sol-diff.is-intermediate { + background: rgba(234, 179, 8, 0.12); + color: #a16207; +} + +.sol-diff.is-advanced { + background: rgba(226, 67, 40, 0.1); + color: #b1351c; +} + +.sol-time, +.sol-fact { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: calc(12 / var(--rem-base) * 1rem); + color: var(--grey-500-new); + white-space: nowrap; +} + +.sol-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font-family: inherit; + font-size: calc(14 / var(--rem-base) * 1rem); + font-weight: 600; + line-height: 1.2; + padding: 0.625rem 1rem; + border-radius: var(--radius-md-new); + border: 1px solid var(--grey-200-new); + background: #fff; + color: var(--grey-800-new); + text-decoration: none; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.sol-btn:hover { + background: var(--grey-50-new); + border-color: var(--grey-300-new); + text-decoration: none; +} + +.sol-btn:focus-visible { + outline: 2px solid var(--component-500); + outline-offset: 2px; +} + +.sol-btn--primary { + background: var(--component-600); + border-color: var(--component-600); + color: #fff; +} + +.sol-btn--primary:hover { + background: var(--component-700); + border-color: var(--component-700); + color: #fff; +} + +.sol-btn--ghost { + background: transparent; + border-color: transparent; + color: var(--component-700); +} + +.sol-btn--ghost:hover { + background: var(--component-bg-tint); + border-color: transparent; +} + +.sol-btn--sm { + font-size: calc(13 / var(--rem-base) * 1rem); + padding: 0.5rem 0.75rem; +} + +.sol-btn[aria-pressed="true"] { + background: var(--component-50); + border-color: var(--component-200); + color: var(--component-700); +} + +.sol-btn[hidden] { + display: none; +} + +.sol-link-btn { + font-family: inherit; + font-size: calc(13 / var(--rem-base) * 1rem); + font-weight: 600; + color: var(--component-700); + background: none; + border: 0; + padding: 0; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.sol-bar { + height: 6px; + border-radius: var(--radius-full-new); + background: var(--grey-100-new); + overflow: hidden; +} + +.sol-bar--sm { + height: 4px; +} + +.sol-bar-fill { + height: 100%; + width: 0; + border-radius: inherit; + background: var(--component-500); + transition: width 0.3s ease; +} + +.sol-panel { + padding: 1rem; + border: 1px solid var(--grey-100-new); + border-radius: var(--radius-lg-new); + background: #fff; + box-shadow: var(--shadow-sm-new); +} + +.sol-panel + .sol-panel { + margin-top: 0.75rem; +} + +.sol-panel-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.sol-panel-title { + font-family: var(--font-display); + font-size: calc(14 / var(--rem-base) * 1rem); + font-weight: 600; + margin: 0 0 0.375rem; + color: var(--grey-900-new); +} + +.sol-panel-head .sol-panel-title { + margin: 0; +} + +.sol-panel-text { + font-size: calc(13 / var(--rem-base) * 1rem); + line-height: 1.5; + color: var(--grey-600-new); + margin: 0 0 0.75rem; +} + +/* ---- overview + step page shell ------------------------------------------- */ + +.doc.sol-doc { + hyphens: manual; +} + +@media (min-width: 1024px) { + .doc.sol-doc { + max-width: 1200px; + margin-right: 48px; + } +} + +.sol-hero { + padding: 0.5rem 0 1.25rem; + border-bottom: 1px solid var(--grey-100-new); + margin-bottom: 1.25rem; +} + +.sol-hero .sol-title, +.sol-step-header .sol-title { + font-family: var(--font-display); + font-size: calc(36 / var(--rem-base) * 1rem); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.15; + margin: 0 0 0.75rem; +} + +.sol-lede { + font-size: calc(18 / var(--rem-base) * 1rem); + line-height: 1.55; + color: var(--grey-600-new); + max-width: 52rem; + margin: 0 0 1.25rem; +} + +.sol-hero--home .sol-lede { + margin-bottom: 0.5rem; +} + +.sol-banner { + padding: 0.75rem 1rem; + border-radius: var(--radius-md-new); + margin: 0 0 1.25rem; + font-size: calc(14 / var(--rem-base) * 1rem); +} + +.sol-banner--deprecated { + background: rgba(226, 67, 40, 0.08); + border: 1px solid rgba(226, 67, 40, 0.25); + color: #7f2a1a; +} + +.sol-hero-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.sol-hero-progress { + font-size: calc(13 / var(--rem-base) * 1rem); + color: var(--grey-500-new); +} + +.sol-meta { + display: flex; + flex-wrap: wrap; + gap: 1rem 2rem; + margin: 0 0 2rem; + padding: 1rem 1.25rem; + border-radius: var(--radius-lg-new); + background: var(--component-bg-tint); +} + +.sol-meta-item { + min-width: 0; +} + +.sol-meta-item--wide { + flex: 1 1 100%; +} + +.sol-meta dt { + font-size: calc(11 / var(--rem-base) * 1rem); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--grey-500-new); + margin: 0 0 0.25rem; +} + +.sol-meta dd { + display: flex; + align-items: center; + gap: 0.375rem; + margin: 0; + font-size: calc(14 / var(--rem-base) * 1rem); + color: var(--grey-800-new); +} + +.sol-version { + font-size: calc(13 / var(--rem-base) * 1rem); +} + +.sol-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 2.5rem; + align-items: start; +} + +.sol-main { + min-width: 0; +} + +.sol-rail { + position: sticky; + top: calc(var(--body-top, 4rem) + 1rem); + min-width: 0; +} + +.sol-rail-details > .sol-rail-summary { + display: none; +} + +.sol-rail-summary { + cursor: pointer; + font-weight: 600; + padding: 0.75rem 1rem; + border: 1px solid var(--grey-100-new); + border-radius: var(--radius-lg-new); + background: #fff; + list-style: none; +} + +.sol-rail-summary::-webkit-details-marker { + display: none; +} + +.sol-rail-summary::after { + content: ""; + float: right; + width: 0.5rem; + height: 0.5rem; + margin-top: 0.4rem; + border-right: 2px solid var(--grey-500-new); + border-bottom: 2px solid var(--grey-500-new); + transform: rotate(45deg); +} + +.sol-rail-details[open] > .sol-rail-summary::after { + transform: rotate(-135deg); + margin-top: 0.6rem; +} + +/* ---- steps (overview) ----------------------------------------------------- */ + +.sol-steps { + margin: 2.5rem 0; +} + +.sol-step-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.5rem; +} + +.sol-step-link { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.875rem; + padding: 0.75rem 1rem; + border: 1px solid var(--grey-100-new); + border-radius: var(--radius-lg-new); + background: #fff; + color: inherit; + text-decoration: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.sol-step-link:hover { + border-color: var(--component-200); + box-shadow: var(--shadow-sm-new); + text-decoration: none; +} + +.sol-step-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius-full-new); + background: var(--component-bg-tint); + color: var(--component-700); + font-size: calc(13 / var(--rem-base) * 1rem); + font-weight: 700; +} + +.sol-step-body { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.25rem 0.75rem; + min-width: 0; +} + +.sol-step-title { + font-weight: 600; + color: var(--grey-900-new); +} + +.sol-step-time { + font-size: calc(12 / var(--rem-base) * 1rem); + color: var(--grey-500-new); +} + +.sol-step-check, +.sol-progress-step-check, +.sol-nav-step-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: var(--radius-full-new); + border: 1.5px solid var(--grey-200-new); + color: transparent; + flex: none; +} + +.is-complete > .sol-step-link .sol-step-check, +.is-complete .sol-progress-step-check, +.is-complete .sol-nav-step-check { + background: var(--component-600); + border-color: var(--component-600); + color: #fff; +} + +.is-current > .sol-step-link { + border-color: var(--component-500); +} + +.is-current > .sol-step-link .sol-step-num { + background: var(--component-600); + color: #fff; +} + +/* ---- progress panel ------------------------------------------------------- */ + +.sol-progress-count { + font-size: calc(13 / var(--rem-base) * 1rem); + font-weight: 600; + color: var(--grey-700-new); + white-space: nowrap; +} + +.sol-progress-state { + font-size: calc(13 / var(--rem-base) * 1rem); + color: var(--grey-500-new); + margin: 0.5rem 0 0; +} + +.sol-progress-steps { + display: none; + list-style: none; + margin: 0.75rem 0 0; + padding: 0; +} + +.solution-step .sol-progress-steps { + display: block; +} + +.sol-progress-step a { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 0; + font-size: calc(13 / var(--rem-base) * 1rem); + color: var(--grey-700-new); + text-decoration: none; +} + +.sol-progress-step a:hover { + color: var(--component-700); +} + +.sol-progress-step.is-current a { + color: var(--component-700); + font-weight: 600; +} + +.sol-progress-step .sol-progress-step-check { + width: 1rem; + height: 1rem; +} + +.sol-progress-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.875rem; +} + +.sol-sync-state { + font-size: calc(12 / var(--rem-base) * 1rem); + color: var(--grey-500-new); +} + +.sol-sync-state.is-error { + color: #b1351c; +} + +.sol-version-notice { + margin-top: 0.875rem; + padding: 0.625rem 0.75rem; + border-radius: var(--radius-md-new); + background: rgba(234, 179, 8, 0.12); + border: 1px solid rgba(234, 179, 8, 0.35); + font-size: calc(13 / var(--rem-base) * 1rem); + color: var(--grey-800-new); +} + +.sol-version-notice p { + margin: 0 0 0.375rem; +} + +.sol-done { + margin-top: 0.875rem; + padding: 0.75rem; + border-radius: var(--radius-md-new); + background: var(--component-50); + border: 1px solid var(--component-200); +} + +.sol-done-title { + font-weight: 700; + color: var(--component-700); + margin: 0 0 0.25rem; +} + +.sol-done-sub { + font-size: calc(13 / var(--rem-base) * 1rem); + color: var(--grey-600-new); + margin: 0 0 0.625rem; +} + +/* ---- download + files ----------------------------------------------------- */ + +.sol-download .sol-btn { + width: 100%; +} + +.sol-download-asset { + margin: 0.5rem 0 0; + font-size: calc(12 / var(--rem-base) * 1rem); + color: var(--grey-500-new); +} + +.sol-gate { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem 0.5rem; + margin-top: 0.75rem; + padding: 0.5rem 0.625rem; + border-radius: var(--radius-md-new); + background: var(--grey-50-new); + font-size: calc(12 / var(--rem-base) * 1rem); + color: var(--grey-600-new); +} + +.sol-gate-icon { + color: var(--grey-500-new); + flex: none; +} + +.sol-gate-text { + flex: 1 1 12rem; +} + +.sol-file-list { + list-style: none; + margin: 0; + padding: 0; +} + +.sol-file-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0; + font-size: calc(13 / var(--rem-base) * 1rem); + color: var(--grey-700-new); + text-decoration: none; + word-break: break-all; +} + +.sol-file-link:hover { + color: var(--component-700); +} + +.sol-file-link svg { + color: var(--grey-400-new); + flex: none; +} + +/* ---- step page ------------------------------------------------------------ */ + +.sol-step-header { + padding: 0.5rem 0 1rem; + border-bottom: 1px solid var(--grey-100-new); + margin-bottom: 1.5rem; +} + +.sol-step-facts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.sol-fact--status { + color: var(--component-700); + font-weight: 600; +} + +.sol-step-actions { + margin: 2.5rem 0 1rem; + padding-top: 1.5rem; + border-top: 1px solid var(--grey-100-new); + display: grid; + gap: 1rem; +} + +.sol-step-actions-main, +.sol-step-actions-nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.sol-step-actions-nav { + justify-content: space-between; +} + +.sol-step-nav { + text-align: left; +} + +.sol-step-nav-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.sol-step-nav-kicker { + font-size: calc(11 / var(--rem-base) * 1rem); + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--grey-500-new); +} + +.sol-step-nav--next { + margin-left: auto; + text-align: right; +} + +.sol-step-nav--next .sol-step-nav-text { + align-items: flex-end; +} + +/* Authoring roles */ +.sol-doc .solution-verify, +.sol-doc .production-note { + margin: 1.5rem 0; + padding: 1rem 1.25rem 1rem 1.5rem; + border-radius: var(--radius-lg-new); + border: 1px solid var(--grey-100-new); + border-left: 4px solid var(--component-500); + background: var(--component-bg-tint); +} + +/* Asciidoctor puts a sidebar block's title inside .content; an open block's + title sits directly under the block. Match both. */ +.sol-doc .solution-verify > .title, +.sol-doc .production-note > .title, +.sol-doc .solution-verify > .content > .title, +.sol-doc .production-note > .content > .title { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-display); + font-size: calc(13 / var(--rem-base) * 1rem); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--component-700); + margin-bottom: 0.5rem; +} + +.sol-doc .solution-verify > .title::before, +.sol-doc .production-note > .title::before, +.sol-doc .solution-verify > .content > .title::before, +.sol-doc .production-note > .content > .title::before { + content: ""; + width: 0.5rem; + height: 0.5rem; + border-radius: var(--radius-full-new); + background: var(--component-500); +} + +.sol-doc .production-note { + border-left-color: var(--grey-500-new); + background: var(--grey-50-new); +} + +.sol-doc .production-note > .title, +.sol-doc .production-note > .content > .title { + color: var(--grey-700-new); +} + +.sol-doc .production-note > .title::before, +.sol-doc .production-note > .content > .title::before { + background: var(--grey-500-new); +} + +.sol-doc .solution-verify > :last-child, +.sol-doc .production-note > :last-child, +.sol-doc .solution-verify > .content > :last-child, +.sol-doc .production-note > .content > :last-child { + margin-bottom: 0; +} + +/* ---- related --------------------------------------------------------------- */ + +.sol-related { + margin: 2.5rem 0; +} + +.sol-related-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.375rem; +} + +.sol-related-link { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--grey-800-new); + text-decoration: none; + padding: 0.25rem 0; +} + +.sol-related-link svg { + color: var(--component-600); + flex: none; +} + +.sol-related-link:hover { + color: var(--component-700); + text-decoration: underline; +} + +/* ---- recommendations on Product Docs -------------------------------------- */ + +.sol-recs { + margin: 3rem 0 1rem; + padding: 1.5rem; + border-radius: var(--radius-xl-new); + border: 1px solid var(--grey-100-new); + background: var(--component-bg-tint); +} + +.sol-recs-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.25rem 1rem; + margin-bottom: 1rem; +} + +.sol-recs-title { + font-family: var(--font-display); + font-size: calc(20 / var(--rem-base) * 1rem); + font-weight: 600; + letter-spacing: -0.01em; + margin: 0; +} + +.sol-recs-sub { + flex: 1 1 100%; + font-size: calc(14 / var(--rem-base) * 1rem); + color: var(--grey-600-new); + margin: 0; +} + +.sol-recs-grid, +.sol-continue-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.sol-rec-card { + display: flex; + flex-direction: column; + gap: 0.375rem; + padding: 1rem; + border-radius: var(--radius-lg-new); + border: 1px solid var(--grey-100-new); + background: #fff; + color: inherit; + text-decoration: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} + +.sol-rec-card:hover { + border-color: var(--component-200); + box-shadow: var(--shadow-md-new); + transform: translateY(-1px); + text-decoration: none; +} + +.sol-rec-eyebrow, +.sol-card-eyebrow { + font-size: calc(11 / var(--rem-base) * 1rem); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--component-600); +} + +.sol-rec-title { + font-family: var(--font-display); + font-size: calc(15 / var(--rem-base) * 1rem); + font-weight: 600; + color: var(--grey-900-new); + line-height: 1.35; +} + +.sol-rec-desc { + font-size: calc(13 / var(--rem-base) * 1rem); + line-height: 1.5; + color: var(--grey-600-new); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.sol-rec-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + margin-top: auto; + padding-top: 0.25rem; +} + +.sol-rec-why { + font-size: calc(12 / var(--rem-base) * 1rem); + color: var(--grey-500-new); + font-style: italic; + border-top: 1px dashed var(--grey-200-new); + padding-top: 0.375rem; +} + +.sol-recs--home { + margin-top: 0; +} + +/* ---- landing page ---------------------------------------------------------- */ + +.sol-home-layout { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + gap: 2rem; + align-items: start; + margin-top: 1.5rem; +} + +.sol-filters { + position: sticky; + top: calc(var(--body-top, 4rem) + 1rem); +} + +.sol-filters-summary { + display: none; + cursor: pointer; + font-weight: 600; + padding: 0.625rem 0.875rem; + border: 1px solid var(--grey-100-new); + border-radius: var(--radius-lg-new); + background: #fff; + list-style: none; +} + +.sol-filters-summary::-webkit-details-marker { + display: none; +} + +.sol-filters-active { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + margin-left: 0.5rem; + padding: 0 0.375rem; + border-radius: var(--radius-full-new); + background: var(--component-600); + color: #fff; + font-size: calc(11 / var(--rem-base) * 1rem); +} + +.sol-filters-body { + display: grid; + gap: 1.25rem; +} + +.sol-search { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0 0.75rem; + border: 1px solid var(--grey-200-new); + border-radius: var(--radius-md-new); + background: #fff; + color: var(--grey-500-new); +} + +.sol-search:focus-within { + border-color: var(--component-500); + box-shadow: 0 0 0 3px var(--component-bg-tint-active); +} + +.sol-search input { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + padding: 0.625rem 0; + font: inherit; + font-size: calc(14 / var(--rem-base) * 1rem); + color: var(--grey-900-new); + outline: none; +} + +.sol-filter-group { + border: 0; + margin: 0; + padding: 0; + min-width: 0; +} + +.sol-filter-title { + font-size: calc(11 / var(--rem-base) * 1rem); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--grey-500-new); + padding: 0; + margin-bottom: 0.5rem; +} + +.sol-filter-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; + font-size: calc(14 / var(--rem-base) * 1rem); + color: var(--grey-700-new); + cursor: pointer; +} + +.sol-filter-row input { + margin: 0; +} + +.sol-results-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.sol-results-count { + margin: 0; + font-size: calc(13 / var(--rem-base) * 1rem); + color: var(--grey-500-new); +} + +.sol-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; +} + +.sol-card { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1.25rem; + border-radius: var(--radius-xl-new); + border: 1px solid var(--grey-100-new); + background: #fff; + color: inherit; + text-decoration: none; + box-shadow: var(--shadow-sm-new); + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} + +.sol-card:hover { + border-color: var(--component-200); + box-shadow: var(--shadow-lg-new); + transform: translateY(-2px); + text-decoration: none; +} + +.sol-card.is-featured { + border-color: var(--component-200); + background: linear-gradient(180deg, var(--component-bg-tint), #fff 40%); +} + +.sol-card.is-deprecated { + opacity: 0.75; +} + +.sol-card[hidden] { + display: none; +} + +.sol-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.sol-card-title { + font-family: var(--font-display); + font-size: calc(18 / var(--rem-base) * 1rem); + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.3; + margin: 0; + color: var(--grey-900-new); +} + +.sol-card-desc { + font-size: calc(14 / var(--rem-base) * 1rem); + line-height: 1.5; + color: var(--grey-600-new); + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.sol-card-foot { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: auto; + padding-top: 0.5rem; +} + +.sol-card-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.sol-card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.sol-card-progress { + display: flex; + align-items: center; + gap: 0.5rem; + padding-top: 0.25rem; +} + +.sol-card-progress .sol-bar { + flex: 1; +} + +.sol-card-progress-text { + font-size: calc(12 / var(--rem-base) * 1rem); + color: var(--grey-500-new); + white-space: nowrap; +} + +.sol-empty { + text-align: center; + padding: 3rem 1rem; + border: 1px dashed var(--grey-200-new); + border-radius: var(--radius-xl-new); + color: var(--grey-600-new); +} + +.sol-empty svg { + color: var(--grey-400-new); +} + +.sol-empty h3 { + font-family: var(--font-display); + font-size: calc(18 / var(--rem-base) * 1rem); + margin: 0.5rem 0 0.25rem; + color: var(--grey-800-new); +} + +.sol-empty p { + margin: 0 0 1rem; +} + +.sol-continue { + margin: 1.5rem 0 0; + padding: 1.25rem; + border-radius: var(--radius-xl-new); + border: 1px solid var(--component-200); + background: var(--component-bg-tint); +} + +.sol-continue .sol-rec-card { + gap: 0.5rem; +} + +/* ---- toast ----------------------------------------------------------------- */ + +.sol-toast { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 1000; + max-width: 340px; + padding: 12px 14px; + border-radius: var(--radius-lg-new); + border-left: 3px solid var(--component-500); + background: var(--grey-900-new); + color: var(--grey-50-new); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28); + font-size: calc(14 / var(--rem-base) * 1rem); + line-height: 1.45; + opacity: 0; + transition: opacity 0.2s ease; +} + +.sol-toast.is-visible { + opacity: 1; +} + +/* ---- sidebar nav ----------------------------------------------------------- */ + +.sol-nav-heading { + font-size: calc(11 / var(--rem-base) * 1rem); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--grey-500-new); +} + +.sol-nav-steps { + list-style: none; + margin: 0; + padding: 0; +} + +.sol-nav-step .nav-link { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.sol-nav-step-num { + flex: none; + width: 1.25rem; + font-size: calc(11 / var(--rem-base) * 1rem); + font-weight: 700; + color: var(--grey-400-new); +} + +.sol-nav-step.is-complete .sol-nav-step-num { + color: var(--component-600); +} + +.sol-nav-step-title { + flex: 1; + min-width: 0; +} + +.sol-nav-step-check { + width: 1rem; + height: 1rem; +} + +/* ---- search result meta ---------------------------------------------------- */ + +.result-solution-meta { + text-transform: capitalize; +} + +/* ---- dark mode ------------------------------------------------------------- */ + +html[data-theme="dark"] .sol-panel, +html[data-theme="dark"] .sol-btn, +html[data-theme="dark"] .sol-step-link, +html[data-theme="dark"] .sol-rec-card, +html[data-theme="dark"] .sol-card, +html[data-theme="dark"] .sol-search, +html[data-theme="dark"] .sol-rail-summary, +html[data-theme="dark"] .sol-filters-summary { + background: var(--grey-800-new); + border-color: var(--grey-700-new); + color: var(--grey-100-new); +} + +html[data-theme="dark"] .sol-card.is-featured { + background: linear-gradient(180deg, var(--component-bg-tint-hover), var(--grey-800-new) 40%); + border-color: var(--component-700); +} + +html[data-theme="dark"] .sol-btn:hover { + background: var(--grey-700-new); + border-color: var(--grey-600-new); +} + +html[data-theme="dark"] .sol-btn--primary { + background: var(--component-600); + border-color: var(--component-600); + color: #fff; +} + +html[data-theme="dark"] .sol-btn--primary:hover { + background: var(--component-500); + border-color: var(--component-500); +} + +html[data-theme="dark"] .sol-btn--ghost { + background: transparent; + border-color: transparent; + color: var(--component-200); +} + +html[data-theme="dark"] .sol-btn--ghost:hover { + background: var(--component-bg-tint-hover); +} + +html[data-theme="dark"] .sol-btn[aria-pressed="true"] { + background: var(--component-bg-tint-active); + border-color: var(--component-700); + color: var(--component-100); +} + +html[data-theme="dark"] .sol-eyebrow, +html[data-theme="dark"] .sol-eyebrow-link, +html[data-theme="dark"] .sol-rec-eyebrow, +html[data-theme="dark"] .sol-card-eyebrow, +html[data-theme="dark"] .sol-link-btn, +html[data-theme="dark"] .sol-done-title, +html[data-theme="dark"] .sol-fact--status, +html[data-theme="dark"] .sol-nav-step.is-complete .sol-nav-step-num, +html[data-theme="dark"] .sol-progress-step.is-current a, +html[data-theme="dark"] .sol-related-link svg { + color: var(--component-200); +} + +html[data-theme="dark"] .sol-title, +html[data-theme="dark"] .sol-panel-title, +html[data-theme="dark"] .sol-step-title, +html[data-theme="dark"] .sol-rec-title, +html[data-theme="dark"] .sol-card-title, +html[data-theme="dark"] .sol-empty h3, +html[data-theme="dark"] .sol-related-link, +html[data-theme="dark"] .sol-meta dd, +html[data-theme="dark"] .sol-search input, +html[data-theme="dark"] .sol-version-notice { + color: var(--grey-100-new); +} + +html[data-theme="dark"] .sol-lede, +html[data-theme="dark"] .sol-panel-text, +html[data-theme="dark"] .sol-rec-desc, +html[data-theme="dark"] .sol-card-desc, +html[data-theme="dark"] .sol-recs-sub, +html[data-theme="dark"] .sol-done-sub, +html[data-theme="dark"] .sol-progress-count, +html[data-theme="dark"] .sol-filter-row, +html[data-theme="dark"] .sol-file-link, +html[data-theme="dark"] .sol-progress-step a, +html[data-theme="dark"] .sol-empty, +html[data-theme="dark"] .sol-gate { + color: var(--grey-300-new); +} + +html[data-theme="dark"] .sol-hero, +html[data-theme="dark"] .sol-step-header, +html[data-theme="dark"] .sol-step-actions { + border-color: var(--grey-700-new); +} + +html[data-theme="dark"] .sol-meta, +html[data-theme="dark"] .sol-recs, +html[data-theme="dark"] .sol-continue, +html[data-theme="dark"] .sol-doc .solution-verify { + background: var(--component-bg-tint-hover); + border-color: var(--grey-700-new); +} + +html[data-theme="dark"] .sol-doc .production-note, +html[data-theme="dark"] .sol-gate { + background: var(--grey-800-new); + border-color: var(--grey-700-new); +} + +html[data-theme="dark"] .sol-doc .solution-verify > .title { + color: var(--component-200); +} + +html[data-theme="dark"] .sol-doc .production-note > .title { + color: var(--grey-300-new); +} + +html[data-theme="dark"] .sol-bar, +html[data-theme="dark"] .sol-step-check, +html[data-theme="dark"] .sol-progress-step-check, +html[data-theme="dark"] .sol-nav-step-check, +html[data-theme="dark"] .sol-empty { + border-color: var(--grey-600-new); +} + +html[data-theme="dark"] .sol-bar { + background: var(--grey-700-new); +} + +html[data-theme="dark"] .sol-step-num { + background: var(--component-bg-tint-active); + color: var(--component-200); +} + +html[data-theme="dark"] .sol-chip { + background: var(--grey-700-new); + border-color: var(--grey-600-new); + color: var(--grey-200-new); +} + +html[data-theme="dark"] .sol-chip--tech { + background: var(--component-bg-tint-active); + border-color: transparent; + color: var(--component-200); +} + +html[data-theme="dark"] .sol-done { + background: var(--component-bg-tint-active); + border-color: var(--component-700); +} + +html[data-theme="dark"] .sol-version-notice { + background: rgba(234, 179, 8, 0.15); + border-color: rgba(234, 179, 8, 0.4); +} + +html[data-theme="dark"] .sol-banner--deprecated { + background: rgba(226, 67, 40, 0.15); + border-color: rgba(226, 67, 40, 0.4); + color: #f87171; +} + +html[data-theme="dark"] .sol-diff { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); +} + +html[data-theme="dark"] .sol-diff.is-beginner { + background: rgba(34, 197, 94, 0.15); + color: #4ade80; +} + +html[data-theme="dark"] .sol-diff.is-intermediate { + background: rgba(234, 179, 8, 0.15); + color: #fbbf24; +} + +html[data-theme="dark"] .sol-diff.is-advanced { + background: rgba(226, 67, 40, 0.15); + color: #f87171; +} + +html[data-theme="dark"] .sol-chip--status.is-deprecated { + background: rgba(226, 67, 40, 0.15); + color: #f87171; +} + +html[data-theme="dark"] .sol-chip--status.is-draft { + background: rgba(234, 179, 8, 0.15); + color: #fbbf24; +} + +html[data-theme="dark"] .sol-toast { + background: var(--grey-100-new); + color: var(--grey-900-new); +} + +/* ---- responsive ------------------------------------------------------------ */ + +@media (max-width: 1024px) { + .sol-layout { + grid-template-columns: minmax(0, 1fr) 260px; + gap: 1.5rem; + } + + .sol-home-layout { + grid-template-columns: 220px minmax(0, 1fr); + gap: 1.5rem; + } +} + +@media (max-width: 768px) { + .sol-layout, + .sol-home-layout { + grid-template-columns: minmax(0, 1fr); + } + + .sol-rail, + .sol-filters { + position: static; + } + + .sol-rail { + order: -1; + } + + .sol-rail-details > .sol-rail-summary, + .sol-filters-summary { + display: block; + } + + .sol-rail-body, + .sol-filters-body { + margin-top: 0.75rem; + } + + .sol-hero .sol-title, + .sol-step-header .sol-title { + font-size: calc(28 / var(--rem-base) * 1rem); + } + + .sol-lede { + font-size: calc(16 / var(--rem-base) * 1rem); + } + + .sol-recs-grid, + .sol-continue-grid, + .sol-grid { + grid-template-columns: minmax(0, 1fr); + } + + .sol-recs { + padding: 1rem; + } + + .sol-step-actions-nav { + flex-direction: column; + align-items: stretch; + } + + .sol-step-nav--next { + margin-left: 0; + } +} + +@media (max-width: 400px) { + .sol-meta { + gap: 0.75rem 1.25rem; + padding: 0.75rem 1rem; + } + + .sol-hero-actions .sol-btn { + width: 100%; + } + + .sol-step-link { + grid-template-columns: auto minmax(0, 1fr); + } + + .sol-step-link .sol-step-check { + display: none; + } + + .sol-toast { + left: 16px; + right: 16px; + max-width: none; + } +} From 8d8292d9cafd2f142a826d4dd93011440f8ede21 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Sun, 13 Sep 2026 08:00:52 +0100 Subject: [PATCH 004/142] Add Solutions progress and landing scripts and let the sign-in modal take custom copy and return path --- src/js/26-docs-account.js | 79 ++- src/js/28-solution-progress.js | 1045 +++++++++++++++++++++++++++++++ src/js/29-solutions-home.js | 248 ++++++++ src/partials/header-content.hbs | 11 +- 4 files changed, 1364 insertions(+), 19 deletions(-) create mode 100644 src/js/28-solution-progress.js create mode 100644 src/js/29-solutions-home.js diff --git a/src/js/26-docs-account.js b/src/js/26-docs-account.js index 55991a2b..9f47dae4 100644 --- a/src/js/26-docs-account.js +++ b/src/js/26-docs-account.js @@ -22,8 +22,37 @@ var modal = container.querySelector('[data-signin-modal]') var modalCta = container.querySelector('[data-signin-modal-continue]') var modalSignup = container.querySelector('[data-signin-modal-signup]') + var modalTitle = modal && modal.querySelector('[data-signin-modal-title]') + var modalLead = modal && modal.querySelector('[data-signin-modal-sub]') + var modalFeatures = modal && modal.querySelector('[data-signin-modal-features]') var nudge = container.querySelector('[data-signin-nudge]') + // Default modal copy, restored on close after another surface (the Solutions + // progress/download gates) opened the modal with its own title, lead, CTA + // label and return_to via the docs-account:open-signin event detail. + var modalDefaults = { + title: modalTitle ? modalTitle.textContent : '', + lead: modalLead ? modalLead.textContent : '', + cta: modalCta ? modalCta.textContent : '', + } + var customReturnTo = null + + function applyModalDetail (detail) { + var custom = !!(detail && (detail.title || detail.lead || detail.cta || detail.returnTo)) + if (modalTitle) modalTitle.textContent = (custom && detail.title) || modalDefaults.title + if (modalLead) modalLead.textContent = (custom && detail.lead) || modalDefaults.lead + if (modalCta && !modalCta.classList.contains('is-loading')) { + modalCta.textContent = (custom && detail.cta) || modalDefaults.cta + } + // The feature list pitches the AI agent; it is noise under a task-specific + // title like "Sign in to save your progress". + if (modalFeatures) modalFeatures.hidden = custom + customReturnTo = custom && typeof detail.returnTo === 'string' && detail.returnTo.charAt(0) === '/' + ? detail.returnTo + : null + applyHrefs() + } + var CACHE_KEY = 'docs-account-me' // First-view nudge, shown once. localStorage (not session) so it doesn't // return in every new tab, and deliberately per-browser: a nudge is not worth @@ -55,6 +84,31 @@ return encodeURIComponent(window.location.pathname + window.location.search) } + // Where the modal's own CTA sends the reader back to: a same-origin path a + // caller supplied through the open-signin detail (for example a Solutions + // step with ?intent=save), else the current page. + function modalReturnTo () { + return customReturnTo ? encodeURIComponent(customReturnTo) : returnTo() + } + + function applyHrefs () { + signinLink.href = '/login?return_to=' + returnTo() + signoutLink.href = '/logout?return_to=' + returnTo() + // disclosed=1: the modal shows the privacy/data-collection note itself, so + // /login can skip the server interstitial (which exists to show that note) + // and go straight to Auth0. The bare signinLink href (middle-click, or no + // modal markup) stays undisclosed and gets the interstitial. + if (modalCta) modalCta.href = '/login?disclosed=1&return_to=' + modalReturnTo() + // "Create a free account" — a same-origin path instead of a hardcoded + // cloud.redpanda.com URL, since docs-ui has no way to know which Cloud + // environment a given deploy (prod vs integration preview) should point + // at. docs-login.mjs's own /signup already resolves that per context and + // 302s straight to the right signup URL. Its own path rather than a + // /login query flag: unlike modalCta above, it shares no state with + // sign-in (no CSRF nonce, no PKCE, no auth-request write). + if (modalSignup) modalSignup.href = '/signup?return_to=' + modalReturnTo() + } + // Feature modal shown before sending the user to /login. // It is an aria-modal dialog, so manage focus: move focus in on open, trap // Tab within it, and restore focus to the opener on close (WCAG 2.4.3). @@ -68,12 +122,16 @@ .filter(function (el) { return el.offsetParent !== null }) // visible only } - function openModal () { + // `e` is the docs-account:open-signin CustomEvent when another surface opened + // the modal; its detail {title, lead, cta, returnTo} customises the copy and + // the sign-in return path. The header link calls this with no argument. + function openModal (e) { if (!modal) return // Covers every entry point, including docs-account:open-signin from the Ask // AI panel, not just the header link's own click handler. retireNudge() warm() + applyModalDetail(e && e.detail) lastFocused = document.activeElement modal.hidden = false document.addEventListener('keydown', onModalKey) @@ -91,6 +149,9 @@ if (!modal) return modal.hidden = true document.removeEventListener('keydown', onModalKey) + // Back to the default pitch and the current-page return path, so a later + // open from the header link doesn't inherit a Solutions intent. + applyModalDetail(null) // Restore focus to whatever opened the modal (the Sign in trigger). if (lastFocused && typeof lastFocused.focus === 'function') lastFocused.focus() lastFocused = null @@ -248,21 +309,7 @@ nudge.hidden = signinLink.hidden || nudgeAlreadySeen() } } - signinLink.href = '/login?return_to=' + returnTo() - signoutLink.href = '/logout?return_to=' + returnTo() - // disclosed=1: the modal shows the privacy/data-collection note itself, so - // /login can skip the server interstitial (which exists to show that note) - // and go straight to Auth0. The bare signinLink href (middle-click, or no - // modal markup) stays undisclosed and gets the interstitial. - if (modalCta) modalCta.href = '/login?disclosed=1&return_to=' + returnTo() - // "Create a free account" — a same-origin path instead of a hardcoded - // cloud.redpanda.com URL, since docs-ui has no way to know which Cloud - // environment a given deploy (prod vs integration preview) should point - // at. docs-login.mjs's own /signup already resolves that per context and - // 302s straight to the right signup URL. Its own path rather than a - // /login query flag: unlike modalCta above, it shares no state with - // sign-in (no CSRF nonce, no PKCE, no auth-request write). - if (modalSignup) modalSignup.href = '/signup?return_to=' + returnTo() + applyHrefs() // Signed in: the console link lives in the account dropdown, so hide the // standalone toolbar/overflow Cloud Console links (avoid two paths) diff --git a/src/js/28-solution-progress.js b/src/js/28-solution-progress.js new file mode 100644 index 00000000..63701d30 --- /dev/null +++ b/src/js/28-solution-progress.js @@ -0,0 +1,1045 @@ +/* global localStorage, sessionStorage, fetch, IntersectionObserver, CustomEvent, URLSearchParams */ +/** + * Solutions progress: local-first, synced for signed-in readers. + * + * Runs on every page (it owns window.docsSolutions, which the landing page + * reads for "Continue learning" and which recommendation cards use for + * analytics) but only wires page UI on solution overview and step pages + * (). + * + * Store (localStorage 'docs-solutions-progress'): + * { v: 1, updatedAt, solutions: { : { + * completedSteps: [stepId], currentStep, startedAt, updatedAt, + * completedAt, solutionVersion } } } + * Timestamps are epoch milliseconds. Storage failures fail closed: reads give + * an empty store, writes are dropped, and the page keeps an in-memory copy so + * the UI still works for the current view. Caps: 50 solutions x 100 steps, + * evicting the least recently updated first. + * + * Rules: + * - Nothing is ever marked complete implicitly. Only the explicit "Mark step + * complete" click does that. Loading a step only records it as current. + * - Completed count = completedSteps that are still step ids of the current + * version. Unknown ids are kept (they may return) but never counted. + * - A stored solutionVersion that differs from the page's version shows the + * version-changed notice once per tab session. + * - Signed in (rp_docs_auth hint cookie): GET /solutions/progress (60 s + * sessionStorage cache); first sign-in in this browser or dirty local state + * PUTs the merged store and the server's answer replaces local. Hint + * false -> true merges; true -> false (sign-out) clears local state. + * - Merge (per solution): ordered union of completedSteps (earlier-updated + * record's steps first, then the later record's additions), max updatedAt, + * min startedAt, currentStep and solutionVersion from the later record + * (falling back to the other when null), completedAt = earliest unless the + * two versions differ, in which case the later record's value. Ties on + * updatedAt treat the first argument (local) as the later record. The same + * rules run server-side; tests/solution-progress/fixtures/merge-vectors.json + * is the shared contract. + * - Gates exist only on Save and on the authenticated download. Anonymous + * readers can start, mark steps, and see progress on this device. + * - Analytics: track(name, props) -> window.heap.track when present, and + * window.dataLayer.push always. No PII. + */ +;(function () { + 'use strict' + + var STORE_KEY = 'docs-solutions-progress' + var HINT_KEY = 'docs-solutions-progress-hint' + var DIRTY_KEY = 'docs-solutions-progress-dirty' + var REMOTE_CACHE_KEY = 'docs-solutions-progress-remote' + var PENDING_KEY = 'docs-solutions-pending-intent' + var ACTIVITY_KEY = 'docs-solutions-activity-sent' + var VERSION_NOTICE_KEY = 'docs-solutions-version-notice' + var DEBUG_RECS_KEY = 'docs-debug-recs' + var PROGRESS_ENDPOINT = '/solutions/progress' + var DOWNLOAD_ENDPOINT = '/solutions/download' + var ACTIVITY_ENDPOINT = '/docs-activity' + var MAX_SOLUTIONS = 50 + var MAX_STEPS = 100 + var REMOTE_TTL_MS = 60 * 1000 + var PENDING_TTL_MS = 15 * 60 * 1000 + var PUT_DEBOUNCE_MS = 800 + + // --------------------------------------------------------------------------- + // Small utilities + // --------------------------------------------------------------------------- + + function now () { return Date.now() } + + function isString (value) { return typeof value === 'string' && value.length > 0 } + + function toTime (value) { + if (typeof value === 'number' && isFinite(value) && value > 0) return Math.floor(value) + if (isString(value)) { + var parsed = Date.parse(value) + if (!isNaN(parsed)) return parsed + var asNumber = Number(value) + if (isFinite(asNumber) && asNumber > 0) return Math.floor(asNumber) + } + return null + } + + function toArray (list) { + var out = [] + if (!list) return out + for (var i = 0; i < list.length; i++) out.push(list[i]) + return out + } + + function clone (value) { return JSON.parse(JSON.stringify(value)) } + + function hasClass (el, name) { + return !!(el && el.classList && el.classList.contains(name)) + } + + function attr (el, name) { return el && el.getAttribute ? el.getAttribute(name) : null } + + function readJSON (storage, key) { + try { + var raw = storage.getItem(key) + return raw ? JSON.parse(raw) : null + } catch (e) { + return null + } + } + + function writeJSON (storage, key, value) { + try { + storage.setItem(key, JSON.stringify(value)) + return true + } catch (e) { + return false + } + } + + function readString (storage, key) { + try { return storage.getItem(key) } catch (e) { return null } + } + + function writeString (storage, key, value) { + try { storage.setItem(key, value) } catch (e) { /* fail closed */ } + } + + function removeKey (storage, key) { + try { storage.removeItem(key) } catch (e) { /* ignore */ } + } + + function hasAuthHint () { + return /(?:^|;\s*)rp_docs_auth=1(?:;|$)/.test(document.cookie) + } + + function dispatch (name, detail) { + var event + try { + event = new CustomEvent(name, { detail: detail }) + } catch (e) { + try { + event = document.createEvent('CustomEvent') + event.initCustomEvent(name, false, false, detail) + } catch (err) { + return + } + } + window.dispatchEvent(event) + } + + // --------------------------------------------------------------------------- + // Analytics + // --------------------------------------------------------------------------- + + function track (name, props) { + var payload = {} + if (props) { + for (var key in props) { + if (Object.prototype.hasOwnProperty.call(props, key) && props[key] !== undefined && props[key] !== null) { + payload[key] = props[key] + } + } + } + try { + if (window.heap && typeof window.heap.track === 'function') window.heap.track(name, payload) + } catch (e) { /* analytics must never break the page */ } + try { + window.dataLayer = window.dataLayer || [] + var entry = { event: name } + for (var k in payload) if (Object.prototype.hasOwnProperty.call(payload, k)) entry[k] = payload[k] + window.dataLayer.push(entry) + } catch (e) { /* ignore */ } + } + + // --------------------------------------------------------------------------- + // Store + // --------------------------------------------------------------------------- + + var memoryStore = null + + function emptyStore () { return { v: 1, updatedAt: 0, solutions: {} } } + + function normalizeRecord (raw) { + if (!raw || typeof raw !== 'object') return null + var steps = [] + var seen = {} + var source = Array.isArray(raw.completedSteps) ? raw.completedSteps : [] + for (var i = 0; i < source.length; i++) { + var step = source[i] + if (isString(step) && !seen[step]) { + seen[step] = true + steps.push(step) + } + } + if (steps.length > MAX_STEPS) steps = steps.slice(steps.length - MAX_STEPS) + return { + completedSteps: steps, + currentStep: isString(raw.currentStep) ? raw.currentStep : null, + startedAt: toTime(raw.startedAt), + updatedAt: toTime(raw.updatedAt) || 0, + completedAt: toTime(raw.completedAt), + solutionVersion: isString(raw.solutionVersion) ? raw.solutionVersion : null, + } + } + + function applyCaps (store) { + var ids = Object.keys(store.solutions) + if (ids.length > MAX_SOLUTIONS) { + ids.sort(function (a, b) { + return (store.solutions[b].updatedAt || 0) - (store.solutions[a].updatedAt || 0) + }) + var kept = {} + for (var i = 0; i < MAX_SOLUTIONS; i++) kept[ids[i]] = store.solutions[ids[i]] + store.solutions = kept + } + return store + } + + function normalizeStore (raw) { + var store = emptyStore() + if (!raw || typeof raw !== 'object' || !raw.solutions || typeof raw.solutions !== 'object') return store + var maxUpdated = toTime(raw.updatedAt) || 0 + for (var id in raw.solutions) { + if (!Object.prototype.hasOwnProperty.call(raw.solutions, id) || !isString(id)) continue + var record = normalizeRecord(raw.solutions[id]) + if (!record) continue + store.solutions[id] = record + if (record.updatedAt > maxUpdated) maxUpdated = record.updatedAt + } + store.updatedAt = maxUpdated + return applyCaps(store) + } + + function loadStore () { + if (memoryStore) return memoryStore + memoryStore = normalizeStore(readJSON(localStorage, STORE_KEY)) + return memoryStore + } + + function persistStore (store) { + memoryStore = applyCaps(store) + writeJSON(localStorage, STORE_KEY, memoryStore) + dispatch('docs-solutions:change', { store: clone(memoryStore) }) + return memoryStore + } + + function clearLocal () { + memoryStore = emptyStore() + removeKey(localStorage, STORE_KEY) + removeKey(localStorage, DIRTY_KEY) + removeKey(sessionStorage, REMOTE_CACHE_KEY) + dispatch('docs-solutions:change', { store: emptyStore() }) + } + + function getRecord (id) { + var store = loadStore() + return store.solutions[id] ? clone(store.solutions[id]) : null + } + + function setRecord (id, record) { + var store = loadStore() + record.updatedAt = record.updatedAt || now() + store.solutions[id] = normalizeRecord(record) + if (store.solutions[id].updatedAt > store.updatedAt) store.updatedAt = store.solutions[id].updatedAt + return persistStore(store) + } + + function isDirty () { return readString(localStorage, DIRTY_KEY) === '1' } + function markDirty () { writeString(localStorage, DIRTY_KEY, '1') } + function clearDirty () { removeKey(localStorage, DIRTY_KEY) } + + // --------------------------------------------------------------------------- + // Merge (shared contract with docs-site; see merge-vectors.json) + // --------------------------------------------------------------------------- + + function minTime (a, b) { + if (a === null || a === undefined) return b === undefined ? null : b + if (b === null || b === undefined) return a + return Math.min(a, b) + } + + function union (first, second) { + var out = first.slice() + var seen = {} + for (var i = 0; i < out.length; i++) seen[out[i]] = true + for (var j = 0; j < second.length; j++) { + if (!seen[second[j]]) { + seen[second[j]] = true + out.push(second[j]) + } + } + return out + } + + function mergeRecord (a, b) { + a = normalizeRecord(a) + b = normalizeRecord(b) + if (!a) return b + if (!b) return a + var later = b.updatedAt > a.updatedAt ? b : a + var earlier = later === a ? b : a + var versionsDiffer = !!(a.solutionVersion && b.solutionVersion && a.solutionVersion !== b.solutionVersion) + return { + completedSteps: union(earlier.completedSteps, later.completedSteps).slice(-MAX_STEPS), + currentStep: later.currentStep || earlier.currentStep || null, + startedAt: minTime(a.startedAt, b.startedAt), + updatedAt: Math.max(a.updatedAt, b.updatedAt), + completedAt: versionsDiffer ? later.completedAt : minTime(a.completedAt, b.completedAt), + solutionVersion: later.solutionVersion || earlier.solutionVersion || null, + } + } + + function mergeStores (local, remote) { + local = normalizeStore(local) + remote = normalizeStore(remote) + var merged = emptyStore() + var ids = {} + var id + for (id in local.solutions) if (Object.prototype.hasOwnProperty.call(local.solutions, id)) ids[id] = true + for (id in remote.solutions) if (Object.prototype.hasOwnProperty.call(remote.solutions, id)) ids[id] = true + for (id in ids) { + var record = mergeRecord(local.solutions[id], remote.solutions[id]) + if (!record) continue + merged.solutions[id] = record + if (record.updatedAt > merged.updatedAt) merged.updatedAt = record.updatedAt + } + merged.updatedAt = Math.max(merged.updatedAt, local.updatedAt || 0, remote.updatedAt || 0) + return applyCaps(merged) + } + + // --------------------------------------------------------------------------- + // Page context + // --------------------------------------------------------------------------- + + var body = document.body + var solutionId = attr(body, 'data-solution-id') + var isOverview = hasClass(body, 'solution') + var isStep = hasClass(body, 'solution-step') + var isSolutionPage = !!solutionId && (isOverview || isStep) + var stepId = attr(body, 'data-step-id') || null + var pageVersion = attr(body, 'data-solution-version') || null + var component = attr(body, 'data-component') || null + + function $ (selector, root) { return (root || document).querySelector(selector) } + function $$ (selector, root) { return toArray((root || document).querySelectorAll(selector)) } + + // Step ids and urls, in order, from the rendered step lists. + var stepIndex = null + function steps () { + if (stepIndex) return stepIndex + var ids = [] + var urls = {} + var seen = {} + // A step id can appear more than once (the overview's step list and the + // rail's compact list); take the url from whichever element carries it. + $$('[data-sol-step-id]').forEach(function (el) { + var id = attr(el, 'data-sol-step-id') + if (!isString(id)) return + if (!seen[id]) { + seen[id] = true + ids.push(id) + } + var url = attr(el, 'data-sol-step-url') + if (url && !urls[id]) urls[id] = url + }) + stepIndex = { ids: ids, urls: urls } + return stepIndex + } + + function countDone (record, ids) { + if (!record) return 0 + var count = 0 + for (var i = 0; i < ids.length; i++) { + if (record.completedSteps.indexOf(ids[i]) !== -1) count++ + } + return count + } + + function allDone (record, ids) { + return ids.length > 0 && countDone(record, ids) === ids.length + } + + function baseProps (extra) { + var props = { solution_id: solutionId, solution_version: pageVersion, component: component } + if (stepId) props.step_id = stepId + if (extra) for (var k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) props[k] = extra[k] + return props + } + + // --------------------------------------------------------------------------- + // Toast + sync state + // --------------------------------------------------------------------------- + + function toast (message) { + if (!body || !document.createElement) return + var el = document.createElement('div') + el.className = 'sol-toast' + el.setAttribute('role', 'status') + el.textContent = message + body.appendChild(el) + setTimeout(function () { el.classList.add('is-visible') }, 10) + setTimeout(function () { + el.classList.remove('is-visible') + setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el) }, 250) + }, 4000) + } + + function setSyncState (text, isError) { + $$('[data-sol-sync-state]').forEach(function (el) { + el.textContent = text || '' + if (el.classList) el.classList[isError ? 'add' : 'remove']('is-error') + }) + } + + // --------------------------------------------------------------------------- + // Remote sync + // --------------------------------------------------------------------------- + + var putTimer = null + var pendingPut = null + + function readRemoteCache () { + var cached = readJSON(sessionStorage, REMOTE_CACHE_KEY) + if (cached && cached.at && now() - cached.at < REMOTE_TTL_MS && cached.store) return normalizeStore(cached.store) + return null + } + + function writeRemoteCache (store) { + writeJSON(sessionStorage, REMOTE_CACHE_KEY, { at: now(), store: store }) + } + + function fetchRemote () { + var cached = readRemoteCache() + if (cached) return Promise.resolve(cached) + if (typeof fetch !== 'function') return Promise.resolve(null) + return fetch(PROGRESS_ENDPOINT, { credentials: 'include', headers: { Accept: 'application/json' } }) + .then(function (res) { + if (res.status === 401) return null + if (res.status === 204 || res.status === 404) return emptyStore() + if (!res.ok) return null + return res.json().then(function (data) { + var store = normalizeStore(data && data.solutions ? data : (data && data.progress) || emptyStore()) + writeRemoteCache(store) + return store + }) + }) + .catch(function () { return null }) + } + + function putStore (store) { + if (typeof fetch !== 'function') return Promise.resolve(false) + var payload = { v: 1, updatedAt: store.updatedAt || now(), solutions: store.solutions } + setSyncState('Saving...') + var request = fetch(PROGRESS_ENDPOINT, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload), + }) + .then(function (res) { + if (res.status === 401) { + setSyncState('Sign in to sync', true) + return false + } + if (res.status === 409) { + setSyncState('Not synced yet', true) + return false + } + if (!res.ok) { + setSyncState('Not synced', true) + return false + } + return res.json().then(function (data) { + var serverStore = data && data.solutions ? normalizeStore(data) : normalizeStore(payload) + persistStore(serverStore) + writeRemoteCache(serverStore) + clearDirty() + setSyncState('Saved to your account') + return true + }, function () { + // 200 with no JSON body: the server accepted what we sent. + clearDirty() + writeRemoteCache(normalizeStore(payload)) + setSyncState('Saved to your account') + return true + }) + }) + .catch(function () { + setSyncState('Offline, saved on this device', true) + return false + }) + pendingPut = request.then(function (ok) { pendingPut = null; render(); return ok }) + return pendingPut + } + + function scheduleSync () { + if (!hasAuthHint()) return + if (putTimer) clearTimeout(putTimer) + putTimer = setTimeout(function () { + putTimer = null + putStore(loadStore()) + }, PUT_DEBOUNCE_MS) + } + + function save () { + if (!hasAuthHint()) { + openSignin('save') + return Promise.resolve(false) + } + markDirty() + if (putTimer) { clearTimeout(putTimer); putTimer = null } + return putStore(loadStore()).then(function (ok) { + if (ok) { + toast('Progress saved') + track('solution_save_progress', baseProps({ intent: 'save' })) + } + return ok + }) + } + + var syncPromise = null + + function syncOnLoad () { + var signedIn = hasAuthHint() + var previous = readString(localStorage, HINT_KEY) + if (!signedIn) { + // true -> false: the reader signed out. Their progress lives in their + // account now; do not leave a copy on a possibly shared device. + if (previous === 'true') clearLocal() + writeString(localStorage, HINT_KEY, 'false') + syncPromise = Promise.resolve(false) + return syncPromise + } + var firstSignIn = previous !== 'true' + writeString(localStorage, HINT_KEY, 'true') + syncPromise = fetchRemote().then(function (remote) { + // 401 or network failure: keep local as is and try again next load. + if (remote === null) return false + var merged = mergeStores(loadStore(), remote) + if (firstSignIn || isDirty()) { + persistStore(merged) + return putStore(merged) + } + persistStore(merged) + render() + return true + }) + return syncPromise + } + + // --------------------------------------------------------------------------- + // Signed-in activity beacon (once per solution transition) + // --------------------------------------------------------------------------- + + function recordActivity (event) { + if (!hasAuthHint() || !solutionId || typeof fetch !== 'function') return + var key = solutionId + ':' + event + var sent = readJSON(localStorage, ACTIVITY_KEY) || [] + if (sent.indexOf(key) !== -1) return + sent.push(key) + writeJSON(localStorage, ACTIVITY_KEY, sent.slice(-200)) + try { + fetch(ACTIVITY_ENDPOINT, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + path: window.location.pathname, + component: component, + solution: { id: solutionId, event: event }, + }), + keepalive: true, + }).catch(function () { /* best effort */ }) + } catch (e) { /* never break the page */ } + } + + // --------------------------------------------------------------------------- + // Progress operations + // --------------------------------------------------------------------------- + + function newRecord (version) { + var t = now() + return { completedSteps: [], currentStep: null, startedAt: t, updatedAt: t, completedAt: null, solutionVersion: version || null } + } + + function setCurrent (id, step, version) { + id = id || solutionId + step = step || stepId + version = version || pageVersion + if (!id || !step) return getRecord(id) + var record = getRecord(id) + var started = false + if (!record) { + record = newRecord(version) + started = true + } + var changed = started || record.currentStep !== step + record.currentStep = step + if (changed) { + record.updatedAt = now() + setRecord(id, record) + markDirty() + scheduleSync() + } + if (started) { + track('solution_start', baseProps()) + recordActivity('started') + } + render() + return getRecord(id) + } + + function markComplete (id, step, version) { + id = id || solutionId + step = step || stepId + version = version || pageVersion + if (!id || !step) return getRecord(id) + var record = getRecord(id) || newRecord(version) + var t = now() + var wasComplete = record.completedSteps.indexOf(step) !== -1 + if (!wasComplete) record.completedSteps.push(step) + record.currentStep = step + record.solutionVersion = version || record.solutionVersion + record.updatedAt = t + var ids = id === solutionId ? steps().ids : [] + var finished = false + if (!record.completedAt && allDone(record, ids)) { + record.completedAt = t + finished = true + } + setRecord(id, record) + markDirty() + scheduleSync() + if (!wasComplete) track('solution_step_complete', baseProps({ step_id: step })) + if (finished) { + track('solution_complete', baseProps({ duration_ms: record.startedAt ? t - record.startedAt : null })) + recordActivity('completed') + } + render() + return getRecord(id) + } + + function unmarkComplete (id, step) { + id = id || solutionId + step = step || stepId + if (!id || !step) return getRecord(id) + var record = getRecord(id) + if (!record) return null + var idx = record.completedSteps.indexOf(step) + if (idx === -1) return record + record.completedSteps.splice(idx, 1) + record.completedAt = null + record.updatedAt = now() + setRecord(id, record) + markDirty() + scheduleSync() + render() + return getRecord(id) + } + + function getState () { return clone(loadStore()) } + + // --------------------------------------------------------------------------- + // Auth gates + // --------------------------------------------------------------------------- + + function authAvailable () { + if (hasAuthHint()) return true + if (window.isUiPreview) return true + if (window.__KAPA_LOGIN_URL) return true + var account = $('[data-docs-account]') + return !!(account && !account.hidden) + } + + var SIGNIN_COPY = { + save: { + title: 'Sign in to save your progress', + lead: 'Your progress is kept on this device. Sign in to keep it across devices and pick up where you left off.', + cta: 'Sign in and save', + }, + download: { + title: 'Sign in to download the complete example', + lead: 'Get the full working code for this Solution, and keep your progress across devices.', + cta: 'Sign in and download', + }, + } + + function intentReturnTo (intent) { + var returnTo = window.location.pathname + '?intent=' + intent + if (stepId) returnTo += '&step=' + encodeURIComponent(stepId) + if (intent === 'download' && pageVersion) returnTo += '&version=' + encodeURIComponent(pageVersion) + return returnTo + } + + function openSignin (intent) { + intent = intent === 'download' ? 'download' : 'save' + if (!authAvailable()) { + toast('Progress is saved on this device.') + return false + } + writeJSON(sessionStorage, PENDING_KEY, { + intent: intent, + solution: solutionId, + step: stepId, + version: pageVersion, + at: now(), + }) + track('solution_login_cta_click', baseProps({ intent: intent })) + var copy = SIGNIN_COPY[intent] + dispatch('docs-account:open-signin', { + title: copy.title, + lead: copy.lead, + cta: copy.cta, + returnTo: intentReturnTo(intent), + }) + return true + } + + function downloadUrl (version) { + var link = $('[data-sol-download]') + var href = link && link.getAttribute('href') + if (href) return href + return DOWNLOAD_ENDPOINT + '?solution=' + encodeURIComponent(solutionId || '') + + '&version=' + encodeURIComponent(version || pageVersion || '') + + '&return=' + encodeURIComponent(window.location.pathname) + } + + function stripIntentParams (params) { + params.delete('intent') + params.delete('step') + params.delete('version') + var qs = params.toString() + try { + window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '') + window.location.hash) + } catch (e) { /* ignore */ } + } + + // Returning from sign-in with ?intent=save|download (the return_to we built + // in openSignin). Runs once: the params are stripped whatever happens. + function handleIntent () { + var params + try { params = new URLSearchParams(window.location.search) } catch (e) { return } + var intent = params.get('intent') + if (!intent) return + var version = params.get('version') + var pending = readJSON(sessionStorage, PENDING_KEY) + removeKey(sessionStorage, PENDING_KEY) + stripIntentParams(params) + if (!hasAuthHint()) return + var fresh = !!(pending && pending.intent === intent && pending.solution === solutionId && + pending.at && now() - pending.at < PENDING_TTL_MS) + if (intent === 'save') { + markDirty() + ;(syncPromise || Promise.resolve(false)).then(function () { + if (pendingPut) return pendingPut + return putStore(loadStore()) + }).then(function (ok) { + if (ok) toast('Progress saved') + track('solution_save_progress', baseProps({ intent: 'return' })) + }) + return + } + if (intent === 'download') { + // The pending record is the replay guard: a bookmarked or shared URL with + // intent=download must not start a download on its own. + if (!fresh) return + track('solution_download', baseProps({ source: 'intent' })) + try { window.location.assign(downloadUrl(version || (pending && pending.version))) } catch (e) { /* ignore */ } + } + } + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + + function versionNoticeDismissed () { + return readString(sessionStorage, VERSION_NOTICE_KEY) === solutionId + '@' + pageVersion + } + + function dismissVersionNotice () { + writeString(sessionStorage, VERSION_NOTICE_KEY, solutionId + '@' + pageVersion) + $$('[data-sol-version-notice]').forEach(function (el) { el.hidden = true }) + } + + function setText (selector, text) { + $$(selector).forEach(function (el) { el.textContent = text }) + } + + function render () { + if (!isSolutionPage) return + var record = getRecord(solutionId) + var ids = steps().ids + var urls = steps().urls + var total = ids.length + var done = countDone(record, ids) + var finished = !!(record && total && done === total) + var signedIn = hasAuthHint() + var available = authAvailable() + + setText('[data-sol-progress-count]', done + ' of ' + total) + $$('[data-sol-progress-fill]').forEach(function (el) { + el.style.width = (total ? Math.round((done / total) * 100) : 0) + '%' + }) + $$('[data-sol-progress-bar]').forEach(function (el) { el.setAttribute('aria-valuenow', String(done)) }) + setText('[data-sol-progress-state]', !record ? 'Not started' : finished ? 'Completed' : 'In progress') + + // Step ticks (overview list, rail list) and the current marker. + $$('[data-sol-step-id]').forEach(function (el) { + var id = attr(el, 'data-sol-step-id') + var complete = !!record && record.completedSteps.indexOf(id) !== -1 + var current = !!record && record.currentStep === id && !finished + if (el.classList) { + el.classList[complete ? 'add' : 'remove']('is-complete') + el.classList[current ? 'add' : 'remove']('is-current') + } + var status = $('[data-sol-step-status]', el) + if (status) status.textContent = complete ? '(completed)' : current ? '(current)' : '' + }) + // Sidebar nav items match on url. + $$('[data-sol-nav-url]').forEach(function (el) { + var url = attr(el, 'data-sol-nav-url') + var complete = false + if (record) { + for (var i = 0; i < ids.length; i++) { + if (urls[ids[i]] === url && record.completedSteps.indexOf(ids[i]) !== -1) complete = true + } + } + if (el.classList) el.classList[complete ? 'add' : 'remove']('is-complete') + }) + + // Hero CTA: Continue where you left off. + var start = $('[data-sol-start]') + if (start) { + var label = $('[data-sol-start-label]', start) + if (record && !finished && record.currentStep && urls[record.currentStep]) { + start.setAttribute('href', urls[record.currentStep]) + if (label) label.textContent = 'Continue' + } else if (record && finished && ids.length && urls[ids[0]]) { + if (label) label.textContent = 'Start again' + } + var heroProgress = $('[data-sol-hero-progress]') + if (heroProgress) { + heroProgress.hidden = !record || !done + heroProgress.textContent = record && done ? done + ' of ' + total + ' steps done' : '' + } + } + + // Step page controls. + var complete = $('[data-sol-complete]') + if (complete && stepId) { + var isDone = !!record && record.completedSteps.indexOf(stepId) !== -1 + complete.setAttribute('aria-pressed', isDone ? 'true' : 'false') + var completeLabel = $('[data-sol-complete-label]', complete) + if (completeLabel) completeLabel.textContent = isDone ? 'Completed' : 'Mark step complete' + var headerStatus = $('[data-sol-step-header-status]') + if (headerStatus) headerStatus.hidden = !isDone + } + + // Done panel. + $$('[data-sol-done]').forEach(function (el) { el.hidden = !finished }) + + // Gate + download visibility. + $$('[data-sol-gate]').forEach(function (el) { + el.hidden = signedIn + var text = $('[data-sol-gate-text]', el) + var button = $('[data-sol-signin]', el) + if (!available) { + if (text) text.textContent = 'Progress is saved on this device.' + if (button) button.hidden = true + } else { + if (text) text.textContent = 'Sign in to save progress and download the complete example.' + if (button) button.hidden = false + } + }) + $$('[data-sol-download][data-requires-auth]').forEach(function (el) { el.hidden = !available }) + $$('[data-sol-download-panel]').forEach(function (el) { + var authOnly = !!$('[data-sol-download][data-requires-auth]', el) + el.hidden = authOnly && !available + }) + $$('[data-sol-save]').forEach(function (el) { + el.hidden = !available + var saveLabel = $('[data-sol-save-label]', el) + if (saveLabel) saveLabel.textContent = signedIn ? 'Save now' : 'Save progress' + }) + if (!signedIn) setSyncState(available ? (record ? 'Saved on this device' : '') : (record ? 'Saved on this device' : '')) + + // Version-changed notice. + var mismatch = !!(record && record.solutionVersion && pageVersion && record.solutionVersion !== pageVersion) + $$('[data-sol-version-notice]').forEach(function (el) { + el.hidden = !(mismatch && !versionNoticeDismissed()) + }) + } + + // --------------------------------------------------------------------------- + // Recommendation debug + impressions (all pages) + // --------------------------------------------------------------------------- + + function revealRecReasons () { + var debug = false + try { debug = localStorage.getItem(DEBUG_RECS_KEY) === '1' } catch (e) { /* ignore */ } + if (!debug) { + try { debug = new URLSearchParams(window.location.search).get('debug') === 'recs' } catch (e) { /* ignore */ } + } + if (!debug) return + $$('[data-sol-rec-why]').forEach(function (el) { el.hidden = false }) + } + + function observeImpressions () { + var targets = [] + $$('[data-sol-card]').forEach(function (el, i) { + targets.push({ el: el, name: 'solution_card_impression', props: { solution_id: attr(el, 'data-solution-id'), position: i + 1 } }) + }) + $$('[data-sol-rec]').forEach(function (el, i) { + targets.push({ + el: el, + name: 'product_doc_solution_rec_impression', + props: { + solution_id: attr(el, 'data-solution-id'), + provenance: attr(el, 'data-provenance'), + position: Number(attr(el, 'data-position')) || i + 1, + component: component, + }, + }) + el.addEventListener('click', function () { + track('product_doc_solution_rec_click', { + solution_id: attr(el, 'data-solution-id'), + provenance: attr(el, 'data-provenance'), + position: Number(attr(el, 'data-position')) || i + 1, + component: component, + }) + }) + }) + $$('[data-sol-gate]').forEach(function (el) { + targets.push({ el: el, name: 'solution_login_cta_impression', props: baseProps({ intent: 'download' }) }) + }) + if (!targets.length) return + if (typeof IntersectionObserver !== 'function') { + targets.forEach(function (t) { track(t.name, t.props) }) + return + } + var byEl = [] + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return + for (var i = 0; i < byEl.length; i++) { + if (byEl[i].el === entry.target && !byEl[i].seen) { + byEl[i].seen = true + if (!byEl[i].el.hidden) track(byEl[i].name, byEl[i].props) + observer.unobserve(entry.target) + } + } + }) + }, { threshold: 0.5 }) + targets.forEach(function (t) { + byEl.push(t) + observer.observe(t.el) + }) + } + + // --------------------------------------------------------------------------- + // Rail
: collapsed on narrow screens, always open otherwise. + // --------------------------------------------------------------------------- + + function manageDetails (selector) { + var els = $$(selector) + if (!els.length || typeof window.matchMedia !== 'function') return + var mq = window.matchMedia('(max-width: 768px)') + var apply = function () { + els.forEach(function (el) { + if (mq.matches) { + if (!el.__solToggled) el.open = false + } else { + el.open = true + } + }) + } + els.forEach(function (el) { + el.addEventListener('toggle', function () { if (mq.matches) el.__solToggled = true }) + }) + apply() + if (typeof mq.addEventListener === 'function') mq.addEventListener('change', apply) + else if (typeof mq.addListener === 'function') mq.addListener(apply) + } + + // --------------------------------------------------------------------------- + // Wire up + // --------------------------------------------------------------------------- + + window.docsSolutions = { + getState: getState, + getRecord: getRecord, + markComplete: markComplete, + unmarkComplete: unmarkComplete, + setCurrent: setCurrent, + save: save, + merge: mergeStores, + mergeRecord: mergeRecord, + track: track, + hasAuthHint: hasAuthHint, + manageDetails: manageDetails, + } + + syncOnLoad() + revealRecReasons() + + if (isSolutionPage) { + if (isStep && stepId) { + setCurrent(solutionId, stepId, pageVersion) + track('solution_step_view', baseProps({ step_index: Number(attr(body, 'data-step-index')) || null })) + } + + $$('[data-sol-complete]').forEach(function (el) { + el.addEventListener('click', function () { + var record = getRecord(solutionId) + if (record && record.completedSteps.indexOf(stepId) !== -1) unmarkComplete(solutionId, stepId) + else markComplete(solutionId, stepId, pageVersion) + }) + }) + $$('[data-sol-save]').forEach(function (el) { + el.addEventListener('click', function () { save() }) + }) + $$('[data-sol-signin]').forEach(function (el) { + el.addEventListener('click', function () { openSignin(attr(el, 'data-intent') || 'download') }) + }) + $$('[data-sol-download]').forEach(function (el) { + el.addEventListener('click', function (e) { + if (el.hasAttribute('data-requires-auth') && !hasAuthHint()) { + e.preventDefault() + openSignin('download') + return + } + track('solution_download', baseProps({ source: 'click' })) + }) + }) + $$('[data-sol-version-dismiss]').forEach(function (el) { + el.addEventListener('click', dismissVersionNotice) + }) + $$('[data-sol-start]').forEach(function (el) { + el.addEventListener('click', function () { track('solution_start_click', baseProps()) }) + }) + + manageDetails('[data-sol-rail]') + render() + handleIntent() + // 26-docs-account.js may learn auth availability after us. + window.addEventListener('kapa-session', render) + } + + observeImpressions() +})() diff --git a/src/js/29-solutions-home.js b/src/js/29-solutions-home.js new file mode 100644 index 00000000..342ac93a --- /dev/null +++ b/src/js/29-solutions-home.js @@ -0,0 +1,248 @@ +/* global URLSearchParams */ +/** + * Solutions landing page: client-side filtering over the catalog the build + * embedded ( {{else}}
-

No Solutions published yet

+

No solutions published yet

Solutions appear here as soon as the first one is published.

{{/with}} diff --git a/src/partials/component-home-v3.hbs b/src/partials/component-home-v3.hbs index 4490058a..7fee6736 100644 --- a/src/partials/component-home-v3.hbs +++ b/src/partials/component-home-v3.hbs @@ -725,7 +725,7 @@

{{{page.attributes.solutions-title}}}

{{#if page.attributes.solutions-link}} - Browse all Solutions + Browse all solutions {{/if}}
diff --git a/src/partials/nav-tree-solution.hbs b/src/partials/nav-tree-solution.hbs index 2867da97..3c4177b4 100644 --- a/src/partials/nav-tree-solution.hbs +++ b/src/partials/nav-tree-solution.hbs @@ -92,7 +92,7 @@ {{#with (get-solutions-catalog) as |catalog|}} {{#if catalog.all.length}}
diff --git a/src/partials/product-switcher.hbs b/src/partials/product-switcher.hbs index 40c2f131..4a1e2ad3 100644 --- a/src/partials/product-switcher.hbs +++ b/src/partials/product-switcher.hbs @@ -9,6 +9,7 @@ - Self-Managed (self-managed component) containing: - Streaming (streaming component) - Connect (connect component) + 3. Solutions (solutions component), only when the site has it CONFIGURATION: Each component must have component-metadata in its antora.yml: @@ -110,6 +111,16 @@ {{this.title}} {{/with}} {{/with}} + {{else if (eq page.component.name 'solutions')}} + {{!-- Solutions pages: rocket icon in the component's own color --}} + {{#with (find-component site 'solutions')}} + {{#with (get-component-header-data this)}} + + + + {{#if this.title}}{{this.title}}{{else}}Solutions{{/if}} + {{/with}} + {{/with}} {{else if (eq page.component.name 'agentic-data-plane')}} {{!-- ADP pages: show sparkles icon --}} {{#with (find-component site 'agentic-data-plane')}} @@ -311,6 +322,38 @@ {{/with}} {{/with}} + {{!-- 3. Solutions (component `solutions`). Renders only when the site has + the component; color comes from its component-metadata. --}} + {{#if (has-component site 'solutions')}} + {{#with (find-component site 'solutions')}} + {{#with (get-component-header-data this)}} + + {{/with}} + {{/with}} + {{/if}} + {{!-- Footer with Cloud link --}}
From 85aff5f7b772fac09b18bac1ee4905b819d8b3bc Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Sun, 13 Sep 2026 16:05:43 +0100 Subject: [PATCH 019/142] Make the rail a compact progress card with the page TOC and leave navigation to the sidebar --- src/css/solutions.css | 126 ++++++++++++------------- src/js/28-solution-progress.js | 31 ++++-- src/layouts/solution-step.hbs | 37 ++++---- src/layouts/solution.hbs | 39 ++++---- src/partials/solution-hero.hbs | 6 +- src/partials/solution-progress.hbs | 40 ++++---- src/partials/solution-step-header.hbs | 2 +- tests/solution-progress/gate.test.js | 3 +- tests/solution-progress/helpers/dom.js | 8 +- tests/solution-progress/helpers/run.js | 36 +++---- tests/solution-progress/store.test.js | 3 +- 11 files changed, 172 insertions(+), 159 deletions(-) diff --git a/src/css/solutions.css b/src/css/solutions.css index 9242bd25..27d554c2 100644 --- a/src/css/solutions.css +++ b/src/css/solutions.css @@ -25,7 +25,6 @@ .doc .sol-eyebrow-link, .doc .sol-related-link, .doc .sol-file-link, -.doc .sol-progress-step a, .doc .sol-btn, .doc .sol-btn:hover, .doc .sol-step-nav, @@ -55,14 +54,12 @@ } .doc .sol-related-link, -.doc .sol-file-link, -.doc .sol-progress-step a { +.doc .sol-file-link { color: var(--grey-700-new); } .doc .sol-related-link:hover, -.doc .sol-file-link:hover, -.doc .sol-progress-step a:hover { +.doc .sol-file-link:hover { color: var(--component-700); } @@ -470,24 +467,57 @@ font-size: calc(13 / var(--rem-base) * 1rem); } -.sol-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 300px; - gap: 2.5rem; - align-items: start; +/* Shell: main > .content is the default-page flex row on desktop (main.css), + with article.doc first and the rail as the sidebar column, so the + on-this-page TOC script finds the article's headings. */ +.sol-rail { + min-width: 0; } -.sol-main { - min-width: 0; +@media screen and (min-width: 1025px) { + .sol-rail { + flex: 0 0 300px; + order: 1; + position: sticky; + top: calc(var(--navbar-height, 4rem) + 1rem); + align-self: flex-start; + max-height: calc(100vh - var(--navbar-height, 4rem) - 2rem); + overflow-y: auto; + scrollbar-width: thin; + } + + .sol-rail-details > .sol-rail-summary { + display: none; + } + + /* The article's own action row duplicates the rail's card on desktop. */ + .sol-step-actions-main { + display: none; + } } -.sol-rail { - position: sticky; - top: calc(var(--body-top, 4rem) + 1rem); +/* The on-this-page TOC (toc.hbs) inside the rail: static, full width, and + visible on narrow screens too (main.css hides aside.toc.sidebar there). */ +.sol-rail .toc.sidebar { + display: block; + position: static; + top: auto; + flex: none; + width: auto; + max-width: none; min-width: 0; + max-height: none; + margin: 1rem 0 0; + padding: 0; +} + +.sol-rail .toc.sidebar .toc-menu { + max-height: 40vh; + overflow-y: auto; } -.sol-rail-details > .sol-rail-summary { +.sol-rail .toc.sidebar .thumbs, +.sol-rail .toc.sidebar .feedback-success { display: none; } @@ -587,7 +617,6 @@ } .sol-step-check, -.sol-progress-step-check, .sol-nav-step-check { display: inline-flex; align-items: center; @@ -601,7 +630,6 @@ } .is-complete > .sol-step-link .sol-step-check, -.is-complete .sol-progress-step-check, .is-complete .sol-nav-step-check { background: var(--component-600); border-color: var(--component-600); @@ -632,39 +660,12 @@ margin: 0.5rem 0 0; } -.sol-progress-steps { - display: none; - list-style: none; - margin: 0.75rem 0 0; - padding: 0; -} - -.solution-step .sol-progress-steps { - display: block; -} - -.sol-progress-step a { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.3rem 0; - font-size: calc(13 / var(--rem-base) * 1rem); - color: var(--grey-700-new); - text-decoration: none; -} - -.sol-progress-step a:hover { - color: var(--component-700); -} - -.sol-progress-step.is-current a { - color: var(--component-700); - font-weight: 600; +.sol-progress-complete { + margin-top: 0.875rem; } -.sol-progress-step .sol-progress-step-check { - width: 1rem; - height: 1rem; +.sol-btn--block { + width: 100%; } .sol-progress-actions { @@ -1500,7 +1501,6 @@ html[data-theme="dark"] .sol-link-btn, html[data-theme="dark"] .sol-done-title, html[data-theme="dark"] .sol-fact--status, html[data-theme="dark"] .sol-nav-step.is-complete .sol-nav-step-num, -html[data-theme="dark"] .sol-progress-step.is-current a, html[data-theme="dark"] .sol-related-link svg { color: var(--component-200); } @@ -1527,7 +1527,6 @@ html[data-theme="dark"] .sol-done-sub, html[data-theme="dark"] .sol-progress-count, html[data-theme="dark"] .sol-filter-row, html[data-theme="dark"] .sol-file-link, -html[data-theme="dark"] .sol-progress-step a, html[data-theme="dark"] .sol-empty, html[data-theme="dark"] .sol-gate { color: var(--grey-300-new); @@ -1563,7 +1562,6 @@ html[data-theme="dark"] .sol-doc .production-note > .title { html[data-theme="dark"] .sol-bar, html[data-theme="dark"] .sol-step-check, -html[data-theme="dark"] .sol-progress-step-check, html[data-theme="dark"] .sol-nav-step-check, html[data-theme="dark"] .sol-empty { border-color: var(--grey-600-new); @@ -1644,32 +1642,26 @@ html[data-theme="dark"] .sol-toast { /* ---- responsive ------------------------------------------------------------ */ @media (max-width: 1024px) { - .sol-layout { - grid-template-columns: minmax(0, 1fr) 260px; - gap: 1.5rem; + /* Single column: the collapsed rail sits above the article. */ + .sol-content { + display: flex; + flex-direction: column; } - .sol-home-layout { - grid-template-columns: 220px minmax(0, 1fr); - gap: 1.5rem; + .sol-rail { + order: -1; + position: static; + margin: 0 1rem 1rem; } -} -@media (max-width: 768px) { - .sol-layout, .sol-home-layout { grid-template-columns: minmax(0, 1fr); } - .sol-rail, .sol-filters { position: static; } - .sol-rail { - order: -1; - } - .sol-rail-details > .sol-rail-summary, .sol-filters-summary { display: block; @@ -1679,7 +1671,9 @@ html[data-theme="dark"] .sol-toast { .sol-filters-body { margin-top: 0.75rem; } +} +@media (max-width: 768px) { .sol-hero .sol-title, .sol-step-header .sol-title { font-size: calc(28 / var(--rem-base) * 1rem); diff --git a/src/js/28-solution-progress.js b/src/js/28-solution-progress.js index ec6922dd..08379a51 100644 --- a/src/js/28-solution-progress.js +++ b/src/js/28-solution-progress.js @@ -388,16 +388,19 @@ var ids = [] var urls = {} var seen = {} - // A step id can appear more than once (the overview's step list and the - // rail's compact list); take the url from whichever element carries it. - $$('[data-sol-step-id]').forEach(function (el) { - var id = attr(el, 'data-sol-step-id') + // Step ids and urls come from the sidebar entries (nav-tree-solution: + // data-sol-nav-step + data-sol-nav-url, present on every solution page) + // and from the overview's Steps list (solution-steps: data-sol-step-id + + // data-sol-step-url). An id can appear in both; keep the first order seen + // and take the url from whichever element carries one. + $$('[data-sol-step-id], [data-sol-nav-step]').forEach(function (el) { + var id = attr(el, 'data-sol-step-id') || attr(el, 'data-sol-nav-step') if (!isString(id)) return if (!seen[id]) { seen[id] = true ids.push(id) } - var url = attr(el, 'data-sol-step-url') + var url = attr(el, 'data-sol-step-url') || attr(el, 'data-sol-nav-url') if (url && !urls[id]) urls[id] = url }) stepIndex = { ids: ids, urls: urls } @@ -867,13 +870,20 @@ var status = $('[data-sol-step-status]', el) if (status) status.textContent = complete ? '(completed)' : current ? '(current)' : '' }) - // Sidebar nav items match on url. - $$('[data-sol-nav-url]').forEach(function (el) { + // Sidebar nav items: match on step id, falling back to url. + $$('[data-sol-nav-url], [data-sol-nav-step]').forEach(function (el) { + var navId = attr(el, 'data-sol-nav-step') var url = attr(el, 'data-sol-nav-url') var complete = false if (record) { - for (var i = 0; i < ids.length; i++) { - if (urls[ids[i]] === url && record.completedSteps.indexOf(ids[i]) !== -1) complete = true + if (navId && record.completedSteps.indexOf(navId) !== -1) complete = true + if (!complete && url) { + for (var i = 0; i < ids.length; i++) { + if (urls[ids[i]] === url && record.completedSteps.indexOf(ids[i]) !== -1) { + complete = true + break + } + } } } if (el.classList) el.classList[complete ? 'add' : 'remove']('is-complete') @@ -1015,7 +1025,8 @@ function manageDetails (selector) { var els = $$(selector) if (!els.length || typeof window.matchMedia !== 'function') return - var mq = window.matchMedia('(max-width: 768px)') + // Same breakpoint as the two-column layout in solutions.css. + var mq = window.matchMedia('(max-width: 1024px)') var apply = function () { els.forEach(function (el) { if (mq.matches) { diff --git a/src/layouts/solution-step.hbs b/src/layouts/solution-step.hbs index fd955587..373da5db 100644 --- a/src/layouts/solution-step.hbs +++ b/src/layouts/solution-step.hbs @@ -6,35 +6,38 @@ {{!-- See solution.hbs for why page-solution is parsed per partial. Step pages add the step id and position from the page-solution-step-* - attributes the solutions-catalog extension sets. --}} + attributes the solutions-catalog extension sets. + + Shell: the same as default pages (main > .content holds article.doc + and a sidebar aside), because 02-on-this-page.js builds the TOC from + headings that are direct .sect children of article.doc. The left nav + (nav-tree-solution) owns navigation; the right rail is the progress + card, the on-this-page TOC and the code panel. --}} {{> header}}
{{> nav}}
{{> toolbar}} -
+
{{> breadcrumbs}} {{> solution-step-header solution=(parse-json page.attributes.solution)}} -
-
- {{{page.contents}}} - {{> solution-step-actions solution=(parse-json page.attributes.solution)}} -
- -
+ {{{page.contents}}} + {{> solution-step-actions solution=(parse-json page.attributes.solution)}} {{> feedback-footer}} {{> tracking-pixel}}
+
{{> footer}}
diff --git a/src/layouts/solution.hbs b/src/layouts/solution.hbs index 8deca809..28c32923 100644 --- a/src/layouts/solution.hbs +++ b/src/layouts/solution.hbs @@ -8,37 +8,40 @@ build time. It is parsed per partial (solution=...) so every shared partial (header, nav, footer) keeps the root context. When the attribute is missing the data-solution-* attributes are omitted and - 28-solution-progress.js leaves the page alone. --}} + 28-solution-progress.js leaves the page alone. + + Shell: the same as default pages (main > .content holds article.doc + and a sidebar aside), so the on-this-page TOC script can read the + article's headings. The left nav owns navigation; the body Steps list + (solution-steps) carries the ticks on the overview; the right rail is + the progress card, the TOC and the code panel. --}} {{> header}}
{{> nav}}
{{> toolbar}} -
+
{{> breadcrumbs}} {{> solution-hero solution=(parse-json page.attributes.solution)}} {{> solution-meta solution=(parse-json page.attributes.solution)}} -
-
- {{{page.contents}}} - {{> solution-steps solution=(parse-json page.attributes.solution)}} - {{> solution-related-docs solution=(parse-json page.attributes.solution)}} -
- -
+ {{{page.contents}}} + {{> solution-steps solution=(parse-json page.attributes.solution)}} + {{> solution-related-docs solution=(parse-json page.attributes.solution)}} {{> feedback-footer}} {{> tracking-pixel}}
+
{{> footer}}
diff --git a/src/partials/solution-hero.hbs b/src/partials/solution-hero.hbs index 491ca1f3..f7ce3075 100644 --- a/src/partials/solution-hero.hbs +++ b/src/partials/solution-hero.hbs @@ -21,10 +21,10 @@

{{{this}}}

{{/with}} {{#if solution.description}} -

{{solution.description}}

+

{{solution.description}}

{{/if}} {{#if (eq solution.status 'deprecated')}} -
+
This solution is no longer maintained. {{#if solution.supersededBy}} {{#if solution.supersededBy.url}} @@ -33,7 +33,7 @@ {{/if}}
{{/if}} -
+
{{#with solution.steps.[0]}} Start building diff --git a/src/partials/solution-progress.hbs b/src/partials/solution-progress.hbs index 862f19bc..02ae81a5 100644 --- a/src/partials/solution-progress.hbs +++ b/src/partials/solution-progress.hbs @@ -1,39 +1,35 @@ {{!-- - Progress panel for the sticky rail. Context: `solution`. - Everything here is driven by 28-solution-progress.js: the bar, the "n of M" - count, the state line, the save control and its sync state, the - version-changed notice, and the done panel. Server-rendered text is the - anonymous, nothing-started baseline so the panel reads correctly without JS. + Compact progress card for the right rail. Context: `solution`. + Navigation (Solutions home, Overview, the ordered steps with ticks) lives in + the left nav (nav-tree-solution); this card only shows state: bar, "n of M + steps", state text, the version-changed notice, Save progress with its + status line, and on step pages the Mark step complete button (the same + button also sits under the article for narrow screens, where the rail + collapses; CSS shows one or the other). Everything is driven by + 28-solution-progress.js, which reads the step ids from the sidebar entries + (data-sol-nav-step) or the overview's Steps list (data-sol-step-id). --}}

Your progress

- 0 of {{solution.steps.length}} + 0 of {{solution.steps.length}} steps

Not started

- {{!-- Compact step list: the id/url pairs 28-solution-progress.js needs on - every solution page (the overview's big list lives in solution-steps). - Shown in the rail on step pages only; see solutions.css. --}} -
    - {{#each solution.steps}} -
  1. - - - {{title}} - - -
  2. - {{/each}} -
+ {{#if (eq @root.page.layout 'solution-step')}} +
+ +
+ {{/if}}
{{/if}} + {{!-- The two facets the landing page filters on. Plain chips rather than + links: they are here so a reader (and an author checking their own + attributes) can see which use case and industry this solution is + filed under. Industry is absent on most solutions and renders + nothing. --}} + {{#if solution.useCases.length}} +
+
Use case
+
+ {{#each solution.useCases}} + {{this}} + {{/each}} +
+
+ {{/if}} + {{#if solution.industries.length}} +
+
Industry
+
+ {{#each solution.industries}} + {{this}} + {{/each}} +
+
+ {{/if}} {{#if solution.technologies.length}}
Technologies
diff --git a/src/partials/solutions-filters.hbs b/src/partials/solutions-filters.hbs index 4767af00..e33d52b7 100644 --- a/src/partials/solutions-filters.hbs +++ b/src/partials/solutions-filters.hbs @@ -2,7 +2,7 @@ Landing page filters. Context: `catalog` from get-solutions-catalog; each facet is an array of {value, count}. The checkbox value is the raw facet value, which is what 29-solutions-home.js matches against the cards' - data-* attributes and writes into the URL (?category=&difficulty=&tech=&platform=). + data-* attributes and writes into the URL (?use-case=&industry=&category=&difficulty=&tech=&platform=). Rendered as
so it can collapse on narrow screens; on desktop the summary is hidden and 29-solutions-home.js keeps it open. --}} @@ -17,6 +17,26 @@ + {{!-- Use case first: it is the axis the catalog is organised around, and + the one a reader arrives with ("I need change data capture"). Both + groups render only when the extension published values for them, and + it publishes none for a facet that matches every solution. --}} + {{#if catalog.facets.useCases.length}} +
+ Use case + {{#each catalog.facets.useCases}} + + {{/each}} +
+ {{/if}} + {{#if catalog.facets.industries.length}} +
+ Industry + {{#each catalog.facets.industries}} + + {{/each}} +
+ {{/if}} {{#if catalog.facets.difficulty.length}}
Difficulty diff --git a/tests/solution-facets/facets.test.js b/tests/solution-facets/facets.test.js new file mode 100644 index 00000000..6429fb23 --- /dev/null +++ b/tests/solution-facets/facets.test.js @@ -0,0 +1,246 @@ +/** + * Filtering the solutions landing page by industry and use case: the facets + * get-solutions-catalog hands the templates, the two groups + * solutions-filters.hbs renders, and the matching 29-solutions-home.js does + * over them. + * + * The contract that ties the three together: solutions-catalog publishes an + * EMPTY array for a facet that cannot narrow the catalogue (every solution + * carries the same value, or there is only one value). Empty must survive all + * the way to the page, or a filter group appears that filters nothing. + */ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const test = require('node:test') + +const { renderFilters, renderMeta, renderCard, attributesOf, checkboxesOf, legendsOf } = require('./render') +const { run } = require('./run') + +const catalogHelper = require('../../src/helpers/get-solutions-catalog') +const ENGINE = fs.readFileSync(path.join(__dirname, '../../src/js/29-solutions-home.js'), 'utf8') + +// A record as the extension publishes it, trimmed to what these tests touch. +function record (id, extra) { + return Object.assign({ + id, + title: id.toUpperCase(), + url: '/solutions/' + id + '/', + status: 'published', + difficulty: 'beginner', + categories: [], + technologies: [], + platforms: [], + useCases: [], + industries: [], + steps: [], + }, extra || {}) +} + +const RECORDS = [ + record('cdc', { useCases: ['Change data capture'], industries: [], technologies: ['Debezium'] }), + record('lakehouse', { useCases: ['Data lakehouse', 'Change data capture'] }), + record('gaming', { useCases: ['Real-time analytics'], industries: ['Gaming'] }), +] + +// The facets as the extension would publish them for RECORDS. +const FACETS = { + useCases: [ + { value: 'Change data capture', count: 2 }, + { value: 'Data lakehouse', count: 1 }, + { value: 'Real-time analytics', count: 1 }, + ], + industries: [{ value: 'Gaming', count: 1 }], + categories: [], + technologies: [], + difficulty: [], + platforms: [], +} + +// Runs the helper the way a page does: it reads the catalog off the component +// version attribute, as JSON. +function helper (catalog) { + const root = { site: { components: { solutions: { name: 'solutions', latest: { asciidoc: { attributes: { 'solutions-catalog': JSON.stringify(catalog) } } } } } } } + return catalogHelper({ data: { root } }) +} + +// --------------------------------------------------------------------------- +// get-solutions-catalog +// --------------------------------------------------------------------------- + +test('an empty facet stays empty, because the extension dropped it on purpose', () => { + const catalog = helper({ solutions: RECORDS, facets: FACETS }) + // difficulty is 'beginner' on all three, so it narrows nothing and the + // extension published []. Deriving it back from the records here is exactly + // the bug this asserts against. + assert.deepEqual(catalog.facets.difficulty, []) + assert.deepEqual(catalog.facets.categories, []) + // Sanity: the same records with the facet absent instead of empty DO get it + // derived, which is what keeps a hand-written catalog working. + const derived = helper({ solutions: RECORDS, facets: { useCases: FACETS.useCases } }) + assert.deepEqual(derived.facets.difficulty, [{ value: 'beginner', count: 3 }]) +}) + +test('use case and industry come through, busiest value first', () => { + const catalog = helper({ solutions: RECORDS, facets: FACETS }) + assert.deepEqual(catalog.facets.useCases, [ + { value: 'Change data capture', count: 2 }, + { value: 'Data lakehouse', count: 1 }, + { value: 'Real-time analytics', count: 1 }, + ], 'count desc, then alphabetical so the order is stable') + assert.deepEqual(catalog.facets.industries, [{ value: 'Gaming', count: 1 }]) + // The axes are on the records too, which is what the cards filter on. + const byId = {} + catalog.all.forEach((r) => { byId[r.id] = r }) + assert.deepEqual(byId.lakehouse.useCases, ['Data lakehouse', 'Change data capture']) + assert.deepEqual(byId.cdc.industries, [], 'absent industry is an empty list, not undefined') +}) + +test('a record that omits the axes entirely still normalizes', () => { + const bare = { id: 'x', title: 'X', url: '/solutions/x/', status: 'published' } + const catalog = helper({ solutions: [bare], facets: {} }) + assert.deepEqual(catalog.all[0].useCases, []) + assert.deepEqual(catalog.all[0].industries, []) + assert.deepEqual(catalog.facets.useCases, []) + assert.deepEqual(catalog.facets.industries, []) +}) + +// --------------------------------------------------------------------------- +// the templates +// --------------------------------------------------------------------------- + +test('the filters render a group per non-empty facet, use case first', () => { + const html = renderFilters({ catalog: { facets: FACETS } }) + assert.deepEqual(legendsOf(html), ['Use case', 'Industry'], + 'difficulty, category, technology and platform are empty here and render nothing') + assert.deepEqual(checkboxesOf(html), [ + { name: 'use-case', value: 'Change data capture' }, + { name: 'use-case', value: 'Data lakehouse' }, + { name: 'use-case', value: 'Real-time analytics' }, + { name: 'industry', value: 'Gaming' }, + ]) + assert.match(html, /Change data capture<\/span>2 { + const html = renderFilters({ catalog: { facets: { useCases: [], industries: [], categories: [], technologies: [], difficulty: [], platforms: [] } } }) + assert.deepEqual(legendsOf(html), []) + assert.deepEqual(checkboxesOf(html), []) + assert.match(html, /data-sol-filter-q/, 'the search box is not a facet and stays') +}) + +test('a card carries both axes, pipe-joined, for the engine to match on', () => { + const attrs = attributesOf(renderCard({ solution: RECORDS[1] })) + assert.equal(attrs['data-use-cases'], 'Data lakehouse|Change data capture') + assert.equal(attrs['data-industries'], '') + assert.equal(attributesOf(renderCard({ solution: RECORDS[2] }))['data-industries'], 'Gaming') +}) + +test('the overview metadata strip shows what the solution is filed under', () => { + const html = renderMeta({ solution: RECORDS[2] }) + assert.match(html, /
Use case<\/dt>[\s\S]*?Real-time analytics/) + assert.match(html, /
Industry<\/dt>[\s\S]*?Gaming/) + // No industry, no row: most solutions are not vertical-specific. + const noIndustry = renderMeta({ solution: RECORDS[0] }) + assert.doesNotMatch(noIndustry, /
Industry<\/dt>/) + assert.match(noIndustry, /
Use case<\/dt>/) +}) + +// --------------------------------------------------------------------------- +// the engine +// --------------------------------------------------------------------------- + +test('checking a use case hides the solutions that do not have it', () => { + const r = run({ records: RECORDS, facets: FACETS }) + assert.deepEqual(r.shown(), ['cdc', 'lakehouse', 'gaming'], 'sanity: everything shows first') + r.check('use-case', 'Change data capture') + assert.deepEqual(r.shown(), ['cdc', 'lakehouse']) + assert.equal(r.count.textContent, '2 solutions') + assert.equal(r.url(), '/solutions/?use-case=Change+data+capture') + assert.equal(r.active.textContent, '1') + assert.equal(r.clear.hidden, false) +}) + +test('two values in one group are OR, two groups are AND', () => { + const r = run({ records: RECORDS, facets: FACETS }) + r.check('use-case', 'Data lakehouse') + r.check('use-case', 'Real-time analytics') + assert.deepEqual(r.shown(), ['lakehouse', 'gaming'], 'either use case') + + const both = run({ records: RECORDS, facets: FACETS }) + both.check('use-case', 'Real-time analytics') + both.check('industry', 'Gaming') + assert.deepEqual(both.shown(), ['gaming']) + + const neither = run({ records: RECORDS, facets: FACETS }) + neither.check('use-case', 'Data lakehouse') + neither.check('industry', 'Gaming') + assert.deepEqual(neither.shown(), [], 'no solution is both, and the empty state shows') + assert.equal(neither.empty.hidden, false) + assert.equal(neither.count.textContent, '0 solutions') +}) + +test('the two axes survive the URL, so a filtered link can be shared', () => { + const r = run({ records: RECORDS, facets: FACETS, search: '?industry=Gaming&use-case=Real-time+analytics' }) + assert.deepEqual(r.shown(), ['gaming']) + assert.equal(r.boxes.find((b) => b.attrs.value === 'Gaming').checked, true, 'the box is ticked from the URL') + assert.equal(r.active.textContent, '2') +}) + +test('clearing resets the new axes as well as the old ones', () => { + const r = run({ records: RECORDS, facets: FACETS, search: '?industry=Gaming' }) + assert.deepEqual(r.shown(), ['gaming']) + r.clickClear() + assert.deepEqual(r.shown(), ['cdc', 'lakehouse', 'gaming']) + assert.equal(r.boxes.find((b) => b.attrs.value === 'Gaming').checked, false) + assert.equal(r.url(), '/solutions/', 'and the parameter is gone from the URL') + assert.equal(r.active.hidden, true) +}) + +test('search matches a use case or industry, not only the title', () => { + const r = run({ records: RECORDS, facets: FACETS }) + r.search('lakehouse') + assert.deepEqual(r.shown(), ['lakehouse']) + r.search('gaming') + assert.deepEqual(r.shown(), ['gaming'], 'from the industry, which appears nowhere in the title') + r.search('nothing here') + assert.deepEqual(r.shown(), []) +}) + +// --------------------------------------------------------------------------- +// the wiring between them +// --------------------------------------------------------------------------- + +test('every checkbox the filters render is a facet the engine knows', () => { + // FACET_ATTR is the engine's single declaration of the axes. Parse it out + // rather than restating it, so this test fails when the two drift. + const block = ENGINE.match(/var FACET_ATTR = \{([\s\S]*?)\n \}/) + assert.ok(block, 'FACET_ATTR not found in 29-solutions-home.js') + const declared = {} + block[1].split('\n').forEach((line) => { + const m = line.match(/^\s*'?([\w-]+)'?:\s*'([\w-]+)',?\s*$/) + if (m) declared[m[1]] = m[2] + }) + assert.ok(Object.keys(declared).length >= 6, 'parsed the facet declarations: ' + JSON.stringify(declared)) + + const html = renderFilters({ + catalog: { + facets: { + useCases: [{ value: 'U', count: 1 }], industries: [{ value: 'I', count: 1 }], + categories: [{ value: 'C', count: 1 }], technologies: [{ value: 'T', count: 1 }], + difficulty: [{ value: 'beginner', count: 1 }], platforms: [{ value: 'cloud', count: 1 }], + }, + }, + }) + const names = Array.from(new Set(checkboxesOf(html).map((b) => b.name))) + assert.equal(names.length, 6, 'a group per facet: ' + names.join(', ')) + names.forEach((name) => { + assert.ok(declared[name], 'the engine has no attribute mapped for name="' + name + '"') + }) + + // And the card supplies every attribute those facets read. + const attrs = attributesOf(renderCard({ solution: RECORDS[0] })) + Object.keys(declared).forEach((facet) => { + assert.ok(declared[facet] in attrs, 'solution-card.hbs has no ' + declared[facet] + ' for the ' + facet + ' facet') + }) +}) diff --git a/tests/solution-facets/render.js b/tests/solution-facets/render.js new file mode 100644 index 00000000..4c1409b4 --- /dev/null +++ b/tests/solution-facets/render.js @@ -0,0 +1,56 @@ +'use strict' + +/** + * Renders the landing page's real templates and builds the test DOM out of + * what they actually emit, so a typo in a card attribute or a checkbox name + * fails the engine tests rather than passing a hand-written stand-in. + */ +const fs = require('node:fs') +const path = require('node:path') +const Handlebars = require('handlebars') + +const ROOT = path.join(__dirname, '..', '..') +const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8') +const helper = (name) => require(path.join(ROOT, 'src/helpers', name + '.js')) + +const hbs = Handlebars.create() +;['eq', 'ne', 'lt', 'gt', 'and', 'or', 'format-duration', 'format-release-date', 'format-verified-evidence', 'relativize'].forEach((name) => { + hbs.registerHelper(name, helper(name)) +}) +hbs.registerPartial('solution-card', read('src/partials/solution-card.hbs')) + +const renderCard = hbs.compile('{{> solution-card solution=solution}}') +const renderFilters = hbs.compile(read('src/partials/solutions-filters.hbs')) +const renderMeta = hbs.compile(read('src/partials/solution-meta.hbs')) + +/** Every attribute of the first element in `html`, valueless ones as ''. */ +function attributesOf (html) { + const tag = String(html).trim().match(/<[a-zA-Z]+[\s\S]*?>/) + if (!tag) throw new Error('no element found in rendered output') + const out = {} + const re = /([a-zA-Z][\w-]*)(?:="([^"]*)")?/g + let first = true + let m + while ((m = re.exec(tag[0]))) { + if (first) { first = false; continue } // the tag name + out[m[1]] = m[2] === undefined ? '' : m[2] + } + return out +} + +/** The checkboxes the filters partial rendered, in document order. */ +function checkboxesOf (html) { + const out = [] + const re = //g + let m + while ((m = re.exec(html))) out.push({ name: m[1], value: m[2] }) + return out +} + +/** The titles, so a test can assert the order a reader sees. */ +function legendsOf (html) { + return (html.match(/([^<]+)<\/legend>/g) || []) + .map((l) => l.replace(/<[^>]+>/g, '')) +} + +module.exports = { hbs, renderCard, renderFilters, renderMeta, attributesOf, checkboxesOf, legendsOf } diff --git a/tests/solution-facets/run.js b/tests/solution-facets/run.js new file mode 100644 index 00000000..1bf02c51 --- /dev/null +++ b/tests/solution-facets/run.js @@ -0,0 +1,95 @@ +'use strict' + +/** + * Runs src/js/29-solutions-home.js against a landing page whose cards and + * checkboxes come from the real templates (see render.js), in a fresh vm + * context. Returns the handles the tests filter and assert on. + */ +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const { el, makeDocument } = require('../solution-progress/helpers/dom') +const { renderCard, renderFilters, attributesOf, checkboxesOf } = require('./render') + +const SCRIPT = fs.readFileSync(path.join(__dirname, '../../src/js/29-solutions-home.js'), 'utf8') + +function run (options) { + const o = options || {} + const records = o.records || [] + const catalog = { count: records.length, all: records, facets: o.facets || {} } + + // Cards, straight from solution-card.hbs. + const cards = records.map((solution) => el('a', attributesOf(renderCard({ solution })))) + + // Checkboxes, straight from solutions-filters.hbs, so the names under test + // are the names a reader's browser would post. + const filtersHtml = renderFilters({ catalog }) + const boxes = checkboxesOf(filtersHtml).map((box) => + el('input', { type: 'checkbox', name: box.name, value: box.value })) + boxes.forEach((box) => { box.value = box.attrs.value }) + ;(o.checked || []).forEach(([name, value]) => { + const box = boxes.find((b) => b.attrs.name === name && b.attrs.value === value) + if (!box) throw new Error('no checkbox rendered for ' + name + '=' + value) + box.checked = true + }) + + const q = el('input', { type: 'search', 'data-sol-filter-q': '' }) + const active = el('span', { 'data-sol-filters-active': '', hidden: '' }) + const clear = el('button', { type: 'button', 'data-sol-filters-clear': '', hidden: '' }) + const count = el('p', { 'data-sol-count': '' }) + const empty = el('div', { 'data-sol-empty': '', hidden: '' }) + const form = el('form', { 'data-sol-filters-form': '' }, [q].concat(boxes, [clear])) + const body = el('body', {}, [ + el('details', { 'data-sol-filters': '', open: '' }, [el('summary', {}, [active]), form]), + el('div', { 'data-sol-results': '' }, [count, el('div', { 'data-sol-grid': '' }, cards), empty]), + ]) + + const document = makeDocument(body) + const replaceState = [] + const context = { + console, + document, + URLSearchParams, + window: { + location: { pathname: '/solutions/', search: o.search || '', hash: '' }, + history: { replaceState: (state, title, url) => replaceState.push(url) }, + addEventListener: () => {}, + docsSolutions: null, + }, + } + context.window.window = context.window + vm.runInNewContext(SCRIPT, context) + + const shown = () => cards.filter((c) => !c.hidden).map((c) => c.getAttribute('data-solution-id')) + return { + cards, + boxes, + q, + form, + clear, + active, + count, + empty, + filtersHtml, + replaceState, + shown, + // The URL the module last wrote, which is also the state it would restore. + url: () => (replaceState.length ? replaceState[replaceState.length - 1] : null), + check: (name, value, on) => { + const box = run.find(boxes, name, value) + box.checked = on !== false + form.dispatch('change', { target: box }) + return box + }, + search: (text) => { q.value = text; form.dispatch('input', { target: q }) }, + clickClear: () => clear.dispatch('click', {}), + } +} + +run.find = function (boxes, name, value) { + const box = boxes.find((b) => b.attrs.name === name && b.attrs.value === value) + if (!box) throw new Error('no checkbox for ' + name + '=' + value) + return box +} + +module.exports = { run } From 385e8993b85f43bc933adbe334089efce0459c11 Mon Sep 17 00:00:00 2001 From: Jake Cahill Date: Tue, 15 Sep 2026 17:16:11 +0100 Subject: [PATCH 037/142] Fix the overview summary expression that failed the site build `{{(parse-json page.attributes.solution).steps.length}}` is not valid Handlebars. It reached a published UI bundle because `handlebars.compile()` is lazy: it does not parse until the template is rendered, so nothing on this side complained. The first thing to render a solution overview was Netlify, where Antora died with a parse error and the deploy preview failed with a build script exit code and no clue in any check name. The preview had been broken since v4.2.0-beta.3 went in, about 20 hours. The result of a helper goes through `#with`, and the block also degrades correctly: a page with no solution attribute renders the summary without the count instead of an empty span. tests/template-syntax parses every template in src/layouts, src/partials and src/helpers with `handlebars.parse()`, which is eager, and asserts that the broken form throws while the `#with` form does not. Reintroducing the bad line fails that test and names the file and line. --- .github/workflows/validate-build.yml | 2 + package.json | 3 +- src/layouts/solution.hbs | 5 ++- tests/template-syntax/parse.test.js | 58 ++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/template-syntax/parse.test.js diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml index d30801a9..f395be9a 100644 --- a/.github/workflows/validate-build.yml +++ b/.github/workflows/validate-build.yml @@ -83,6 +83,8 @@ jobs: run: npm run test:solution-code-fold - name: Test Solutions industry and use-case filtering run: npm run test:solution-facets + - name: Test every Handlebars template parses + run: npm run test:template-syntax - name: Cache generated files uses: actions/cache@v4 with: diff --git a/package.json b/package.json index 4739d6b9..70803777 100644 --- a/package.json +++ b/package.json @@ -88,8 +88,9 @@ "test:solution-progress": "node --test tests/solution-progress/*.test.js", "test:solution-recommendations": "node --test tests/solution-recommendations/*.test.js", "test:solution-code-fold": "node --test tests/solution-code-fold/*.test.js", + "test:template-syntax": "node --test tests/template-syntax/*.test.js", "test:solution-facets": "node --test tests/solution-facets/*.test.js", - "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:property-tooltips && npm run test:signin-nudge && npm run test:chat-panel-navigation && npm run test:toc-collapsible && npm run test:kapa-source-groups && npm run test:solution-progress && npm run test:solution-recommendations && npm run test:solution-code-fold && npm run test:solution-facets", + "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:property-tooltips && npm run test:signin-nudge && npm run test:chat-panel-navigation && npm run test:toc-collapsible && npm run test:kapa-source-groups && npm run test:solution-progress && npm run test:solution-recommendations && npm run test:solution-code-fold && npm run test:solution-facets && npm run test:template-syntax", "build:wasm": "cd blobl-editor/wasm && GOOS=js GOARCH=wasm go build -o ../../src/static/blobl.wasm .", "copy:wasm-exec": "cp \"$(go env GOROOT)/lib/wasm/wasm_exec.js\" src/js/vendor/", "serve:playground": "npx serve ." diff --git a/src/layouts/solution.hbs b/src/layouts/solution.hbs index ce7930b4..e2126ee9 100644 --- a/src/layouts/solution.hbs +++ b/src/layouts/solution.hbs @@ -34,7 +34,10 @@
diff --git a/src/partials/solution-step-actions.hbs b/src/partials/solution-step-actions.hbs index c8a3281b..545d752c 100644 --- a/src/partials/solution-step-actions.hbs +++ b/src/partials/solution-step-actions.hbs @@ -15,10 +15,21 @@ 0 of {{solution.steps.length}} steps -
+
+ {{!-- The rail's "You finished" card is inside the collapsed rail on + narrow screens, so the same message and offer sit here too. + 28-solution-progress.js shows this copy only at or below the rail + breakpoint, once every step is done. --}} +
- {{#if solution.technologies.length}} + {{!-- Chips skip technologies every solution has (Redpanda), which would + otherwise take one of the three slots on every card. --}} + {{#with (without solution.technologies (lookup (get-solutions-catalog) 'universalTechnologies')) as |techs|}} + {{#if techs.length}}
- {{#each solution.technologies}}{{#if (lt @index 3)}}{{this}}{{/if}}{{/each}} + {{#each techs}}{{#if (lt @index 3)}}{{this}}{{/if}}{{/each}}
{{/if}} + {{/with}}