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
|
||||
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
|
||||
resource/category, responsive directories, focus, link semantics and sanitization
|
||||
impact. Check wrong-code feedback, keyboard unlock, no-JS explanation and refresh.
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
**Last updated:** October 30, 2026 (UTC)
|
||||
**Repository:** `AIAMarketing/azinstitute4autism.com`
|
||||
**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`
|
||||
**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.
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## 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.
|
||||
No internal resource was published. Full evidence and boundaries are in
|
||||
[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 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.
|
||||
No diagnostic conditional note or service preselection is introduced. The
|
||||
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);
|
||||
frame.removeEventListener('load', onLoad);
|
||||
container.removeEventListener('aia:jotform-activate', activate);
|
||||
container.removeEventListener('aia:jotform-reset', reset);
|
||||
window.removeEventListener('pagehide', dispose);
|
||||
document.removeEventListener('astro:before-swap', dispose);
|
||||
delete container.dataset.jotformFallbackInitialized;
|
||||
@@ -118,6 +119,20 @@ const headingId = `${idBase}-heading`;
|
||||
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') {
|
||||
container.addEventListener('aia:jotform-activate', activate, { once: true });
|
||||
} else {
|
||||
|
||||
@@ -169,6 +169,9 @@ const consultationTestEnabled = formAIframe.enabled && formAIframe.context === '
|
||||
|
||||
{consultationTestEnabled
|
||||
? <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 />
|
||||
</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.">
|
||||
|
||||
@@ -36,7 +36,38 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
|
||||
[hidden] { display: none; }
|
||||
.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(table) { display: block; overflow-x: auto; max-width: 100%; }
|
||||
.employee-portal__content :global(td), .employee-portal__content :global(th) { padding: .5rem; }
|
||||
.employee-portal__content { line-height: 1.65; }
|
||||
.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; } }
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
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,
|
||||
* 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('referrerpolicy', 'no-referrer');
|
||||
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. */ }
|
||||
}
|
||||
// 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);
|
||||
target.appendChild(element);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isSuppressed, reachedHalf, suppressionDuration, suppressionKey } from '../utils/drawer-preference';
|
||||
import { isFormACompletion } from '../utils/jotform-completion';
|
||||
|
||||
let disposeAll: (() => void) | undefined;
|
||||
// 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);
|
||||
let timer: number | undefined;
|
||||
let closeTimer: number | undefined;
|
||||
let completionTimer: number | undefined;
|
||||
const acknowledgment = dialog.querySelector<HTMLElement>('[data-service-inquiry-completed]');
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
let trigger: HTMLElement | null = null;
|
||||
@@ -47,6 +50,11 @@ export function initializeServiceInquiryDrawers() {
|
||||
if (document.querySelector('dialog[open]')) return;
|
||||
autoConsumed = true;
|
||||
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();
|
||||
document.documentElement.classList.add('service-inquiry-open');
|
||||
dialog.querySelector('[data-jotform-form-a]')?.dispatchEvent(new Event('aia:jotform-activate'));
|
||||
@@ -59,7 +67,8 @@ export function initializeServiceInquiryDrawers() {
|
||||
const dismiss = () => {
|
||||
if (!dialog.open || closing) return;
|
||||
closing = true;
|
||||
remember();
|
||||
clearTimeout(completionTimer);
|
||||
if (dialog.dataset.submissionCompleted !== 'true') remember();
|
||||
stopAuto();
|
||||
dialog.classList.remove('is-open');
|
||||
const finish = () => { dialog.close(); closing = false; };
|
||||
@@ -74,6 +83,7 @@ export function initializeServiceInquiryDrawers() {
|
||||
dialog.addEventListener('cancel', (event) => { event.preventDefault(); dismiss(); }, { signal });
|
||||
dialog.addEventListener('click', (event) => { if (event.target === dialog) dismiss(); }, { signal });
|
||||
dialog.addEventListener('close', () => {
|
||||
clearTimeout(completionTimer);
|
||||
stopAuto();
|
||||
dialog.classList.remove('is-open');
|
||||
document.documentElement.classList.remove('service-inquiry-open');
|
||||
@@ -81,6 +91,20 @@ export function initializeServiceInquiryDrawers() {
|
||||
trigger = null;
|
||||
}, { signal });
|
||||
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) {
|
||||
anchor.setAttribute('aria-haspopup', 'dialog');
|
||||
anchor.setAttribute('aria-controls', dialog.id);
|
||||
@@ -103,7 +127,7 @@ export function initializeServiceInquiryDrawers() {
|
||||
}
|
||||
addEventListener('storage', (event) => { if (event.key === suppressionKey && suppressed()) stopAuto(); }, { signal });
|
||||
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');
|
||||
if (dialog.open) dialog.close();
|
||||
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
|
||||
? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE }
|
||||
: { 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)], {
|
||||
cwd: root,
|
||||
env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' },
|
||||
@@ -67,9 +68,10 @@ try {
|
||||
const duration = 30 * 86400000;
|
||||
const staffCode = 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: [
|
||||
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-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>', managementCode),
|
||||
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>' + styledFixture, managementCode),
|
||||
] };
|
||||
const guard = async (context, inject = false) => {
|
||||
await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); });
|
||||
@@ -91,7 +93,7 @@ try {
|
||||
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 target of routes.filter((r) => r.drawer)) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 844 }, serviceWorkers: 'block', reducedMotion: 'reduce' });
|
||||
@@ -194,6 +196,92 @@ try {
|
||||
evidence.checks++;
|
||||
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.
|
||||
for (const width of widths) {
|
||||
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.evaluate(() => document.activeElement?.hasAttribute('data-portal-content')), true);
|
||||
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));
|
||||
await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true });
|
||||
evidence.screenshots.push('portal-synthetic-' + width + '.png');
|
||||
@@ -220,6 +324,9 @@ try {
|
||||
await p.locator('[data-portal-content]').waitFor({ state: 'visible' });
|
||||
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/);
|
||||
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();
|
||||
}
|
||||
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 { decryptPortalStream, encryptPortalStream, validatePortalPayload, portalAlgorithm } from '../src/utils/portal-crypto.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', () => {
|
||||
const now = 10000000000;
|
||||
|
||||
Reference in new issue
Block a user