@duffcloudservices/site-forms
| 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-againworkspace the package is also reachable as@duffcloudservices/site-formsviaworkspace:*(the workspace name and the published name are the same). In sibling customer-site repos install the published package from public npm — seePUBLISHING.mdfor 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:
-
vite-plugin-yamlso 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. -
A
formsModulesloader in your site that does theimport.meta.globfrom a path Vite can resolve (see below). -
Env vars the source reads (see the caveat below):
Variable Purpose VITE_DCS_PUBLIC_APIBase URL of the DCS public API (no trailing slash). VITE_DCS_SITE_SLUGDefault site slug used when the prop is omitted. Caveat (C-1564, W80): the published
distdoes NOT read either variable. The library build statically erasesimport.meta.env(the dist carries{}there), so a site that installs this package from npm cannot configure it through env. Use theapi-base/site-slugprops. No fleet site needsapi-basein production: see Public API base.
Public API base
<DcsForm> resolves where it posts in this order:
- the
api-baseprop; VITE_DCS_PUBLIC_API(workspace-source consumers only, per the caveat above);- in the browser on
localhost/127.0.0.1/::1: the relative/api/v1(the dev proxy); - on any other host: the package default,
package.jsonconfig.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
hiddenfield has no input, so it keeps the initial value through user edits,reset()and submission. It appears in the submittedvalues. - 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-blockid) 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,initialValuesis 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
autocompletetoken derived from the field'srole, or, without one (or withcustom), from its wholeid. The label is never read.Declared as Token role contact-name; idname,full-namenameid first-name,given-namegiven-nameid last-name,family-namefamily-namerole contact-email; idemailemailrole contact-phone; idphone,phone-number,teltelrole contact-company; idcompany,organizationorganizationanything 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 (emailon email or text,telon 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-fieldautocompleteproperty yet; a site that needs a different token renders its own control in the field's#inputslot, whose scope now carriesautocomplete. -
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-boxand0.625remblock padding. The native control keeps its size. The radio/checkbox group gap went from0.75remto0.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)withbox-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 doesstyle.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 ownmin-heightbelow 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.tsmeasure: +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
...Servicetype, so aGovernmentServiceorTaxiServicenode, orServicewritten ashttps://schema.org/Serviceorschema: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,...Salonand...Storestill count. Also fixed: an IRI-form explicit name such ashttps://schema.org/Plumberwas missed.failure-contact.test.tspins all 29 cmsLOCAL_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
failureContactprop,DcsFormFailureContact,resolveSiteContactand the othersiteContacthelpers, and the.dcs-form__submit-contactrules. Additive: no fleet site has aresume-submissionform 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.tsmeasure); 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.readBuildTimeSeois gone from the exports (never published);readPageJsonLdContactandresolveJsonLdContactare 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 carriesagentActions.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, alintscript and two devDependencies; seveneslint-disablecomments 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) orsrc/__tests__/. The rest ofsrc/ships as before (C-1920 tracks the full trim todist).
0.8.0 release notes
- API:
validateFormDefinition,warnIfInvalidandSchemaValidationResultare 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-formatsand the form-definition schema snapshot). See "Schema validation" below. Measured aswebmcp-lazy.test.tsmeasures 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,warnIfInvalidand theSchemaValidationResulttype are no longer exported; a census of the monorepo and every fleet site found no importer.ajvandajv-formatsare still listed independencies, althoughdist/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-onlyand.dcs-form--success:focus-visiblerules, instyle.cssand in the builtdist/site-forms.css. - C-1920: the tarball ships
dist/only, pluspackage.jsonand this README, which npm always adds.fileswasdist+src, so 0.7.9 also carried 28src/files that nothing imports. Measured withpnpm pack: 60 → 32 entries, 145,038 → 115,071 B packed, 537,547 → 394,773 B unpacked; everydist/file is byte-identical. The public surface is unchanged:exportshas 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 withERR_PACKAGE_PATH_NOT_EXPORTED. A new public subpath needs anexportsentry that points intodist/;tarball-files.test.tspins 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 })— reactivevalues,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) andmultipart/form-datawhen 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_COPYis 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:
-
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. -
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. -
Set via portal, not YAML. The
isSensitiveflag lives in thePortalSiteFormstable (managed via the portal Form Manager). It is not part of the.dcs/forms/*.yamlsnapshot — 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.mdcovers the YAML schema, worked examples, validation flow, HIPAA guardrails, and the hand-coded →<DcsForm/>migration recipe. - Publishing —
PUBLISHING.mdcovers 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.mdcaptures the shared adaptation model used by forms and future component families. - Validation CLI —
cli/forms/README.mddocumentsdcs forms validateanddcs forms doctor, which lint the.dcs/forms/*.yamlfiles 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
DcsKit Vue runtime — editable primitives over editorBridge markers + the canonic...
Shared Azure Application Insights telemetry for DCS Vue sites — one App Insights...
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 | |
| 0.7.8 | MIT | |
| 0.7.7 | MIT | |
| 0.7.6 | MIT | |
| 0.7.5 | MIT | |
| 0.7.4 | MIT | |
| 0.7.3 | MIT | |
| 0.7.2 | MIT | |
| 0.7.1 | MIT | |
| 0.7.0 | MIT | |
| 0.5.0 | MIT | |
| 0.4.2 | MIT | |
| 0.4.1 | MIT | |
| 0.4.0 | MIT | |
| 0.3.0 | MIT | |
| 0.2.1 | MIT | |
| 0.2.0 | MIT | |
| 0.1.4 | MIT | |
| 0.1.3 | MIT | |
| 0.1.2 | MIT | |
| 0.1.1 | MIT | |
| 0.1.0 | MIT |