19 KiB
Capability Spec: clipboard
Package: @suaveplan/clipboard
Status: ARCHIVED (2026-05-25 enterprise-clipboard)
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: Stable error code registry
The package SHALL expose a CLIPBOARD_CODES constant created via
defineCodes("CLIPBOARD", [...] as const) containing exactly eight stable codes:
API_UNAVAILABLE, PERMISSION_DENIED, FALLBACK_FAILED, INSECURE_CONTEXT,
NO_CONTENT, READ_FAILED, DOCUMENT_UNAVAILABLE, QUEUE_DRAINED. Each code MUST
have a metadata record (httpStatus, retryable, severity, description) and a
class ClipboardError extending SuaveplanError<ClipboardErrorCode> MUST be
constructible with each code.
Scenario: registry shape is frozen
- WHEN a consumer reads
CLIPBOARD_CODES - THEN the returned object MUST contain all eight expected keys with namespaced
string values (
CLIPBOARD.API_UNAVAILABLE, …) and MUST beObject.isFrozen.
Scenario: error metadata is queryable per code
- WHEN a consumer reads
CLIPBOARD_ERROR_METADATA[CLIPBOARD_CODES.INSECURE_CONTEXT] - THEN the returned record MUST report
httpStatus: 400,retryable: false,severity: "error", and a non-emptydescription.
Verified by: src/errors/errors.test.ts::"CLIPBOARD_CODES has all eight stable codes" AND src/errors/errors.test.ts::"CLIPBOARD_ERROR_METADATA reports correct shape per code"
Requirement: ClipboardError extends SuaveplanError
The package SHALL export ClipboardError extends SuaveplanError<ClipboardErrorCode>.
Constructing new ClipboardError(CLIPBOARD_CODES.API_UNAVAILABLE, "msg") MUST yield
an instance whose .code === "CLIPBOARD.API_UNAVAILABLE", whose .message === "msg",
and which satisfies both instanceof ClipboardError and instanceof Error.
Scenario: round-trips through cause chain
- WHEN a
ClipboardErroris constructed withcause: nativeErr - THEN
error.causeMUST return the original native error anderror.codeMUST preserve the namespaced code.
Verified by: src/errors/errors.test.ts::"ClipboardError preserves code and cause"
Requirement: Secure-context guard
The package SHALL expose isSecure(window?) and requireSecureContext(window?). The
former MUST return window.isSecureContext when the supplied window exposes that
field and true when running outside a window (so universal SSR call sites do not
throw). The latter MUST throw ClipboardError(CLIPBOARD_CODES.INSECURE_CONTEXT) when
the window reports isSecureContext === false.
Scenario: insecure-context detection
- WHEN
requireSecureContextis invoked with{ isSecureContext: false } - THEN a
ClipboardErrorwith codeCLIPBOARD.INSECURE_CONTEXTMUST be thrown.
Scenario: secure context passes through
- WHEN
requireSecureContextis invoked with{ isSecureContext: true } - THEN the call MUST return without throwing.
Verified by: src/security/security.test.ts::"requireSecureContext throws INSECURE_CONTEXT when window flags insecure" AND src/security/security.test.ts::"requireSecureContext returns silently for secure context"
Requirement: AsyncQueue serialization
The package SHALL expose an AsyncQueue whose enqueue(fn) chains every operation
to the prior one such that callbacks execute in arrival order regardless of
individual rejection. A rejection MUST NOT prevent the next queued operation from
running. drain() MUST return a promise that resolves once the current tail
settles.
Scenario: concurrent enqueues run in arrival order
- WHEN three callbacks
A,B,Care enqueued in that order, each writing its identifier to a shared array before resolving - THEN the resulting array MUST equal
["A", "B", "C"]and everyenqueuepromise MUST resolve with its own callback's return value.
Scenario: rejection does not stall queue
- WHEN the first enqueued callback rejects synchronously
- THEN the second enqueued callback MUST still execute and its returned promise MUST resolve with the second callback's value.
Verified by: src/queue/queue.test.ts::"enqueue preserves arrival order" AND src/queue/queue.test.ts::"rejection does not stall later operations"
Requirement: Permission state probe
ClipboardManager.checkPermissions() SHALL return { read, write } populated from
navigator.permissions.query({name: "clipboard-read"|"clipboard-write"}). When the
permissions API is missing the manager MUST return { read: "prompt", write: "prompt" }.
When a query rejects the manager MUST treat that slot as "prompt" and MUST NOT
re-throw.
Scenario: granted/prompt reflection
- WHEN
permissions.queryreturnsgrantedforclipboard-readandpromptforclipboard-write - THEN
checkPermissions()MUST return{ read: "granted", write: "prompt" }.
Scenario: missing permissions API
- WHEN the injected navigator has no
permissionsfield - THEN
checkPermissions()MUST resolve to{ read: "prompt", write: "prompt" }.
Verified by: src/permissions/permissions.test.ts::"checkPermissions reflects granted and prompt states" AND src/permissions/permissions.test.ts::"checkPermissions returns prompt defaults when API absent"
Requirement: Permission change subscription
ClipboardManager.onPermissionChange(cb) SHALL subscribe cb to changes on both
clipboard-read and clipboard-write PermissionStatus objects and return an
unsubscribe function. The subscription MUST invoke cb(next, name) whenever either
status fires change, and the unsubscribe MUST detach both listeners.
Scenario: change event delivery and unsubscribe
- WHEN a consumer subscribes via
onPermissionChange(cb)and the readPermissionStatus.statetransitions fromprompttogranted - THEN
cbMUST be invoked exactly once with({read, write}, "clipboard-read")reflecting the new state; after calling the returned unsubscribe and triggering a second change,cbMUST NOT be invoked again.
Verified by: src/permissions/permissions.test.ts::"onPermissionChange delivers updates and unsubscribes cleanly"
Requirement: Plain-text copy via Clipboard API
ClipboardManager.copyText(text) SHALL invoke navigator.clipboard.writeText when
the API is present and return { success: true, data: text, method: "api" }. The
operation MUST emit clipboard_copy_total{method=api, kind=text} once and record
clipboard_op_latency_ms{op=copyText, method=api}.
Scenario: writeText resolves and returns api result
- WHEN
copyText("hello")is called against a navigator whoseclipboard.writeTextresolves - THEN the result MUST equal
{ success: true, data: "hello", method: "api" }andclipboard_copy_total{method=api, kind=text}MUST have advanced by 1.
Verified by: src/manager/manager.test.ts::"copyText writes via Clipboard API and emits api metrics"
Requirement: Plain-text copy fallback
When the Clipboard API is unavailable or rejects and options.useFallback is true
(the default), copyText SHALL invoke a hidden <textarea> + document.execCommand("copy")
fallback and return method: "fallback". When useFallback is false, the API
rejection SHALL propagate as { success: false, code: "CLIPBOARD.API_UNAVAILABLE" | "CLIPBOARD.READ_FAILED" or related, method: "api", error: <message> }. The fallback
MUST emit clipboard_fallback_used_total{reason}.
Scenario: api throws → fallback runs and succeeds
- WHEN
clipboard.writeTextrejects anduseFallback: true - THEN
copyTextMUST return{ success: true, method: "fallback", data }andclipboard_fallback_used_total{reason="api_threw"}MUST advance.
Scenario: api throws → fallback disabled
- WHEN
clipboard.writeTextrejects anduseFallback: false - THEN
copyTextMUST return{ success: false, method: "api", error: <string>, code: "CLIPBOARD.READ_FAILED" }.
Scenario: execCommand returns false
- WHEN
document.execCommand("copy")returns false during fallback - THEN
copyTextMUST return{ success: false, method: "fallback", code: "CLIPBOARD.FALLBACK_FAILED" }.
Scenario: document is unavailable
- WHEN the fallback runs in an environment whose
documentis undefined - THEN
copyTextMUST return{ success: false, method: "fallback", code: "CLIPBOARD.DOCUMENT_UNAVAILABLE" }.
Verified by: src/manager/manager.test.ts::"copyText falls back to execCommand when API throws" AND src/manager/manager.test.ts::"copyText reports CLIPBOARD.READ_FAILED when fallback disabled" AND src/fallback/fallback.test.ts::"copyTextFallback reports FALLBACK_FAILED when execCommand returns false" AND src/fallback/fallback.test.ts::"copyTextFallback reports DOCUMENT_UNAVAILABLE when document missing"
Requirement: Rich-content copy with image and blobs
ClipboardManager.copyRichContent(data) SHALL accept ClipboardData with optional
text, html, image (Blob | ImageBitmap), and arbitrary blobs: Record<string, Blob> fields. When ClipboardItem is available and at least one field is populated,
the call SHALL construct a single ClipboardItem whose items map covers every
provided MIME and write it via navigator.clipboard.write. When data carries no
fields the call MUST return code: CLIPBOARD.NO_CONTENT.
Scenario: text+html+image are written as one ClipboardItem
- WHEN
copyRichContent({ text, html, image: pngBlob })is called - THEN the resulting
ClipboardItemMUST contain entries fortext/plain,text/html, andimage/pngand the returned result MUST be{ success: true, method: "api", data }.
Scenario: empty data returns NO_CONTENT
- WHEN
copyRichContent({})is called against a navigator with the API present - THEN the result MUST be
{ success: false, method: "api", code: "CLIPBOARD.NO_CONTENT" }.
Scenario: api throws with html-only payload → fallback rejects with NO_CONTENT
- WHEN
clipboard.writerejects,useFallback: true, anddatacarries onlyhtml(notext) - THEN
copyRichContentMUST return{ success: false, method: "fallback", code: "CLIPBOARD.NO_CONTENT" }because the fallback path requirestext.
Verified by: src/manager/manager.test.ts::"copyRichContent writes text html and image in one ClipboardItem" AND src/manager/manager.test.ts::"copyRichContent returns NO_CONTENT for empty data" AND src/manager/manager.test.ts::"copyRichContent fallback rejects html-only payload as NO_CONTENT"
Requirement: Rich-content read with image and blobs
ClipboardManager.readRichContent() SHALL invoke navigator.clipboard.read(),
iterate every returned ClipboardItem, and populate ClipboardData with text,
html, image (the first image/* blob), and blobs (all non-text non-image
MIME → Blob). When the API is unavailable the call MUST return code: CLIPBOARD.API_UNAVAILABLE. When the API throws the call MUST return code: CLIPBOARD.READ_FAILED.
Scenario: image and arbitrary blob are surfaced
- WHEN
clipboard.readyields one item withimage/pngand one withapplication/jsonMIME entries - THEN the result
dataMUST containimage: Blob<image/png>andblobs["application/json"]MUST be the JSON blob.
Scenario: API unavailable
- WHEN
clipboardisundefined - THEN the result MUST be
{ success: false, method: "fallback", code: "CLIPBOARD.API_UNAVAILABLE" }.
Verified by: src/manager/manager.test.ts::"readRichContent surfaces image and arbitrary blob fields" AND src/manager/manager.test.ts::"readRichContent reports API_UNAVAILABLE when clipboard absent"
Requirement: HTML sanitizer hook
When options.sanitizer is provided, readRichContent MUST apply the sanitizer to
every text/html payload before returning. The sanitizer MUST be invoked exactly
once per read with the raw string and its return value MUST replace data.html.
Scenario: sanitizer strips html
- WHEN a sanitizer mapping
(html) => html.replace(/<script[^>]*>.*?<\/script>/g, "")is supplied and the clipboard payload is<p>ok</p><script>x</script> - THEN the returned
data.htmlMUST equal"<p>ok</p>".
Verified by: src/manager/manager.test.ts::"readRichContent applies sanitizer to html payload"
Requirement: Paste-event read fallback
When the Clipboard API is unavailable, readText SHALL attempt a paste-event-based
fallback that creates a hidden focusable textarea, listens for paste, dispatches
focus, and resolves on the first paste event. If no paste arrives within
options.pasteTimeoutMs (default 1500) the call MUST return code: CLIPBOARD.READ_FAILED.
Scenario: paste-event delivers value
- WHEN the fallback is running and a synthetic
pasteevent carryingtext/plaindata is dispatched - THEN
readTextMUST return{ success: true, method: "fallback", data: <text> }.
Scenario: paste-event times out
- WHEN no paste event arrives within the configured timeout
- THEN
readTextMUST return{ success: false, method: "fallback", code: "CLIPBOARD.READ_FAILED" }.
Verified by: src/fallback/fallback.test.ts::"readTextFallback returns pasted value when paste event fires" AND src/fallback/fallback.test.ts::"readTextFallback returns READ_FAILED on timeout"
Requirement: Async queue serializes manager calls
When options.serialize is true (the default), all copyText, copyRichContent,
readText, and readRichContent calls SHALL be queued through the manager's
internal AsyncQueue so they execute in arrival order. When serialize: false,
calls SHALL execute concurrently without queuing.
Scenario: serial dispatch yields arrival order
- WHEN three
copyTextcalls are issued without awaiting and the underlyingwriteTextresolves out of arrival order (B → C → A) - THEN the calls MUST still resolve with
datamatching their own input arguments and thewriteTextinvocation order MUST equal the arrival order["A","B","C"].
Scenario: serialize=false runs concurrently
- WHEN
serialize: falseis set and twocopyTextcalls are issued in parallel - THEN both MUST resolve and the underlying
writeTextMUST have been entered before either completed.
Verified by: src/manager/manager.test.ts::"copyText calls execute in arrival order when serialize is true" AND src/manager/manager.test.ts::"copyText calls run concurrently when serialize is false"
Requirement: Secure-context gate on copy
When options.requireSecureContext is true (default false) and the host window
reports isSecureContext === false, every copyText / copyRichContent call SHALL
short-circuit with code: CLIPBOARD.INSECURE_CONTEXT without invoking the
Clipboard API or fallback.
Scenario: insecure context blocks copy
- WHEN
requireSecureContext: trueand the injected window flagsisSecureContext: false - THEN
copyText("x")MUST return{ success: false, method: "api", code: "CLIPBOARD.INSECURE_CONTEXT" }.
Verified by: src/manager/manager.test.ts::"copyText short-circuits with INSECURE_CONTEXT when requireSecureContext is true"
Requirement: OpenTelemetry instrumentation
The package SHALL expose createClipboardObservability(opts?) returning four
counters (copyTotal, readTotal, fallbackUsedTotal, errorTotal) and one
histogram (opLatencyMs). The manager SHALL record into every instrument across
its public methods. The observability bundle MUST default to no-op meter/tracer
when no provider is supplied.
Scenario: error counter advances on failure
- WHEN
copyTextfails withCLIPBOARD.API_UNAVAILABLE - THEN
clipboard_error_total{code="CLIPBOARD.API_UNAVAILABLE"}MUST advance by 1.
Scenario: latency histogram records per op
- WHEN a successful
copyTextcompletes - THEN
clipboard_op_latency_ms{op="copyText", method="api"}MUST receive at least one observation.
Verified by: src/observability/observability.test.ts::"createClipboardObservability returns wired counters and histogram" AND src/manager/manager.test.ts::"clipboard_error_total advances when copyText fails" AND src/manager/manager.test.ts::"clipboard_op_latency_ms records observations for copyText"
Requirement: Injectable navigator and options bag
ClipboardManager SHALL accept a partial ClipboardOptions bag and a
ClipboardNavigator shape as constructor arguments so unit tests and SSR consumers
can drive the manager without a real browser. Defaults SHALL merge with
DEFAULT_CLIPBOARD_OPTIONS and an absent navigator SHALL fall back to the global
navigator when defined and to an empty object otherwise.
Scenario: defaults merge with overrides
- WHEN the manager is constructed with
{ useFallback: false } - THEN the active options MUST report
useFallback: false,serialize: true(default), and the other default fields unchanged.
Scenario: missing global navigator does not throw
- WHEN the manager is constructed with no
navand nonavigatorglobal - THEN
isSupported()MUST return false andcopyTextMUST returncode: CLIPBOARD.API_UNAVAILABLE(with fallback if available).
Verified by: src/manager/manager.test.ts::"options merge with DEFAULT_CLIPBOARD_OPTIONS" AND src/manager/manager.test.ts::"manager survives missing navigator global"
Requirement: Tree-shakeable subpath exports
The package MUST publish subpath exports for every feature module so consumers can
import only the surface they need: ./manager/manager, ./fallback/fallback,
./permissions/permissions, ./queue/queue, ./observability/observability,
./errors/errors, ./security/security, ./sanitizer/sanitizer, ./types. Each
subpath MUST resolve to its dist .js + .d.ts.
Scenario: subpaths resolve
- WHEN a consumer reads
package.json#exports - THEN at least nine subpaths beyond
"."MUST be present and every entry MUST declare bothtypesanddefault.
Verified by: src/index.test.ts::"package.json exposes one subpath per feature module"
Requirement: Tier purity — no Node-only APIs
The package MUST NOT import from node:*, MUST NOT require any Node-only global
(process.*, Buffer), and MUST declare "browser": "./dist/index.js" in
package.json. The runtime MUST work in browsers, happy-dom unit tests, and
SSR (graceful degradation to code: DOCUMENT_UNAVAILABLE outside a DOM).
Scenario: SSR-safe import
- WHEN the package is imported in an environment without
documentand withoutnavigator - THEN
new ClipboardManager()MUST not throw,isSupported()MUST returnfalse, andcopyTextMUST settle withcode: CLIPBOARD.DOCUMENT_UNAVAILABLE.
Verified by: src/index.test.ts::"package has no node imports" AND src/manager/manager.test.ts::"ClipboardManager constructor is SSR-safe"