fix(forms): close completed drawers and restore portal styling

This commit is contained in:
DeCentN2Madness committed 2026-10-30 14:21:27 -07:00
1 parent e97f0ea9b4
commit 55adb79705
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.
+33 -2
View File
@@ -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).
+17
View File
@@ -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.
+15
View File
@@ -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.">
+33 -2
View File
@@ -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>
+13
View File
@@ -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);
};
+26 -2
View File
@@ -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'); }
+18
View File
@@ -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);
}
+9
View File
@@ -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;
}
+110 -3
View File
@@ -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]) {
+30
View File
@@ -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;