fix(forms): close completed drawers and restore portal styling
This commit is contained in:
15 files changed
+598
-9
No files matched your search
@@ -93,6 +93,24 @@ has not been decrypted or reviewed; any removed behavior must be reviewed with
|
|||||||
the owner before claiming fidelity. It must not silently become arbitrary
|
the owner before claiming fidelity. It must not silently become arbitrary
|
||||||
script execution. No real encrypted stream or plaintext is in Git.
|
script execution. No real encrypted stream or plaintext is in Git.
|
||||||
|
|
||||||
|
### Scoped presentation correction, 2026-10-30
|
||||||
|
|
||||||
|
The renderer now maps only `hs-button`, `content-button`, or
|
||||||
|
`portal-resource-button` anchor tokens, or exact inline AIA orange background
|
||||||
|
colors (`#ffbd59` / `#ff914d`), to the local `portal-resource-button` class.
|
||||||
|
It does not copy the original style or arbitrary classes. These are narrow
|
||||||
|
compatibility candidates, not a claim that encrypted real views have been
|
||||||
|
inspected. HTTPS/mailto/tel links retain the existing safe URL checks; contact
|
||||||
|
links receive a local dark-orange accent for readable contrast. Table scope
|
||||||
|
and bounded numeric row/column spans are preserved. All other executable or
|
||||||
|
unapproved attributes remain removed.
|
||||||
|
|
||||||
|
Scoped typography, spacing, lists, table rules and keyboard focus apply to both
|
||||||
|
unlocked streams. Synthetic staff/management fixtures exercise these mappings
|
||||||
|
at 320/390/768/1440px. Owner must inspect the real views privately to confirm
|
||||||
|
legacy button recognition and source fidelity; if a different class is used,
|
||||||
|
provide sanitized class semantics rather than decrypted content or credentials.
|
||||||
|
|
||||||
Before owner acceptance: validate two real view codes privately, inspect every
|
Before owner acceptance: validate two real view codes privately, inspect every
|
||||||
resource/category, responsive directories, focus, link semantics and sanitization
|
resource/category, responsive directories, focus, link semantics and sanitization
|
||||||
impact. Check wrong-code feedback, keyboard unlock, no-JS explanation and refresh.
|
impact. Check wrong-code feedback, keyboard unlock, no-JS explanation and refresh.
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
**Last updated:** October 30, 2026 (UTC)
|
**Last updated:** October 30, 2026 (UTC)
|
||||||
**Repository:** `AIAMarketing/azinstitute4autism.com`
|
**Repository:** `AIAMarketing/azinstitute4autism.com`
|
||||||
**Working branch:** `faithful-astro-migration`
|
**Working branch:** `faithful-astro-migration`
|
||||||
**Last accepted technical implementation:** Phase 9 isolated consultation integration at `ff80ee516f0185023af1f29937e29dfb30d31f47`; this handoff also describes the consolidated service-drawer and employee-portal checkpoint candidate
|
**Last accepted technical implementation:** consolidated service drawers and employee-portal preview at `e97f0ea9b475715a1ea58110a27cdf50a2b3cb19`; the commit containing this handoff adds the focused completion/styling correction pass
|
||||||
**Previous accepted checkpoint:** P9-03 documentation, signed `537f9864a0a3d0806180cbeca909bf1d03200c6f`
|
**Previous accepted checkpoint:** P9-03 documentation, signed `537f9864a0a3d0806180cbeca909bf1d03200c6f`
|
||||||
**Implementation checkpoint:** the commit containing this handoff update; obtain its actual SHA from Git history after the user creates the signed commit.
|
**Implementation checkpoint:** the commit containing this handoff update; obtain its actual SHA from Git history after the signed checkpoint is created.
|
||||||
**Next milestone:** continue Phase 9 owner decisions and authorized integration batches; then Phase 10 — Final QA/Cutover Readiness.
|
**Next milestone:** continue Phase 9 owner decisions and authorized integration batches; then Phase 10 — Final QA/Cutover Readiness.
|
||||||
|
|
||||||
The landing-page migration is technically completed for checkpoint purposes.
|
The landing-page migration is technically completed for checkpoint purposes.
|
||||||
@@ -401,3 +401,34 @@ staff/management authorization. See
|
|||||||
and [owner utility instructions](../integrations/employee-portal/README.md).
|
and [owner utility instructions](../integrations/employee-portal/README.md).
|
||||||
The actual checkpoint SHA is the commit containing this section; obtain it from
|
The actual checkpoint SHA is the commit containing this section; obtain it from
|
||||||
Git history. No push, deployment or cutover is authorized by this work.
|
Git history. No push, deployment or cutover is authorized by this work.
|
||||||
|
|
||||||
|
## Focused completion, portal styling and Spanish diagnostic — 2026-10-30
|
||||||
|
|
||||||
|
Authorized consultation drawers now accept only the documented
|
||||||
|
`submission-completed` / `formID` envelope from their activated iframe window
|
||||||
|
at the exact configured Jotform origin while the drawer is open. Object and
|
||||||
|
bounded JSON-string envelopes are supported; other actions/messages are ignored.
|
||||||
|
Completion writes the existing global 30-day expiry immediately, reveals EN/ES
|
||||||
|
acknowledgment, then starts slide-out after five seconds. Manual dismissal or
|
||||||
|
navigation cancels the countdown. Duplicate completion cannot restart it.
|
||||||
|
The next deliberate opening replaces the completed iframe; incomplete forms
|
||||||
|
remain intact across ordinary close/reopen. Load remains fallback-only, never
|
||||||
|
submission evidence. Real provider-message acceptance requires one owner-run
|
||||||
|
synthetic submission; automated tests use intercepted local fixtures only.
|
||||||
|
|
||||||
|
Portal presentation now restores scoped blue headings, spacing, directories,
|
||||||
|
orange report buttons and accessible contact accents without retaining arbitrary
|
||||||
|
classes, styles or scripts. Two synthetic encrypted views are verified. Private
|
||||||
|
source exposed no plaintext class/CSS semantics, and real view codes were not
|
||||||
|
available; exact legacy button recognition and visual parity require owner review.
|
||||||
|
No private content/resource or ciphertext was added to Git or public output.
|
||||||
|
|
||||||
|
The reported seven-option Spanish consultation dropdown is an unresolved
|
||||||
|
Jotform configuration issue: generated EN/ES URLs have identical form ID and
|
||||||
|
`formContext=consultation`, differing only in `language`. Astro neither supplies
|
||||||
|
nor edits child dropdown options. Owner should compare the direct hosted Spanish
|
||||||
|
form and review multilingual option translations/Update Options conditions.
|
||||||
|
No account changes or real submissions were performed. Seven production and
|
||||||
|
nine isolated-test placements remain unchanged; production consultation, portal
|
||||||
|
cutover and site cutover remain unauthorized. See the
|
||||||
|
[focused correction evidence](phase-9-drawer-portal-corrections-2026-10-30.md).
|
||||||
@@ -4350,3 +4350,20 @@ owner input. A passive DOM renderer intentionally disallows executable markup,
|
|||||||
remote media and embedded forms; real-view fidelity therefore needs owner review.
|
remote media and embedded forms; real-view fidelity therefore needs owner review.
|
||||||
No internal resource was published. Full evidence and boundaries are in
|
No internal resource was published. Full evidence and boundaries are in
|
||||||
[the consolidated report](phase-9-service-portal-consolidated-2026-10-30.md).
|
[the consolidated report](phase-9-service-portal-consolidated-2026-10-30.md).
|
||||||
|
|
||||||
|
### Focused correction addendum, 2026-10-30
|
||||||
|
|
||||||
|
Consultation drawers now acknowledge strictly verified provider completion for
|
||||||
|
five seconds before the existing slide-out, establish the shared 30-day
|
||||||
|
suppression immediately, and offer a fresh iframe on subsequent manual opening.
|
||||||
|
This owner-authorized behavior is isolated-test-only for the nine consultation
|
||||||
|
placements. Provider acceptance is pending; no real submission was generated.
|
||||||
|
|
||||||
|
Portal typography, spacing, tables and narrow safe button mappings restore AIA
|
||||||
|
visual hierarchy in synthetic encrypted views. The contact-link orange is
|
||||||
|
darkened to `#a94600` for text contrast on white; buttons preserve existing
|
||||||
|
orange/blue design tokens. Actual unlocked source parity remains owner review
|
||||||
|
because credentials were unavailable. No private markup or resources are tracked.
|
||||||
|
The Spanish extra dropdown choices remain a Jotform Builder review item, with
|
||||||
|
fixed Astro context/locale URLs verified. See
|
||||||
|
[focused evidence and remaining acceptance](phase-9-drawer-portal-corrections-2026-10-30.md).
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# Focused consultation completion and portal presentation correction
|
||||||
|
|
||||||
|
Date: 2026-10-30 UTC. Starting branch `faithful-astro-migration`, starting HEAD
|
||||||
|
`e97f0ea9b475715a1ea58110a27cdf50a2b3cb19`. Tracked tree and index were clean;
|
||||||
|
no untracked paths were reported at preflight. The checkpoint is the commit
|
||||||
|
containing this report; no push, deployment, Jotform mutation or real submission
|
||||||
|
is part of this correction.
|
||||||
|
|
||||||
|
## Completion contract and implementation
|
||||||
|
|
||||||
|
[Jotform Support](https://www.jotform.com/answers/30198031-detect-succesfull-submission)
|
||||||
|
and its linked screenshot were inspected. The screenshot shows an object
|
||||||
|
containing `action: 'submission-completed'` and string `formID` emitted by the
|
||||||
|
thank-you document. It is provider documentation, not a captured submission
|
||||||
|
from AIA Form A. The earlier automated support response on the same thread
|
||||||
|
does not describe this contract; the later human support reply does.
|
||||||
|
|
||||||
|
The receiver is installed only for authorized consultation drawers. It requires:
|
||||||
|
|
||||||
|
- Open, non-closing drawer with an activated consultation iframe.
|
||||||
|
- `event.source` equal to that iframe's actual `contentWindow`.
|
||||||
|
- Exact origin equal to the trusted server-rendered URL's origin:
|
||||||
|
`https://form.jotform.com`. No suffix, substring or wildcard match.
|
||||||
|
- Exact action and string Form A ID `262946078314059` matching that fixed URL.
|
||||||
|
- No prior completion for this frame instance.
|
||||||
|
|
||||||
|
Object envelopes and JSON strings of at most 4096 characters are supported.
|
||||||
|
Malformed, numeric-ID, unrelated and untrusted envelopes are rejected. Extra
|
||||||
|
fields are neither collected nor logged. The receiver uses the existing gated
|
||||||
|
URL instead of embedding a production ID/origin in a shared off-mode bundle.
|
||||||
|
No cross-origin DOM inspection, message forwarding, tracking or submission
|
||||||
|
handler was introduced. The official resize helper remains absent.
|
||||||
|
This trusted-window UI signal is not an independent server-side receipt audit.
|
||||||
|
|
||||||
|
On acceptance, the current global key
|
||||||
|
`aia:service-inquiry:auto-suppressed-until` immediately receives a 30-day expiry.
|
||||||
|
The drawer reveals “Thank you! Your message has been received.” or
|
||||||
|
“¡Gracias! Hemos recibido su mensaje.” above the iframe and scrolls it into view.
|
||||||
|
The existing thank-you document remains untouched. The full 5000ms interval
|
||||||
|
precedes the existing 500ms slide-out; reduced motion removes only the animation.
|
||||||
|
Focus then returns through the existing close mechanism.
|
||||||
|
|
||||||
|
Duplicates do not extend the timer or expiry. Manual close cancels the countdown
|
||||||
|
and retains suppression; navigation removes listeners/timers. Completed state
|
||||||
|
survives lifecycle reinitialization. A subsequent deliberate CTA replaces the
|
||||||
|
completed frame with a fresh node, clears only its fallback/load state, and
|
||||||
|
activates the same fixed URL. Old WindowProxy messages then fail source checks.
|
||||||
|
Incomplete frames remain intact across ordinary close/reopen. Load, validation,
|
||||||
|
clicks and timeout never count as completion.
|
||||||
|
|
||||||
|
The four-second/half-scroll opening rules, manual override, shared suppression,
|
||||||
|
bilingual text, no-JS fallback, sandbox, no-referrer and seven-production versus
|
||||||
|
nine-consultation-test route boundaries are unchanged.
|
||||||
|
|
||||||
|
## Portal presentation and evidence limits
|
||||||
|
|
||||||
|
Both streams use the same scoped presentation: blue section/subsection headings,
|
||||||
|
vertical spacing, list indentation, directories, orange action buttons and
|
||||||
|
visible focus. Only a narrow compatibility vocabulary maps to a local button
|
||||||
|
class: `hs-button`, `content-button`, `portal-resource-button`, or an exact
|
||||||
|
inline AIA orange background (`#ffbd59`/`#ff914d`). Styles themselves are never
|
||||||
|
copied. Contact destinations receive a dark-orange `#a94600` underline treatment
|
||||||
|
for readable contrast against white; buttons retain AIA orange/blue tokens.
|
||||||
|
Table `scope` and numeric spans 1–99 preserve directory semantics.
|
||||||
|
|
||||||
|
Arbitrary classes, inline style/event handlers, scripts, forms, remote styles,
|
||||||
|
iframes/media and unsafe URLs remain removed. Crypto, access-code handling,
|
||||||
|
ciphertext, resource mapping and publication gates are unchanged.
|
||||||
|
|
||||||
|
The legitimate private footer was checked only for unencrypted stylesheet/class
|
||||||
|
semantics; none were available. No real code was supplied and no actual stream
|
||||||
|
was decrypted. Button tokens are narrow compatibility candidates, not verified
|
||||||
|
real-view class inventory. Synthetic staff and management encrypted fixtures
|
||||||
|
exercise both class-based and exact brand-fill mappings. Source-faithful
|
||||||
|
restoration in the actual two views needs owner comparison; do not claim
|
||||||
|
pixel parity or expose decrypted HTML to obtain that evidence. No employee
|
||||||
|
resources, credentials, original HTML or real ciphertext were put in Git,
|
||||||
|
screenshots or public static assets.
|
||||||
|
|
||||||
|
## Spanish dropdown diagnosis
|
||||||
|
|
||||||
|
The owner observed five English service options and seven Spanish options,
|
||||||
|
including `Pregunta general` and `Programar una visita guiada`. Generated URLs:
|
||||||
|
|
||||||
|
| Locale | Exact consultation URL |
|
||||||
|
|---|---|
|
||||||
|
| EN | `https://form.jotform.com/262946078314059?formContext=consultation&language=en` |
|
||||||
|
| ES | `https://form.jotform.com/262946078314059?formContext=consultation&language=es` |
|
||||||
|
|
||||||
|
The three standalone and six drawer mappings use the same fixed form/context;
|
||||||
|
only the locale differs. The parent does not create an extra intent question,
|
||||||
|
preselect services, alter iframe fields or forward page queries. In test mode
|
||||||
|
legacy drawer controls are absent from the active collection surface.
|
||||||
|
|
||||||
|
Likely boundary: Jotform multilingual option translations or Update Options /
|
||||||
|
show-hide conditions retaining inquiry choices in Spanish. This is an inference
|
||||||
|
from source and owner observations, not a Builder audit. Owner should open the
|
||||||
|
direct hosted ES consultation URL and compare with EN and the embedded form.
|
||||||
|
If the direct ES version also shows seven choices, review translations of the
|
||||||
|
correct service question and every condition that changes its options. Preserve
|
||||||
|
contact/landing behavior and verify exactly five consultation choices in both
|
||||||
|
languages. No Jotform configuration or client-side workaround was attempted.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Focused unit/isolation/artifact suites: 31 passing tests (including two new
|
||||||
|
tests for completion-envelope validation and narrow portal presentation).
|
||||||
|
- Existing service/portal guarded browser suite: 47 grouped checks, 34 captures,
|
||||||
|
all six service routes at 320/390/768/1440px plus completion lifecycle checks
|
||||||
|
in EN/ES and both synthetic portal views. Evidence:
|
||||||
|
`/tmp/aia-service-portal-eqwVDU/evidence.json`.
|
||||||
|
- Browser environment: repository Nix browser shell, Playwright 1.61.1,
|
||||||
|
Chromium `149.0.7827.55` (actual executable version, not the earlier report's
|
||||||
|
Chromium 151 baseline). No new npm dependency or environment configuration.
|
||||||
|
- Focused correction rerun: 14 overlapping grouped checks, 10 captures,
|
||||||
|
`/tmp/aia-service-portal-CX1fu8/evidence.json`. This rerun adds explicit visible
|
||||||
|
viewport bounds and viewport-only acknowledgment screenshots. CSS-transition
|
||||||
|
time is allowed separately from virtual JavaScript timer time. Both checks
|
||||||
|
passed; overlapping counts are not additional unique route coverage.
|
||||||
|
- Desktop synthetic staff, mobile synthetic management and Spanish completion
|
||||||
|
screenshots were visually inspected. Browser automation is not real
|
||||||
|
screen-reader or fluent-language acceptance.
|
||||||
|
- The full browser run recorded 37 Jotform GET attempts fulfilled by local
|
||||||
|
fixtures; focused rerun recorded six. All non-loopback requests were intercepted
|
||||||
|
before transmission. Zero POST/DELETE, WebSocket, production API, analytics or
|
||||||
|
advertising requests were attempted. Zero external browser requests were sent.
|
||||||
|
|
||||||
|
Reproduction from the existing browser development shell, after the isolated
|
||||||
|
build, is `node tools/service-portal-browser.mjs --correction-only`; omit that
|
||||||
|
flag to include existing timing/scroll/suppression regressions. Every external
|
||||||
|
request is intercepted, service workers are blocked, and portal ciphertext is
|
||||||
|
synthetic response injection. Screenshots remain outside Git.
|
||||||
|
|
||||||
|
Evidence JSON SHA-256 values:
|
||||||
|
|
||||||
|
- Full regression: `5ed8661fdec3a289f43aedeb17113586e4935542cf665ab94759d3aac8794d7a`.
|
||||||
|
- Focused correction: `36bc4804e86fe1d59c9547b8a629a45bef8baac10e34799b845fa1e7642e69fd`.
|
||||||
|
|
||||||
|
Final validation:
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `npm run check` (also executed by builds) | 151 files; zero errors, warnings, hints |
|
||||||
|
| `node --experimental-strip-types --test --test-concurrency=1 tools/*.test.mjs` | 202 passed, zero failed/skipped; includes Markdown, publication, SEO, Library, routes, campaign, Phase 8, form preview/isolation/artifact and service/portal suites |
|
||||||
|
| `npm run audit:links` | Zero broken internal links |
|
||||||
|
| `npm run audit:images` | Zero missing mapped page images |
|
||||||
|
| `npm run audit:blog` | Zero content failures |
|
||||||
|
| `git diff --check` | Passed |
|
||||||
|
|
||||||
|
Documentation link review covered six changed documents. New links resolve.
|
||||||
|
One preexisting migration-summary link targets the archived
|
||||||
|
`route-reconciliation-2026-10-20-offline.json`; it is a historical reference
|
||||||
|
already documented by the handoff archive inventory, not an active dependency.
|
||||||
|
It remains preserved, and no archived report was read or modified.
|
||||||
|
|
||||||
|
Every mode ran `npm run build` and the matching
|
||||||
|
`node tools/phase10-artifact-verify.mjs --mode=...`:
|
||||||
|
|
||||||
|
| Build | Routes | Sitemap URLs | Hreflang | Immediate Form A frames | Deferred frames |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| Isolated test, all consultation flags approved | 128 | 0 | 0 | 10 | 6 |
|
||||||
|
| Fully approved production artifact (local only) | 127 | 109 | 180 | 7 | 0 |
|
||||||
|
| Indexing, forms off | 127 | 109 | 180 | 0 | 0 |
|
||||||
|
| Mock (staging verifier) | 128 | 0 | 0 | 0 | 0 |
|
||||||
|
| Final restored off staging | 128 | 0 | 0 | 0 | 0 |
|
||||||
|
|
||||||
|
All artifact checks passed with zero active native form actions. Final staging
|
||||||
|
has `noindex,nofollow` throughout. Production compilation never authorizes
|
||||||
|
deployment. Temporary review `/lp` remains absent from indexing artifacts.
|
||||||
|
Logs and sanitized artifact results are `/tmp/aia-correction-*.log` and
|
||||||
|
`/tmp/aia-correction-*-artifact.json`; no historical reconciliation was overwritten.
|
||||||
|
|
||||||
|
During development, the artifact verifier correctly rejected an early shared
|
||||||
|
bundle containing the Form A ID. The receiver now derives expected origin/ID
|
||||||
|
from the already authorized iframe URL; subsequent off/mock and gated artifact
|
||||||
|
checks pass without weakening the verifier. Browser full-page captures were
|
||||||
|
supplemented with viewport acknowledgment captures and visible-bounds assertions.
|
||||||
|
|
||||||
|
For consultation testing, set the existing six required values:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PUBLIC_AIA_FORMS_MODE=isolated-test \
|
||||||
|
PUBLIC_AIA_FORMS_ENVIRONMENT=isolated-test \
|
||||||
|
PUBLIC_AIA_FORMS_ISOLATED_TEST_APPROVED=true \
|
||||||
|
PUBLIC_AIA_FORMS_LIVE_APPROVED=false \
|
||||||
|
PUBLIC_AIA_FORMS_CONSULTATION_TEST_APPROVED=true \
|
||||||
|
PUBLIC_ALLOW_INDEXING=false npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
These settings are command-scoped; no local or deployed environment file changed.
|
||||||
|
|
||||||
|
## Changed files
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- `src/utils/jotform-completion.ts` (new strict completion validator).
|
||||||
|
- `src/scripts/service-inquiry.ts` (completion lifecycle and suppression).
|
||||||
|
- `src/components/ServiceInquiryDrawer.astro` (localized acknowledgment).
|
||||||
|
- `src/components/JotformIframe.astro` (controlled completed-frame reset).
|
||||||
|
- `src/utils/portal-presentation.ts` (new narrow presentation mapper).
|
||||||
|
- `src/scripts/employee-portal.ts` (passive presentation/semantic mapping).
|
||||||
|
- `src/components/pages/EmployeePortal.astro` (scoped styles).
|
||||||
|
|
||||||
|
Tests: `tools/service-portal.test.mjs`, `tools/service-portal-browser.mjs`.
|
||||||
|
Documentation: this report, `reports/SESSION_HANDOFF.md`,
|
||||||
|
`reports/phase-9-form-a-owner-acceptance-2026-10-28.md`,
|
||||||
|
`reports/phase-9-two-form-integration-specification-2026-10-28.md`,
|
||||||
|
`reports/migration-summary.md`, `integrations/employee-portal/README.md`.
|
||||||
|
All paths above are relative to `www/`.
|
||||||
|
|
||||||
|
## Owner acceptance and remaining blockers
|
||||||
|
|
||||||
|
1. Perform one real synthetic consultation submission manually on the approved
|
||||||
|
isolated preview. Confirm actual `action`, `formID` and sender origin without
|
||||||
|
recording submitted answers; verify five seconds, native confirmation,
|
||||||
|
suppression, focus restoration, and fresh manual reopening. If the provider
|
||||||
|
changes origin/shape, the listener intentionally fails closed. Do not broaden
|
||||||
|
trust based on an unverified message. Check both locales' presentation.
|
||||||
|
2. Resolve the Spanish dropdown in Builder after comparing the direct hosted
|
||||||
|
form; confirm five choices, requiredness and language-specific validation.
|
||||||
|
3. Privately unlock both portal views and compare report buttons, directory
|
||||||
|
spacing and focus on desktop/mobile. Supply only sanitized class semantics
|
||||||
|
for any missing style mapping. Real resource access control/cutover remain
|
||||||
|
separate decisions.
|
||||||
|
|
||||||
|
Seven production-eligible Form A routes remain unchanged. Production consultation
|
||||||
|
routes, portal cutover and site cutover remain unauthorized. No approval gate
|
||||||
|
is closed by fixtures, static artifacts or this checkpoint.
|
||||||
@@ -57,3 +57,28 @@ The owner reports that Form A now has a `consultation` context with the required
|
|||||||
The new routes require the separate `PUBLIC_AIA_FORMS_CONSULTATION_TEST_APPROVED=true` flag together with every isolated-test gate. They remain absent from normal staging, mock, indexing-enabled, production-gated and deployed artifacts. The existing seven Form A contact/landing production-eligible routes are unchanged.
|
The new routes require the separate `PUBLIC_AIA_FORMS_CONSULTATION_TEST_APPROVED=true` flag together with every isolated-test gate. They remain absent from normal staging, mock, indexing-enabled, production-gated and deployed artifacts. The existing seven Form A contact/landing production-eligible routes are unchanged.
|
||||||
|
|
||||||
The current decision supersedes the earlier plan for a conditional Autism Diagnostic Evaluation explanation. Do not display that note. The AIA client advocate will discuss evaluation options and coordination with qualified diagnostic providers in follow-up. Owner acceptance is still required for the actual dropdown's visibility, requiredness, choices, translations, conditional behavior, embedded submission/confirmation, error behavior and all nine route presentations.
|
The current decision supersedes the earlier plan for a conditional Autism Diagnostic Evaluation explanation. Do not display that note. The AIA client advocate will discuss evaluation options and coordination with qualified diagnostic providers in follow-up. Owner acceptance is still required for the actual dropdown's visibility, requiredness, choices, translations, conditional behavior, embedded submission/confirmation, error behavior and all nine route presentations.
|
||||||
|
|
||||||
|
## 2026-10-30 completion-message and language diagnostic addendum
|
||||||
|
|
||||||
|
The focused correction adds a narrow first-party completion listener only to
|
||||||
|
authorized consultation drawers. This explicitly supersedes any earlier blanket
|
||||||
|
no-message-listener description for that scope; it does not adopt Jotform's
|
||||||
|
resize handler or permit arbitrary origin messages. The support screenshot
|
||||||
|
documents `{ action: 'submission-completed', formID: '<form ID>' }`.
|
||||||
|
Exact origin, actual active iframe window, fixed Form A ID, open drawer and
|
||||||
|
idempotence are checked before acknowledgment/suppression and five-second closure.
|
||||||
|
No payload is logged, forwarded or used for analytics. Load is not success.
|
||||||
|
|
||||||
|
Owner acceptance **remains pending** for the provider's real envelope and origin,
|
||||||
|
EN/ES acknowledgment, thank-you visibility, full delay, focus restoration and
|
||||||
|
fresh-form reopening. Previous owner-reported direct/embedded acceptance is
|
||||||
|
preserved; it does not establish this newly added message contract.
|
||||||
|
|
||||||
|
The owner reports five English service choices but seven Spanish choices,
|
||||||
|
including General Question/Tour translations. Repository URL tests confirm fixed
|
||||||
|
consultation context and locale only; no service options originate in Astro.
|
||||||
|
Compare the direct hosted ES URL with the embedded result without submitting;
|
||||||
|
if both show seven choices, review Jotform multilingual translations and all
|
||||||
|
Update Options/show-hide conditions, including interaction with the inquiry
|
||||||
|
question. This is a Builder-boundary diagnosis, not an independently observed
|
||||||
|
Jotform configuration audit. No Jotform change is authorized or performed here.
|
||||||
@@ -123,3 +123,23 @@ navigation change, new iframe allowance or publication entry. All original
|
|||||||
seven production and nine isolated consultation placements remain unchanged.
|
seven production and nine isolated consultation placements remain unchanged.
|
||||||
No diagnostic conditional note or service preselection is introduced. The
|
No diagnostic conditional note or service preselection is introduced. The
|
||||||
clinical and fluent-language review gates remain open.
|
clinical and fluent-language review gates remain open.
|
||||||
|
|
||||||
|
### 2026-10-30 completion and language correction addendum
|
||||||
|
|
||||||
|
On a strictly validated Form A `submission-completed` message, an open activated
|
||||||
|
consultation drawer records the same shared suppression expiry immediately,
|
||||||
|
shows localized acknowledgment for five seconds, then uses the existing
|
||||||
|
slide-out/close/focus-restoration flow. The 500ms slide-out follows the full
|
||||||
|
five-second acknowledgment; reduced motion closes immediately after that delay.
|
||||||
|
Manual close/navigation cancels timers. A duplicate message is inert. A later
|
||||||
|
manual CTA starts a fresh iframe after completion; an unfinished iframe is
|
||||||
|
retained. This is not analytics, conversion measurement or answer inspection.
|
||||||
|
The exact source window and exact origin from the gated URL must match. No new
|
||||||
|
production route is approved. Provider behavior still requires owner acceptance.
|
||||||
|
|
||||||
|
The owner-reported Spanish seven-option dropdown conflicts with the intended
|
||||||
|
five choices. Astro emits one fixed consultation iframe per surface; EN/ES differ
|
||||||
|
only in language. No option identifiers, duplicate intent field or service
|
||||||
|
preselection are emitted by Astro. Owner must compare the hosted Spanish form,
|
||||||
|
then review multilingual option translations and Update Options conditions.
|
||||||
|
Do not remove options through cross-origin scripts or add another physical form.
|
||||||
@@ -96,6 +96,7 @@ const headingId = `${idBase}-heading`;
|
|||||||
if (timer) window.clearTimeout(timer);
|
if (timer) window.clearTimeout(timer);
|
||||||
frame.removeEventListener('load', onLoad);
|
frame.removeEventListener('load', onLoad);
|
||||||
container.removeEventListener('aia:jotform-activate', activate);
|
container.removeEventListener('aia:jotform-activate', activate);
|
||||||
|
container.removeEventListener('aia:jotform-reset', reset);
|
||||||
window.removeEventListener('pagehide', dispose);
|
window.removeEventListener('pagehide', dispose);
|
||||||
document.removeEventListener('astro:before-swap', dispose);
|
document.removeEventListener('astro:before-swap', dispose);
|
||||||
delete container.dataset.jotformFallbackInitialized;
|
delete container.dataset.jotformFallbackInitialized;
|
||||||
@@ -118,6 +119,20 @@ const headingId = `${idBase}-heading`;
|
|||||||
container.dataset.jotformIframeActivated = 'true';
|
container.dataset.jotformIframeActivated = 'true';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A completed drawer is renewed only on a later deliberate opening.
|
||||||
|
// Replacing the frame also invalidates messages from its old WindowProxy.
|
||||||
|
const reset = () => {
|
||||||
|
if (container.dataset.jotformDeferred !== 'true' || container.dataset.formContext !== 'consultation') return;
|
||||||
|
dispose();
|
||||||
|
const freshFrame = frame.cloneNode(false);
|
||||||
|
freshFrame.removeAttribute('src');
|
||||||
|
frame.replaceWith(freshFrame);
|
||||||
|
delete container.dataset.jotformDocumentLoaded;
|
||||||
|
delete container.dataset.jotformIframeActivated;
|
||||||
|
initialize(container);
|
||||||
|
};
|
||||||
|
container.addEventListener('aia:jotform-reset', reset);
|
||||||
|
|
||||||
if (container.dataset.jotformDeferred === 'true') {
|
if (container.dataset.jotformDeferred === 'true') {
|
||||||
container.addEventListener('aia:jotform-activate', activate, { once: true });
|
container.addEventListener('aia:jotform-activate', activate, { once: true });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === '
|
|||||||
|
|
||||||
{consultationTestEnabled
|
{consultationTestEnabled
|
||||||
? <div class="service-inquiry-drawer__iframe">
|
? <div class="service-inquiry-drawer__iframe">
|
||||||
|
<p data-service-inquiry-completed hidden role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
{lang === 'es' ? '¡Gracias! Hemos recibido su mensaje.' : 'Thank you! Your message has been received.'}
|
||||||
|
</p>
|
||||||
<JotformIframe config={formAIframe} heading={serviceCopy.heading} variant="drawer" deferUntilActivated hideHeading />
|
<JotformIframe config={formAIframe} heading={serviceCopy.heading} variant="drawer" deferUntilActivated hideHeading />
|
||||||
</div>
|
</div>
|
||||||
: <div class="service-inquiry-form" role="group" data-aia-form data-form-mode={mode} data-form-mode-reason={config.reason} data-logical-form="service" data-mock-result={mockResult} data-mock-success="Demo only: the local fixture completed without sending anything." data-mock-failed="Demo only: the local fixture simulated a failed submission." data-mock-unavailable="Demo only: the local fixture simulated an unavailable service.">
|
: <div class="service-inquiry-form" role="group" data-aia-form data-form-mode={mode} data-form-mode-reason={config.reason} data-logical-form="service" data-mock-result={mockResult} data-mock-success="Demo only: the local fixture completed without sending anything." data-mock-failed="Demo only: the local fixture simulated a failed submission." data-mock-unavailable="Demo only: the local fixture simulated an unavailable service.">
|
||||||
|
|||||||
@@ -36,7 +36,38 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
|
|||||||
[hidden] { display: none; }
|
[hidden] { display: none; }
|
||||||
.employee-portal__content :global(a) { color: var(--color-primary); text-decoration: underline; }
|
.employee-portal__content :global(a) { color: var(--color-primary); text-decoration: underline; }
|
||||||
.employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
|
.employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
|
||||||
.employee-portal__content :global(table) { display: block; overflow-x: auto; max-width: 100%; }
|
.employee-portal__content { line-height: 1.65; }
|
||||||
.employee-portal__content :global(td), .employee-portal__content :global(th) { padding: .5rem; }
|
.employee-portal__content :global(:is(h1, h2, h3, h4, h5, h6)) {
|
||||||
|
color: var(--color-primary); font-family: var(--font-heading); line-height: 1.25;
|
||||||
|
margin-block: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.employee-portal__content :global(:is(h1, h2)) { font-size: clamp(1.8rem, 3vw, 2.5rem); }
|
||||||
|
.employee-portal__content :global(h3) { font-size: clamp(1.4rem, 2vw, 1.8rem); }
|
||||||
|
.employee-portal__content :global(h4) { font-size: 1.25rem; }
|
||||||
|
.employee-portal__content :global(p) { margin-block: .75rem 1.25rem; }
|
||||||
|
.employee-portal__content :global(:is(ul, ol)) { padding-inline-start: 1.5rem; margin-block: 1rem 1.5rem; }
|
||||||
|
.employee-portal__content :global(li) { margin-block: .5rem; }
|
||||||
|
.employee-portal__content :global(:is(section, article) + :is(section, article)) { margin-block-start: 2.5rem; }
|
||||||
|
.employee-portal__content :global(hr) { border: 0; border-top: 1px solid var(--color-border); margin-block: 2rem; }
|
||||||
|
.employee-portal__content :global(a.portal-resource-button) {
|
||||||
|
display: inline-block; background: var(--color-accent); border: 2px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius-sm); color: var(--color-primary); font-family: var(--font-body);
|
||||||
|
font-size: 1rem; font-weight: 400; line-height: 1.5; padding: .65rem 1.2rem;
|
||||||
|
margin-block: .35rem; max-width: 100%; text-decoration: none;
|
||||||
|
}
|
||||||
|
.employee-portal__content :global(a.portal-resource-button:is(:hover, :focus-visible)) {
|
||||||
|
background: transparent; border-color: var(--color-accent-strong);
|
||||||
|
}
|
||||||
|
.employee-portal__content :global(a.portal-contact-link) {
|
||||||
|
/* Dark orange preserves the source accent with readable contrast on white. */
|
||||||
|
color: #a94600; text-underline-offset: .2em;
|
||||||
|
}
|
||||||
|
.employee-portal__content :global(table) { display: block; overflow-x: auto; max-width: 100%; border-collapse: collapse; margin-block: 1.5rem; }
|
||||||
|
.employee-portal__content :global(td), .employee-portal__content :global(th) {
|
||||||
|
padding: .75rem; border-bottom: 1px solid var(--color-border); text-align: start; vertical-align: top;
|
||||||
|
}
|
||||||
|
.employee-portal__content :global(th) { background: var(--color-tint); color: var(--color-primary); }
|
||||||
|
.employee-portal__content :global(dt) { font-weight: 600; margin-top: 1rem; }
|
||||||
|
.employee-portal__content :global(dd) { margin-inline-start: 1rem; }
|
||||||
@media (max-width: 500px) { .employee-portal__access { align-items: stretch; flex-direction: column; } }
|
@media (max-width: 500px) { .employee-portal__access { align-items: stretch; flex-direction: column; } }
|
||||||
</style>
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
|
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
|
||||||
|
import { portalLinkPresentation } from '../utils/portal-presentation';
|
||||||
|
|
||||||
/** Build a passive DOM, never inject executable decrypted HTML. Remote media,
|
/** Build a passive DOM, never inject executable decrypted HTML. Remote media,
|
||||||
* embedded forms and styles require owner review and are not loaded on unlock. */
|
* embedded forms and styles require owner review and are not loaded on unlock. */
|
||||||
@@ -21,9 +22,21 @@ export function safePortalContent(html: string): DocumentFragment {
|
|||||||
element.setAttribute('rel', 'noopener noreferrer');
|
element.setAttribute('rel', 'noopener noreferrer');
|
||||||
element.setAttribute('referrerpolicy', 'no-referrer');
|
element.setAttribute('referrerpolicy', 'no-referrer');
|
||||||
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
|
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
|
||||||
|
const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol);
|
||||||
|
if (presentation) element.className = presentation;
|
||||||
}
|
}
|
||||||
} catch { /* Invalid destination remains visible text without navigation. */ }
|
} catch { /* Invalid destination remains visible text without navigation. */ }
|
||||||
}
|
}
|
||||||
|
// Preserve directory semantics without retaining arbitrary legacy attributes.
|
||||||
|
if (source.tagName === 'TH' && ['row', 'col', 'rowgroup', 'colgroup'].includes(source.getAttribute('scope') ?? '')) {
|
||||||
|
element.setAttribute('scope', source.getAttribute('scope')!);
|
||||||
|
}
|
||||||
|
if (['TD', 'TH'].includes(source.tagName)) {
|
||||||
|
for (const attr of ['colspan', 'rowspan']) {
|
||||||
|
const value = source.getAttribute(attr);
|
||||||
|
if (value && /^[1-9]\d?$/.test(value)) element.setAttribute(attr, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const child of source.childNodes) copy(child, element);
|
for (const child of source.childNodes) copy(child, element);
|
||||||
target.appendChild(element);
|
target.appendChild(element);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { isSuppressed, reachedHalf, suppressionDuration, suppressionKey } from '../utils/drawer-preference';
|
import { isSuppressed, reachedHalf, suppressionDuration, suppressionKey } from '../utils/drawer-preference';
|
||||||
|
import { isFormACompletion } from '../utils/jotform-completion';
|
||||||
|
|
||||||
let disposeAll: (() => void) | undefined;
|
let disposeAll: (() => void) | undefined;
|
||||||
// Memory preference also covers storage-denied browsing during this document lifetime.
|
// Memory preference also covers storage-denied browsing during this document lifetime.
|
||||||
@@ -23,6 +24,8 @@ export function initializeServiceInquiryDrawers() {
|
|||||||
.filter((anchor) => anchor.dataset.serviceInquiryTrigger === dialog.dataset.service);
|
.filter((anchor) => anchor.dataset.serviceInquiryTrigger === dialog.dataset.service);
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
let closeTimer: number | undefined;
|
let closeTimer: number | undefined;
|
||||||
|
let completionTimer: number | undefined;
|
||||||
|
const acknowledgment = dialog.querySelector<HTMLElement>('[data-service-inquiry-completed]');
|
||||||
let firstFrame = 0;
|
let firstFrame = 0;
|
||||||
let secondFrame = 0;
|
let secondFrame = 0;
|
||||||
let trigger: HTMLElement | null = null;
|
let trigger: HTMLElement | null = null;
|
||||||
@@ -47,6 +50,11 @@ export function initializeServiceInquiryDrawers() {
|
|||||||
if (document.querySelector('dialog[open]')) return;
|
if (document.querySelector('dialog[open]')) return;
|
||||||
autoConsumed = true;
|
autoConsumed = true;
|
||||||
trigger = manual ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
trigger = manual ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
||||||
|
if (dialog.dataset.submissionCompleted === 'true') {
|
||||||
|
dialog.querySelector('[data-jotform-form-a]')?.dispatchEvent(new Event('aia:jotform-reset'));
|
||||||
|
delete dialog.dataset.submissionCompleted;
|
||||||
|
if (acknowledgment) acknowledgment.hidden = true;
|
||||||
|
}
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
document.documentElement.classList.add('service-inquiry-open');
|
document.documentElement.classList.add('service-inquiry-open');
|
||||||
dialog.querySelector('[data-jotform-form-a]')?.dispatchEvent(new Event('aia:jotform-activate'));
|
dialog.querySelector('[data-jotform-form-a]')?.dispatchEvent(new Event('aia:jotform-activate'));
|
||||||
@@ -59,7 +67,8 @@ export function initializeServiceInquiryDrawers() {
|
|||||||
const dismiss = () => {
|
const dismiss = () => {
|
||||||
if (!dialog.open || closing) return;
|
if (!dialog.open || closing) return;
|
||||||
closing = true;
|
closing = true;
|
||||||
remember();
|
clearTimeout(completionTimer);
|
||||||
|
if (dialog.dataset.submissionCompleted !== 'true') remember();
|
||||||
stopAuto();
|
stopAuto();
|
||||||
dialog.classList.remove('is-open');
|
dialog.classList.remove('is-open');
|
||||||
const finish = () => { dialog.close(); closing = false; };
|
const finish = () => { dialog.close(); closing = false; };
|
||||||
@@ -74,6 +83,7 @@ export function initializeServiceInquiryDrawers() {
|
|||||||
dialog.addEventListener('cancel', (event) => { event.preventDefault(); dismiss(); }, { signal });
|
dialog.addEventListener('cancel', (event) => { event.preventDefault(); dismiss(); }, { signal });
|
||||||
dialog.addEventListener('click', (event) => { if (event.target === dialog) dismiss(); }, { signal });
|
dialog.addEventListener('click', (event) => { if (event.target === dialog) dismiss(); }, { signal });
|
||||||
dialog.addEventListener('close', () => {
|
dialog.addEventListener('close', () => {
|
||||||
|
clearTimeout(completionTimer);
|
||||||
stopAuto();
|
stopAuto();
|
||||||
dialog.classList.remove('is-open');
|
dialog.classList.remove('is-open');
|
||||||
document.documentElement.classList.remove('service-inquiry-open');
|
document.documentElement.classList.remove('service-inquiry-open');
|
||||||
@@ -81,6 +91,20 @@ export function initializeServiceInquiryDrawers() {
|
|||||||
trigger = null;
|
trigger = null;
|
||||||
}, { signal });
|
}, { signal });
|
||||||
if (authorized) {
|
if (authorized) {
|
||||||
|
addEventListener('message', (event) => {
|
||||||
|
const container = dialog.querySelector<HTMLElement>('[data-jotform-form-a]');
|
||||||
|
const frame = container?.querySelector<HTMLIFrameElement>('[data-jotform-iframe-frame]');
|
||||||
|
const active = dialog.open && !closing && dialog.dataset.submissionCompleted !== 'true' &&
|
||||||
|
container?.dataset.formContext === 'consultation' && container.dataset.jotformIframeActivated === 'true';
|
||||||
|
if (!isFormACompletion(event, frame?.contentWindow, active, frame?.dataset.jotformIframeUrl)) return;
|
||||||
|
dialog.dataset.submissionCompleted = 'true';
|
||||||
|
remember();
|
||||||
|
if (acknowledgment) {
|
||||||
|
acknowledgment.hidden = false;
|
||||||
|
acknowledgment.scrollIntoView({ block: 'nearest', behavior: 'instant' });
|
||||||
|
}
|
||||||
|
completionTimer = window.setTimeout(dismiss, 5000);
|
||||||
|
}, { signal });
|
||||||
for (const anchor of triggers) {
|
for (const anchor of triggers) {
|
||||||
anchor.setAttribute('aria-haspopup', 'dialog');
|
anchor.setAttribute('aria-haspopup', 'dialog');
|
||||||
anchor.setAttribute('aria-controls', dialog.id);
|
anchor.setAttribute('aria-controls', dialog.id);
|
||||||
@@ -103,7 +127,7 @@ export function initializeServiceInquiryDrawers() {
|
|||||||
}
|
}
|
||||||
addEventListener('storage', (event) => { if (event.key === suppressionKey && suppressed()) stopAuto(); }, { signal });
|
addEventListener('storage', (event) => { if (event.key === suppressionKey && suppressed()) stopAuto(); }, { signal });
|
||||||
disposers.push(() => {
|
disposers.push(() => {
|
||||||
stopAuto(); clearTimeout(closeTimer); cancelAnimationFrame(firstFrame); cancelAnimationFrame(secondFrame);
|
stopAuto(); clearTimeout(closeTimer); clearTimeout(completionTimer); cancelAnimationFrame(firstFrame); cancelAnimationFrame(secondFrame);
|
||||||
controller.abort(); dialog.classList.remove('is-open');
|
controller.abort(); dialog.classList.remove('is-open');
|
||||||
if (dialog.open) dialog.close();
|
if (dialog.open) dialog.close();
|
||||||
for (const anchor of triggers) { anchor.removeAttribute('aria-controls'); anchor.removeAttribute('aria-haspopup'); }
|
for (const anchor of triggers) { anchor.removeAttribute('aria-controls'); anchor.removeAttribute('aria-haspopup'); }
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** Only the provider's completion envelope is used. Never retain/log answers. */
|
||||||
|
export function isFormACompletion(event: { origin: string; source: unknown; data: unknown },
|
||||||
|
frameWindow: unknown, active: boolean, approvedUrl: string | undefined): boolean {
|
||||||
|
if (!active || !frameWindow || event.source !== frameWindow || !approvedUrl) return false;
|
||||||
|
// This URL comes only from the server-rendered, gated route configuration,
|
||||||
|
// never from the message, current child location, query string or referrer.
|
||||||
|
let expected: URL;
|
||||||
|
try { expected = new URL(approvedUrl); } catch { return false; }
|
||||||
|
if (expected.protocol !== 'https:' || event.origin !== expected.origin) return false;
|
||||||
|
let data = event.data;
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
if (data.length > 4096) return false;
|
||||||
|
try { data = JSON.parse(data); } catch { return false; }
|
||||||
|
}
|
||||||
|
if (!data || typeof data !== 'object' || Array.isArray(data)) return false;
|
||||||
|
const message = data as Record<string, unknown>;
|
||||||
|
return message.action === 'submission-completed' && message.formID === expected.pathname.slice(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Translate a narrow presentation vocabulary; never copy arbitrary classes/styles. */
|
||||||
|
export function portalLinkPresentation(classes: string[], background: string, protocol: string): string | undefined {
|
||||||
|
if (protocol === 'mailto:' || protocol === 'tel:') return 'portal-contact-link';
|
||||||
|
if (classes.some((name) => ['hs-button', 'content-button', 'portal-resource-button'].includes(name)) ||
|
||||||
|
['rgb(255, 189, 89)', 'rgb(255, 145, 77)', '#ffbd59', '#ff914d'].includes(background.toLowerCase())) {
|
||||||
|
return 'portal-resource-button';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ try {
|
|||||||
browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE
|
browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE
|
||||||
? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE }
|
? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE }
|
||||||
: { headless: true });
|
: { headless: true });
|
||||||
|
evidence.browser = browser.version();
|
||||||
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
|
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' },
|
env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' },
|
||||||
@@ -67,9 +68,10 @@ try {
|
|||||||
const duration = 30 * 86400000;
|
const duration = 30 * 86400000;
|
||||||
const staffCode = randomBytes(24).toString('hex');
|
const staffCode = randomBytes(24).toString('hex');
|
||||||
const managementCode = randomBytes(24).toString('hex');
|
const managementCode = randomBytes(24).toString('hex');
|
||||||
|
const styledFixture = '<section><h2>Synthetic Directory</h2><h3>Operational reports</h3><p><a class="hs-button arbitrary-legacy-class" style="position:fixed" onclick="window.portalUnsafe=true" href="https://example.invalid/report">Synthetic report</a></p><ul><li>Synthetic list item</li></ul><table><caption>Fixture directory</caption><thead><tr><th scope="col">Role</th><th scope="col">Contact</th></tr></thead><tbody><tr><td>Fixture role</td><td><a href="mailto:fixture@example.invalid">Fixture email</a></td></tr></tbody></table><p><a style="background-color:#ffbd59" href="https://example.invalid/second">Second fixture report</a></p><a href="javascript:alert(1)">Unsafe destination</a><iframe src="https://example.invalid/frame"></iframe><form action="https://example.invalid/post"><input></form></section>';
|
||||||
const payload = { alg: portalAlgorithm, streams: [
|
const payload = { alg: portalAlgorithm, streams: [
|
||||||
await encryptPortalStream('fixture-staff', '<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="https://example.invalid/document">Synthetic resource</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">', staffCode),
|
await encryptPortalStream('fixture-staff', '<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="https://example.invalid/document">Synthetic resource</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">' + styledFixture, staffCode),
|
||||||
await encryptPortalStream('fixture-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>', managementCode),
|
await encryptPortalStream('fixture-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>' + styledFixture, managementCode),
|
||||||
] };
|
] };
|
||||||
const guard = async (context, inject = false) => {
|
const guard = async (context, inject = false) => {
|
||||||
await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); });
|
await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); });
|
||||||
@@ -91,7 +93,7 @@ try {
|
|||||||
return route.abort();
|
return route.abort();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
if (!process.argv.includes('--portal-only')) {
|
if (!process.argv.includes('--portal-only') && !process.argv.includes('--correction-only')) {
|
||||||
for (const width of widths) {
|
for (const width of widths) {
|
||||||
for (const target of routes.filter((r) => r.drawer)) {
|
for (const target of routes.filter((r) => r.drawer)) {
|
||||||
const context = await browser.newContext({ viewport: { width, height: 844 }, serviceWorkers: 'block', reducedMotion: 'reduce' });
|
const context = await browser.newContext({ viewport: { width, height: 844 }, serviceWorkers: 'block', reducedMotion: 'reduce' });
|
||||||
@@ -194,6 +196,92 @@ try {
|
|||||||
evidence.checks++;
|
evidence.checks++;
|
||||||
await backContext.close();
|
await backContext.close();
|
||||||
}
|
}
|
||||||
|
if (!process.argv.includes('--portal-only')) {
|
||||||
|
// Completion messages originate from a locally fulfilled cross-origin frame.
|
||||||
|
// No fixture submits anything; malformed browser envelopes are injected only in tests.
|
||||||
|
for (const locale of ['en', 'es']) {
|
||||||
|
const ctx = await browser.newContext({ serviceWorkers: 'block', viewport: { width: locale === 'es' ? 390 : 1440, height: 900 } });
|
||||||
|
await guard(ctx);
|
||||||
|
await ctx.addInitScript((key) => localStorage.setItem(key, String(Date.now() + 86400000)), key);
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
await p.clock.install({ time: new Date('2026-10-30T12:00:00Z') });
|
||||||
|
await p.clock.pauseAt(new Date('2026-10-30T12:00:01Z'));
|
||||||
|
await p.goto(origin + (locale === 'es' ? '/es/aba-therapy' : '/aba-therapy'));
|
||||||
|
const d = p.locator('[data-service-inquiry-drawer]');
|
||||||
|
const ack = d.locator('[data-service-inquiry-completed]');
|
||||||
|
const trigger = p.locator('[data-service-inquiry-trigger]');
|
||||||
|
const send = async (changes = {}) => p.evaluate((changes) => {
|
||||||
|
const frame = document.querySelector('dialog iframe');
|
||||||
|
const source = changes.wrongSource ? window : frame.contentWindow;
|
||||||
|
dispatchEvent(new MessageEvent('message', { origin: changes.origin ?? 'https://form.jotform.com', source,
|
||||||
|
data: { action: 'submission-completed', formID: '262946078314059', ...changes.data } }));
|
||||||
|
}, changes);
|
||||||
|
await send(); // Closed and unactivated.
|
||||||
|
assert.equal(await d.getAttribute('data-submission-completed'), null);
|
||||||
|
await trigger.click(); await p.clock.runFor(600);
|
||||||
|
// CSS transition rendering uses browser time, independently of mocked JS timers.
|
||||||
|
await delay(550);
|
||||||
|
await p.waitForFunction(() => [...document.querySelectorAll('iframe')].some(f => f.src.includes('consultation')));
|
||||||
|
for (const wrong of [{ origin: 'https://form.jotform.com.evil.invalid' }, { wrongSource: true },
|
||||||
|
{ data: { formID: 'other' } }, { data: { action: 'setHeight' } }]) await send(wrong);
|
||||||
|
await p.clock.runFor(6000);
|
||||||
|
assert.equal(await d.evaluate(d => d.open), true);
|
||||||
|
assert.equal(await ack.isVisible(), false);
|
||||||
|
evidence.checks++;
|
||||||
|
const child = p.frames().find(f => f.url() === expectedUrl(locale));
|
||||||
|
assert.ok(child, 'authorized fixture frame loaded');
|
||||||
|
await child.evaluate(() => parent.postMessage({ action: 'submission-completed', formID: '262946078314059' }, '*'));
|
||||||
|
await p.waitForFunction(() => document.querySelector('dialog')?.dataset.submissionCompleted === 'true');
|
||||||
|
assert.equal(await ack.isVisible(), true);
|
||||||
|
assert.match(await ack.textContent(), locale === 'es' ? /¡Gracias!/ : /Thank you!/);
|
||||||
|
const expiry = await p.evaluate(key => Number(localStorage.getItem(key)), key);
|
||||||
|
const now = await p.evaluate(() => Date.now());
|
||||||
|
assert.equal(expiry, now + duration);
|
||||||
|
await p.clock.runFor(3000); await send(); // Duplicate must not restart countdown.
|
||||||
|
await p.clock.runFor(1999);
|
||||||
|
assert.equal(await d.evaluate(d => d.open && d.classList.contains('is-open')), true);
|
||||||
|
const acknowledgmentBox = await ack.boundingBox();
|
||||||
|
assert.ok(acknowledgmentBox && acknowledgmentBox.x >= 0 && acknowledgmentBox.y >= 0 &&
|
||||||
|
acknowledgmentBox.y + acknowledgmentBox.height <= 900, 'acknowledgment is inside the visible viewport');
|
||||||
|
await p.screenshot({ path: path.join(output, `completion-${locale}.png`) });
|
||||||
|
evidence.screenshots.push(`completion-${locale}.png`);
|
||||||
|
await p.clock.runFor(1);
|
||||||
|
assert.equal(await d.evaluate(d => d.open && !d.classList.contains('is-open')), true, 'slide-out starts after full 5 seconds');
|
||||||
|
await p.clock.runFor(500);
|
||||||
|
assert.equal(await d.evaluate(d => d.open), false);
|
||||||
|
assert.equal(await trigger.evaluate(e => e === document.activeElement), true);
|
||||||
|
assert.equal(await p.evaluate(key => Number(localStorage.getItem(key)), key), expiry);
|
||||||
|
evidence.checks++;
|
||||||
|
await p.evaluate(() => window.oldFixtureWindow = document.querySelector('dialog iframe').contentWindow);
|
||||||
|
const count = evidence.jotformAttempts.length;
|
||||||
|
await trigger.click(); await p.clock.runFor(600);
|
||||||
|
assert.equal(await ack.isVisible(), false);
|
||||||
|
assert.equal(await d.locator('iframe').count(), 1);
|
||||||
|
assert.equal(await p.evaluate(() => window.oldFixtureWindow === document.querySelector('dialog iframe').contentWindow), false);
|
||||||
|
await p.waitForFunction(() => document.querySelector('[data-jotform-form-a]')?.dataset.jotformDocumentLoaded === 'true');
|
||||||
|
assert.equal(evidence.jotformAttempts.length, count + 1);
|
||||||
|
await p.evaluate(() => dispatchEvent(new MessageEvent('message', { origin: 'https://form.jotform.com', source: window.oldFixtureWindow,
|
||||||
|
data: { action: 'submission-completed', formID: '262946078314059' } })));
|
||||||
|
assert.equal(await ack.isVisible(), false);
|
||||||
|
await send(); await p.clock.runFor(1000);
|
||||||
|
await p.keyboard.press('Escape'); await p.clock.runFor(500);
|
||||||
|
assert.equal(await d.evaluate(d => d.open), false);
|
||||||
|
await trigger.click(); await p.clock.runFor(5000);
|
||||||
|
assert.equal(await d.evaluate(d => d.open), true, 'manual close canceled prior countdown');
|
||||||
|
assert.equal(await ack.isVisible(), false);
|
||||||
|
evidence.checks++;
|
||||||
|
await send();
|
||||||
|
await p.evaluate(() => document.dispatchEvent(new Event('astro:before-swap')));
|
||||||
|
await p.clock.runFor(6000);
|
||||||
|
assert.equal(await d.evaluate(d => d.open), false);
|
||||||
|
await p.evaluate(() => document.dispatchEvent(new Event('astro:page-load')));
|
||||||
|
await trigger.click(); await p.clock.runFor(6000);
|
||||||
|
assert.equal(await ack.isVisible(), false);
|
||||||
|
assert.equal(await d.evaluate(d => d.open), true);
|
||||||
|
evidence.checks++;
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
// Actual portal UI with synthetic encrypted response fixtures, never real private source.
|
// Actual portal UI with synthetic encrypted response fixtures, never real private source.
|
||||||
for (const width of widths) {
|
for (const width of widths) {
|
||||||
const ctx = await browser.newContext({ viewport: { width, height: 844 }, serviceWorkers: 'block' }); await guard(ctx, true);
|
const ctx = await browser.newContext({ viewport: { width, height: 844 }, serviceWorkers: 'block' }); await guard(ctx, true);
|
||||||
@@ -212,6 +300,22 @@ try {
|
|||||||
assert.equal(await p.locator('[data-portal-content] img, [data-portal-content] script').count(), 0);
|
assert.equal(await p.locator('[data-portal-content] img, [data-portal-content] script').count(), 0);
|
||||||
assert.equal(await p.evaluate(() => document.activeElement?.hasAttribute('data-portal-content')), true);
|
assert.equal(await p.evaluate(() => document.activeElement?.hasAttribute('data-portal-content')), true);
|
||||||
assert.equal(await input.inputValue(), '');
|
assert.equal(await input.inputValue(), '');
|
||||||
|
const checkPresentation = async () => {
|
||||||
|
const content = p.locator('[data-portal-content]');
|
||||||
|
assert.equal(await content.locator('.portal-resource-button').count(), 2);
|
||||||
|
assert.equal(await content.locator('[style], [onclick], .arbitrary-legacy-class, iframe, form, input').count(), 0);
|
||||||
|
assert.equal(await content.locator('a[href^="javascript:"]').count(), 0);
|
||||||
|
assert.equal(await content.locator('th[scope="col"]').count(), 2);
|
||||||
|
const button = content.locator('.portal-resource-button').first();
|
||||||
|
assert.equal(await button.evaluate(e => getComputedStyle(e).backgroundColor), 'rgb(255, 189, 89)');
|
||||||
|
assert.equal(await content.locator('h2').first().evaluate(e => getComputedStyle(e).color), 'rgb(37, 64, 128)');
|
||||||
|
assert.ok(await content.locator('h2').first().evaluate(e => parseFloat(getComputedStyle(e).fontSize) >= 28));
|
||||||
|
assert.equal(await content.locator('.portal-contact-link').evaluate(e => getComputedStyle(e).color), 'rgb(169, 70, 0)');
|
||||||
|
await button.focus();
|
||||||
|
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineStyle), 'solid');
|
||||||
|
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineWidth), '3px');
|
||||||
|
};
|
||||||
|
await checkPresentation();
|
||||||
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||||
await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true });
|
await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true });
|
||||||
evidence.screenshots.push('portal-synthetic-' + width + '.png');
|
evidence.screenshots.push('portal-synthetic-' + width + '.png');
|
||||||
@@ -220,6 +324,9 @@ try {
|
|||||||
await p.locator('[data-portal-content]').waitFor({ state: 'visible' });
|
await p.locator('[data-portal-content]').waitFor({ state: 'visible' });
|
||||||
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/);
|
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/);
|
||||||
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/);
|
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/);
|
||||||
|
await checkPresentation();
|
||||||
|
await p.screenshot({ path: path.join(output, 'portal-management-synthetic-' + width + '.png'), fullPage: true });
|
||||||
|
evidence.screenshots.push('portal-management-synthetic-' + width + '.png');
|
||||||
evidence.checks++; await ctx.close();
|
evidence.checks++; await ctx.close();
|
||||||
}
|
}
|
||||||
for (const js of [false, true]) {
|
for (const js of [false, true]) {
|
||||||
|
|||||||
@@ -8,6 +8,36 @@ import { isSuppressed, reachedHalf, suppressionDuration } from '../src/utils/dra
|
|||||||
import { serviceInquiryCopy } from '../src/data/service-inquiry.ts';
|
import { serviceInquiryCopy } from '../src/data/service-inquiry.ts';
|
||||||
import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portalAlgorithm } from '../src/utils/portal-crypto.mjs';
|
import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portalAlgorithm } from '../src/utils/portal-crypto.mjs';
|
||||||
import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs';
|
import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs';
|
||||||
|
import { isFormACompletion } from '../src/utils/jotform-completion.ts';
|
||||||
|
import { portalLinkPresentation } from '../src/utils/portal-presentation.ts';
|
||||||
|
|
||||||
|
test('completion envelope requires exact origin, active frame window and string Form A ID', () => {
|
||||||
|
const source = {};
|
||||||
|
const check = (event, frame, active) => isFormACompletion(event, frame, active,
|
||||||
|
'https://form.jotform.com/262946078314059?formContext=consultation&language=en');
|
||||||
|
const data = { action: 'submission-completed', formID: '262946078314059' };
|
||||||
|
const event = { origin: 'https://form.jotform.com', source, data };
|
||||||
|
assert.equal(check(event, source, true), true);
|
||||||
|
assert.equal(check({ ...event, data: JSON.stringify(data) }, source, true), true);
|
||||||
|
for (const change of [
|
||||||
|
{ origin: 'https://form.jotform.com.evil.invalid' }, { origin: 'null' }, { origin: 'http://form.jotform.com' },
|
||||||
|
{ source: {} }, { source: null }, { data: { ...data, formID: 'other' } },
|
||||||
|
{ data: { ...data, formID: 262946078314059 } }, { data: { ...data, action: 'load' } },
|
||||||
|
{ data: '{broken' }, { data: [] }, { data: null }, { data: 'x'.repeat(4097) },
|
||||||
|
]) assert.equal(check({ ...event, ...change }, source, true), false);
|
||||||
|
assert.equal(check(event, source, false), false);
|
||||||
|
assert.equal(check(event, null, true), false);
|
||||||
|
assert.equal(isFormACompletion(event, source, true, undefined), false);
|
||||||
|
assert.equal(isFormACompletion(event, source, true, 'broken'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portal presentation translates only explicit button tokens/brand fills and contact protocols', () => {
|
||||||
|
assert.equal(portalLinkPresentation(['hs-button', 'untrusted'], '', 'https:'), 'portal-resource-button');
|
||||||
|
assert.equal(portalLinkPresentation([], 'rgb(255, 189, 89)', 'https:'), 'portal-resource-button');
|
||||||
|
assert.equal(portalLinkPresentation(['arbitrary'], 'url(https://example.invalid)', 'https:'), undefined);
|
||||||
|
assert.equal(portalLinkPresentation([], '', 'mailto:'), 'portal-contact-link');
|
||||||
|
assert.equal(portalLinkPresentation([], '', 'tel:'), 'portal-contact-link');
|
||||||
|
});
|
||||||
|
|
||||||
test('global preference validates expiry, corrupted values and clock anomalies', () => {
|
test('global preference validates expiry, corrupted values and clock anomalies', () => {
|
||||||
const now = 10000000000;
|
const now = 10000000000;
|
||||||
|
|||||||
Reference in new issue
Block a user