Skip to content

Release 1.18.2 → main (PROD promotion) - #1997

Merged
jung-thomas merged 241 commits into
mainfrom
DEV
Aug 23, 2026
Merged

Release 1.18.2 → main (PROD promotion)#1997
jung-thomas merged 241 commits into
mainfrom
DEV

Conversation

@jung-thomas

Copy link
Copy Markdown
Contributor

PROD promotion of DEV → main at version 1.18.2.

Version

Scope

239 commits ahead of main. Highlights: content freshness subsystem, a11y landmarks + baseline ratchet, tutorial reading-prefs (super-wide width, hand/eye navigation), selfie advocate frames.

main is a strict subset of DEV (0 main-only commits) — clean fast-forwardable promotion, no reconcile merge needed.

Deploy

After merge, deploy from a FRESH origin/main per the PROD runbook (full build:all + mbt build + cf deploy -e ../deploy/prod.mtaext).

…uard

PR #1884 (feat/image-ingest-force) added GET /content/image-source and
POST /content/image to srv/server.js without mirroring to srv-qa, tripping
check-srv-qa-route-drift and reddening the postbuild:apps unit job on DEV
and main.

Both routes are dark-launched: no approuter route proxies to image-source
on either channel (served HTML still emits raw.githubusercontent.com URLs
via scripts/parsers/images.ts), and the QA publish flow never POSTs images.
Mirroring now would pull image-store/image-ingest deps into the srv-qa cp
list for an unreachable surface. Allowlist both as srv-only (matching the
POST /content/orphan-purge maintenance precedent) with a note to re-evaluate
QA parity when the image proxy goes live.
…-drift-images

fix(ci): allowlist dark-launched image routes in srv-qa route-drift guard
…on fail-open (#1882)

When /img-cdn fail-opens to GitHub on a store miss, the approuter now
fire-and-forgets the fetched ORIGINAL bytes back to the srv's bytes-in
POST /content/image so the store self-populates on first view — closing
the gap between full backfills without depending on the srv's flagged
GitHub egress.

- approuter/lib/img-cdn-heal.js: injectable createHealer() with an
  in-memory TTL dedup map (one heal attempt per url per window),
  self-disabling when CONTENT_API_KEY is unresolvable. Non-blocking,
  never awaited, never throws.
- approuter/server.js: instantiate healer; hook heal() in
  loadOriginalBytes on the github-source path. Gated by IMG_CDN_HEAL
  (default on) and off when IMG_CDN_SOURCE=github.
- Auth: Bearer CONTENT_API_KEY via the credstore the approuter already
  binds. Channel derived server-side (channelFor); slug omitted.
- Tests: test/unit/img-cdn-heal.test.js (9 cases).
Two independent breakages on the tutorial feedback UI, both wrongly
suspected to be CDN/Akamai — verified live on PROD that neither is.

#1890 — "Submit detailed feedback" opened an empty popup. The lazy-load
in feedback-share.html built the island src via
`{{ partial "island-src.html" ... | jsonify }}`. In a <script> JS context
Hugo re-escapes the already-quoted jsonify output, embedding literal quote
chars into the value: `s.src='"/js/tutorial-feedback-<hash>.js"'`. The
browser resolves that relative to /tutorials/ → /tutorials/%22...%22 → 404,
so mountTutorialFeedback never loads and the popup stays empty. Fixed by
using island-src.html's documented contract (bare URL placed INSIDE quotes).
Verified via a minimal Hugo render: old emits `"\"/js/…\""`, new emits
`"/js/…"`.

#1889 — star rating POST /feedback/submit returned 503 "feedback service
unavailable". The Express guard read process.env.SUBMISSION_SALT_SECRET
directly, but the hashing path (feedback-salt.js) resolves the same alias
credstore-first via resolveSecret(). A secret set only in the BTP Credential
Store (the intended admin-UI rotation path) therefore still 503'd the whole
form. Routed the guard through resolveSecret() so it is consistent with the
hashing path and admin-UI rotations take effect without cf set-env. Env
fallback preserved → strictly backward-compatible.

Adds a post-deploy e2e guard (tutorial-serve.test.js) that clicks the button
and asserts the island loads (no 404) and the form renders.
…1882

feat(images): heal-on-request — approuter self-populates image store on fail-open (#1882)
fix(feedback): unblock tutorial feedback form (#1889, #1890)
A full QA content publish peaks ~883M RSS during append-batch BLOB
uploads. At the old 768M the single srv-qa instance OOM-crashes
(exit 134 / 502-503 no available endpoints), orphaning the publish
JobLock and forcing re-runs to 409 until the 30-min TTL. Manual
cf scale reverted on every MTA deploy; pin it in the base mta.yaml.

Fixes #1893
…r joined users

Add a keyword search box to the Devtoberfest sessions grid that matches
across session title, abstract, and each speaker's name, role, company and
bio (issue #1895). Bio was not previously in the feed: extend the speaker
SELECT and mapper to carry it (LargeString, safe alongside metadata like the
existing ABSTRACT).

Also fix the sessions grid never passing :joined to PointsBanner, which made
already-joined users see the 'Join Devtoberfest to earn points' CTA instead
of their score (flagged on the issue). Mirror the schedule island wiring.

Closes #1895
…1895

feat(devtoberfest): keyword search over sessions + fix Join banner
…p MCP OAuth

Joule Desktop (and similar MCP connectors) use an authorization-code+PKCE
public-client flow with a dynamic-port loopback redirect of the form
http://localhost:<port>/mcp-callback. The XSUAA redirect-uri allow-list
previously covered only /callback and /oauth/callback, so the authorize
request failed with 'redirect_uri does not match the configuration'.

Adds http://localhost:*/mcp-callback to oauth2-configuration.redirect-uris
in all four descriptors (dev tutorials + prod tutorials-prod, root + .deploy)
to keep them in sync.
fix(xsuaa): allow localhost /mcp-callback redirect for desktop MCP OAuth (Joule Desktop)
End users need a way to mint MCP Personal Access Tokens without the admin
UI, which most don't have access to. The PatService (/pats, @requires
authenticated-user, per-user row scoping) already supports self-service
mint/revoke — only an end-user surface was missing.

Adds a 'tokens' Vue island (hugo-apps/src/tokens) hosted on a private
Hugo page at /me/tokens/. It lists the signed-in user's tokens and mints
new ones (name, read | read+write scope, TTL) via the existing /pats/*
XSUAA route, revealing the plaintext once, with per-row revoke. Reuses
the /me islands' authenticated-fetch + csrfFetch pattern; no backend
change.

Linked from /me/ (new 'API Tokens' panel) and the API page
(/api-docs/). Updates docs/end-users/mcp-quickstart.md to point PAT
minting at /me/tokens/ instead of /admin-ui/#pats.

Tests: hugo-apps/src/tokens/__tests__/ApiTokens.test.ts (list, auth gate,
mint reveal-once, scope mapping, bound-action revoke). Full hugo-apps
unit suite green (1098 passed); csrf-clients + postbuild guards pass.
…istration

Adds top-level skills/<slug>/SKILL.md entries in the layout the SAP AI
Skills Library (github.com/SAP/ai-skills-library) discovers, so this
public repo can be registered there:

- graphify: knowledge-graph builder (wraps third-party graphifyy; attribution preserved)
- whats-new: self-contained gh-based merged-PR digest builder (repo-agnostic)
- sap-tutorials-content: read-only access to public developers.sap.com tutorial
  content (catalog, HTML/JSON, OData search, and the anonymous /mcp/search MCP endpoint)

Inert to the Hugo/CAP build; exists only for skills-registry discovery.
feat(mcp): self-service /me/tokens PAT minting page (#1897)
fix(deploy): pin tutorials-srv-qa memory to 1536M (#1893)
…ublish

docs(skills): publish skills/ folder for SAP AI Skills Library
#1901)

PR #1897 added the /me/tokens PAT page island under hugo-apps/src/tokens/,
which uses <ui5-text>. The ui5-entry-page-map only mapped the /me area's
islands under hugo-apps/src/me/ to the ui5-me entry (the only entry that
imports Text.js), so the tokens island fell back to ui5-core and the
check-ui5-entry-coverage build guard failed (<ui5-text> not registered).

Extend the ui5-me glob to include hugo-apps/src/tokens/. Runtime rendering
already worked (ui5-bootstrap.ts registers Text.js); this fixes the build
guard so a full DEV build/deploy passes. Verified: guard now reports OK.
Ships the DEV release train to PROD (1.16.0 → 1.17.0):

Features:
- feat(mcp): self-service /me/tokens PAT minting page (#1897)
- feat(devtoberfest): keyword search over sessions + Join banner fix (#1896)
- feat(images): heal-on-request — approuter self-populates image store on fail-open (#1882)

Fixes / chores:
- fix(ui5): map /me/tokens island to ui5-me entry so ui5-text is covered (#1901)
- fix(deploy): pin tutorials-srv-qa memory to 1536M (#1894)
- fix(xsuaa): allow http://localhost:*/mcp-callback redirect for desktop MCP OAuth (#1898)
- fix(feedback): unblock tutorial feedback form (#1889, #1890)
- fix(ci): allowlist dark-launched image routes in srv-qa route-drift guard (#1887)
- docs(skills): add public skills/ folder for SAP AI Skills Library registration (#1900)
… (#1906)

The verdict badge (✓/✗/⚠) sits in an inline-flex legend next to the
question text. With only `width: 1.5rem` and the default `flex-shrink: 1`,
a long question squeezes the fixed-width circle below 1.5rem while its
height stays 1.5rem — rendering as a squished vertical oval.

Add `min-width: 1.5rem` + `flex-shrink: 0` so the badge always stays a
1.5rem circle regardless of question length.

Verified in-browser: long-question legend rendered the old badge at
10.5px x 24px (oval) vs 24px x 24px (circle) with the fix.
… swallowing (#1904) (#1907)

In an expired session AppRouter answers /auth/user and API calls with the
XSUAA login interstitial: HTTP 200 + text/html whose inline <script>
navigates the top-level document to /oauth/authorize. Consumed via fetch()
that script never runs, so csrfFetch's token handshake threw a generic
CsrfFetchError that callers swallowed (e.g. the tutorial 'Done' button
flipped to 'Saving...' then reverted, with no progress and no re-login).

Detect the interstitial (a followed redirect, or a 200 HTML body where JSON
was expected) on both the /auth/user handshake and the mutating request
itself, and force a top-level navigation to /login?returnTo=<path> so the
OAuth redirect actually executes. Mirrors header.html's checkAuth pattern.
204/JSON responses are never flagged; detection is header-bag defensive for
minimal Response stubs.

Covers every mutating action routed through the shared csrfFetch.
…1908) (#1913)

Three easter-egg clues, one per public page, sourced from
hugo/data/scavenger_hunt.json and reusing a shared figure partial:

- Home: subtle right-gutter hotspot; the advocate hero (Ajay Soreng)
  pops out on hover/focus with the '1st letter of first name' clue.
- /ai/: DJ Adams peeks up from the bottom edge and pops fully up on
  hover/focus (no letter, congratulatory clue).
- /api-docs/: Shilpa Shankar embedded inline at the bottom of the
  article via a shortcode, with the '4th letter of first name' clue.

Hero images come from the public /api/advocates/:slug/photo route
(transparent-background WebP). Interactions are pure CSS
(:hover/:focus-within), honour prefers-reduced-motion, and load no JS.
CSS + partial are gated to the three pages only. Adds an anonymous
post-deploy e2e spec (self-skips locally).
…1909) (#1915)

The native <input type=file> behind core:HTML #importFileInput only had its
change listener attached in the view's onAfterRendering. That input lives inside
the edit-mode VBox (visible bound to mode==='edit'), so at initial (list-mode)
render its DOM does not exist and no listener is wired. Clicking 'Create New'
re-renders only that VBox subtree via invalidation — the view's onAfterRendering
does NOT re-fire — so the materialized <input> never got a listener. onImportPress
(repaired in #1834) opened the file dialog, the user picked a file, but the change
event landed on an input with no listener, so onImportFile never ran and the import
silently did nothing.

Fix: extract _wireImportInput() and also call it from onImportPress, where the
input is guaranteed present. onAfterRendering keeps wiring too (belt-and-suspenders).

Tests:
- test/unit/puzzle-import-wiring.test.js loads the real controller (+ real
  puzzle-io/geometry, UI5 deps stubbed) and drives onImportPress → change with the
  exact #1909 file shape (numeric-string rows/cols); fails before the fix.
- puzzle-designer e2e: Create New → Import JSON via the file chooser asserts the
  'Puzzle imported' toast and populated title/slug (Export was covered, Import was not).
Shuffle the fetched slideshow entries with Fisher-Yates in onMounted so
visitors don't always see the newest uploads first. Server still returns a
deterministic order; randomization is purely presentational and re-rolls on
each page load. next/prev/dots stay consistent since they're index-based.

Adds unit tests: entry-preservation invariant + deterministic mocked-RNG shuffle.
Adds an owner-scoped 'withdraw' action to PetoberfestService so a user
can take down their submission even after admin approval. Behaviour:

- Hard-deletes the PetSubmissions row (and its inline image blobs).
- Revokes the PETOBERFEST completion (sets the TaskRecord to SUPERSEDED)
  only when it was the user's last remaining entry for the contest;
  withdrawing one of several keeps the credit.
- SUPERSEDED (not deleted) matches the award idempotency check in
  petoberfest-upload.js, so a later re-upload re-awards cleanly.
- POST action (not DELETE) to survive Akamai's bare-verb block in PROD.

Frontend: adds a Withdraw button + confirm to the 'Your pets' list in
the Vue island; on success it drops the row locally and refreshes the
slideshow.

Note: does not recall a badge already auto-sent to NGDS (no un-send
path); local credit is revoked.

Tests: new test/unit/petoberfest-withdraw.test.js covers owner delete,
owner-scoping (non-owner rejected), last-entry supersede vs keep-credit,
and re-award after full withdraw.
* Release 1.17.0 — DEV → main (PROD promotion) (#1903)

* fix(ci): allowlist dark-launched image routes in srv-qa route-drift guard

PR #1884 (feat/image-ingest-force) added GET /content/image-source and
POST /content/image to srv/server.js without mirroring to srv-qa, tripping
check-srv-qa-route-drift and reddening the postbuild:apps unit job on DEV
and main.

Both routes are dark-launched: no approuter route proxies to image-source
on either channel (served HTML still emits raw.githubusercontent.com URLs
via scripts/parsers/images.ts), and the QA publish flow never POSTs images.
Mirroring now would pull image-store/image-ingest deps into the srv-qa cp
list for an unreachable surface. Allowlist both as srv-only (matching the
POST /content/orphan-purge maintenance precedent) with a note to re-evaluate
QA parity when the image proxy goes live.

* feat(images): heal-on-request — approuter self-populates image store on fail-open (#1882)

When /img-cdn fail-opens to GitHub on a store miss, the approuter now
fire-and-forgets the fetched ORIGINAL bytes back to the srv's bytes-in
POST /content/image so the store self-populates on first view — closing
the gap between full backfills without depending on the srv's flagged
GitHub egress.

- approuter/lib/img-cdn-heal.js: injectable createHealer() with an
  in-memory TTL dedup map (one heal attempt per url per window),
  self-disabling when CONTENT_API_KEY is unresolvable. Non-blocking,
  never awaited, never throws.
- approuter/server.js: instantiate healer; hook heal() in
  loadOriginalBytes on the github-source path. Gated by IMG_CDN_HEAL
  (default on) and off when IMG_CDN_SOURCE=github.
- Auth: Bearer CONTENT_API_KEY via the credstore the approuter already
  binds. Channel derived server-side (channelFor); slug omitted.
- Tests: test/unit/img-cdn-heal.test.js (9 cases).

* fix(feedback): unblock tutorial feedback form (#1889, #1890)

Two independent breakages on the tutorial feedback UI, both wrongly
suspected to be CDN/Akamai — verified live on PROD that neither is.

#1890 — "Submit detailed feedback" opened an empty popup. The lazy-load
in feedback-share.html built the island src via
`{{ partial "island-src.html" ... | jsonify }}`. In a <script> JS context
Hugo re-escapes the already-quoted jsonify output, embedding literal quote
chars into the value: `s.src='"/js/tutorial-feedback-<hash>.js"'`. The
browser resolves that relative to /tutorials/ → /tutorials/%22...%22 → 404,
so mountTutorialFeedback never loads and the popup stays empty. Fixed by
using island-src.html's documented contract (bare URL placed INSIDE quotes).
Verified via a minimal Hugo render: old emits `"\"/js/…\""`, new emits
`"/js/…"`.

#1889 — star rating POST /feedback/submit returned 503 "feedback service
unavailable". The Express guard read process.env.SUBMISSION_SALT_SECRET
directly, but the hashing path (feedback-salt.js) resolves the same alias
credstore-first via resolveSecret(). A secret set only in the BTP Credential
Store (the intended admin-UI rotation path) therefore still 503'd the whole
form. Routed the guard through resolveSecret() so it is consistent with the
hashing path and admin-UI rotations take effect without cf set-env. Env
fallback preserved → strictly backward-compatible.

Adds a post-deploy e2e guard (tutorial-serve.test.js) that clicks the button
and asserts the island loads (no 404) and the form renders.

* fix(deploy): pin tutorials-srv-qa memory to 1536M

A full QA content publish peaks ~883M RSS during append-batch BLOB
uploads. At the old 768M the single srv-qa instance OOM-crashes
(exit 134 / 502-503 no available endpoints), orphaning the publish
JobLock and forcing re-runs to 409 until the 30-min TTL. Manual
cf scale reverted on every MTA deploy; pin it in the base mta.yaml.

Fixes #1893

* feat(devtoberfest): keyword search over sessions + fix Join banner for joined users

Add a keyword search box to the Devtoberfest sessions grid that matches
across session title, abstract, and each speaker's name, role, company and
bio (issue #1895). Bio was not previously in the feed: extend the speaker
SELECT and mapper to carry it (LargeString, safe alongside metadata like the
existing ABSTRACT).

Also fix the sessions grid never passing :joined to PointsBanner, which made
already-joined users see the 'Join Devtoberfest to earn points' CTA instead
of their score (flagged on the issue). Mirror the schedule island wiring.

Closes #1895

* fix(xsuaa): allow http://localhost:*/mcp-callback redirect for desktop MCP OAuth

Joule Desktop (and similar MCP connectors) use an authorization-code+PKCE
public-client flow with a dynamic-port loopback redirect of the form
http://localhost:<port>/mcp-callback. The XSUAA redirect-uri allow-list
previously covered only /callback and /oauth/callback, so the authorize
request failed with 'redirect_uri does not match the configuration'.

Adds http://localhost:*/mcp-callback to oauth2-configuration.redirect-uris
in all four descriptors (dev tutorials + prod tutorials-prod, root + .deploy)
to keep them in sync.

* feat(mcp): self-service /me/tokens PAT minting page (#1897)

End users need a way to mint MCP Personal Access Tokens without the admin
UI, which most don't have access to. The PatService (/pats, @requires
authenticated-user, per-user row scoping) already supports self-service
mint/revoke — only an end-user surface was missing.

Adds a 'tokens' Vue island (hugo-apps/src/tokens) hosted on a private
Hugo page at /me/tokens/. It lists the signed-in user's tokens and mints
new ones (name, read | read+write scope, TTL) via the existing /pats/*
XSUAA route, revealing the plaintext once, with per-row revoke. Reuses
the /me islands' authenticated-fetch + csrfFetch pattern; no backend
change.

Linked from /me/ (new 'API Tokens' panel) and the API page
(/api-docs/). Updates docs/end-users/mcp-quickstart.md to point PAT
minting at /me/tokens/ instead of /admin-ui/#pats.

Tests: hugo-apps/src/tokens/__tests__/ApiTokens.test.ts (list, auth gate,
mint reveal-once, scope mapping, bound-action revoke). Full hugo-apps
unit suite green (1098 passed); csrf-clients + postbuild guards pass.

* docs(skills): add public skills/ folder for SAP AI Skills Library registration

Adds top-level skills/<slug>/SKILL.md entries in the layout the SAP AI
Skills Library (github.com/SAP/ai-skills-library) discovers, so this
public repo can be registered there:

- graphify: knowledge-graph builder (wraps third-party graphifyy; attribution preserved)
- whats-new: self-contained gh-based merged-PR digest builder (repo-agnostic)
- sap-tutorials-content: read-only access to public developers.sap.com tutorial
  content (catalog, HTML/JSON, OData search, and the anonymous /mcp/search MCP endpoint)

Inert to the Hugo/CAP build; exists only for skills-registry discovery.

* fix(ui5): map /me/tokens island to ui5-me entry so ui5-text is covered (#1901)

PR #1897 added the /me/tokens PAT page island under hugo-apps/src/tokens/,
which uses <ui5-text>. The ui5-entry-page-map only mapped the /me area's
islands under hugo-apps/src/me/ to the ui5-me entry (the only entry that
imports Text.js), so the tokens island fell back to ui5-core and the
check-ui5-entry-coverage build guard failed (<ui5-text> not registered).

Extend the ui5-me glob to include hugo-apps/src/tokens/. Runtime rendering
already worked (ui5-bootstrap.ts registers Text.js); this fixes the build
guard so a full DEV build/deploy passes. Verified: guard now reports OK.

* chore(release): bump MTA version to 1.17.0 for PROD release

Ships the DEV release train to PROD (1.16.0 → 1.17.0):

Features:
- feat(mcp): self-service /me/tokens PAT minting page (#1897)
- feat(devtoberfest): keyword search over sessions + Join banner fix (#1896)
- feat(images): heal-on-request — approuter self-populates image store on fail-open (#1882)

Fixes / chores:
- fix(ui5): map /me/tokens island to ui5-me entry so ui5-text is covered (#1901)
- fix(deploy): pin tutorials-srv-qa memory to 1536M (#1894)
- fix(xsuaa): allow http://localhost:*/mcp-callback redirect for desktop MCP OAuth (#1898)
- fix(feedback): unblock tutorial feedback form (#1889, #1890)
- fix(ci): allowlist dark-launched image routes in srv-qa route-drift guard (#1887)
- docs(skills): add public skills/ folder for SAP AI Skills Library registration (#1900)

* fix(petoberfest): don't show upload form to anonymous visitors

probeAuth() checked only r.ok on GET /auth/user. Through the approuter,
an anonymous visitor's request to the XSUAA-gated /auth/user route is
answered with HTTP 200 + an HTML login-redirect page (not a 401), so
r.ok was true for logged-out users and the 'Add your pet' upload form
rendered for everyone instead of the sign-in prompt.

Mirror the canonical robust probe from homepage-personalizer/coordinator.ts:
require a JSON content-type AND a truthy body.authenticated. Verified
against live PROD edge responses (anon 200-HTML -> false, authed JSON -> true).

Adds probeAuth unit tests (incl. the 200-HTML anon case) and App.vue
login-gate render tests (anon -> sign-in prompt, no form; authed -> form).
Puzzle solver pages were Hugo-static — one hand-authored
hugo/content/puzzles/<slug>.md per puzzle, baked into the approuter
static dir. A puzzle created (or slug-renamed) in the admin UI therefore
404'd: its row is in HANA and served by /puzzle-api, but no static page
existed and nothing generated one from the catalog (documented as an
unimplemented fast-follow in hugo/layouts/puzzles/single.html).

Serve the page from CAP for any puzzle that exists in HANA, so an
admin-created puzzle works immediately with no rebuild/deploy. The page
is a thin island shell (the puzzle Vue island fetches grid/clue data
from /puzzle-api at runtime), composed into the __shell__ chrome via the
same composeShell path group/mission/concept pages use.

- srv/lib/puzzle-page.js: renderPuzzleBody + createPuzzlePage handler
  factory (per-slug version+modifiedAt-keyed gzip cache, fail-open
  stale/503, styled 404 short-TTL for unknown slugs). Selects only
  scalar columns (never the layout/solution LOBs).
- chrome-shell.js: canonicalUrlFor + breadcrumb for kind 'puzzle'.
- srv/server.js: GET /content/puzzles/:slug.
- approuter/xs-app.json: /puzzles/<slug> -> /content/puzzles/<slug>
  (anonymous). Bare /puzzles/ and /puzzle-api/* untouched.

Verified: 66 unit tests green; all four postbuild guards pass; live
cds-watch boot returns the styled 404 for an unknown slug.
…draw

feat(petoberfest): let users withdraw their own uploaded photo
…errors

The multipart/form-data upload POST was silently stalled/blocked by the Akamai
edge on developers.sap.com while working fine on the raw cfapps approuter URL.
Switch the transport to a JSON base64 body over the same xsuaa + csrfFetch path
that /chat/stream and /api/* islands already use successfully through the CDN.

Also stop swallowing errors (the 'no response, no error, nothing' symptom):
- client uploadPet now sends {petName,filename,mimeType,photoBase64} JSON,
  surfaces HTTP status + any Akamai reference id on non-JSON edge errors, and
  times out after 60s (AbortController) instead of hanging silently.
- server /petoberfest-api/:slug/upload parses express.json(16mb) + new
  decodePhotoUpload() (10MB decoded cap); multer removed from this route.
- tests: uploadPet JSON/error/timeout, decodePhotoUpload unit, e2e upload leg -> JSON.
…-upload

fix(petoberfest): JSON base64 upload to survive Akamai CDN + surface errors
…oute-1914

fix(puzzles): serve /puzzles/<slug> dynamically from CAP (#1914)
…ation-accuracy

# Conflicts:
#	hugo-apps/src/tutorial-prefs/TutorialPrefsPopover.vue
#	hugo-apps/src/tutorial-prefs/main.ts
#	hugo-apps/src/tutorial-prefs/prefs-store.ts
…on-accuracy

Camera input: accuracy fixes + per-user calibration (eye + hand)
…or-spec

feat(freshness): AI tutorial freshness detector (v1, reviewer-assist)
Re-captured against deployed DEV. region cleared on /, /topics/, /tags/ (and
home image-redundant-alt); developer-advocates dropped from 8 violations
(2 serious) to 3 (0 serious) — nested-interactive / heading-order /
scrollable-region-focusable all resolved.

Baseline: 27 → 16 rule-instances. Criticals 0, serious 1 (a lone color-contrast
on one tutorial). What remains is almost entirely the duplicate-<main> landmark
family (browse/tutorials/advocates) — the next structural follow-up — plus one
region on /concepts/ from a different stray element than nav-progress.
…cepts landmark

Clears the remaining baseline items (all landmark/contrast, no criticals):

- Duplicate <main>: baseof wrapped every page in <main> while browse/advocates/
  tutorial layouts opened their own. Convert main#browse-results and
  main#advocates-mount to <div> (islands mount by id, nothing selects them by
  <main> tag). For tutorial detail pages, suppress baseof's OUTER main (emit
  <div class=site-main> with the same body>main flex) and keep
  <main class=tutorial-main> as the sole landmark — publish-content.ts's search
  body-extraction regex depends on that exact tag, so it is untouched.
- Browse double-banner: drop role=banner from .browse-banner (a section header
  nested under the shellbar's banner landmark).
- color-contrast (serious): .tutorial-figcaption used --sapContent_LabelColor
  (Horizon #556B82 ~4.1:1); pin to #47566a ~6.4:1.
- /concepts/ region: the concept body was spliced into the shell (whose <main>
  is a marker) without its own <main>, leaving the page with zero landmarks.
  Wrap the concept body in <main> (mirrors the browse/topics bodies).

Verified: hugo build parses/builds clean (exit 0); concept-detail-render +
publish-concepts + publish-content tests green; sap-fundamental.css rebuilt via
build:css. Landmark/contrast wins confirm post-deploy via the a11y gate.
a11y: ratchet baseline down after advocates/nav-progress fixes deployed
a11y: duplicate-main + browse banner + figcaption contrast + concepts landmark
Extend the Text > Reading width control (Full/Narrow) with a third
opt-in 'wide' option that uncaps .op-page to the full browser width.
The .op-twocol grid is left intact so the sticky right TOC stays and
the content column absorbs the freed space. Default remains 'full'.

- constants: ReadWidth += 'wide'
- prefs-store: getReadWidth accepts 'wide' (junk -> full)
- head.html: pre-paint accepts 'wide' (no layout flash)
- ui5-overrides.css: data-tut-read-width=wide -> .op-page max-width:none
- popover: third 'Super wide' segmented item + onWidthSelect guard
- tests: prefs-store + display-chrome cover the wide path
…Next

Swipes clicked the inter-tutorial Prev/Next pills (.tutorial-stepnav__slot--*),
so a detected gesture jumped to a different tutorial instead of moving between
steps within the current one.

Retarget nav-dispatch at the layout's step machinery: u1-object-page now
exposes window.opStepNav(dir), reusing the same getCurrentStep/scrollToStep/
resync path as the mobile sheet's prev/next buttons. Boundaries (first/last
step) no-op. Drop the now-dead SEL_NAV_* selectors and rewrite nav-dispatch
tests to pin the opStepNav contract.
…1966

feat(prefs): add Super wide reading-width option (#1966)
Eye auto-scroll couldn't fire for real users: it required iris gazeY > threshold
AND headForward (pitch < 0.55), but live telemetry showed gazeY reads ~0 and
inverted while normalized pitch runs 0.65-1.11 — so headForward was never true
and the gaze envelope sat at the reject floor.

Switch the calibrated signal from iris-gazeY to HEAD PITCH:
- EyeProfile now stores {pitchMin,pitchMax}; CAL_PROFILE_VERSION bumped to 2 so
  stale gaze profiles auto-invalidate and users recalibrate once.
- Calibration samples pitch during the top->bottom scan; GazeDetector fires DOWN
  when pitch enters the top (looking down) end of the envelope and UP the bottom
  end, with a resting-center deadband (CAL_EYE_DOWN/UP_FRACTION). EMA, dwell,
  grace, cooldown unchanged.
- headForward gate dropped; uncalibrated eye tracking is inert (waits for a
  profile rather than guessing an absolute baseline).
- cam-debug overlay shows pitch vs up/down thresholds; gazeY kept for info only.
- Tests + end-user docs updated.
…ps-eye-pitch

fix(tutorial-prefs): hand gestures navigate steps + calibrated head-pitch eye scroll (up/down)
onSizeSelect/onWidthSelect now skip the emit when the picked value equals
the current pref, matching the existing Header guard (mode !== headerMode).
UI5's ui5-segmented-button fires selection-change for the already-pressed
item on mount; the guard stops that redundant localStorage re-write +
applyDisplayChrome() call. onSizeSelect is shared across text/code/img, so
it maps each event to its matching prop.

Adds 8 unit tests (same-value → no emit, changed-value → emit) across all
four controls.
…-guard

fix(prefs): guard size/width segmented-button emit on mount (#1980)
Add advocate cut-out frames from D:/tmp/NewPhotos: Ad, Ajay,
Antonio_Sport, cwb, Kasimir_Hat, Kasimir_Josh, Kasimir_Smiles,
Kasimir_Types, Rekha, Sheena, Shilpa, Shrini, Singers.

Full frames downscaled to <=1600px longest side (alpha preserved),
320px-wide thumbnails generated. Registered in data-frames CSV in
hugo/layouts/devtoberfest/selfie.html. Frames force-added past the
global *.png gitignore, matching the existing 24 tracked frames.
The 'blank' entry in data-frames provides a transparent no-advocate
frame, but frames/blank.png was untracked while its thumbnail was
committed — a clean checkout would 404 the frame when 'blank' is
picked. Force-add the 600x600 fully-transparent PNG past the *.png
gitignore, matching the other tracked frames.
…frames

feat(selfie): add 13 advocate frames + thumbnails
#1989 removed tutorials' outer <main> (baseof) to fix duplicate-main. That
un-scoped the tutorial's <header class=op-header> (a sibling of
main.tutorial-main), so it resolved to a second banner landmark colliding with
the shellbar (axe landmark-no-duplicate-banner). Make op-header a <div> — its
class-based CSS + the .op-page > .op-header ResizeObserver selector are
unaffected, and the shellbar stays the sole banner. The h1 title remains.

Also bump the a11y scan settle 2500ms -> 4000ms: on heavy tutorial pages UI5
theme application wasn't complete at 2500ms, so axe sampled a pre-theme
transient figcaption colour (#6f7b8a, 4.3:1) and false-flagged color-contrast.
At full settle every figcaption is the fixed #47566a (6.4:1).
a11y: fix tutorial duplicate-banner regression + lengthen scan settle
…serious

Post-deploy retest of the landmark/contrast fixes. The scan now injects a style
tag zeroing animation/transition durations before axe runs: tutorial figures
fade opacity 0→1 (.tutorial-figure), and axe sampling one mid-fade composited the
caption colour toward white and reported a transient color-contrast failure
(~4.3:1) though the settled caption is #47566a (6.4:1). Collapsing fades to their
rest state removes the false positive (verified: all .tutorial-figure reach
opacity 1, color-contrast gone).

Re-baselined against deployed DEV: 27 → 2 rule-instances, criticals 0, serious 0.
6 of 8 sampled pages fully clean; the two tutorials retain only 'region'
(moderate) — the h1/tab-strip sit outside the single <main>, accepted as-is
(fixing it needs a tutorial-layout + search-extraction refactor).
a11y: neutralize animations in scan + ratchet baseline to 0 critical/serious
Patch bump ahead of the DEV → main PROD promotion.
chore(release): bump MTA version to 1.18.2 for PROD release
@jung-thomas
jung-thomas merged commit 71a1271 into main Aug 23, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant