Instruction file imported from Ocean-Industries-Concept-Lab/openbridge-webcomponents (
.cursor/rules/jsdoc.mdc). Copyright stays with the author.
JSDoc Documentation Rules
The full JSDoc template, the @slot/@fires contract, @availableWhen,
component lifecycle tags, and the three documentation patterns. AGENTS.md § 3
carries the summary; docs/agents/jsdoc.md is the source of truth.
Key points:
- One-line summary with the tag name and a brief description.
- Features / Variants — bullet list of capabilities and configuration options.
- Usage Guidelines — when and how to use the component; contrast with similar components.
- Slots — table of slot names, conditions, and purposes.
- Events — a
@firestag for every event the component exposes, custom and native (a passthrough<button>'sclickincluded). See below. - Properties are documented in the class JSDoc, one tag per public property, without a type —
@property name - description— placed after the Markdown sections and before@slot/@fires. Conditional properties add a line@availableWhen name conditiondirectly under their tag. No inline JSDoc above@property()fields (npm run lint:commentswarns;--fixhoists them). A tag naming a property that does not exist is a ghost manifest member —npm run lint:slotsfails on it. Mixin-provided properties (svghelpers/setpoint-mixin.ts,svghelpers/setpoint-bundle.ts) keep their inline docs. - Tone: Do NOT mention "maritime", "industrial", "bridge", or domain qualifiers; keep text domain-agnostic.
- If purpose is unclear, insert
**TODO(designer)**instead of guessing. @availableWhenfor conditional properties — see below.- Exactly one lifecycle tag on every
@customElementclass — see below.
Component lifecycle tags (@stable / @beta / @experimental / @deprecated)
Every class registered with @customElement carries exactly one lifecycle
tag in its class JSDoc. This tag is the single source of truth for the
component's API maturity — Storybook mirrors it, never the other way around.
| Tag | Meaning |
|---|---|
@stable |
Production-ready, stable API |
@beta |
Feature-complete, API may still change |
@experimental |
Early stage, API likely to change |
@deprecated |
Slated for removal |
/**
* Speed readout.
*
* @slot value-icon - Icon shown beside the value.
* @fires {CustomEvent<{value: number}>} change - When the value changes.
* @experimental
*/
@customElement("obc-readout")
export class ObcReadout extends LitElement {}
Put the tag last in the block, after @slot / @fires. It must be in a
real JSDoc block (/** … */) — a plain /* … */ comment is invisible to
cem analyze and to the lint rules.
Mapping to Storybook meta.tags:
| Class JSDoc | meta.tags entry |
Sidebar badge |
|---|---|---|
@stable |
(none) | — |
@beta |
'beta' |
Beta |
@experimental |
'experimental' |
Experimental |
@deprecated |
'deprecated' |
Deprecated |
@stable deliberately emits no story tag, so a badge always means "there is a
caveat here". The retired 'wip' and 'alpha' tags no longer exist — use the
code-side tag instead.
Never hand-write the lifecycle entry in meta.tags. Set the tag on the
class, then let the lint rule write the story:
npm run lint:fix:stories
That script is deliberately scoped to src/**/*.stories.ts. Do not run a
repo-wide eslint 'src/**/*.ts' --fix: --fix applies every fixable rule at
every severity, so it silently rewrites unrelated files (today it strips
eslint-disable directives out of the generated src/generated/locales/*).
Two ESLint rules enforce this, both part of npm run lint:eslint:
openbridge/component-lifecycle-tag(warning) — fires on a source file whose@customElementclass has no lifecycle tag, or more than one. Not auto-fixable: classifying a component is a human decision.openbridge/story-lifecycle-tags(error, auto-fixable) — fires on a*.stories.tswhosemeta.tagsdisagrees with the class JSDoc of itsmeta.component. Stories without ameta.component(the pure function module pattern below) are skipped.
Version tags ('6.0', '6.1') and tooling tags ('autodocs', 'skip-test',
'!snapshot') are unrelated to lifecycle, stay hand-written, and are preserved
by the autofix.
Slots and events are consumer-critical (@slot / @fires)
Two independent tools read these tags, and they do not read the same thing. This is the single most important fact in this section:
| Consumer | Reads | Consequence of a missing/malformed tag |
|---|---|---|
lit labs gen (npm run wrappers) → -react, -vue, -ng, -svelte |
the source JSDoc, class-level tags only | the wrapper has no onX binding at all — the event is unreachable from the framework packages |
cem analyze (npm run analyze) → custom-elements.json → IDE autocomplete, Storybook autodocs, playgrounds |
the source, but more leniently | blank or type-polluted entry in the manifest |
The wrappers do not read custom-elements.json. A correct manifest is
therefore not evidence that a component is correctly documented. obc-poi-group
demonstrated this: cem analyze inferred obc-poi-group-target-released from the
dispatchEvent(...) call and listed it in the manifest, while the React wrapper
had no binding for it whatsoever (issue #1109, PR #1110).
The two tags behave differently, and both are easy to get silently wrong (issue #1033):
- Slots are detected ONLY from
@slottags. The analyzer never reads<slot>elements in the template. A<slot name="leading-icon">with no@slot leading-icontag is invisible to every consumer even though it works at runtime — this is the exact "wrappers don't know about the slot" bug. Every rendered<slot>/<slot name="…">needs a matching@slottag (@slot -for the default slot). - Do not confuse a slot with a projection.
<slot name="x">exposes a slot to your consumers.<el slot="x">(aslotattribute on a non-<slot>element) projects that element into a child component's slot — it exposes nothing. Writing@slot xfor a projection creates a phantom slot (a documented control that does nothing). Only tag real<slot>elements. - Dynamic slot names (
<slot name="tab-${id}-icon">) can't be enumerated; document them once with a placeholder, e.g.@slot tab-<id>-icon. - An untagged event costs you the wrapper, not just a description.
cem analyzeinfers events fromthis.dispatchEvent(new CustomEvent('x')), so an untagged event still reaches the manifest (with an empty description) — butlit labs geninfers nothing, so the framework wrappers get no binding. Every event a component dispatches needs an explicit@firestag. - Write the type first:
@fires {Type} name - description.lit labs genparses either order, butcem analyzeonly strips{Type}when it precedes the name. The name-first form@fires name {Type} descleaves a literal{Type}at the head of the manifest description and drops the payload type from thetypefield. PR #1110 normalized 190 tags for this reason — do not reintroduce the name-first form. - Tags must be class-level. Only the JSDoc block attached to the
@customElementclass is read. A@firestag inside a method docblock reaches neither tool. Document the event on the class; if a method docblock also mentions it, keep the method's own summary line and leave the tag bare (@fires name) so the two do not compete. - Inherited slots/events: the
@slot/@firestag must live on the concrete@customElementclass, because CEM emits one manifest entry per registered element. A subclass that renders slots via its base class'srender()still needs its own tags. A tag on an unregistered base class documents the dispatch site only — it produces no manifest entry and no wrapper binding. - Native
clickneeds a tag too. A component whose template is a passthrough<button>/<a>with nodispatchEventstill has a public activation event: the nativeclickiscomposed, so it crosses the shadow boundary. Tag it untyped —@fires click - Fired when the button is clicked.— matchingobc-buttonandobc-icon-button. Do not type it as{CustomEvent<…>}; what consumers receive is the native event. React consequence: in@lit/react, any prop named in the generatedeventsmap is attached withaddEventListenerinstead of being forwarded toReact.createElement. Declaring@fires clicktherefore movesonClickoff React's synthetic delegation onto a direct DOM listener — handlers receive a nativePointerEvent, andstopPropagation()inside one will stop the event before React's root delegation, killingonClickon React ancestors. This is a public-API change; note it in the release notes when adding the tag to an existing component. - Always dispatch with
this.. A baredispatchEvent(new CustomEvent('x'))inside a class method resolves toglobalThis.dispatchEvent, firing the event onwindowwhere no consumer of the element can observe it (fixed inobc-navigation-item, PR #1112). Caught bynpm run lint:slots.
Run npm run lint:slots (part of npm run lint) to catch missing/phantom
@slot tags, undocumented events, and bare dispatchEvent( calls automatically.
It reports empty descriptions as warnings for class-level tags only.
Conditional properties (@availableWhen)
A property whose value only has an observable effect when another property is set a certain way is a conditional property. Document the dependency in the class JSDoc, on an @availableWhen name condition line directly under that property's @property tag. The tag names the dependent property first, then the condition — it is not inline JSDoc above the field:
/**
* ...
* @property alert - Wraps the button in an alert frame.
* @property alertFrameStatus - Alert status the frame is coloured for.
* @availableWhen alertFrameStatus alert==true
* @property showAlertCategoryIcon - Shows the alert category icon inside the frame.
* @availableWhen showAlertCategoryIcon alert==true && alertFrameType in [LargeSideFlip, BottomFlip, TopFlip]
*/
@customElement('obc-automation-button')
export class ObcAutomationButton extends LitElement {
Condition grammar (the part after the property name):
- Boolean:
showFoo==trueorshowFoo==false. - Enum / string equality:
type==label— the enum member name or its string value, no quotes. - Enum / string inequality:
state!=overlapped— handy for "all values except one". - Set membership:
type in [LargeSideFlip, BottomFlip, TopFlip]— use the enum member identifier names, not the string values. - Non-empty string:
label!=''— forstringprops (default'') that gate another prop by being non-empty. - Empty / non-empty array:
centerReadouts==[](available only while the array is empty) oradvices!=[]— forArrayprops whose emptiness gates another prop. - Defined / non-null:
courseArrowPx!=undefined(forX | undefined) orheadingSetpoint!=null(forX | null). - Combine: join with
&&(all required) or||(any sufficient). Always use==/!=(never a single=).
Rules:
- Never annotate the gate itself — only the dependent property. In the example,
alertis the gate and stays unannotated. - Self-gated props are not conditional — a prop that does nothing when its own value is
0/''/undefinedis not@availableWhen(that dependency is on itself, not another property). - Multi-path props are not conditional — if a prop still has an observable effect via some always-on path (e.g. it is also emitted in an event or applied as a CSS class regardless of the gate), do not annotate it.
- The condition must hold against the actual render/behavior logic (trace into helpers, getters, and child components the prop is forwarded to), not just the prop's name.
- A property without a
@propertytag (undocumented, or documented inline for a reasonlint:commentsaccepts) still gets its@availableWhenline in the class JSDoc, on its own — the tools key on the property name, not on the line's position. - For properties added by
SetpointMixin, the@availableWhentags live insvghelpers/setpoint-mixin.ts; components that consume the mixin inherit them and must not re-annotate.
What the tag does in Storybook
npm run analyze resolves each condition it can into an availableWhenIf
entry on the manifest member, and the availableWhenEnhancer in
.storybook/manifest-docs.ts turns that into the argType's if:. The
enhancer applies a gate only when the story itself sets the gate arg
(in its own args or the meta's). Storybook never seeds args from the
component's own property defaults, and an arg whose if is false is removed
from render() entirely — not merely hidden in the controls panel — so
gating on an arg the story leaves unset would silently drop properties the
story does set. Set the gate arg in the story when you want the control to
appear and disappear with it. Conditions the plugin cannot resolve —
in [...], &&, ||, and enum values it cannot look up — never produce an
availableWhenIf, so no control is hidden for them; the condition text still
reaches the manifest and the description. Write a manual if: in the story's
argTypes if that control has to be hidden.
The three documentation patterns (concrete components, pure function modules, abstract base classes) are covered in full below — see Documentation by code pattern.
Comment style
Implementation comments follow coding-standards.md (why-only, three
lines, no history, state-then-cite, CSS one-liners, writing style). The class
and module JSDoc described in this file is separate and always required.
The JSDoc content template (overview, features, usage, slots, events,
example) is maintained in script/docgen/prompt-system.txt, which the docgen
CLI feeds to the model — edit it there; this file only carries the rules the
tooling enforces.
Documentation by code pattern (regular components, pure functions, abstract classes)
Not all code in this repo is a concrete Lit web component. The three main patterns require different documentation approaches because Storybook's autodocs resolves a story to its docs through the custom-elements.json entry that carries a tagName; modules and abstract bases are in the manifest too, but only a registered custom element is found automatically.
a) Regular concrete components (default case)
Examples: obc-area-graph, obc-line-graph, obc-bar-vertical
- JSDoc lives on the class (following the full template above).
- Properties are documented in the class JSDoc tag block (
@property name - description); Storybook reads them from the manifest exactly as it read inline docs. - The story meta uses
component: 'obc-tag-name'to link Storybook autodocs to thecustom-elements.jsonentry. - Storybook automatically extracts the class JSDoc,
@propertytypes,@slottags, and@firesevents. - The story file does not need
parameters.docs.description.component— autodocs handles it.
This is the standard path. The template sections above (Overview, Features, Slots, Events, etc.) apply directly.
b) Pure function modules (no component class)
Examples: external-scale.ts (exports renderExternalScale(), computeExternalScaleLayout(), etc.)
These modules export pure functions that return SVGTemplateResult fragments, not a LitElement. The manifest does carry the module (its /** @module ... */ description and each exported function), but there is no custom element tag, so autodocs cannot wire the docs to a component on its own.
Source file:
- Place a comprehensive JSDoc block comment at the top of the module (above the first export). Use the same structure as a component JSDoc (overview, features, usage examples) — but write it as a module description rather than a component description.
Story file:
- Omit
component:(there is no tag to point to). - Read the module JSDoc from the manifest:
parameters: {docs: {description: {component: moduleDocs('building-blocks/external-scale/external-scale.ts')}}}(moduleDocsfrom.storybook/manifest-docs.js). The/** @module … */block at the top of the source is the single source;npm run analyzecopies it intocustom-elements.json. - Manually define
argTypes(no manifest members to extract from).
c) Abstract base classes
Examples: ObcChartLineBase (abstract base for obc-line-graph and obc-area-graph)
The class has rich JSDoc and @property declarations, but it cannot be instantiated and is not registered as a custom element. It is in the manifest — description and members included — yet with no tagName for autodocs to resolve, the story has to point at the entry itself.
Source file:
- Place the full JSDoc on the abstract class just like a regular component. Do not add
@ignore— the class must stay in the manifest so subclasses inherit its property docs and stories can read its description withclassDocs('ObcChartLineBase').
Story file:
- Set
component:to a concrete subclass tag andparameters.docs.description.component: classDocs('ObcChartLineBase').
The story reads the class docs from the manifest via classDocs(); there is no copy to keep in sync.
Summary table
| Aspect | Concrete component | Pure function module | Abstract base class |
|---|---|---|---|
| JSDoc location | On the class | Module-level block comment | On the abstract class (no @ignore) |
Story meta.component |
'obc-tag-name' |
Omitted | Concrete subclass tag |
Story parameters.docs.description |
Not needed (auto) | moduleDocs() |
classDocs() |
argTypes |
Auto from manifest | Manual | Partially auto (from concrete subclass) |
| Rendering in story | Direct <obc-tag> |
Inline element built in render() (e.g. an <svg>) |
Concrete subclass element |
Structured-tag rules (apply to EVERY component)
● After all Markdown sections, append a short tag block, in this order:
- one
@property name - descriptiontag per public property (no type), a conditional property followed by its@availableWhen name conditionline - one
@slottag for each content slot - one
@fires(or@event) tag for each event the component exposes — custom events and native ones that cross the shadow boundary, such as theclickfrom a passthrough<button> - exactly one lifecycle tag, last —
@stable/@beta/@experimental/@deprecated(see Component lifecycle tags)
Nothing else goes in the block.
● Why this matters — two separate tools read these tags, and they do not read
the same thing. The full contract, including the two-consumer table, the
obc-poi-group worked example, inherited slots/events, and the React
onClick consequence of tagging native click, is in
Slots and events are consumer-critical
above. Getting the tags wrong produces the exact symptoms in issue #1033
(ghost attribute, missing slot, phantom slot) and issue #1109 (an event present
in custom-elements.json but absent from every framework wrapper).
● Run npm run lint:slots (script/check-slot-event-docs.ts, part of
npm run lint) to automatically catch missing/phantom @slot tags, undocumented
events, and bare dispatchEvent( calls.
● Do NOT mix Markdown headings inside the tag block.
Example skeleton:
/**
* <markdown sections …>
*
* @property showIcon - Whether to show the leading-icon slot.
* @slot - Default leading-icon slot (shown when `showIcon` is true)
* @fires {CustomEvent<{label:string}>} remove-chip - Fired when the chip's remove button is clicked.
* @stable
*/
● When you're using icons as examples, instead of writing emojis, use
<obi-placeholder></obi-placeholder>, or other similar icons. OpenBridge has
1000+ icons and you can use them in slots by using this format. Another working
icon import example: <obi-arrow></obi-arrow>, <obi-search></obi-search>.