@duffcloudservices/site-forms

Shared runtime for DCS customer sites — renders managed form definitions from .dcs/forms/ .yaml
4,646
Install
npm install @duffcloudservices/site-forms
Latest Version:0.8.4
Vue:3
License:MIT
Last Updated:Oct 18, 2026
Links: npm

@duffcloudservices/site-forms

Shared <DcsForm/> runtime for DCS customer sites. Renders a managed form definition (created in the portal Form Manager) from a build-time .dcs/forms/<formId>.yaml snapshot, validates user input, and posts submissions to the public site-forms API.

This is the single import surface for managed-form runtime code on customer sites — do not redefine field components per site.

Inside the dcs-again workspace the package is also reachable as @duffcloudservices/site-forms via workspace:* (the workspace name and the published name are the same). In sibling customer-site repos install the published package from public npm — see PUBLISHING.md for the consumption story and registry setup.

Install

In a workspace app (inside dcs-again)

// portal/package.json
{
  "dependencies": {
    "@duffcloudservices/site-forms": "workspace:*"
  }
}

Then pnpm install from the repo root.

In a sibling customer-site repo (e.g. ktbraunlaw, kept)

pnpm add @duffcloudservices/site-forms

See PUBLISHING.md for the registry, version, and release-workflow details.

Stylesheet

The library build extracts its CSS; dist/index.js does not inject it. Import it once in the site's entry (or theme index.ts):

import '@duffcloudservices/site-forms/style.css'

The ./style.css export resolves to dist/site-forms.css from 0.7.8 on (C-1828; 0.7.7 and earlier pointed at a dist/style.css the build never emitted).

A site that restyles the dcs-form* classes instead of importing this file must, from 0.7.9, give .dcs-form--success:focus-visible a visible indicator that passes 3:1 against the surrounding background, and must not remove it with outline: none. The browser default ring is acceptable. Focus now lands on that container after a successful submit (C-1916). The screen-reader-only labels need no site rule, because the declarations ride inline (C-1885). A site must not override position, clip or width on those legends and spans with !important.

Vite setup

Three pieces are required in the consuming site:

  1. vite-plugin-yaml so YAML modules return parsed objects:

    // vite.config.ts
    import yaml from '@modyfi/vite-plugin-yaml'
    
    export default defineConfig({
      plugins: [vue(), yaml()],
    })
    

    Without it, the loader falls back to parsing raw strings via js-yaml, which works but pays the parse cost at boot.

  2. A formsModules loader in your site that does the import.meta.glob from a path Vite can resolve (see below).

  3. Env vars the source reads (see the caveat below):

    Variable Purpose
    VITE_DCS_PUBLIC_API Base URL of the DCS public API (no trailing slash).
    VITE_DCS_SITE_SLUG Default site slug used when the prop is omitted.

    Caveat (C-1564, W80): the published dist does NOT read either variable. The library build statically erases import.meta.env (the dist carries {} there), so a site that installs this package from npm cannot configure it through env. Use the api-base / site-slug props. No fleet site needs api-base in production: see Public API base.

Public API base

<DcsForm> resolves where it posts in this order:

  1. the api-base prop;
  2. VITE_DCS_PUBLIC_API (workspace-source consumers only, per the caveat above);
  3. in the browser on localhost / 127.0.0.1 / ::1: the relative /api/v1 (the dev proxy);
  4. on any other host: the package default, package.json config.publicApiBase (https://api.duffcloudservices.com/api/v1, the production DCS public API).

Step 4 is the documented production contract, not a fallback: no fleet production build sets the env or passes the prop, and they all post to the default. In a production build it is silent. In a non-production build on a non-localhost host (a LAN or tunnel dev server, a dev-mode preview) it logs one console.warn per page load, because there a form under test is posting to the production API. The mode is process.env.NODE_ENV === 'production', which survives the library build and is replaced by the consuming bundler (Vite and VitePress define it for dependencies). Under Node (SSR / prerender) the base is '' and nothing is posted.

Why the formsModules prop is required in real sites

<DcsForm/> ships with an internal import.meta.glob('/.dcs/forms/*.yaml') fallback, but Vite resolves the leading / against the consumer's Vite project root (the directory containing vite.config.ts). On every customer-site repo today, the .dcs/forms/ directory lives at the repo root, one or more levels above the Vite root (typically site/ or docs/). The internal glob therefore matches nothing and you get:

[@duffcloudservices/site-forms] No form definition found for "contact".
Expected a YAML at /.dcs/forms/contact.yaml.

The fix is a one-file loader the rest of your site imports from.

Vue SPA (vite.config.ts in site/)
// site/src/dcs-forms.ts
const modules = import.meta.glob('../../.dcs/forms/*.yaml', {
  eager: true,
  import: 'default',
})
export const dcsFormsModules: Record<string, unknown> = modules
VitePress (vite block in docs/.vitepress/config.ts)
// docs/.vitepress/dcs-forms-loader.ts
const modules = import.meta.glob('../../.dcs/forms/*.yaml', {
  eager: true,
  import: 'default',
})
export const dcsFormsModules: Record<string, unknown> = modules

The relative depth (../../) depends on where the loader file lives relative to the repo root. Adjust as needed.

Usage

Place a YAML file at <site>/.dcs/forms/contact.yaml:

formId: contact
submission:
  kind: lead
fields:
  - id: name
    type: text
    label: Name
    required: true
  - id: email
    type: email
    label: Email
    required: true
  - id: message
    type: textarea
    label: Message
    required: true

Then in any page component:

<script setup lang="ts">
import { DcsForm } from '@duffcloudservices/site-forms'
import { dcsFormsModules } from '@/dcs-forms'
</script>

<template>
  <DcsForm
    form-id="contact"
    :forms-modules="dcsFormsModules"
    @submit-success="onSuccess"
    @submit-error="onError"
  />
</template>

Props

Prop Type Default Notes
formId string (required) — Matches .dcs/forms/<formId>.yaml.
siteSlug string import.meta.env.VITE_DCS_SITE_SLUG Path segment in the submission URL.
definitionOverride PortalFormDefinition — Used by the portal preview iframe to show in-flight edits.
apiBase string the production public API (see Public API base) Override for tests / non-prod environments. Silent default in production builds.
captchaToken string — Attached to the submission payload when set.
formsModules Record<string, unknown> internal fallback glob (rarely matches) Required in real sites. Pass a Record<string, unknown> from your own import.meta.glob('../../.dcs/forms/*.yaml', { eager: true, import: 'default' }) — see Vite setup section.
initialValues FormValues (Record<string, unknown>) — Runtime values merged over the YAML defaultValues at init and on reset. Merge order: YAML default < initialValues < user input. Every key must be a value field the definition declares; an unknown or layout-only id throws at setup. Read once, not watched. See Page context.
failureContact DcsSiteContact ({ email?, phone? }) | false — The email and phone shown under a failed submit. resume-submission forms default to the site's own contact from the page's JSON-LD (written from .dcs/seo.yaml by the cms SEO plugin); false turns the line off; a value on another form kind opts it in. See A failed careers submit.

Page context (initialValues)

Use initialValues to record where a lead came from. Declare a hidden field in .dcs/forms/<formId>.yaml (and in the portal form, per the site-forms 3-way schema sync; the server stores only what the definition declares), then set it per page:

<DcsForm
  form-id="inquiry"
  :forms-modules="dcsFormsModules"
  :initial-values="{ course: 'pinehurst-no-2' }"
/>
  • A hidden field has no input, so it keeps the initial value through user edits, reset() and submission. It appears in the submitted values.
  • A visible field shows the initial value and the visitor can change it; the typed value wins.
  • A key the definition does not declare (or a section-heading / html-block id) throws an error naming the key and the declared fields. A typo fails the prerender or dev server instead of silently losing the context. When the form definition itself is missing, initialValues is ignored so the "not configured" state still renders.
  • Added in the release after 0.7.5 (C-1767).

Emits

Event Payload When
submit-success DcsFormSubmitSuccess API responded 2xx with a JSON (or empty) body.
submit-error DcsFormSubmitError Network error (status undefined, raw exception in error; 0.7.9), non-2xx after retries, or a 2xx whose body is not JSON (nonJsonResponse: true).
validation-error FormErrors Submit attempted with invalid required/regex/etc. fields.

A 2xx with a non-JSON body is a FAILURE (0.6.0, C-301)

Before 0.6.0 the submission helper swallowed every body-parse failure and returned null, so a POST answered by HTTP 200 text/html — a static host's SPA shell, a Front Door catch-all, a captive-portal or corporate-proxy interstitial — was indistinguishable from a quiet success. The visitor saw the success state and the success chime while nothing was stored: silent lead loss.

Now:

Response Behaviour
2xx + JSON body success, response = parsed JSON (unchanged)
2xx + empty body (incl. 204) success, response = null (unchanged)
2xx + any other body submit-error with nonJsonResponse: true, never retried (retrying could double-store a lead), plus a console.error naming the resolved base
non-2xx submit-error; see "The error line" below

The error line (0.7.8, C-1828)

A 4xx used to render Submission failed (404): <whole response body> in the visible alert, which on a static host is kilobytes of HTML. Now error.message is only the package's own sentence (Submission failed (404)), a short server reason (a JSON error/message string or clipped plain text, at most 200 characters, never an HTML page) travels as DcsFormSubmitError.detail and renders behind a <details> toggle (.dcs-form__submit-error-detail), and the body, trimmed to 500 characters, goes to console.error.

Pre-hydration submit (0.7.8, C-1828)

The rendered <form> carries method="post", and the default submit button is disabled until the component has mounted. A prerendered form clicked before the app hydrates therefore never submits as a GET with the visitor's values in the URL. A site that replaces the actions slot should bind its own button's disabled to the slot's hydrated flag.

The assertion is inlined here on purpose rather than delegated to @duffcloudservices/cms-core's platformFetch: site-forms is a leaf package that sites pin independently (coron8 sits on 0.1.4), and a new cms-core dependency would make the fix unpublishable to exactly the sites that most need it.

The success state is announced and takes focus (0.7.9, C-1916)

After a 2xx the <form> is swapped for div.dcs-form.dcs-form--success. That container now carries role="status", aria-live="polite" and tabindex="-1", and focus moves to it once it renders, because removing the focused <form> drops focus to <body> (WCAG 4.1.3). If the visitor has already moved focus elsewhere on the page, it stays there. style.css shows a ring only under :focus-visible (keyboard users, not after a mouse click): outline: 2px solid currentColor; outline-offset: 4px. currentColor makes the ring at least as contrasting as the success text, so text that passes 4.5:1 gives a ring that passes the 3:1 non-text bar. A custom success slot renders inside the same container.

Since 0.8.0 (C-1923) the move happens only for a submit made from the form itself (its Send button, or Enter in one of its fields), and not when the visitor presses anywhere outside the form while the POST is in flight. A press on plain text drops focus to <body>, so the <body> check alone used to pull focus, and the page's scroll, back to the form. A script's requestSubmit() with nothing in the form focused no longer moves focus either.

Group labels print once without a stylesheet (0.7.9, C-1885)

The radio and checkbox-group <legend>s and the single checkbox's wrapper label are screen-reader-only (class="sr-only"). The package shipped no rule for that class, so a site with no stylesheet and no Tailwind printed each group label twice. style.css now has .dcs-form .sr-only, and the same visually-hidden declarations are bound inline on those elements, so the prerendered HTML hides the copy with no stylesheet and before hydration. No global CSS is needed.

A network failure shows the form's own sentence (0.7.9, C-1886)

Failure DcsFormSubmitError Visitor line (.dcs-form__submit-error-message)
non-2xx (4xx, or a 5xx after its retry) status = the code, error.message = the sentence, detail = the server's short reason when it sent one (a 5xx's since 0.8.0) exactly Submission failed (<status>), plus the Details toggle when there is a detail
2xx without a receipt (C-301/C-310) status = the 2xx the "could not confirm" sentence (unchanged)
transport: fetch rejected (offline, DNS, CORS, abort, timeout, a refused redirect) status undefined, error = the RAW exception We could not send your message. Check your connection and try again.

The browser's own text (Failed to fetch, Load failed, NetworkError when attempting to fetch resource.) never reaches the page. It stays on the emitted error, which is what a site's telemetry hook should record. The form keeps its values and an enabled Send button, which is the retry. A transport failure is retried once, like a 5xx. A 5xx that was still failing after its retry used to read Server error <status>; it now reads Submission failed (<status>) like every other non-2xx. A custom layout on submitFormValues should branch on status === undefined and never render error.message in that case.

A failed careers submit shows the site's own contact (0.8.1, C-2029)

When a resume-submission form fails to send (any non-2xx, an unconfirmed 2xx, or a transport failure), a second line renders under the error sentence, inside the same role="alert" region:

You can also email us at frontdesk@example.com or call (555) 010-2030.

With only an email it reads You can also email us at <email>.; with only a phone, You can also call us at <phone>. Both are links (mailto:, and tel: with the phone's digits and no guessed country code). The class is .dcs-form__submit-contact and the element carries data-form-failure-contact. A site that restyles the dcs-form* classes should cover it; the package rule inherits the error colour and underlines the links.

The values come from the site, never from the package. When the submit fails, DcsForm reads the page's own JSON-LD: every script[type="application/ld+json"] in the document, which the cms dcsSeoPlugin writes into every page head from the LocalBusiness node of .dcs/seo.yaml (and leaves in place across in-app navigation). It takes email (a mailto: prefix is stripped) and telephone from a business node only (Organization, LocalBusiness and its subtypes, bare or as a https://schema.org/ or schema: IRI; never a Person, Place, Event, WebSite or an offering such as Service, GovernmentService or TaxiService), both from the same node: the first with both, else the first with either. A script that is not valid JSON is skipped, a value that does not parse is dropped, and a page with neither renders no line. A page without that JSON-LD (no cms SEO plugin) has no default; pass failure-contact instead. Other form kinds show the line only when the site passes failure-contact. A site-local form can render the same line with the exported DcsFormFailureContact (contact prop) and readPageJsonLdContact(), or resolveSiteContact(seoConfigOrSchemaList) when it already holds the config (kept's careers page passes useSEO().config.schemas).

Bundle cost. The default added 1,171 B to the package entry in 0.8.1: 15,717 B in 0.8.0 -> 16,888 B (gzip -9, webmcp-lazy.test.ts); the 0.8.2 business-type fix adds 82 B more (16,970 B). Nothing scales with the site. It does not read the __DCS_SEO__ define: the cms plugin replaces that identifier with the whole seo.yaml as a literal, so reading it from here would put a second full copy of the site's seo.yaml into the chunk that carries DcsForm, on every form kind. consumer-seo-bundle.test.ts builds a consumer with a 20 KB+ seo.yaml defined and requires the form chunk to be byte-identical with and without it.

Unreleased (next after 0.8.3): autofill tokens and a 44 px radio/checkbox row (C-2243)

  • WCAG 1.3.5: text, email and tel inputs carry an HTML autocomplete token derived from the field's role, or, without one (or with custom), from its whole id. The label is never read.

    Declared as Token
    role contact-name; id name, full-name name
    id first-name, given-name given-name
    id last-name, family-name family-name
    role contact-email; id email email
    role contact-phone; id phone, phone-number, tel tel
    role contact-company; id company, organization organization
    anything else (subject, message, spouse-email, ...) no attribute

    The type alone never sets a token, so a second person's email or phone field (spouse-email) is not autofilled with the visitor's own. A token renders only on an input type it fits (email on email or text, tel on tel or text, the rest on text). The HTML spec has no token for a subject line, so it gets none. There are no address tokens: the schema has no address field type. The schema has no per-field autocomplete property yet; a site that needs a different token renders its own control in the field's #input slot, whose scope now carries autocomplete.

  • WCAG 2.5.8 (C-239 item 5): each radio and checkbox row (.dcs-form-radio, .dcs-form-checkbox, .dcs-form-checkbox-single, the <label> that wraps the control) is at least 44x44 CSS px: min-height/min-width: 44px, box-sizing: border-box and 0.625rem block padding. The native control keeps its size. The radio/checkbox group gap went from 0.75rem to 0.25rem, so a one-line option's pitch goes from about 36 px to 48 px. Text inputs are unchanged. A site that restyles these classes should keep the floor.

  • The same floor also works without style.css, which no fleet site imports. The radio, checkbox-group and checkbox components render one <style data-dcs-form-touch-floor> rule next to their rows: :where(.dcs-form-checkbox,.dcs-form-radio,.dcs-form-checkbox-single) with box-sizing: border-box; display: flex; align-items: center; min-width: 44px; min-height: 44px. :where() has zero specificity, so any rule a site writes on these classes wins, and so does style.css, which keeps its padded flex-start row. It is not an inline style, because an inline style would override a site's own taller rows (an 80 px card would drop to 44 px). It is in the prerendered HTML. A site that sets its own min-height below 44 px on these classes still overrides the floor.

  • Cost: the package entry went from 16,970 B to 17,337 B gzip (+367 B, webmcp-lazy.test.ts measure: +232 B for the tokens, +135 B for the rendered floor rule); its ceiling moved to 17,415 B.

0.8.2 release notes

  • C-2037 (JP46 F1): the failure-contact reader takes business JSON-LD types only. 0.8.1 matched any ...Service type, so a GovernmentService or TaxiService node, or Service written as https://schema.org/Service or schema:Service, could supply the contact. It now strips a schema.org IRI prefix (https://schema.org/, http://schema.org/, schema:) and names the five LocalBusiness service types (ProfessionalService, LegalService, AccountingService, FinancialService, EmergencyService); ...Business, ...Organization, ...Contractor, ...Agency, ...Clinic, ...Salon and ...Store still count. Also fixed: an IRI-form explicit name such as https://schema.org/Plumber was missed. failure-contact.test.ts pins all 29 cms LOCAL_BUSINESS_TYPES (read from the cms source) as accepted.
  • Cost: the package entry went from 16,888 B to 16,970 B gzip (+82 B); its ceiling moved to 17,050 B.

0.8.1 release notes

  • C-2029 (Q-1002 = A, kept careers-form incident guard G7): the section above. New: the failureContact prop, DcsFormFailureContact, resolveSiteContact and the other siteContact helpers, and the .dcs-form__submit-contact rules. Additive: no fleet site has a resume-submission form today, so no live form changes on the bump.
  • Cost. The package entry grew from 15,717 B to 16,888 B gzip (webmcp-lazy.test.ts measure); its ceiling moved to 16,950 B. That is the whole cost to a consumer: the site's seo.yaml is not copied into the form chunk.
  • JP44 F1 (fixed before publish, W310): the first cut of 0.8.1 (a4485bd2a) read the default from the __DCS_SEO__ define. In a consumer build that inlined the whole seo.yaml into the form chunk for every form kind: with kept's seo.yaml (26,489 B JSON) the chunk went from 15,052 B to 20,681 B gzip (+5,629 B). The default now reads the page's JSON-LD when the submit fails; the same consumer build is 15,334 B with or without the seo.yaml defined. readBuildTimeSeo is gone from the exports (never published); readPageJsonLdContact and resolveJsonLdContact are new.

0.7.9 release notes

  • C-1916, C-1885, C-1886: the three sections above.
  • Ride-along, all unreleased at 0.7.8 (ebee7d60d, the published 0.7.8 source):
    • 962dfb8bc (W179): the inert WebMCP fill-only hook (C-1907), off until the server switch ships; 0 behaviour change. Its chunk loads only when the compliance response carries agentActions.enabled === true, the site is not PHI and the browser has WebMCP; no server sends that yet.
    • dd70d0d92 (W188): isVisible's callback parameter typed (id: string); types only.
    • a5e420951 (W129): an eslint config, a lint script and two devDependencies; seven eslint-disable comments removed and one added; two tests restructured. No runtime change.
    • d7fddd5a8: two comment lines in C-1907 files.
  • The tarball no longer ships src/webmcp/ (it imports the private, unpublished @dcs/webmcp) or src/__tests__/. The rest of src/ ships as before (C-1920 tracks the full trim to dist).

0.8.0 release notes

  • API: validateFormDefinition, warnIfInvalid and SchemaValidationResult are removed from the package root (no consumer). Removing public exports is a minor bump on 0.x (PUBLISHING.md), so this release is 0.8.0, not 0.7.10.
  • C-1923, entry trim: the entry chunk no longer carries the dev-only schema validator (ajv, ajv-formats and the form-definition schema snapshot). See "Schema validation" below. Measured as webmcp-lazy.test.ts measures it (gzip level 9, no sourcemap): 20,940 B in 0.7.9, 15,604 B after the trim, 15,717 B with the two fixes below. Its ceiling fell from 21,000 B to 15,800 B. validateFormDefinition, warnIfInvalid and the SchemaValidationResult type are no longer exported; a census of the monorepo and every fleet site found no importer. ajv and ajv-formats are still listed in dependencies, although dist/ no longer imports them.
  • C-1923 (JS32 F1): the success focus move no longer steals focus. See "The success state is announced and takes focus" above.
  • C-1923 (JS40): a 5xx that still fails after its retry keeps the server's short reason as detail, so the Details toggle renders as it does for a 4xx. A transport failure after a 5xx clears that reason along with the status.
  • C-1923: tests pin the .dcs-form .sr-only and .dcs-form--success:focus-visible rules, in style.css and in the built dist/site-forms.css.
  • C-1920: the tarball ships dist/ only, plus package.json and this README, which npm always adds. files was dist + src, so 0.7.9 also carried 28 src/ files that nothing imports. Measured with pnpm pack: 60 → 32 entries, 145,038 → 115,071 B packed, 537,547 → 394,773 B unpacked; every dist/ file is byte-identical. The public surface is unchanged: exports has only . and ./style.css, which are the only two specifiers a consumer census of the fleet found, and any other subpath (/src/..., /dist/...) was already refused with ERR_PACKAGE_PATH_NOT_EXPORTED. A new public subpath needs an exports entry that points into dist/; tarball-files.test.ts pins this.

Slots

Every slot exposes scoped data so consumers (KT Braun, Kept) can swap shadcn primitives in without forking field components.

Slot Scope Default
header { definition } Empty; page/section headings live outside the managed form
progress { current, total, step } Step N of M — Title (multi-step only)
actions { isFirstStep, isLastStep, submitting, hydrated, prev, next } Plain <button> elements (submit disabled until hydrated)
success { definition } definition.successMessage
missing { formId } Friendly fallback when the YAML can't be found

Per-field components (DcsFormText etc.) expose #input slots so a shadcn site can replace the underlying primitive while keeping the wrapper, label, help, and error-message structure.

Composables

For sites that want a fully custom layout, drop <DcsForm/> and use the underlying composables directly:

import {
  useDcsForm,
  validateForm,
  submitFormValues,
  parseFormYaml,
} from '@duffcloudservices/site-forms'
  • useDcsForm({ definition }) — reactive values, errors, steps, next, prev, validateAll, collectSubmissionValues, etc.
  • validateForm(def, values, fieldIds?) — pure validator usable in any setting (server-side, tests, custom adapters).
  • submitFormValues({ apiBase, siteSlug, payload }) — one-shot POST with a single retry on a 5xx or a transport failure (a 4xx, and a 2xx without a receipt, are never retried) and multipart/form-data when files are present.
  • fetchSiteHandlesPhi({ apiBase, siteSlug }) — the site's PHI posture (C-415), memoized per site. <DcsForm/> already calls it; a custom layout should use it rather than inventing a second HIPAA signal. PHI_GUIDANCE_COPY is the standard wording, resetSitePhiPostureCache() clears the memo (tests / the portal preview iframe).

File fields emit a single File by default. When attachmentPolicy.maxFiles is greater than 1, the file field emits File[], enables multiple selection, previews selected files, and allows removing files before submit.

Visual editor integration

The form root carries data-form-key="<formId>" and every field wrapper carries data-form-field-key="<fieldId>". The portal preview iframe bridge uses these to discover managed forms, show the preview affordance, and route preview click / context-menu actions into the same portal FormManagerSheet. Do not strip these attributes in custom layouts.

definitionOverride is a preview-only draft path for the iframe. The durable form truth still lives in the form definition saved by the portal and in the committed .dcs/forms/<formId>.yaml snapshot consumed by the site runtime.

For the cross-package first-party component contract (runtime markers, bridge discovery, portal entry points, rollout, validation), see ../FIRST-PARTY-COMPONENTS.md.

Schema validation

When <DcsForm> is compiled from source in a dev build (import.meta.env.DEV === true: the portal, which aliases this package's src/, and this package's own tests), it validates each loaded definition against the JSON Schema in src/schema/form-definition.schema.json (snapshot of contracts/dist/form-definition.schema.json) and logs failures via console.warn. The validator loads through a dev-only import().

The published dist/ is a library build with DEV fixed to false, so since 0.8.0 (C-1923) it carries no validator at all: no ajv, no ajv-formats, no schema snapshot. Until 0.7.9 the entry carried all three and ran the validator on every mount, then discarded the result without warning. A site validates its YAML at authoring time instead (dcs forms validate, below). validateFormDefinition and warnIfInvalid are no longer exported. A census of this monorepo and every fleet site found no importer.

When the contracts schema is regenerated (pnpm --filter @dcs/contracts generate), refresh the snapshot:

Copy-Item ../../contracts/dist/form-definition.schema.json ./src/schema/form-definition.schema.json -Force
pnpm --filter @duffcloudservices/site-forms test --run

Scripts

pnpm --filter @duffcloudservices/site-forms build       # vite library build (esm + dts)
pnpm --filter @duffcloudservices/site-forms test        # vitest --run
pnpm --filter @duffcloudservices/site-forms type-check  # vue-tsc --noEmit

Compliance requirements for form submissions

Sensitive forms (isSensitive: true)

Forms flagged as sensitive (law firm intake, HIPAA intake, any privilege-sensitive questionnaire) carry platform-enforced rules that form authors and site integrators must not override:

  1. Notification emails suppress submission content. When isSensitive: true, the portal notification email for a new submission contains only a portal link — never field values, names, or any submission body. This is required for attorney-client privilege (ABA Rule 1.6) and HIPAA confidentiality.

  2. Access audit logging. Every time a portal user opens a sensitive-form submission, the platform writes an audit log entry (PortalAuditLog, event: submission_viewed). This cannot be disabled.

  3. Set via portal, not YAML. The isSensitive flag lives in the PortalSiteForms table (managed via the portal Form Manager). It is not part of the .dcs/forms/*.yaml snapshot — the runtime itself has no concept of sensitivity.

Form version tracking

Every submission row stores formVersion (the schema version of the form at submission time). If a form's fields change after submissions are collected, old submissions remain interpretable: the portal can reconstruct what was shown by looking up the version-keyed schema. This is required for compliance audit trails.

General form requirements

All DCS-managed forms that collect personal data must:

  • Link a Privacy Policy URL in proximity to the submit action
  • Store formVersionId, submission timestamp, and submitter IP with every submission row (handled automatically by the platform)
  • Not collect unnecessary data fields (data minimization)

Intake questionnaires (attorneys, healthcare)

Use the Legal Intake — Standard form template (created via the portal Form Manager → Templates) when building intake forms for law firms. This template:

  • Pre-populates the attorney-client privilege disclaimer and consent checkbox (non-removable)
  • Automatically sets isSensitive: true
  • Includes matter-type and adverse-parties fields for conflict screening

For healthcare intake, mark each PHI-bearing field phi: true in the YAML. There is no hipaa form kind (the kinds are freeform, contact, revenue-contractor, resume-submission); PHI handling is driven by the per-field phi marker plus the site-level predicate below.

PHI guidance on HIPAA-mode tenants (C-415)

On a HIPAA-mode tenant, <DcsForm/> renders a standard guidance line immediately above the first free-text field:

Please don't include personal health details. A general description of what you need is enough — we'll go over anything specific with you directly.

It is on by default and there is deliberately no prop and no YAML flag to force it on or off. The verdict comes from the platform: on mount the component GETs /api/v1/sites/{slug}/forms/compliance (memoized per site; the slug-free /api/v1/forms/compliance when the build has no baked slug), which is a boolean projection of the server's one site-level PHI predicate, portal.Service.SiteHandlesPHI — authoritative input SiteAgentConfigs/{slug}/default.HipaaMode, the same row the C-352 redaction gate reads. A site-local switch could disagree with the tenant's real posture, which is exactly the class C-352 fixed.

If the read fails the component renders nothing; the server-side redactions remain the actual control. Style it via .dcs-form__phi-guidance; probe for it with [data-form-phi-guidance] (the copy contains no "HIPAA"/"protected health information" legalese by design — it is guidance for a visitor, not a legal notice).


Related docs

  • Authoring guide — .docs/forms/AUTHORING.md covers the YAML schema, worked examples, validation flow, HIPAA guardrails, and the hand-coded → <DcsForm/> migration recipe.
  • Publishing — PUBLISHING.md covers the registry, OIDC trusted publishing, version bump policy, and the exact dep line sibling customer-site repos should add.
  • First-party visual-editor contract — ../FIRST-PARTY-COMPONENTS.md captures the shared adaptation model used by forms and future component families.
  • Validation CLI — cli/forms/README.md documents dcs forms validate and dcs forms doctor, which lint the .dcs/forms/*.yaml files in a customer-site repo.

Ownership

Per packages/README.md: external/consumer-facing docs live here, not in repo-root docs. Cross-cutting details (e.g. the public submissions API contract) belong in contracts/README.md.

Related Packages

@duffcloudservices/kit

DcsKit Vue runtime — editable primitives over editorBridge markers + the canonic...

1,699
@duffcloudservices/cms

Vue 3 composables and Vite plugins for DCS CMS integration

10,104
@duffcloudservices/telemetry

Shared Azure Application Insights telemetry for DCS Vue sites — one App Insights...

2,031
@formio/vue

A vue.js renderer for form.io forms.

73,476 122
vue-formio

A vue.js renderer for form.io forms.

33,583 122

Version History

Version Released License
0.8.4 MIT
0.8.3 MIT
0.8.1 MIT
0.8.0 MIT
0.7.9 MIT