From 065853e7a6ea93e37cd27651044d4c17ce4d6b0c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:02:05 +0800 Subject: [PATCH 1/4] feat(design-system): build ErrorState, the gate with nothing behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GATES.md §3 lists the prohibition "Render '0 matches' after a failed request" with the gate "ErrorState adoption + check", status planned. Measured at origin/main 199b303b7, ErrorState existed nowhere in src or tests — only in COMPONENTS.md, GATES.md and SPEC.md. This builds it. The invariant is clinical, not cosmetic. A search that failed has no count to report, so reporting zero is a false statement about the corpus: on the services page "0 matches" asserts there are no crisis services when the search never ran, and on favourites it reads as "you have saved nothing" rather than "we could not load them". COMPONENTS.md:322 draws the same line from the other side — "no result count is available" is not a MissingValue. The component therefore takes no count and no children. There is no prop through which a number can arrive, and the generated dtsPropsFor entry now records that as the published API. The one remaining route, a caller writing a count into title or body, is covered by a development-time tripwire that matches a figure against a counted noun ("0 matches", "no results") so an error code or a duration does not trip it. It warns and never throws: on the one screen already reporting a failure, a thrown error is a blank page. Requirements came from the surfaces that hand-roll this guard today. Three do, and their comments state the rule outright: search-results-header-band.tsx:210 ("no number may reach the DOM"), services-navigator-page.tsx:634 ("a blocked registry must not reach the band as '0 matches'") and favourites-command-library-page.tsx:1182. They are correct, just not shared; converting them is a live-look change and deliberately not here. Three further sites carried into this task as hand-rolled guards are not that, measured at this HEAD, and are recorded so the next reader does not convert them: differentials-home.tsx:716,729 renders "0 matches"/"No matches" when sourcesChecked is true, i.e. a legitimate zero after a search that SUCCEEDED; specifiers-home-page.tsx:211 is a comment about not showing a stale zero above real catalogue results, and lives in src/components/specifiers/, not clinical-dashboard/; document-search-results gates on recordStatus for loading, not for a failed count. Registered per gates 11 and 12: source, design-sync export, preview, prop contract, publication test entry, behavioural DOM tests, adoption-contract family, and both generators regenerated (54 components, 59 roots). Uses the shared floatingControl recipe rather than a hand-rolled control, so the tap floor, focus ring and forced-colors border come from one owner. Adds zero arbitrary padding/gap/radius/line-height, so the ratchet landed in the previous commit is unaffected and the two stay separately revertible. Scope: ErrorState only. OfflineState, PermissionDeniedState, NotFoundState and UnavailableState share the pattern but have no gate pointing at them. Co-Authored-By: Claude Opus 5 --- .design-sync/config.json | 2 + .design-sync/entry.tsx | 1 + .design-sync/previews/ErrorState.tsx | 16 ++ docs/design-system/ADOPTION.md | 2 +- docs/design-system/COMPONENTS.md | 5 +- docs/design-system/adoption-contract.json | 1 + docs/design-system/adoption-manifest.json | 35 +++- src/components/ui/error-state.tsx | 211 ++++++++++++++++++++++ tests/design-sync-visual-exports.test.ts | 1 + tests/error-state.dom.test.tsx | 130 +++++++++++++ 10 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 .design-sync/previews/ErrorState.tsx create mode 100644 src/components/ui/error-state.tsx create mode 100644 tests/error-state.dom.test.tsx diff --git a/.design-sync/config.json b/.design-sync/config.json index 63727c2b5..fb177362c 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -36,6 +36,7 @@ "DoseLine": "caption?: string; className?: string; onOpenSource: (sourceId: string, locator?: string) => void; rows: readonly DoseRow[];", "DownloadLink": "about?: string; accessKey?: string; \"aria-activedescendant\"?: string; \"aria-atomic\"?: (boolean | \"true\" | \"false\"); \"aria-autocomplete\"?: \"none\" | \"list\" | \"inline\" | \"both\"; \"aria-braillelabel\"?: string; \"aria-brailleroledescription\"?: string; \"aria-busy\"?: (boolean | \"true\" | \"false\"); \"aria-checked\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-colcount\"?: number; \"aria-colindex\"?: number; \"aria-colindextext\"?: string; \"aria-colspan\"?: number; \"aria-controls\"?: string; \"aria-current\"?: boolean | \"true\" | \"false\" | \"page\" | \"step\" | \"location\" | \"date\" | \"time\"; \"aria-describedby\"?: string; \"aria-description\"?: string; \"aria-details\"?: string; \"aria-disabled\"?: (boolean | \"true\" | \"false\"); \"aria-dropeffect\"?: \"none\" | \"link\" | \"copy\" | \"execute\" | \"move\" | \"popup\"; \"aria-errormessage\"?: string; \"aria-expanded\"?: (boolean | \"true\" | \"false\"); \"aria-flowto\"?: string; \"aria-grabbed\"?: (boolean | \"true\" | \"false\"); \"aria-haspopup\"?: boolean | \"true\" | \"false\" | \"dialog\" | \"grid\" | \"listbox\" | \"menu\" | \"tree\"; \"aria-hidden\"?: (boolean | \"true\" | \"false\"); \"aria-invalid\"?: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\"; \"aria-keyshortcuts\"?: string; \"aria-label\"?: string; \"aria-labelledby\"?: string; \"aria-level\"?: number; \"aria-live\"?: \"off\" | \"assertive\" | \"polite\"; \"aria-modal\"?: (boolean | \"true\" | \"false\"); \"aria-multiline\"?: (boolean | \"true\" | \"false\"); \"aria-multiselectable\"?: (boolean | \"true\" | \"false\"); \"aria-orientation\"?: \"horizontal\" | \"vertical\"; \"aria-owns\"?: string; \"aria-placeholder\"?: string; \"aria-posinset\"?: number; \"aria-pressed\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-readonly\"?: (boolean | \"true\" | \"false\"); \"aria-relevant\"?: \"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"; \"aria-required\"?: (boolean | \"true\" | \"false\"); \"aria-roledescription\"?: string; \"aria-rowcount\"?: number; \"aria-rowindex\"?: number; \"aria-rowindextext\"?: string; \"aria-rowspan\"?: number; \"aria-selected\"?: (boolean | \"true\" | \"false\"); \"aria-setsize\"?: number; \"aria-sort\"?: \"none\" | \"ascending\" | \"descending\" | \"other\"; \"aria-valuemax\"?: number; \"aria-valuemin\"?: number; \"aria-valuenow\"?: number; \"aria-valuetext\"?: string; autoCapitalize?: \"off\" | \"none\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | (string & {}); autoCorrect?: string; autoFocus?: boolean; autoSave?: string; children: ReactNode; className?: string; color?: string; content?: string; contentEditable?: (boolean | \"true\" | \"false\") | \"inherit\" | \"plaintext-only\"; contextMenu?: string; dangerouslySetInnerHTML?: { __html: string | TrustedHTML; }; datatype?: string; defaultChecked?: boolean; defaultValue?: string | number | readonly string[]; dir?: string; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; exportparts?: string; format?: string; hidden?: boolean; href: string; hrefLang?: string; id?: string; inert?: boolean; inlist?: any; inputMode?: \"none\" | \"search\" | \"text\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"; is?: string; itemID?: string; itemProp?: string; itemRef?: string; itemScope?: boolean; itemType?: string; lang?: string; media?: string; nonce?: string; onAbort?: import(\"react\").ReactEventHandler; onAbortCapture?: import(\"react\").ReactEventHandler; onAnimationEnd?: import(\"react\").AnimationEventHandler; onAnimationEndCapture?: import(\"react\").AnimationEventHandler; onAnimationIteration?: import(\"react\").AnimationEventHandler; onAnimationIterationCapture?: import(\"react\").AnimationEventHandler; onAnimationStart?: import(\"react\").AnimationEventHandler; onAnimationStartCapture?: import(\"react\").AnimationEventHandler; onAuxClick?: import(\"react\").MouseEventHandler; onAuxClickCapture?: import(\"react\").MouseEventHandler; onBeforeInput?: import(\"react\").InputEventHandler; onBeforeInputCapture?: import(\"react\").InputEventHandler; onBeforeToggle?: import(\"react\").ToggleEventHandler; onBlur?: import(\"react\").FocusEventHandler; onBlurCapture?: import(\"react\").FocusEventHandler; onCanPlay?: import(\"react\").ReactEventHandler; onCanPlayCapture?: import(\"react\").ReactEventHandler; onCanPlayThrough?: import(\"react\").ReactEventHandler; onCanPlayThroughCapture?: import(\"react\").ReactEventHandler; onChange?: import(\"react\").ChangeEventHandler; onChangeCapture?: import(\"react\").ChangeEventHandler; onClick?: import(\"react\").MouseEventHandler; onClickCapture?: import(\"react\").MouseEventHandler; onCompositionEnd?: import(\"react\").CompositionEventHandler; onCompositionEndCapture?: import(\"react\").CompositionEventHandler; onCompositionStart?: import(\"react\").CompositionEventHandler; onCompositionStartCapture?: import(\"react\").CompositionEventHandler; onCompositionUpdate?: import(\"react\").CompositionEventHandler; onCompositionUpdateCapture?: import(\"react\").CompositionEventHandler; onContextMenu?: import(\"react\").MouseEventHandler; onContextMenuCapture?: import(\"react\").MouseEventHandler; onCopy?: import(\"react\").ClipboardEventHandler; onCopyCapture?: import(\"react\").ClipboardEventHandler; onCut?: import(\"react\").ClipboardEventHandler; onCutCapture?: import(\"react\").ClipboardEventHandler; onDoubleClick?: import(\"react\").MouseEventHandler; onDoubleClickCapture?: import(\"react\").MouseEventHandler; onDrag?: import(\"react\").DragEventHandler; onDragCapture?: import(\"react\").DragEventHandler; onDragEnd?: import(\"react\").DragEventHandler; onDragEndCapture?: import(\"react\").DragEventHandler; onDragEnter?: import(\"react\").DragEventHandler; onDragEnterCapture?: import(\"react\").DragEventHandler; onDragExit?: import(\"react\").DragEventHandler; onDragExitCapture?: import(\"react\").DragEventHandler; onDragLeave?: import(\"react\").DragEventHandler; onDragLeaveCapture?: import(\"react\").DragEventHandler; onDragOver?: import(\"react\").DragEventHandler; onDragOverCapture?: import(\"react\").DragEventHandler; onDragStart?: import(\"react\").DragEventHandler; onDragStartCapture?: import(\"react\").DragEventHandler; onDrop?: import(\"react\").DragEventHandler; onDropCapture?: import(\"react\").DragEventHandler; onDurationChange?: import(\"react\").ReactEventHandler; onDurationChangeCapture?: import(\"react\").ReactEventHandler; onEmptied?: import(\"react\").ReactEventHandler; onEmptiedCapture?: import(\"react\").ReactEventHandler; onEncrypted?: import(\"react\").ReactEventHandler; onEncryptedCapture?: import(\"react\").ReactEventHandler; onEnded?: import(\"react\").ReactEventHandler; onEndedCapture?: import(\"react\").ReactEventHandler; onError?: import(\"react\").ReactEventHandler; onErrorCapture?: import(\"react\").ReactEventHandler; onFocus?: import(\"react\").FocusEventHandler; onFocusCapture?: import(\"react\").FocusEventHandler; onGotPointerCapture?: import(\"react\").PointerEventHandler; onGotPointerCaptureCapture?: import(\"react\").PointerEventHandler; onInput?: import(\"react\").InputEventHandler; onInputCapture?: import(\"react\").InputEventHandler; onInvalid?: import(\"react\").ReactEventHandler; onInvalidCapture?: import(\"react\").ReactEventHandler; onKeyDown?: import(\"react\").KeyboardEventHandler; onKeyDownCapture?: import(\"react\").KeyboardEventHandler; onKeyPress?: import(\"react\").KeyboardEventHandler; onKeyPressCapture?: import(\"react\").KeyboardEventHandler; onKeyUp?: import(\"react\").KeyboardEventHandler; onKeyUpCapture?: import(\"react\").KeyboardEventHandler; onLoad?: import(\"react\").ReactEventHandler; onLoadCapture?: import(\"react\").ReactEventHandler; onLoadedData?: import(\"react\").ReactEventHandler; onLoadedDataCapture?: import(\"react\").ReactEventHandler; onLoadedMetadata?: import(\"react\").ReactEventHandler; onLoadedMetadataCapture?: import(\"react\").ReactEventHandler; onLoadStart?: import(\"react\").ReactEventHandler; onLoadStartCapture?: import(\"react\").ReactEventHandler; onLostPointerCapture?: import(\"react\").PointerEventHandler; onLostPointerCaptureCapture?: import(\"react\").PointerEventHandler; onMouseDown?: import(\"react\").MouseEventHandler; onMouseDownCapture?: import(\"react\").MouseEventHandler; onMouseEnter?: import(\"react\").MouseEventHandler; onMouseLeave?: import(\"react\").MouseEventHandler; onMouseMove?: import(\"react\").MouseEventHandler; onMouseMoveCapture?: import(\"react\").MouseEventHandler; onMouseOut?: import(\"react\").MouseEventHandler; onMouseOutCapture?: import(\"react\").MouseEventHandler; onMouseOver?: import(\"react\").MouseEventHandler; onMouseOverCapture?: import(\"react\").MouseEventHandler; onMouseUp?: import(\"react\").MouseEventHandler; onMouseUpCapture?: import(\"react\").MouseEventHandler; onPaste?: import(\"react\").ClipboardEventHandler; onPasteCapture?: import(\"react\").ClipboardEventHandler; onPause?: import(\"react\").ReactEventHandler; onPauseCapture?: import(\"react\").ReactEventHandler; onPlay?: import(\"react\").ReactEventHandler; onPlayCapture?: import(\"react\").ReactEventHandler; onPlaying?: import(\"react\").ReactEventHandler; onPlayingCapture?: import(\"react\").ReactEventHandler; onPointerCancel?: import(\"react\").PointerEventHandler; onPointerCancelCapture?: import(\"react\").PointerEventHandler; onPointerDown?: import(\"react\").PointerEventHandler; onPointerDownCapture?: import(\"react\").PointerEventHandler; onPointerEnter?: import(\"react\").PointerEventHandler; onPointerLeave?: import(\"react\").PointerEventHandler; onPointerMove?: import(\"react\").PointerEventHandler; onPointerMoveCapture?: import(\"react\").PointerEventHandler; onPointerOut?: import(\"react\").PointerEventHandler; onPointerOutCapture?: import(\"react\").PointerEventHandler; onPointerOver?: import(\"react\").PointerEventHandler; onPointerOverCapture?: import(\"react\").PointerEventHandler; onPointerUp?: import(\"react\").PointerEventHandler; onPointerUpCapture?: import(\"react\").PointerEventHandler; onProgress?: import(\"react\").ReactEventHandler; onProgressCapture?: import(\"react\").ReactEventHandler; onRateChange?: import(\"react\").ReactEventHandler; onRateChangeCapture?: import(\"react\").ReactEventHandler; onReset?: import(\"react\").ReactEventHandler; onResetCapture?: import(\"react\").ReactEventHandler; onScroll?: import(\"react\").UIEventHandler; onScrollCapture?: import(\"react\").UIEventHandler; onScrollEnd?: import(\"react\").UIEventHandler; onScrollEndCapture?: import(\"react\").UIEventHandler; onSeeked?: import(\"react\").ReactEventHandler; onSeekedCapture?: import(\"react\").ReactEventHandler; onSeeking?: import(\"react\").ReactEventHandler; onSeekingCapture?: import(\"react\").ReactEventHandler; onSelect?: import(\"react\").ReactEventHandler; onSelectCapture?: import(\"react\").ReactEventHandler; onStalled?: import(\"react\").ReactEventHandler; onStalledCapture?: import(\"react\").ReactEventHandler; onSubmit?: import(\"react\").SubmitEventHandler; onSubmitCapture?: import(\"react\").SubmitEventHandler; onSuspend?: import(\"react\").ReactEventHandler; onSuspendCapture?: import(\"react\").ReactEventHandler; onTimeUpdate?: import(\"react\").ReactEventHandler; onTimeUpdateCapture?: import(\"react\").ReactEventHandler; onToggle?: import(\"react\").ToggleEventHandler; onTouchCancel?: import(\"react\").TouchEventHandler; onTouchCancelCapture?: import(\"react\").TouchEventHandler; onTouchEnd?: import(\"react\").TouchEventHandler; onTouchEndCapture?: import(\"react\").TouchEventHandler; onTouchMove?: import(\"react\").TouchEventHandler; onTouchMoveCapture?: import(\"react\").TouchEventHandler; onTouchStart?: import(\"react\").TouchEventHandler; onTouchStartCapture?: import(\"react\").TouchEventHandler; onTransitionCancel?: import(\"react\").TransitionEventHandler; onTransitionCancelCapture?: import(\"react\").TransitionEventHandler; onTransitionEnd?: import(\"react\").TransitionEventHandler; onTransitionEndCapture?: import(\"react\").TransitionEventHandler; onTransitionRun?: import(\"react\").TransitionEventHandler; onTransitionRunCapture?: import(\"react\").TransitionEventHandler; onTransitionStart?: import(\"react\").TransitionEventHandler; onTransitionStartCapture?: import(\"react\").TransitionEventHandler; onVolumeChange?: import(\"react\").ReactEventHandler; onVolumeChangeCapture?: import(\"react\").ReactEventHandler; onWaiting?: import(\"react\").ReactEventHandler; onWaitingCapture?: import(\"react\").ReactEventHandler; onWheel?: import(\"react\").WheelEventHandler; onWheelCapture?: import(\"react\").WheelEventHandler; part?: string; ping?: string; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; referrerPolicy?: import(\"react\").HTMLAttributeReferrerPolicy; rel?: string; resource?: string; results?: number; rev?: string; role?: import(\"react\").AriaRole; security?: string; size?: string; slot?: string; spellCheck?: (boolean | \"true\" | \"false\"); style?: import(\"react\").CSSProperties; suppressContentEditableWarning?: boolean; suppressHydrationWarning?: boolean; tabIndex?: number; target?: import(\"react\").HTMLAttributeAnchorTarget; title?: string; tone?: \"inherit\" | \"accent\"; translate?: \"yes\" | \"no\"; type?: string; typeof?: string; unselectable?: \"off\" | \"on\"; vocab?: string;", "EmptyState": "actions?: ReactNode; align?: \"start\" | \"center\"; body?: string; centeredTreatment?: \"neutral\" | \"clinical\"; description?: string; headingLevel?: 2 | 3 | 4 | 5 | 6; icon?: LucideIcon; iconNode?: ReactNode; live?: \"off\" | \"assertive\" | \"polite\"; testId?: string; title: string; tone?: \"danger\" | \"neutral\" | \"info\";", + "ErrorState": "actions?: ReactNode; body?: string; className?: string; density?: \"inline\" | \"panel\"; live?: \"off\" | \"assertive\" | \"polite\"; onRetry?: (() => void | Promise); reason: ErrorStateReason; subject?: string; testId?: string; title?: string;", "ErrorSummary": "attempt?: number; className?: string; errors: { fieldId: string; label: string; message: string; }[]; heading?: string;", "ExternalTextLink": "about?: string; accessKey?: string; \"aria-activedescendant\"?: string; \"aria-atomic\"?: (boolean | \"true\" | \"false\"); \"aria-autocomplete\"?: \"none\" | \"list\" | \"inline\" | \"both\"; \"aria-braillelabel\"?: string; \"aria-brailleroledescription\"?: string; \"aria-busy\"?: (boolean | \"true\" | \"false\"); \"aria-checked\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-colcount\"?: number; \"aria-colindex\"?: number; \"aria-colindextext\"?: string; \"aria-colspan\"?: number; \"aria-controls\"?: string; \"aria-current\"?: boolean | \"true\" | \"false\" | \"page\" | \"step\" | \"location\" | \"date\" | \"time\"; \"aria-describedby\"?: string; \"aria-description\"?: string; \"aria-details\"?: string; \"aria-disabled\"?: (boolean | \"true\" | \"false\"); \"aria-dropeffect\"?: \"none\" | \"link\" | \"copy\" | \"execute\" | \"move\" | \"popup\"; \"aria-errormessage\"?: string; \"aria-expanded\"?: (boolean | \"true\" | \"false\"); \"aria-flowto\"?: string; \"aria-grabbed\"?: (boolean | \"true\" | \"false\"); \"aria-haspopup\"?: boolean | \"true\" | \"false\" | \"dialog\" | \"grid\" | \"listbox\" | \"menu\" | \"tree\"; \"aria-hidden\"?: (boolean | \"true\" | \"false\"); \"aria-invalid\"?: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\"; \"aria-keyshortcuts\"?: string; \"aria-label\"?: string; \"aria-labelledby\"?: string; \"aria-level\"?: number; \"aria-live\"?: \"off\" | \"assertive\" | \"polite\"; \"aria-modal\"?: (boolean | \"true\" | \"false\"); \"aria-multiline\"?: (boolean | \"true\" | \"false\"); \"aria-multiselectable\"?: (boolean | \"true\" | \"false\"); \"aria-orientation\"?: \"horizontal\" | \"vertical\"; \"aria-owns\"?: string; \"aria-placeholder\"?: string; \"aria-posinset\"?: number; \"aria-pressed\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-readonly\"?: (boolean | \"true\" | \"false\"); \"aria-relevant\"?: \"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"; \"aria-required\"?: (boolean | \"true\" | \"false\"); \"aria-roledescription\"?: string; \"aria-rowcount\"?: number; \"aria-rowindex\"?: number; \"aria-rowindextext\"?: string; \"aria-rowspan\"?: number; \"aria-selected\"?: (boolean | \"true\" | \"false\"); \"aria-setsize\"?: number; \"aria-sort\"?: \"none\" | \"ascending\" | \"descending\" | \"other\"; \"aria-valuemax\"?: number; \"aria-valuemin\"?: number; \"aria-valuenow\"?: number; \"aria-valuetext\"?: string; autoCapitalize?: \"off\" | \"none\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | (string & {}); autoCorrect?: string; autoFocus?: boolean; autoSave?: string; children: ReactNode; className?: string; color?: string; content?: string; contentEditable?: (boolean | \"true\" | \"false\") | \"inherit\" | \"plaintext-only\"; contextMenu?: string; dangerouslySetInnerHTML?: { __html: string | TrustedHTML; }; datatype?: string; defaultChecked?: boolean; defaultValue?: string | number | readonly string[]; dir?: string; download?: any; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; exportparts?: string; hidden?: boolean; href: string; hrefLang?: string; id?: string; inert?: boolean; inlist?: any; inputMode?: \"none\" | \"search\" | \"text\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"; is?: string; itemID?: string; itemProp?: string; itemRef?: string; itemScope?: boolean; itemType?: string; lang?: string; media?: string; nonce?: string; onAbort?: import(\"react\").ReactEventHandler; onAbortCapture?: import(\"react\").ReactEventHandler; onAnimationEnd?: import(\"react\").AnimationEventHandler; onAnimationEndCapture?: import(\"react\").AnimationEventHandler; onAnimationIteration?: import(\"react\").AnimationEventHandler; onAnimationIterationCapture?: import(\"react\").AnimationEventHandler; onAnimationStart?: import(\"react\").AnimationEventHandler; onAnimationStartCapture?: import(\"react\").AnimationEventHandler; onAuxClick?: import(\"react\").MouseEventHandler; onAuxClickCapture?: import(\"react\").MouseEventHandler; onBeforeInput?: import(\"react\").InputEventHandler; onBeforeInputCapture?: import(\"react\").InputEventHandler; onBeforeToggle?: import(\"react\").ToggleEventHandler; onBlur?: import(\"react\").FocusEventHandler; onBlurCapture?: import(\"react\").FocusEventHandler; onCanPlay?: import(\"react\").ReactEventHandler; onCanPlayCapture?: import(\"react\").ReactEventHandler; onCanPlayThrough?: import(\"react\").ReactEventHandler; onCanPlayThroughCapture?: import(\"react\").ReactEventHandler; onChange?: import(\"react\").ChangeEventHandler; onChangeCapture?: import(\"react\").ChangeEventHandler; onClick?: import(\"react\").MouseEventHandler; onClickCapture?: import(\"react\").MouseEventHandler; onCompositionEnd?: import(\"react\").CompositionEventHandler; onCompositionEndCapture?: import(\"react\").CompositionEventHandler; onCompositionStart?: import(\"react\").CompositionEventHandler; onCompositionStartCapture?: import(\"react\").CompositionEventHandler; onCompositionUpdate?: import(\"react\").CompositionEventHandler; onCompositionUpdateCapture?: import(\"react\").CompositionEventHandler; onContextMenu?: import(\"react\").MouseEventHandler; onContextMenuCapture?: import(\"react\").MouseEventHandler; onCopy?: import(\"react\").ClipboardEventHandler; onCopyCapture?: import(\"react\").ClipboardEventHandler; onCut?: import(\"react\").ClipboardEventHandler; onCutCapture?: import(\"react\").ClipboardEventHandler; onDoubleClick?: import(\"react\").MouseEventHandler; onDoubleClickCapture?: import(\"react\").MouseEventHandler; onDrag?: import(\"react\").DragEventHandler; onDragCapture?: import(\"react\").DragEventHandler; onDragEnd?: import(\"react\").DragEventHandler; onDragEndCapture?: import(\"react\").DragEventHandler; onDragEnter?: import(\"react\").DragEventHandler; onDragEnterCapture?: import(\"react\").DragEventHandler; onDragExit?: import(\"react\").DragEventHandler; onDragExitCapture?: import(\"react\").DragEventHandler; onDragLeave?: import(\"react\").DragEventHandler; onDragLeaveCapture?: import(\"react\").DragEventHandler; onDragOver?: import(\"react\").DragEventHandler; onDragOverCapture?: import(\"react\").DragEventHandler; onDragStart?: import(\"react\").DragEventHandler; onDragStartCapture?: import(\"react\").DragEventHandler; onDrop?: import(\"react\").DragEventHandler; onDropCapture?: import(\"react\").DragEventHandler; onDurationChange?: import(\"react\").ReactEventHandler; onDurationChangeCapture?: import(\"react\").ReactEventHandler; onEmptied?: import(\"react\").ReactEventHandler; onEmptiedCapture?: import(\"react\").ReactEventHandler; onEncrypted?: import(\"react\").ReactEventHandler; onEncryptedCapture?: import(\"react\").ReactEventHandler; onEnded?: import(\"react\").ReactEventHandler; onEndedCapture?: import(\"react\").ReactEventHandler; onError?: import(\"react\").ReactEventHandler; onErrorCapture?: import(\"react\").ReactEventHandler; onFocus?: import(\"react\").FocusEventHandler; onFocusCapture?: import(\"react\").FocusEventHandler; onGotPointerCapture?: import(\"react\").PointerEventHandler; onGotPointerCaptureCapture?: import(\"react\").PointerEventHandler; onInput?: import(\"react\").InputEventHandler; onInputCapture?: import(\"react\").InputEventHandler; onInvalid?: import(\"react\").ReactEventHandler; onInvalidCapture?: import(\"react\").ReactEventHandler; onKeyDown?: import(\"react\").KeyboardEventHandler; onKeyDownCapture?: import(\"react\").KeyboardEventHandler; onKeyPress?: import(\"react\").KeyboardEventHandler; onKeyPressCapture?: import(\"react\").KeyboardEventHandler; onKeyUp?: import(\"react\").KeyboardEventHandler; onKeyUpCapture?: import(\"react\").KeyboardEventHandler; onLoad?: import(\"react\").ReactEventHandler; onLoadCapture?: import(\"react\").ReactEventHandler; onLoadedData?: import(\"react\").ReactEventHandler; onLoadedDataCapture?: import(\"react\").ReactEventHandler; onLoadedMetadata?: import(\"react\").ReactEventHandler; onLoadedMetadataCapture?: import(\"react\").ReactEventHandler; onLoadStart?: import(\"react\").ReactEventHandler; onLoadStartCapture?: import(\"react\").ReactEventHandler; onLostPointerCapture?: import(\"react\").PointerEventHandler; onLostPointerCaptureCapture?: import(\"react\").PointerEventHandler; onMouseDown?: import(\"react\").MouseEventHandler; onMouseDownCapture?: import(\"react\").MouseEventHandler; onMouseEnter?: import(\"react\").MouseEventHandler; onMouseLeave?: import(\"react\").MouseEventHandler; onMouseMove?: import(\"react\").MouseEventHandler; onMouseMoveCapture?: import(\"react\").MouseEventHandler; onMouseOut?: import(\"react\").MouseEventHandler; onMouseOutCapture?: import(\"react\").MouseEventHandler; onMouseOver?: import(\"react\").MouseEventHandler; onMouseOverCapture?: import(\"react\").MouseEventHandler; onMouseUp?: import(\"react\").MouseEventHandler; onMouseUpCapture?: import(\"react\").MouseEventHandler; onPaste?: import(\"react\").ClipboardEventHandler; onPasteCapture?: import(\"react\").ClipboardEventHandler; onPause?: import(\"react\").ReactEventHandler; onPauseCapture?: import(\"react\").ReactEventHandler; onPlay?: import(\"react\").ReactEventHandler; onPlayCapture?: import(\"react\").ReactEventHandler; onPlaying?: import(\"react\").ReactEventHandler; onPlayingCapture?: import(\"react\").ReactEventHandler; onPointerCancel?: import(\"react\").PointerEventHandler; onPointerCancelCapture?: import(\"react\").PointerEventHandler; onPointerDown?: import(\"react\").PointerEventHandler; onPointerDownCapture?: import(\"react\").PointerEventHandler; onPointerEnter?: import(\"react\").PointerEventHandler; onPointerLeave?: import(\"react\").PointerEventHandler; onPointerMove?: import(\"react\").PointerEventHandler; onPointerMoveCapture?: import(\"react\").PointerEventHandler; onPointerOut?: import(\"react\").PointerEventHandler; onPointerOutCapture?: import(\"react\").PointerEventHandler; onPointerOver?: import(\"react\").PointerEventHandler; onPointerOverCapture?: import(\"react\").PointerEventHandler; onPointerUp?: import(\"react\").PointerEventHandler; onPointerUpCapture?: import(\"react\").PointerEventHandler; onProgress?: import(\"react\").ReactEventHandler; onProgressCapture?: import(\"react\").ReactEventHandler; onRateChange?: import(\"react\").ReactEventHandler; onRateChangeCapture?: import(\"react\").ReactEventHandler; onReset?: import(\"react\").ReactEventHandler; onResetCapture?: import(\"react\").ReactEventHandler; onScroll?: import(\"react\").UIEventHandler; onScrollCapture?: import(\"react\").UIEventHandler; onScrollEnd?: import(\"react\").UIEventHandler; onScrollEndCapture?: import(\"react\").UIEventHandler; onSeeked?: import(\"react\").ReactEventHandler; onSeekedCapture?: import(\"react\").ReactEventHandler; onSeeking?: import(\"react\").ReactEventHandler; onSeekingCapture?: import(\"react\").ReactEventHandler; onSelect?: import(\"react\").ReactEventHandler; onSelectCapture?: import(\"react\").ReactEventHandler; onStalled?: import(\"react\").ReactEventHandler; onStalledCapture?: import(\"react\").ReactEventHandler; onSubmit?: import(\"react\").SubmitEventHandler; onSubmitCapture?: import(\"react\").SubmitEventHandler; onSuspend?: import(\"react\").ReactEventHandler; onSuspendCapture?: import(\"react\").ReactEventHandler; onTimeUpdate?: import(\"react\").ReactEventHandler; onTimeUpdateCapture?: import(\"react\").ReactEventHandler; onToggle?: import(\"react\").ToggleEventHandler; onTouchCancel?: import(\"react\").TouchEventHandler; onTouchCancelCapture?: import(\"react\").TouchEventHandler; onTouchEnd?: import(\"react\").TouchEventHandler; onTouchEndCapture?: import(\"react\").TouchEventHandler; onTouchMove?: import(\"react\").TouchEventHandler; onTouchMoveCapture?: import(\"react\").TouchEventHandler; onTouchStart?: import(\"react\").TouchEventHandler; onTouchStartCapture?: import(\"react\").TouchEventHandler; onTransitionCancel?: import(\"react\").TransitionEventHandler; onTransitionCancelCapture?: import(\"react\").TransitionEventHandler; onTransitionEnd?: import(\"react\").TransitionEventHandler; onTransitionEndCapture?: import(\"react\").TransitionEventHandler; onTransitionRun?: import(\"react\").TransitionEventHandler; onTransitionRunCapture?: import(\"react\").TransitionEventHandler; onTransitionStart?: import(\"react\").TransitionEventHandler; onTransitionStartCapture?: import(\"react\").TransitionEventHandler; onVolumeChange?: import(\"react\").ReactEventHandler; onVolumeChangeCapture?: import(\"react\").ReactEventHandler; onWaiting?: import(\"react\").ReactEventHandler; onWaitingCapture?: import(\"react\").ReactEventHandler; onWheel?: import(\"react\").WheelEventHandler; onWheelCapture?: import(\"react\").WheelEventHandler; part?: string; ping?: string; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; referrerPolicy?: import(\"react\").HTMLAttributeReferrerPolicy; resource?: string; results?: number; rev?: string; role?: import(\"react\").AriaRole; security?: string; slot?: string; spellCheck?: (boolean | \"true\" | \"false\"); style?: import(\"react\").CSSProperties; suppressContentEditableWarning?: boolean; suppressHydrationWarning?: boolean; tabIndex?: number; title?: string; tone?: \"inherit\" | \"accent\"; translate?: \"yes\" | \"no\"; type?: string; typeof?: string; unselectable?: \"off\" | \"on\"; vocab?: string;", "FieldError": "children: string; id: string;", @@ -120,6 +121,7 @@ "DoseLine": "src/components/ui/answer-card.tsx", "DownloadLink": "src/components/ui/link.tsx", "EmptyState": "src/components/ui-primitives.tsx", + "ErrorState": "src/components/ui/error-state.tsx", "ErrorSummary": "src/components/ui/form-field.tsx", "ExternalTextLink": "src/components/ui/link.tsx", "FieldError": "src/components/ui/form-field.tsx", diff --git a/.design-sync/entry.tsx b/.design-sync/entry.tsx index e6a939588..8d20aabc2 100644 --- a/.design-sync/entry.tsx +++ b/.design-sync/entry.tsx @@ -55,6 +55,7 @@ export { type DisclosureGroupProps, type DisclosureProps, } from "@/components/ui/disclosure"; +export { ErrorState, errorStateCopy, type ErrorStateProps, type ErrorStateReason } from "@/components/ui/error-state"; export { ErrorSummary, FieldError, diff --git a/.design-sync/previews/ErrorState.tsx b/.design-sync/previews/ErrorState.tsx new file mode 100644 index 000000000..a13c8d122 --- /dev/null +++ b/.design-sync/previews/ErrorState.tsx @@ -0,0 +1,16 @@ +import { ErrorState } from "prompt-for-codex-medical-knowledge-base"; + +// A failed request has no count to report, so none of these render a number. +// "0 matches" on a failed services search asserts there are no crisis services +// when the search never ran — see SPEC §10 and COMPONENTS §0.3. +export const Reasons = () => ( +
+ {}} /> + + {}} /> +
+); + +// `inline` drops the panel chrome for a surface that already owns a border, +// exactly as the search band's fault panel sits inside the results ribbon. +export const Inline = () => ; diff --git a/docs/design-system/ADOPTION.md b/docs/design-system/ADOPTION.md index eee8fc506..b734870ab 100644 --- a/docs/design-system/ADOPTION.md +++ b/docs/design-system/ADOPTION.md @@ -326,7 +326,7 @@ multiply-owned routes fail the check. `src/app/api/**` and `src/app/mockups/**` product exclusions; the only route-only disposition is the documented legacy document-source redirect. Shared shell/component roots carry their own explicit `shared-shell` disposition. -Registered public components: 53 +Registered public components: 54 Declared product roots: 59 Roots with a literal `.ckb-v2` opt-in: 1 Roots inheriting `.ckb-v2` from the global ``: 58 diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 601576f07..7ee1e9fe6 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -945,8 +945,8 @@ tests to run. ## Generated maturity snapshot -Registered public components: 53 -Components with a valid design-sync preview: 53 +Registered public components: 54 +Components with a valid design-sync preview: 54 Components with product imports: 31 This generated snapshot is a local source-derived inventory. It does not assert remote design-project publication. @@ -970,6 +970,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `DoseLine` | answer | yes | yes | no | yes | no | 0 | | `DownloadLink` | controls | yes | yes | no | yes | no | 0 | | `EmptyState` | feedback | yes | yes | inherited-global-root | yes | no | 12 | +| `ErrorState` | feedback | yes | yes | no | yes | no | 0 | | `ErrorSummary` | feedback | yes | yes | no | yes | no | 0 | | `ExternalTextLink` | controls | yes | yes | no | yes | no | 0 | | `FieldError` | feedback | yes | yes | no | yes | no | 0 | diff --git a/docs/design-system/adoption-contract.json b/docs/design-system/adoption-contract.json index c0ee72000..169ba2527 100644 --- a/docs/design-system/adoption-contract.json +++ b/docs/design-system/adoption-contract.json @@ -659,6 +659,7 @@ "DoseLine": "answer", "DownloadLink": "controls", "EmptyState": "feedback", + "ErrorState": "feedback", "ErrorSummary": "feedback", "ExternalTextLink": "controls", "FieldError": "feedback", diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 6cf447807..cc26b9d63 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -207,7 +207,8 @@ "src/components/DocumentManagementActions.tsx", "src/components/clinical-dashboard/account-setup-dialog.tsx", "src/components/clinical-dashboard/auth-panel.tsx", - "src/components/clinical-dashboard/search-results-header-band.tsx" + "src/components/clinical-dashboard/search-results-header-band.tsx", + "src/components/ui/error-state.tsx" ], "productImportFiles": [ "src/components/DocumentManagementActions.tsx", @@ -688,6 +689,34 @@ "proofDeclared": true, "baselineCommitted": false }, + { + "name": "ErrorState", + "family": "feedback", + "source": "src/components/ui/error-state.tsx", + "sourceExported": true, + "entryExported": true, + "directImportFiles": [], + "productImportFiles": [], + "designSync": { + "listedInSourceMap": true, + "listedInDtsProps": true, + "preview": ".design-sync/previews/ErrorState.tsx", + "previewValid": true + }, + "testFiles": ["tests/design-sync-visual-exports.test.ts", "tests/error-state.dom.test.tsx"], + "baseline": { + "targetLayer": "v2", + "liveLayer": "v2", + "remoteStatus": "unverified", + "visualBaselineStatus": "not-committed" + }, + "built": true, + "locallyRegistered": true, + "v2ShellMounted": false, + "v2MountMode": "none", + "proofDeclared": true, + "baselineCommitted": false + }, { "name": "ErrorSummary", "family": "feedback", @@ -3314,8 +3343,8 @@ "declaredV2SurfaceCount": 14 }, "summary": { - "registeredComponentCount": 53, - "previewCount": 53, + "registeredComponentCount": 54, + "previewCount": 54, "productImportedComponentCount": 31, "rootCount": 59, "productionRouteCount": 51, diff --git a/src/components/ui/error-state.tsx b/src/components/ui/error-state.tsx new file mode 100644 index 000000000..9ca87f0d4 --- /dev/null +++ b/src/components/ui/error-state.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { CircleAlert } from "lucide-react"; +import { useState, type ReactNode } from "react"; + +import { AsyncButton, cn, floatingControl } from "@/components/ui-primitives"; +import { createBoundedDiagnosticRecorder } from "@/components/ui/design-system-diagnostics"; + +/** + * COMPONENTS §0.3 and SPEC §10, "Search failed → `ErrorState`", with the copy + * rule stated as a prohibition: **never "0 matches" after a failed request**. + * + * The distinction this component exists to hold is not cosmetic. A search that + * failed has no count to report, so reporting zero is a false statement about + * the corpus rather than a tidy fallback — on the services page "0 matches" + * asserts there are no crisis services when the search never ran, and on + * favourites it reads as "you have saved nothing" rather than "we could not + * load them". `MissingValue` draws the same line from the other side: "no + * result count is available" is not a missing value. + * + * So the component takes **no count and no children**. There is no prop through + * which a number can arrive, which is the only version of this rule that + * survives a hurried call site. The one remaining route — a caller writing the + * count into `title` or `body` themselves — is covered by a development-time + * tripwire below rather than by trust. + * + * Six surfaces hand-roll this guard today, several with comments explaining the + * rule (`search-results-header-band.tsx`, `services-navigator-page.tsx`, + * `favourites-command-library-page.tsx`, `document-search-results.tsx`, and the + * differentials pages). They are correct; they are simply not shared. Adopting + * them is a live-look change and deliberately not part of landing this. + */ + +export type ErrorStateReason = "request_failed" | "unauthorized" | "timeout" | "unknown"; + +export type ErrorStateProps = { + reason: ErrorStateReason; + /** + * What could not be loaded — "Services", "Favourites", "DSM diagnoses". Names + * the subject of the failed request, never its size. Omitted, the copy falls + * back to a subject-less phrasing rather than inventing one. + */ + subject?: string; + /** Overrides the derived heading. Still may not carry a result count. */ + title?: string; + /** Overrides the derived supporting line. Still may not carry a result count. */ + body?: string; + /** + * Recovery. Async so a call site can await its refetch and get the busy state + * for free; a rejected retry leaves this state on screen rather than escaping + * as an unhandled rejection, because the failure surface is already correct. + */ + onRetry?: () => void | Promise; + /** Additional recovery affordances — a sign-in link, "Browse library". */ + actions?: ReactNode; + /** + * `inline` drops the panel chrome for use inside a surface that already owns + * a border, exactly as the search band's fault panel sits inside the ribbon. + */ + density?: "panel" | "inline"; + /** + * Announcement. A failure introduced dynamically is worth interrupting for, + * so this defaults to `assertive`; pass `off` where the state is present on + * first paint and the surrounding region already names it. + */ + live?: "off" | "polite" | "assertive"; + testId?: string; + className?: string; +}; + +const TITLES: Record string> = { + request_failed: (subject) => (subject ? `${subject} could not be loaded` : "That could not be loaded"), + unauthorized: () => "Sign in to continue", + timeout: (subject) => (subject ? `${subject} took too long to load` : "That took too long to load"), + unknown: (subject) => (subject ? `${subject} could not be loaded` : "That could not be loaded"), +}; + +const BODIES: Record = { + request_failed: "The request could not be completed. Try again shortly.", + unauthorized: "Your session has expired. Sign in again to run this search.", + timeout: "The request did not finish in time. Try again shortly.", + unknown: "The request could not be completed. Try again shortly.", +}; + +/** + * A result count written into caller copy — "0 matches", "no results found", + * "2 documents". The number is matched against the counted noun rather than on + * its own, so an error code ("Error 503") or a duration ("retry in 30 seconds") + * does not trip it. Warn-only and development-only: a noisy false positive on a + * clinical surface is worse than the miss, and a thrown error here would turn a + * copy defect into a blank page on the one screen already reporting a failure. + */ +const RESULT_COUNT_IN_COPY = /\b(?:\d[\d,.]*|no|zero)\s+(?:match|result|document|source|item|record)s?\b/i; + +const recordDiagnostic = createBoundedDiagnosticRecorder({ + emit: (message) => { + if (typeof process === "undefined" || process.env?.NODE_ENV !== "test") console.warn(message); + }, +}); + +/** Enum resilience per SPEC §7: an unrecognised reason degrades and never throws. */ +export function errorStateCopy( + reason: ErrorStateReason, + subject?: string, +): { title: string; body: string; reason: ErrorStateReason } { + const known = Object.hasOwn(TITLES, reason) && typeof TITLES[reason] === "function"; + if (!known) { + const key = String(reason); + recordDiagnostic( + key, + JSON.stringify({ level: "warn", message: "error-state: unrecognized reason", field: "reason", value: key }), + ); + return { title: TITLES.unknown(subject), body: BODIES.unknown, reason: "unknown" }; + } + return { title: TITLES[reason](subject), body: BODIES[reason], reason }; +} + +export function ErrorState({ + reason, + subject, + title, + body, + onRetry, + actions, + density = "panel", + live = "assertive", + testId, + className, +}: ErrorStateProps) { + const [retrying, setRetrying] = useState(false); + const derived = errorStateCopy(reason, subject); + const resolvedTitle = title ?? derived.title; + const resolvedBody = body ?? derived.body; + + for (const [field, value] of [ + ["title", title], + ["body", body], + ] as const) { + if (value && RESULT_COUNT_IN_COPY.test(value)) { + recordDiagnostic( + `count:${field}:${value}`, + JSON.stringify({ + level: "warn", + message: "error-state: a failed request has no count to report", + field, + value, + }), + ); + } + } + + const retry = async () => { + if (!onRetry) return; + setRetrying(true); + try { + await onRetry(); + } catch { + // This state is already the failure surface. A rejected retry leaves it + // on screen rather than escaping as an unhandled rejection. + } finally { + setRetrying(false); + } + }; + + return ( +
+
+ {/* Shape as well as hue. A failure that is only a different colour is + invisible to a reader who cannot separate the two, and an SVG stroke + survives forced colors where a tinted background does not. */} + +
+

{resolvedTitle}

+

{resolvedBody}

+ {onRetry || actions ? ( + // Wrapping is load-bearing: a call site can pass Retry plus two + // links, and a non-wrapping row clips the trailing action away on a + // narrow phone rather than pushing it to a second line. +
+ {onRetry ? ( + + Retry + + ) : null} + {actions} +
+ ) : null} +
+
+
+ ); +} diff --git a/tests/design-sync-visual-exports.test.ts b/tests/design-sync-visual-exports.test.ts index 20eb2e449..0928531ac 100644 --- a/tests/design-sync-visual-exports.test.ts +++ b/tests/design-sync-visual-exports.test.ts @@ -26,6 +26,7 @@ const visualExports = [ "DoseLine", "DownloadLink", "EmptyState", + "ErrorState", "ErrorSummary", "ExternalTextLink", "FieldError", diff --git a/tests/error-state.dom.test.tsx b/tests/error-state.dom.test.tsx new file mode 100644 index 000000000..463fef25e --- /dev/null +++ b/tests/error-state.dom.test.tsx @@ -0,0 +1,130 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { ErrorState, errorStateCopy, type ErrorStateProps, type ErrorStateReason } from "@/components/ui/error-state"; + +/** + * GATES.md §3, "Render '0 matches' after a failed request". The gate names + * `ErrorState`; these are the assertions behind it. + * + * The invariant under test is not "the copy is nice". It is that a failed + * request cannot report a count — on the services page "0 matches" asserts + * there are no crisis services when the search never ran. + */ +describe("ErrorState", () => { + it("never renders a digit for any reason, with or without a subject", () => { + const reasons: ErrorStateReason[] = ["request_failed", "unauthorized", "timeout", "unknown"]; + for (const reason of reasons) { + for (const subject of [undefined, "Services"]) { + const { container, unmount } = render( {}} />); + expect(container.textContent ?? "").not.toMatch(/\d/); + unmount(); + } + } + }); + + it("says what could not be loaded rather than that nothing matched", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("Services could not be loaded"); + expect(screen.queryByText(/no matches/i)).toBeNull(); + expect(screen.queryByText(/0 matches/i)).toBeNull(); + }); + + it("names the subject only when given one, and never invents a count in its place", () => { + const { rerender } = render(); + expect(screen.getByRole("alert")).toHaveTextContent("That could not be loaded"); + rerender(); + expect(screen.getByRole("alert")).toHaveTextContent("Sign in to continue"); + }); + + it("exposes no prop through which a count could arrive", () => { + // The structural half of the rule, asserted at compile time: `children` and + // any count-shaped prop are absent from ErrorStateProps, so the only route + // left is caller copy — which the tripwire below covers. Adding either prop + // later turns these into type errors and fails `npm run typecheck`. + // + // Written as type-level equalities rather than a `@ts-expect-error` render: + // whether TypeScript rejects an excess property through a JSX spread of a + // variable is a detail of excess-property checking, and if it ever stopped + // rejecting it, the unused directive would itself become the error. + type HasChildren = "children" extends keyof ErrorStateProps ? true : false; + type CountProp = Extract; + type HasCount = [CountProp] extends [never] ? false : true; + + const hasChildren: HasChildren = false; + const hasCount: HasCount = false; + expect([hasChildren, hasCount]).toEqual([false, false]); + }); + + it("warns when a caller writes a result count into its copy", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + render(); + // The tripwire records rather than throws: a thrown error here turns a + // copy defect into a blank page on the one screen already reporting a + // failure. The recorder is bounded and deduplicated, and silenced under + // NODE_ENV=test, so the assertion is on the rendered outcome. + expect(screen.getByRole("alert")).toHaveTextContent("0 matches found"); + } finally { + warn.mockRestore(); + } + }); + + it("does not trip on a number that is not a result count", () => { + // "Error 503" and "retry in 30 seconds" are legitimate; only a figure + // attached to a counted noun is the defect. + expect(() => render()).not.toThrow(); + expect(screen.getByRole("alert")).toHaveTextContent("Error 503"); + }); + + it("runs the retry action and restores itself when the retry rejects", async () => { + const user = userEvent.setup(); + const onRetry = vi.fn().mockRejectedValue(new Error("still down")); + render(); + const retry = screen.getByRole("button", { name: /retry/i }); + await user.click(retry); + expect(onRetry).toHaveBeenCalledTimes(1); + // A rejected retry leaves the failure surface on screen rather than + // escaping as an unhandled rejection. + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /retry/i })).toBeEnabled(); + }); + + it("omits the retry control entirely when no handler is supplied", () => { + render(); + expect(screen.queryByRole("button", { name: /retry/i })).toBeNull(); + }); + + it("renders caller actions alongside retry", () => { + // A plain button rather than an anchor: internal navigation in this repo + // goes through ``, and a raw `` fails + // `@next/next/no-html-link-for-pages` even inside a test. What is under + // test is that an arbitrary ReactNode reaches the action row at all. + render( + {}} actions={} />, + ); + expect(screen.getByRole("button", { name: "Browse library" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + it("degrades an unrecognised reason to the neutral copy and never throws", () => { + const reason = "catastrophe" as unknown as ErrorStateReason; + expect(() => render()).not.toThrow(); + expect(screen.getByRole("alert")).toHaveTextContent("Services could not be loaded"); + expect(screen.getByRole("alert")).toHaveAttribute("data-reason", "unknown"); + }); + + it("survives a prototype-chain reason without throwing", () => { + const reason = "toString" as unknown as ErrorStateReason; + expect(() => render()).not.toThrow(); + expect(errorStateCopy(reason).reason).toBe("unknown"); + }); + + it("maps live to a role so a state present on first paint need not interrupt", () => { + const { rerender } = render(); + expect(screen.getByTestId("es")).toHaveAttribute("role", "status"); + rerender(); + expect(screen.getByTestId("es")).not.toHaveAttribute("role"); + }); +}); From d0aeaeebb1a260d124a5f751e0a0bfd59530b7bc Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:52:53 +0800 Subject: [PATCH 2/4] feat(design-system): ratchet raw gap literals, the family #1780 left uncovered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1780 landed rawPaddingLiterals, rawRadiusLiterals and rawLineHeightLiterals for #262 part 3. Gap was the one remaining spacing surface a hand-picked value could hide in: gap-[9px] and `gap: 18px` were counted by no ratchet at all. This adds rawGapLiterals on that commit's own predicate and wiring. Measured against origin/main ef9bb5166: 34 sites across 9 files — 21 Tailwind utilities, every one under src/components/therapy-compass/, plus 13 CSS declarations in globals.css that a utility-only scan misses. Covering both spellings is the same reason #1780 counts both: otherwise a literal escapes by moving from a class into globals.css. Kept as its own metric rather than folded into rawPaddingLiterals so the therapy-compass cleanup can be paid down and re-pinned independently of the padding debt, which is spread across fifteen unrelated files. Reuses RAW_LITERAL_VALUE unchanged, so a value containing a CSS function (env(, clamp(, max(, calc() stays a sanctioned computed form and is exempt. Also corrects the §3 prohibition row, which #1780 left reading "implemented-partial (colour/shadow/tap literals only)" and which named none of the metrics it had just shipped. A row that understates shipped work is what sends the next session to rebuild it — this change was itself started as a duplicate of #262 part 3 for exactly that reason. Mutation-verified in both halves, each naming the metric and the path: a gap-[3px] utility gives "rawGapLiterals increased from 34 to 35" plus "at src/components/ui/missing-value.tsx increased from 0 to 1"; a `gap: 19px` declaration gives the same total plus "at src/app/globals.css increased from 13 to 14". Baseline diff is additive only. legacyShadowAliases measures 218 against its pinned 220 on main; that slack is left exactly as found. Co-Authored-By: Claude Opus 5 --- docs/design-system/GATES.md | 80 ++++++++++---------- scripts/check-design-system-contract.mjs | 5 +- scripts/design-system-contract-baseline.json | 12 +++ scripts/design-system-contract-utils.mjs | 26 ++++++- 4 files changed, 81 insertions(+), 42 deletions(-) diff --git a/docs/design-system/GATES.md b/docs/design-system/GATES.md index 4096b46f6..b5cc3a57a 100644 --- a/docs/design-system/GATES.md +++ b/docs/design-system/GATES.md @@ -16,19 +16,19 @@ than CI, two of them by the second reader; review does not scale past two carefu **[verified against this worktree and the export]** -| Check | What it enforces | Label | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -| `npm run check:design-system-contract` (token baseline + adoption + design-sync contracts) | Raw colour literals (ratchet against `design-system-contract-baseline.json`), literal `shadow-[…]` classes, the legacy tap class, interactive tap-size literals, therapy-compass `tc-btn` base-class use; border+ring edge conflicts, 1px shadow spreads, layout-property transitions and legacy shadow aliases (all ratcheted **per path**, so new use fails anywhere); status-coloured numerals and colour-only status indicators; raw padding, radius and line-height literals in both classes and CSS declarations (ratcheted **per path**; named utilities and Tailwind arbitrary-property forms such as `[padding:22px]` both count; values computed with `var()`/`env()`/`calc()`/`clamp()`/`max()`, zero with any CSS unit identifier, and the CSS-wide keywords are exempt); declared-but-unselected type steps (**hard rule** with a gated exemption list); `dark:` colour overrides, legacy palette utilities and image-inversion filters (all pinned at **zero**, not ratcheted); deterministic adoption truth; local source/export/preview/design-sync parity. Mockups exempt. | implemented-blocking | -| `tests/ckb-v2-token-contract.test.ts` | v2 layer stays class-scoped (no `:root` leak) · `--border-lux` solid and no lighter than `--border`, both themes · dark surface ramp monotonic and separated · `--surface-subtle` aliases up · text/muted/heading ≥4.5:1 on the light shell, muted ≥4.5:1 dark · `--text-soft` pinned **below** 4.5:1 and ≥3:1 (both sides, so the tier cannot be "fixed" away) · command pair ≥4.5:1 both themes · `--shadow-inset` true inset · elevation ladder carries no baked 1px hairline · tap floor ≠ chip height ≠ compact row · reduced-motion zeroes durations · every type step has its own line-height and tracking | implemented-blocking | -| `tests/design-token-contract.test.ts` | Live-layer token relationships (companion to the v2 contract) | implemented-blocking | -| `tests/source-badges-off-vocab.dom.test.tsx` | Enum resilience: off-vocabulary `clinical_validation_status` degrades to the neutral triad, logs once, never throws | implemented-blocking | -| `tests/source-metadata-browser-safety.test.ts` | The `process is not defined` client-bundle crash stays fixed (server logger never ships to the browser) | implemented-blocking | -| `tests/accessible-table.dom.test.tsx`, `tests/accessible-table-alignment.dom.test.tsx` | Table semantics, numeric alignment, expander `aria-controls` | implemented-blocking | -| `tests/ui-primitives.dom.test.tsx`, `tests/ui-v2-components.dom.test.tsx` | Behavioural coverage for the tested subset (COMPONENTS §0) | implemented-blocking (subset) | -| ESLint repo rules: `no-hardcoded-hex`, `require-button-wiring`, `require-lucide-icon-aria`, `require-z-index-ladder`, `restrict-suppress-hydration-warning` | Raw hex, un-wired buttons, icon aria, z-ladder discipline, hydration-warning abuse | implemented-blocking | -| `tests/route-reachability.test.ts` | No orphan production routes | implemented-blocking | -| `npm run verify:ui` (+ `verify:phone-chrome`) | Chromium production journeys; phone-chrome owners | implemented-blocking (journey scope) | -| Visual-baseline harness (PR #1404) | Screenshot baselines exist but ship **zero committed baselines with `continue-on-error` on, deliberately** — a held gate until the design is declared final (issue `#118`). Includes the `--spacing-tap` probe floor. | implemented-partial (non-blocking by hold) | +| Check | What it enforces | Label | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | +| `npm run check:design-system-contract` (token baseline + adoption + design-sync contracts) | Raw colour literals (ratchet against `design-system-contract-baseline.json`), literal `shadow-[…]` classes, the legacy tap class, interactive tap-size literals, therapy-compass `tc-btn` base-class use; border+ring edge conflicts, 1px shadow spreads, layout-property transitions and legacy shadow aliases (all ratcheted **per path**, so new use fails anywhere); status-coloured numerals and colour-only status indicators; raw padding, radius, gap and line-height literals in both classes and CSS declarations (ratcheted **per path**; named utilities and Tailwind arbitrary-property forms such as `[padding:22px]` both count; values computed with `var()`/`env()`/`calc()`/`clamp()`/`max()`, zero with any CSS unit identifier, and the CSS-wide keywords are exempt); declared-but-unselected type steps (**hard rule** with a gated exemption list); `dark:` colour overrides, legacy palette utilities and image-inversion filters (all pinned at **zero**, not ratcheted); deterministic adoption truth; local source/export/preview/design-sync parity. Mockups exempt. | implemented-blocking | +| `tests/ckb-v2-token-contract.test.ts` | v2 layer stays class-scoped (no `:root` leak) · `--border-lux` solid and no lighter than `--border`, both themes · dark surface ramp monotonic and separated · `--surface-subtle` aliases up · text/muted/heading ≥4.5:1 on the light shell, muted ≥4.5:1 dark · `--text-soft` pinned **below** 4.5:1 and ≥3:1 (both sides, so the tier cannot be "fixed" away) · command pair ≥4.5:1 both themes · `--shadow-inset` true inset · elevation ladder carries no baked 1px hairline · tap floor ≠ chip height ≠ compact row · reduced-motion zeroes durations · every type step has its own line-height and tracking | implemented-blocking | +| `tests/design-token-contract.test.ts` | Live-layer token relationships (companion to the v2 contract) | implemented-blocking | +| `tests/source-badges-off-vocab.dom.test.tsx` | Enum resilience: off-vocabulary `clinical_validation_status` degrades to the neutral triad, logs once, never throws | implemented-blocking | +| `tests/source-metadata-browser-safety.test.ts` | The `process is not defined` client-bundle crash stays fixed (server logger never ships to the browser) | implemented-blocking | +| `tests/accessible-table.dom.test.tsx`, `tests/accessible-table-alignment.dom.test.tsx` | Table semantics, numeric alignment, expander `aria-controls` | implemented-blocking | +| `tests/ui-primitives.dom.test.tsx`, `tests/ui-v2-components.dom.test.tsx` | Behavioural coverage for the tested subset (COMPONENTS §0) | implemented-blocking (subset) | +| ESLint repo rules: `no-hardcoded-hex`, `require-button-wiring`, `require-lucide-icon-aria`, `require-z-index-ladder`, `restrict-suppress-hydration-warning` | Raw hex, un-wired buttons, icon aria, z-ladder discipline, hydration-warning abuse | implemented-blocking | +| `tests/route-reachability.test.ts` | No orphan production routes | implemented-blocking | +| `npm run verify:ui` (+ `verify:phone-chrome`) | Chromium production journeys; phone-chrome owners | implemented-blocking (journey scope) | +| Visual-baseline harness (PR #1404) | Screenshot baselines exist but ship **zero committed baselines with `continue-on-error` on, deliberately** — a held gate until the design is declared final (issue `#118`). Includes the `--spacing-tap` probe floor. | implemented-partial (non-blocking by hold) | ⚠️ **Type-scale and icon-scale enforcement — corrected 6 Aug 2026.** The previous wording here ("no such rule files exist in `eslint-rules/`… currently **unenforced**") was true about @@ -99,33 +99,33 @@ theme-list parity, and remote design-project publication remain separate concern ## 3 · The prohibition table -| Never do this | Gate | Status | -| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Reintroduce `.ckb-v2:not(.dark)` | Cascade computed-style test (PR 1) + contract-test selector filter update | implemented-blocking — landed #1538; contract parser + dark-ink regression guard | -| Add `prefers-color-scheme` rules to the v2 layer | — (users pin a theme against OS preference) | **manual** | -| Paint a numeral with a status colour; use amber/red decoratively | `check:design-system-contract` — `statusColouredNumerals` + `colourOnlyStatusIndicators` | **implemented-blocking** (9 Aug 2026) — ratcheted at 2 and 4 with per-path pins; see §5 | -| Put `--decoration-soft`/`--text-soft` on a text node | Tier pinned both sides in the token contract; recipe AST (`decoration-on-text.contract.test.ts`) | implemented-blocking (recipes); broader AST follow-on | -| Uppercase a dose unit | `Quantity` pins `normal-case` + unit test | implemented-blocking (branch) | -| Invert a PDF, diagram or clinical image in any theme | `check:design-system-contract` — `imageInversions`, pinned at **zero**, not ratcheted | **implemented-blocking** (9 Aug 2026) — CSS `filter`/`backdrop-filter` plus the Tailwind `invert`/`hue-rotate` utilities; see §5 | -| Border **and** ring on one surface, or a 1px spread in a drop shadow | `check:design-system-contract` — `edgeOwnershipConflicts` (27) + `onePixelShadowSpreads` (2) | **implemented-blocking for new use** — AST/CSS ratchets with per-path pins; the recorded debt itself is Gate 8's remaining half | -| A child shadow heavier than its parent's | Gate 7 | implemented-partial | -| Use `--shadow-tight`/any alias in new code | `check:design-system-contract` — `legacyShadowAliases`, ratcheted at 224 with per-path pins | **implemented-blocking for new use** — a new alias in any file fails; retiring the existing 224 is `#262` | -| Raw pixel size, padding, radius or line-height in markup | Contract ratchet | implemented-partial (colour/shadow/tap literals only) | -| Animate `width`, `height`, `grid-template-*`, `top`, `left`, `gap` | `check:design-system-contract` — `layoutTransitionExceptions`, ratcheted at 12 with per-path pins | **implemented-blocking for new use** — `SAFE_TRANSITION_PROPERTIES` carries the compositor-only allowlist; phone chrome's deliberate `grid-template-rows` is in the recorded 12 | -| Hardcode a transition duration | `check:design-system-contract` — `hardcodedMotionClasses` (**zero**) + `hardcodedCssMotionDurations` (42) | **implemented-blocking** for the Tailwind `duration-*`/`delay-*`/`transition-all` form; the CSS form is a ratchet, so its 42 are debt | -| A `z-` value outside the named rungs | `require-z-index-ladder` (ESLint) | implemented-blocking | -| Ship a registered component without a direct publication test | Gate 12 | implemented-blocking | -| Lower a production tap target below 48px | Gate 2 + `ui-smoke` flake history + held visual probe | implemented-partial — token floor + declared carriers only; fixed `h-10` interactive controls and the unwired Chromium audit are not blocked | -| Construct an enabled control with no action, destination or name | PR 4 discriminated unions + DOM contracts; `require-button-wiring` covers raw `