@effect.healthcare/messenger 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,864 +0,0 @@
1
- # State
2
-
3
- This document defines an implementation policy for **component state**, then illustrates how specifications are implemented by applying this policy.
4
-
5
- This document is intentionally **state** only, but part of a set of implementation policies which together defines the policies which should be followed to transform specifications into an implementation which is predictable, maintainable, and production-ready at scale.
6
-
7
- ---
8
-
9
- # How to Read This Policy
10
-
11
- Rules in this policy fall into three strengths. Read each rule with its strength in mind:
12
-
13
- - **Principles** are non-negotiable. They derive from framework correctness or TDD discipline and hold for every component. A conflict with a principle is a defect.
14
- - **Templates** are invariant skeletons with explicit variation points. The skeleton applies to every component; specific steps activate or deactivate based on the component's declared classification (e.g., attribute-backed vs non-attribute, readonly vs writable, payload-bearing vs payload-free). Variation is part of the template, not a deviation from it.
15
- - **Conventions** are canonical defaults that keep implementations readable across components. A second component may deviate when its shape genuinely warrants it; the deviation should be explained at review time, not forbidden by the policy.
16
-
17
- When applying this policy to a new component, classify each rule before reconciling conflicts. A conflict with a principle is a defect; a conflict with a template means a variation point needs to be made explicit; a conflict with a convention is a documented deviation.
18
-
19
- ---
20
-
21
- ## 1) State Contract Must Be Declared in Specification
22
-
23
- Each component specification must declare its state contract under `States`.
24
-
25
- `States` must be an object whose property names are the canonical state names.
26
-
27
- Each state definition must include:
28
-
29
- - `type`
30
- - `description`
31
- - `values`
32
- - `default`
33
- - `readonly`
34
-
35
- A state definition may also include:
36
-
37
- - `attribute`
38
-
39
- If `attribute` is present, it must include:
40
-
41
- - `optional`
42
-
43
- Interpretation:
44
-
45
- - if `attribute` is present, the state is attribute-backed
46
- - if `attribute.optional = true`, the attribute-backed state is optional
47
- - if `attribute.optional = false`, the attribute-backed state is compulsory
48
- - if `attribute` is absent, the state is non-attribute state
49
- - `readonly` applies to all state, regardless of whether it is attribute-backed
50
-
51
- ## Template Variation Points
52
-
53
- The classification of each declared state determines which sub-rules of this policy activate when it is implemented:
54
-
55
- - **`attribute` present, `attribute.optional: true`** — activates implicit-default initialization (Section 5.1), optional attribute reflection (Section 5.2), and null normalization in the mutation path (Section 7).
56
- - **`attribute` present, `attribute.optional: false`** — activates explicit-default materialization (Section 5.1) and compulsory attribute reflection (Section 5.2). Null normalization does not apply.
57
- - **`attribute` absent** — Sections 3–7 do not apply. The state participates only in the non-attribute access contract (Section 8).
58
- - **`readonly: false`** — activates the public setter and the writable mutation path. If attribute-backed, the state participates in declarative mutation via `_attributeHandlers` (Section 6).
59
- - **`readonly: true`** — no public setter; if attribute-backed, declarative mutation is intercepted to restore the canonical value (Section 9.2). Internal mutation only.
60
-
61
- These are declared variation points of the template. A state whose classification activates a combination not described here is a signal that this list needs to be extended, not that the state deviates from the template.
62
-
63
- ## 2) State Model Must Be Explicit
64
-
65
- This framework distinguishes:
66
-
67
- - `Attributes`: the set of state keys that are DOM attribute-backed
68
- - `State`: the set of all state keys known to the component
69
-
70
- Policy:
71
-
72
- - All `Attributes` are state.
73
- - Not all state is attribute-backed.
74
-
75
- ### 2.1) State Value Domains Must Be Defined in Metadata
76
-
77
- For every state declared in the specification, the implementation must define corresponding metadata in `<component>.meta.ts`.
78
-
79
- This metadata serves two distinct purposes:
80
-
81
- - state keys
82
- - state value domains
83
-
84
- Policy:
85
-
86
- - Every attribute-backed state must have:
87
- - a key in `Attributes`
88
- - a key in `State`
89
- - a corresponding value-domain definition
90
- - Every non-attribute state must have:
91
- - a key in `State`
92
- - a corresponding value-domain definition
93
-
94
- Where:
95
-
96
- - `Attributes` contains only attribute-backed state keys
97
- - `State` contains all state keys known to the component
98
- - value-domain definitions declare the allowed runtime values for each state
99
-
100
- State key metadata pattern:
101
-
102
- ```typescript
103
- export const Attributes = {
104
- VISIBILITY: "visibility",
105
- STATUS: "status",
106
- } as const;
107
-
108
- export type Attributes = typeof Attributes;
109
-
110
- export const State = {
111
- ...Attributes,
112
- LOCKED: "locked",
113
- PERSISTED: "persisted",
114
- } as const;
115
-
116
- export type State = (typeof State)[keyof typeof State];
117
- ```
118
-
119
- Note: `type Attributes = typeof Attributes` exports the object shape — useful for typing the static `Attributes` getter on the component class. `type State = (typeof State)[keyof typeof State]` exports a union of all state values — useful for constraining which strings are valid state identifiers. These different shapes are intentional and serve different purposes.
120
-
121
- Attribute value-domain metadata pattern:
122
-
123
- ```typescript
124
- export const Visibility = {
125
- VISIBLE: "visible",
126
- HIDDEN: "hidden",
127
- } as const;
128
-
129
- export type Visibility = (typeof Visibility)[keyof typeof Visibility];
130
-
131
- export const Status = {
132
- PINNED: "pinned",
133
- UNPINNED: "unpinned",
134
- } as const;
135
-
136
- export type Status = (typeof Status)[keyof typeof Status];
137
- ```
138
-
139
- Non-attribute value-domain metadata pattern:
140
-
141
- ```typescript
142
- export const Locked = {
143
- TRUE: true,
144
- FALSE: false,
145
- } as const;
146
-
147
- export type Locked = (typeof Locked)[keyof typeof Locked];
148
-
149
- export type Timestamp = number;
150
- export const Persisted = {
151
- NONE: null,
152
- } as const;
153
-
154
- export type Persisted = Timestamp | (typeof Persisted)[keyof typeof Persisted];
155
- ```
156
-
157
- Note: For boolean value domains such as `Locked`, `Object.values(Locked).includes(value as Locked)` is always true for any runtime boolean. The validation check is structurally consistent with other domains but serves primarily as a type narrowing mechanism rather than a runtime guard against invalid input.
158
-
159
- ### 2.2) State Validation Must Be Implemented in `<component>.validation.ts`
160
-
161
- For every state declared in the specification, the implementation must define validation logic in `<component>.validation.ts`.
162
-
163
- This validation serves as the runtime enforcement layer for the value domains declared in `<component>.meta.ts`.
164
-
165
- Policy:
166
-
167
- - Validation logic for state must be implemented in `<component>.validation.ts`
168
- - Validation must enforce the allowed runtime values defined by the state value-domain metadata in `<component>.meta.ts`
169
- - Public writable mutation paths must call validation before mutation proceeds
170
- - If validation fails, mutation must not proceed
171
- - Validation functions should return the validated value in its narrowed runtime type
172
-
173
- Example:
174
-
175
- ```typescript
176
- import { Visibility, Status } from "./pin.meta.js";
177
-
178
- export class Validate {
179
- public static visibility = (value: string) => {
180
- const valid = Object.values(Visibility).includes(value as Visibility);
181
- if (!valid) {
182
- throw new Error(`Invalid visibility value: ${value}`);
183
- }
184
- return value as Visibility;
185
- };
186
-
187
- public static status = (value: string) => {
188
- const valid = Object.values(Status).includes(value as Status);
189
- if (!valid) {
190
- throw new Error(`Invalid status value: ${value}`);
191
- }
192
- return value as Status;
193
- };
194
- }
195
- ```
196
-
197
- This makes the contract explicit:
198
-
199
- - `<component>.meta.ts` defines the domain
200
- - `<component>.validation.ts` enforces the domain
201
- - `<component>.ts` uses validation in the mutation path
202
-
203
- ## 3) Attribute Observation Must Be Contract-Driven
204
-
205
- Attribute-backed state is observed through the base `Component` contract.
206
-
207
- In this framework, the base component defines attribute observation as follows:
208
-
209
- ```typescript
210
- public static get observedAttributes(): string[] {
211
- return Object.values(this.Attributes);
212
- }
213
-
214
- attributeChangedCallback(name: string, oldValue: any, newValue: any) {
215
- const handler = this._attributeHandlers[name];
216
- handler && handler(newValue);
217
- }
218
- ```
219
-
220
- Policy:
221
-
222
- - Only state keys declared in `Attributes` participate in DOM attribute observation.
223
- - Only specification states with an `attribute` contract may be declared in `Attributes`.
224
- - Observed attribute changes must be routed through `_attributeHandlers`.
225
- - If a state is not declared in `Attributes`, it is not observed by `attributeChangedCallback`.
226
-
227
- Note: When a component is parsed from HTML with attributes already present (e.g., `<pin-button status="pinned">`), `attributeChangedCallback` fires before `connectedCallback`. These attribute changes are routed through `_attributeHandlers` → setter. When `_initialize` subsequently runs, it finds the attribute already present and does not overwrite it. External attributes set at parse time are therefore handled by the existing `_attributeHandlers` path, not by `_initialize`.
228
-
229
- ## 4) Attribute-Backed State Must Be Classified
230
-
231
- Every attribute-backed state must be classified as exactly one of:
232
-
233
- - **Optional** (`attribute.optional = true`)
234
- - **Compulsory** (`attribute.optional = false`)
235
-
236
- This classification determines how the state is represented in the DOM and how missing attributes are handled.
237
-
238
- ## 5) Default Representation Policy Must Match Attribute Classification
239
-
240
- The classification of an attribute-backed state as **optional** or **compulsory** determines two implementation behaviors:
241
-
242
- 1. how missing attributes are handled during initialization
243
- 2. how state is reflected back into the DOM during mutation
244
-
245
-
246
- ### 5.1) Initialization Policy Must Match Attribute Classification
247
-
248
- - **Optional**: implicit default
249
- The attribute may be absent. Initialization must not force materialization of the default attribute.
250
-
251
- - **Compulsory**: explicit representation
252
- The attribute must always have an explicit DOM representation. If missing, it must be materialized in `_initialize`.
253
-
254
- ```typescript
255
- private _status: Status = Status.UNPINNED;
256
-
257
- protected _initialize = () => {
258
- !this.hasAttribute(Attributes.STATUS) &&
259
- this.setAttribute(Attributes.STATUS, this._status);
260
- };
261
- ```
262
-
263
-
264
- ### 5.2) Attribute Reflection Policy Must Match Attribute Classification
265
-
266
- - **Optional**: default may be represented by attribute absence
267
- The setter must manage both the state value and the presence of the attribute. Assigning the default removes the attribute. Because attribute absence corresponds to the default state, nullish incoming values must be normalized to the default before reflection. Assigning the default may remove the attribute.
268
-
269
- ```typescript
270
- public set visibility(visibility: Visibility) {
271
- visibility = visibility ?? Visibility.VISIBLE;
272
-
273
- visibility = Validate.visibility(visibility);
274
-
275
- if (this._visibility === visibility) return;
276
-
277
- this._visibility = visibility;
278
-
279
- visibility === Visibility.VISIBLE && this.removeAttribute(Attributes.VISIBILITY);
280
- visibility === Visibility.HIDDEN && this.setAttribute(Attributes.VISIBILITY, visibility);
281
- }
282
- ```
283
-
284
- - **Compulsory**: current value must always be explicit
285
- The setter must always reflect the current value into the DOM using `setAttribute(...)`.
286
-
287
- ```typescript
288
- public set status(status: Status) {
289
- status = Validate.status(status);
290
-
291
- if (this._status === status) return;
292
-
293
- this._status = status;
294
- this.setAttribute(Attributes.STATUS, status);
295
- }
296
- ```
297
-
298
-
299
- ## 6) Mutation Entry Styles Must Converge on the Public Mutation Path
300
-
301
- Attribute-backed writable state has two entry styles:
302
-
303
- - **Imperative**: direct setter assignment
304
- - **Declarative**: DOM attribute changes routed through `_attributeHandlers`
305
-
306
- ```typescript
307
- protected _attributeHandlers = {
308
- [Attributes.VISIBILITY]: (value: Visibility) => (this.visibility = value),
309
- [Attributes.STATUS]: (value: Status) => (this.status = value),
310
- };
311
- ```
312
-
313
- `_attributeHandlers` are only for declarative mutation. The setter is the single unification point for writable attribute-backed state.
314
-
315
- For readonly attribute-backed state, declarative mutation must still be intercepted in `_attributeHandlers`, but it must not become authoritative. Instead, the implementation must restore the canonical internally owned value. See Section 9.2 for the readonly interceptor pattern.
316
-
317
- ## 7) Public Mutation Order Must Be Normalize → Validate → Guard → Mutate
318
-
319
- For writable state, the public mutation path must follow this order:
320
-
321
- 1. **Normalize**
322
- 2. **Validate**
323
- 3. **Guard**
324
- 4. **Mutate**
325
-
326
- Validation must be performed by logic defined in `<component>.validation.ts`, against the value-domain metadata defined in `<component>.meta.ts`. If the normalized value is invalid, validation must fail and the mutation must not proceed.
327
-
328
- A mutation path may omit phases that are not needed for a given state, but the order of any phases that are used must remain Normalize → Validate → Guard → Mutate.
329
-
330
- Note: The normalization step for optional attribute-backed state handles a specific platform behavior: `attributeChangedCallback` passes `null` as the new value when an attribute is removed from the DOM. Without normalization, this `null` would reach validation and throw. Normalization ensures attribute removal is treated as equivalent to assigning the default value. Use `??` (nullish coalescing) rather than `||` (logical OR) so that only `null` and `undefined` are normalized — any other invalid value is left for validation to reject.
331
-
332
- Optional attribute-backed example:
333
-
334
- ```typescript
335
- public set visibility(visibility: Visibility) {
336
- // Normalize — ?? handles null passed by attributeChangedCallback on attribute removal
337
- visibility = visibility ?? Visibility.VISIBLE;
338
-
339
- // Validate
340
- visibility = Validate.visibility(visibility);
341
-
342
- // Guard
343
- if (this._visibility === visibility) return;
344
-
345
- // Mutate
346
- this._visibility = visibility;
347
- visibility === Visibility.VISIBLE && this.removeAttribute(Attributes.VISIBILITY);
348
- visibility === Visibility.HIDDEN && this.setAttribute(Attributes.VISIBILITY, visibility);
349
- }
350
- ```
351
-
352
- Compulsory attribute-backed example:
353
-
354
- ```typescript
355
- public set status(status: Status) {
356
- // Validate
357
- status = Validate.status(status);
358
-
359
- // Guard
360
- if (this._status === status) return;
361
-
362
- // Mutate
363
- this._status = status;
364
- this.setAttribute(Attributes.STATUS, status);
365
- }
366
- ```
367
-
368
- ## 8) Non-Attribute State Must Declare External Access Contract
369
-
370
- For non-attribute state, the implementation must declare its external access contract as one of:
371
-
372
- - **Read-only external**: public getter, internal mutation only
373
- - **Writable external**: public getter and public setter
374
-
375
- Non-attribute state does not participate in DOM attribute observation or reflection.
376
-
377
- ## 9) Readonly Policy Must Define Internal Ownership
378
-
379
- `readonly` defines whether state is externally writable.
380
-
381
- - `readonly: false`
382
- The state may participate in the component's public mutation contract.
383
-
384
- - `readonly: true`
385
- The state is externally readable but internally owned. External mutation must not be authoritative.
386
-
387
- This rule applies to both:
388
-
389
- - non-attribute state
390
- - attribute-backed state
391
-
392
- Readonly therefore does **not** mean the value never changes. It means the value is **not writable from the outside**. The component implementation retains ownership of mutation.
393
-
394
- ### 9.1) Non-Attribute Readonly State
395
-
396
- For non-attribute state with `readonly: true`:
397
-
398
- - expose a public getter
399
- - do not expose a public setter
400
- - internal logic may update the canonical value
401
-
402
- This is the standard pattern for runtime-only derived, lifecycle, or persistence state.
403
-
404
- Example: `persisted`
405
-
406
- ```typescript
407
- private _persisted: Persisted = Persisted.NONE;
408
-
409
- public get persisted(): Persisted {
410
- return this._persisted;
411
- }
412
-
413
- // Internal mutation only
414
- protected _markPersisted = (timestamp: Timestamp) => {
415
- this._persisted = timestamp;
416
- };
417
- ```
418
-
419
- In this pattern:
420
-
421
- - the state is readable from the component API
422
- - the state is not reflected to the DOM
423
- - the state is mutated only by internal component logic
424
-
425
- ### 9.2) Attribute-Backed Readonly State
426
-
427
- For attribute-backed state with `readonly: true`:
428
-
429
- - expose a public getter
430
- - do not expose a public setter
431
- - maintain a canonical internal source of truth
432
- - reflect that canonical value to the DOM attribute
433
- - if the attribute is externally changed, restore the canonical value
434
-
435
- This is the standard pattern for reflected but internally owned state.
436
-
437
- Example: `id` generated from a UUID during instantiation
438
-
439
- Specification fragment:
440
-
441
- ```json
442
- {
443
- "States": {
444
- "id": {
445
- "type": "Id",
446
- "description": "Stable component identity generated during instantiation.",
447
- "values": ["<uuid>"],
448
- "default": "<generated>",
449
- "readonly": true,
450
- "attribute": { "optional": false }
451
- }
452
- }
453
- }
454
- ```
455
-
456
- Metadata pattern:
457
-
458
- ```typescript
459
- export const Attributes = {
460
- ID: "id",
461
- } as const;
462
-
463
- export type Attributes = typeof Attributes;
464
-
465
- export const State = {
466
- ...Attributes,
467
- } as const;
468
-
469
- export type State = (typeof State)[keyof typeof State];
470
-
471
- export type Id = string;
472
- ```
473
-
474
- Implementation pattern:
475
-
476
- ```typescript
477
- private _id: Id = crypto.randomUUID();
478
-
479
- public get id(): Id {
480
- return this._id;
481
- }
482
-
483
- protected _initialize = () => {
484
- !this.hasAttribute(Attributes.ID) &&
485
- this.setAttribute(Attributes.ID, this._id);
486
- };
487
-
488
- protected _attributeHandlers = {
489
- [Attributes.ID]: (value: Id) =>
490
- value !== this._id && this.setAttribute(Attributes.ID, this._id),
491
- };
492
- ```
493
-
494
- In this pattern:
495
-
496
- - the component creates the canonical value internally
497
- - the attribute is reflected for DOM visibility
498
- - external mutation attempts do not become authoritative
499
- - the DOM is restored to the canonical value
500
-
501
- ### 9.3) Canonical Source of Truth May Be Stored or Computed
502
-
503
- Readonly state must have a canonical internal source of truth.
504
-
505
- That source may be:
506
-
507
- - a private backing field
508
- - a value computed from other internal state
509
-
510
- The policy standardizes ownership, not storage strategy.
511
-
512
- Computed example:
513
-
514
- ```typescript
515
- public get persisted(): Persisted {
516
- return this._saveSucceededAt ?? Persisted.NONE;
517
- }
518
- ```
519
-
520
- A computed getter is valid as long as:
521
-
522
- - the value is internally owned
523
- - no public setter is exposed
524
- - external mutation is not authoritative
525
-
526
- ### 9.4) Readonly Does Not Replace Optional / Compulsory Classification
527
-
528
- If a readonly state is attribute-backed, it must still be classified as:
529
-
530
- - **Optional**
531
- - **Compulsory**
532
-
533
- That means the existing rules in Section 4 and Section 5 still apply.
534
-
535
- So a readonly attribute-backed state must still define:
536
-
537
- - whether the attribute may be absent
538
- - how missing attributes are handled during initialization
539
- - how the state is reflected in the DOM
540
-
541
- `readonly` adds ownership constraints.
542
- It does not replace the existing attribute classification model.
543
-
544
- ### 9.5) Standardized Interpretation
545
-
546
- Use the following interpretation consistently across all components:
547
-
548
- - `readonly: false`
549
- externally writable according to the normal state contract
550
-
551
- - `readonly: true`
552
- externally readable, internally owned, externally non-authoritative for mutation
553
-
554
- ---
555
-
556
- # Applying the Policy to a Specification
557
-
558
- Specification fragment:
559
-
560
- ```json
561
- {
562
- "States": {
563
- "visibility": {
564
- "type": "Visibility",
565
- "description": "Indicates whether the component is shown or hidden.",
566
- "values": ["visible", "hidden"],
567
- "default": "visible",
568
- "readonly": false,
569
- "attribute": {
570
- "optional": true
571
- }
572
- },
573
- "status": {
574
- "type": "Status",
575
- "description": "Indicates the component's status.",
576
- "values": ["pinned", "unpinned"],
577
- "default": "unpinned",
578
- "readonly": false,
579
- "attribute": {
580
- "optional": false
581
- }
582
- },
583
- "locked": {
584
- "type": "Locked",
585
- "description": "Indicates whether external logic allows edits.",
586
- "values": [true, false],
587
- "default": false,
588
- "readonly": false
589
- },
590
- "persisted": {
591
- "type": "Persisted",
592
- "description": "Stores the last successful persistence marker.",
593
- "values": [null, "<timestamp>"],
594
- "default": null,
595
- "readonly": true
596
- }
597
- }
598
- }
599
- ```
600
-
601
-
602
- How this specification is implemented:
603
-
604
- Note: the examples below are intentionally state-only and exclude event-dispatch logic. The additional `locked` and `persisted` examples illustrate non-attribute state patterns beyond the fragment shown.
605
-
606
- - `visibility`
607
- - Attribute-backed, optional, writable
608
- - Implicit default (`visible`), no forced `_initialize` materialization
609
- - Getter returns the canonical internal state
610
- - Setter normalizes nullish input — including `null` passed by `attributeChangedCallback` when the attribute is removed — validates against metadata, and reflects/removes the attribute
611
- - `status`
612
- - Attribute-backed, compulsory, writable
613
- - Explicit DOM representation, must be materialized in `_initialize` when absent
614
- - Getter returns the canonical internal state
615
- - Setter validates against metadata and always keeps the attribute explicit
616
- - `locked`
617
- - Non-attribute, writable
618
- - Runtime-only state, not in `Attributes`, not observed via callback pipeline
619
- - Public getter + public setter
620
- - `persisted`
621
- - Non-attribute, read-only
622
- - Runtime-only state, not in `Attributes`, not observed via callback pipeline
623
- - Public getter only; updated internally by other component logic
624
-
625
- `<component>.validation.ts` for this specification:
626
-
627
- ```typescript
628
- import { Visibility, Status, Locked } from "./pin.meta.js";
629
-
630
- export class Validate {
631
- public static visibility = (value: string) => {
632
- const valid = Object.values(Visibility).includes(value as Visibility);
633
- if (!valid) throw new Error(`Invalid visibility value: ${value}`);
634
- return value as Visibility;
635
- };
636
-
637
- public static status = (value: string) => {
638
- const valid = Object.values(Status).includes(value as Status);
639
- if (!valid) throw new Error(`Invalid status value: ${value}`);
640
- return value as Status;
641
- };
642
-
643
- public static locked = (value: boolean) => {
644
- const valid = Object.values(Locked).includes(value as Locked);
645
- if (!valid) throw new Error(`Invalid locked value: ${value}`);
646
- return value as Locked;
647
- };
648
- }
649
- ```
650
-
651
- `visibility` implementation (attribute-backed, optional):
652
-
653
- ```typescript
654
- private _visibility: Visibility = Visibility.VISIBLE;
655
-
656
- public get visibility(): Visibility {
657
- return this._visibility;
658
- }
659
-
660
- public set visibility(visibility: Visibility) {
661
- visibility = visibility ?? Visibility.VISIBLE;
662
- visibility = Validate.visibility(visibility);
663
-
664
- if (this._visibility === visibility) return;
665
-
666
- this._visibility = visibility;
667
- visibility === Visibility.VISIBLE && this.removeAttribute(Attributes.VISIBILITY);
668
- visibility === Visibility.HIDDEN && this.setAttribute(Attributes.VISIBILITY, visibility);
669
- }
670
- ```
671
-
672
-
673
- `status` implementation (attribute-backed, compulsory):
674
-
675
- ```typescript
676
- private _status: Status = Status.UNPINNED;
677
-
678
- public get status(): Status {
679
- return this._status;
680
- }
681
-
682
- public set status(status: Status) {
683
- status = Validate.status(status);
684
-
685
- if (this._status === status) return;
686
-
687
- this._status = status;
688
- this.setAttribute(Attributes.STATUS, status);
689
- }
690
-
691
- protected _initialize = () => {
692
- !this.hasAttribute(Attributes.STATUS) &&
693
- this.setAttribute(Attributes.STATUS, this._status);
694
- };
695
- ```
696
-
697
- Declarative attribute wiring (`_attributeHandlers`):
698
-
699
- ```typescript
700
- protected _attributeHandlers = {
701
- [Attributes.VISIBILITY]: (value: Visibility) => (this.visibility = value),
702
- [Attributes.STATUS]: (value: Status) => (this.status = value),
703
- };
704
- ```
705
-
706
- `persisted` implementation (non-attribute, read-only):
707
-
708
- ```typescript
709
- private _persisted: Persisted = Persisted.NONE;
710
-
711
- public get persisted(): Persisted {
712
- return this._persisted;
713
- }
714
-
715
- // No setter since readonly
716
- ```
717
-
718
- `locked` implementation (non-attribute, writable):
719
-
720
- ```typescript
721
- private _locked: Locked = Locked.FALSE;
722
-
723
- public get locked(): Locked {
724
- return this._locked;
725
- }
726
-
727
- public set locked(locked: Locked) {
728
- locked = Validate.locked(locked);
729
-
730
- if (this._locked === locked) return;
731
-
732
- this._locked = locked;
733
- }
734
- ```
735
-
736
- ---
737
-
738
- # Policy Checklist
739
-
740
- 1. Define `type`, `description`, `values`, `default`, and `readonly`.
741
- 2. Decide state type: attribute-backed or non-attribute.
742
- 3. If attribute-backed, define `attribute.optional` and classify the state as optional or compulsory.
743
- 4. Define state metadata in `<component>.meta.ts`.
744
- 5. Define state validation in `<component>.validation.ts`.
745
- 6. Ensure validation enforces the value domains declared in `<component>.meta.ts`.
746
- 7. If attribute-backed, declare the state in `Attributes` and include it in `State`.
747
- 8. If non-attribute, declare the state in `State` but not in `Attributes`.
748
- 9. If attribute-backed and compulsory, materialize the attribute in `_initialize()` when absent.
749
- 10. If attribute-backed and optional, do not force default attribute materialization in `_initialize()`.
750
- 11. If attribute-backed and writable, map declarative mutation in `_attributeHandlers`.
751
- 12. If attribute-backed and readonly, intercept declarative mutation in `_attributeHandlers` and restore the canonical internal value.
752
- 13. Implement the public mutation path using normalize → validate → guard → mutate.
753
- 14. Validate incoming values against metadata value domains.
754
- 15. If validation fails, the mutation must not proceed.
755
- 16. For optional attribute-backed state, use `??` to normalize `null` and `undefined` to the default — this handles `attributeChangedCallback` passing `null` when an attribute is removed. Manage both value and attribute presence during reflection.
756
- 17. For compulsory attribute-backed state, always keep the current value explicitly reflected in the DOM attribute using a single `setAttribute(...)` call.
757
- 18. For non-attribute state, choose external contract: read-only or writable.
758
- 19. For readonly state, ensure mutation remains internally owned and external mutation is non-authoritative.
759
-
760
- ---
761
-
762
- # Component State Specification Schema
763
-
764
- JSON Schema:
765
-
766
- ```json
767
- {
768
- "$schema": "https://json-schema.org/draft/2020-12/schema",
769
- "$id": "https://scalable.software/schema/component-states.schema.json",
770
- "title": "Component States",
771
- "description": "Schema for the States object in a component specification.",
772
- "type": "object",
773
- "propertyNames": {
774
- "type": "string",
775
- "pattern": "^[a-z][a-zA-Z0-9]*$",
776
- "description": "Canonical state name in lowerCamelCase, for example: visibility, status, locked, persisted."
777
- },
778
- "additionalProperties": {
779
- "type": "object",
780
- "description": "A state definition.",
781
- "required": ["type", "description", "values", "default", "readonly"],
782
- "additionalProperties": false,
783
- "properties": {
784
- "type": {
785
- "type": "string",
786
- "pattern": "^[A-Z][a-zA-Z0-9]*$",
787
- "description": "PascalCase type name for the state value domain, derived from the state name, for example: Visibility, Status, Locked, Persisted."
788
- },
789
- "description": {
790
- "type": "string",
791
- "minLength": 1,
792
- "description": "Human-readable description of the state."
793
- },
794
- "values": {
795
- "type": "array",
796
- "minItems": 1,
797
- "description": "Allowed values or symbolic domain markers for the state, for example ['visible', 'hidden'] or [null, '<timestamp>']."
798
- },
799
- "default": {
800
- "description": "Default value for the state."
801
- },
802
- "readonly": {
803
- "type": "boolean",
804
- "description": "Whether the state is externally writable. If true, the state is externally readable but internally owned — external mutation is not authoritative."
805
- },
806
- "attribute": {
807
- "type": "object",
808
- "description": "Attribute-specific configuration. Presence of this object means the state is DOM attribute-backed. Absence means the state is non-attribute state.",
809
- "required": ["optional"],
810
- "additionalProperties": false,
811
- "properties": {
812
- "optional": {
813
- "type": "boolean",
814
- "description": "Whether the DOM attribute may be absent. If true, attribute absence represents the default — the setter removes the attribute when assigning the default value. If false, the attribute is compulsory and must always be explicitly present in the DOM."
815
- }
816
- }
817
- }
818
- }
819
- }
820
- }
821
- ```
822
-
823
- Example JSON:
824
-
825
- ```json
826
- {
827
- "States": {
828
- "visibility": {
829
- "type": "Visibility",
830
- "description": "Indicates whether the component is shown or hidden.",
831
- "values": ["visible", "hidden"],
832
- "default": "visible",
833
- "readonly": false,
834
- "attribute": {
835
- "optional": true
836
- }
837
- },
838
- "status": {
839
- "type": "Status",
840
- "description": "Indicates the component's status.",
841
- "values": ["pinned", "unpinned"],
842
- "default": "unpinned",
843
- "readonly": false,
844
- "attribute": {
845
- "optional": false
846
- }
847
- },
848
- "locked": {
849
- "type": "Locked",
850
- "description": "Indicates whether external logic allows edits.",
851
- "values": [true, false],
852
- "default": false,
853
- "readonly": false
854
- },
855
- "persisted": {
856
- "type": "Persisted",
857
- "description": "Stores the last successful persistence marker.",
858
- "values": [null, "<timestamp>"],
859
- "default": null,
860
- "readonly": true
861
- }
862
- }
863
- }
864
- ```