@peter.prib/renderengine 0.2.6 → 0.2.7

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.
Files changed (49) hide show
  1. package/README.md +4 -0
  2. package/REFERENCE.md +180 -2
  3. package/dist/renderengine-workspace.umd.js +8704 -5310
  4. package/dist/renderengine.umd.js +5675 -3398
  5. package/examples/archimate.html +68 -66
  6. package/examples/data-model.html +223 -13
  7. package/package.json +1 -1
  8. package/src/bian.js +182 -0
  9. package/src/charting/DiagramEditor.js +5 -26
  10. package/src/charting/DiagramTypeRegistry.js +8 -2
  11. package/src/charting/ModelCheckDialog.js +63 -0
  12. package/src/charting/ModelChecker.js +26 -0
  13. package/src/charting/ViewTabStrip.js +94 -81
  14. package/src/charting/datamodel/BianLandscapeReader.js +185 -0
  15. package/src/charting/datamodel/BianSemanticApiReader.js +183 -0
  16. package/src/charting/datamodel/DataModel.js +356 -10
  17. package/src/charting/datamodel/DataModelChecker.js +92 -0
  18. package/src/charting/datamodel/DataModelCompared.js +127 -0
  19. package/src/charting/datamodel/DataModelFileFormat.js +33 -0
  20. package/src/charting/datamodel/DataModelPerspective.js +45 -0
  21. package/src/charting/datamodel/DataType.js +108 -0
  22. package/src/charting/datamodel/EnumerationType.js +51 -0
  23. package/src/charting/datamodel/ServiceDomain.js +177 -0
  24. package/src/charting/datamodel/ServiceLandscape.js +104 -0
  25. package/src/charting/datamodel/StructuredType.js +63 -0
  26. package/src/charting/datamodel/index.js +10 -0
  27. package/src/charting/diagramModel/MergeModelCommand.js +43 -37
  28. package/src/charting/diagramModel/OpenViewList.js +75 -0
  29. package/src/charting/diagramModel/ViewSetChecker.js +22 -34
  30. package/src/charting/diagrams/DataModelRelationshipStrategy.js +12 -4
  31. package/src/charting/diagrams/EdgeGeometry.js +578 -553
  32. package/src/charting/diagrams/GraphDiagram.js +11 -1
  33. package/src/charting/diagrams/NodeGeometry.js +169 -158
  34. package/src/charting/glyphs/PersonGlyph.js +24 -23
  35. package/src/index.js +2666 -2664
  36. package/src/workspace/EditorShell.js +166 -67
  37. package/src/workspace/PanelLayout.js +56 -0
  38. package/src/workspace/layout/TabsNode.js +205 -191
  39. package/src/workspace/parts/ArchimateCanvasPart.js +5 -7
  40. package/src/workspace/parts/DataModelPart.js +339 -29
  41. package/src/workspace/parts/DiagramEditorPart.js +2 -8
  42. package/src/workspace/parts/EditorPart.js +461 -407
  43. package/src/workspace/parts/ModelFile.js +216 -187
  44. package/src/workspace/parts/ModelsPart.js +483 -192
  45. package/src/workspace/storage/BackupFolder.js +145 -0
  46. package/src/workspace/storage/KeyValueStore.js +92 -0
  47. package/src/workspace/storage/ModelLibrary.js +65 -0
  48. package/src/workspace/workArea.js +417 -413
  49. package/src/yaml/YamlReader.js +372 -0
package/src/bian.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * BIAN vocabulary — the Banking Industry Architecture Network's service
3
+ * domain terms, as one registry (as src/archimate.js and src/datamodel.js are)
4
+ * that pickers, dialogs and service domains read rather than restate.
5
+ *
6
+ * Sources: the BIAN Semantic API Practitioner Guide V8.1 (December 2024) for the
7
+ * functional patterns' descriptions and the action terms in their four groups;
8
+ * BIAN's v14 model as its service landscape portal publishes it
9
+ * (bian.org/servicelandscape-14-0-0) for each pattern's generic artifact, its
10
+ * behaviour qualifier type (the type the artifact aggregates) and the creating
11
+ * action term it aggregates; BIAN's release 14.0.0 semantic APIs
12
+ * (github.com/bian-official/public, Apache-2.0) for the names control records
13
+ * have and, where a pattern aggregates two creating terms (Monitor: evaluate
14
+ * and initiate), the one its APIs use. Every value is BIAN's; `inferred` is
15
+ * kept for a value that is not. See BIAN_ALIGNMENT.md.
16
+ */
17
+
18
+ /** One way a service domain behaves: what it does to its asset, and the record it keeps of it. */
19
+ export class BianFunctionalPattern {
20
+ /**
21
+ * @param {string} name - 'Fulfill'
22
+ * @param {Object} spec
23
+ * @param {string} spec.artifact - The generic artifact a control record is ('Facility')
24
+ * @param {string} spec.description
25
+ * @param {string} spec.creates - The action term that makes a new control record ('initiate')
26
+ * @param {string} spec.qualifierType - How the control record is broken down ('Fulfillment')
27
+ * @param {string[]} [spec.inferred] - Which of creates/qualifierType are inferred, not confirmed
28
+ * @param {string[]} [spec.formerArtifacts] - Names the artifact had before ('Arrangement' for Fulfill's)
29
+ */
30
+ constructor(name, { artifact, description, creates, qualifierType, inferred = [], formerArtifacts = [] }) {
31
+ this.name = name;
32
+ this.artifact = artifact;
33
+ this.formerArtifacts = Object.freeze([...formerArtifacts]);
34
+ this.description = description;
35
+ this.creates = creates;
36
+ this.qualifierType = qualifierType;
37
+ this.inferred = Object.freeze([...inferred]);
38
+ }
39
+
40
+ /** The control record's name for an asset: 'Current Account' gives 'Current Account Facility'. */
41
+ controlRecordName(asset) {
42
+ return [asset, this.artifact].filter(Boolean).join(' ');
43
+ }
44
+ }
45
+
46
+ /** An action term: what a service operation does, and to what. */
47
+ export class BianActionTerm {
48
+ /**
49
+ * @param {string} name - 'initiate'
50
+ * @param {'service-domain'|'create'|'instance'|'read'} group
51
+ * @param {string} description
52
+ */
53
+ constructor(name, group, description) {
54
+ this.name = name;
55
+ this.group = group;
56
+ this.description = description;
57
+ }
58
+ }
59
+
60
+ const fp = (name, artifact, description, creates, qualifierType, inferred = [], formerArtifacts = []) =>
61
+ [name, new BianFunctionalPattern(name, { artifact, description, creates, qualifierType, inferred, formerArtifacts })];
62
+
63
+ /**
64
+ * The nineteen functional patterns, each with its artifact, creating action term
65
+ * and behaviour qualifier type as BIAN's v14 model has them.
66
+ */
67
+ const FUNCTIONAL_PATTERNS = new Map([
68
+ fp('Direct', 'Strategy', 'Define the policies, goals and objectives and strategies for an organizational entity or unit.', 'create', 'Goal'),
69
+ fp('Manage', 'Management Plan', 'Oversee the working of a business unit, assign work, manage against a plan and troubleshoot issues.', 'create', 'Duty'),
70
+ fp('Administer', 'Administrative Plan', 'Handle and assign the day to day activities, capture time worked, costs and income for an operational unit.', 'create', 'Routine'),
71
+ fp('Design', 'Specification', 'Create and maintain a design for a procedure, product/service model or other such entity.', 'create', 'Aspect'),
72
+ fp('Develop', 'Development', 'Build or enhance something, typically an IT production system: development, assessment and deployment.', 'create', 'Deliverable'),
73
+ fp('Process', 'Procedure', 'Complete work tasks following a procedure in support of general office activities and product and service delivery.', 'initiate', 'Workstep'),
74
+ fp('Operate', 'Operating Session', 'Operate equipment and/or a largely automated facility.', 'initiate', 'Function'),
75
+ fp('Maintain', 'Maintenance Arrangement', 'Provide a maintenance service and repair devices/equipment as necessary.', 'initiate', 'Item'),
76
+ fp('Fulfill', 'Facility', 'Fulfill scheduled and ad-hoc obligations under a service arrangement, most typically for a financial product or facility.', 'initiate', 'Fulfillment', [], ['Arrangement']),
77
+ fp('Transact', 'Transaction', 'Execute a well bounded financial transaction/task, typically largely automated, structured fulfillment processing.', 'initiate', 'Task'),
78
+ fp('Agree Terms', 'Agreement', 'Maintain the terms and conditions that apply to a commercial relationship.', 'evaluate', 'Term'),
79
+ fp('Assess', 'Assessment', 'Test or assess an entity, possibly against some formal qualification or certification requirement.', 'evaluate', 'Test'),
80
+ fp('Analyze', 'Analysis', 'Analyze the performance or behavior of some on-going activity or entity.', 'evaluate', 'Algorithm'),
81
+ fp('Monitor', 'State', 'Monitor and define the state/rating of some entity.', 'initiate', 'Indicator'),
82
+ fp('Track', 'Log', 'Maintain a log of transactions or activity, typically a financial account/journal or a log of activity.', 'initiate', 'Event', [], ['Log Record']),
83
+ fp('Catalog', 'Directory Entry', 'Capture and maintain reference information about some type of entity.', 'register', 'Property'),
84
+ fp('Enroll', 'Membership', 'Maintain a membership for some group or related collection of parties.', 'register', 'Clause'),
85
+ fp('Allocate', 'Allocation', 'Maintain an inventory or holding of some resource and make assignments/allocations as requested.', 'provide', 'Criteria'),
86
+ fp('Advise', 'Advice', 'Provide specialist advice and/or support as an ongoing service or for a specific task/event.', 'initiate', 'Topic'),
87
+ ]);
88
+
89
+ /** Names a pattern has had ('Analyse', the guide's; BIAN's model has 'Analyze'). */
90
+ const PATTERN_ALIASES = new Map([['Analyse', 'Analyze']]);
91
+
92
+ const at = (name, group, description) => [name, new BianActionTerm(name, group, description)];
93
+
94
+ /** The seventeen action terms in their four groups (Practitioner Guide V8.1, Figure 14 and after). */
95
+ const ACTION_TERMS = new Map([
96
+ at('activate', 'service-domain', 'Start the service domain operating as a service center.'),
97
+ at('configure', 'service-domain', 'Change how the service domain operates.'),
98
+ at('feedback', 'service-domain', 'Give feedback on how the service domain operates.'),
99
+ at('create', 'create', 'Make a new control record (a plan, a design, a solution).'),
100
+ at('initiate', 'create', 'Create and start a new control record, or a behaviour qualifier of one.'),
101
+ at('register', 'create', 'Add a new entry: catalogue or enroll.'),
102
+ at('evaluate', 'create', 'Start a test, check or analysis.'),
103
+ at('provide', 'create', 'Provide a new instance (an allocation, a log record).'),
104
+ at('update', 'instance', 'Change the values of an active control record.'),
105
+ at('control', 'instance', 'Change the state of an active control record (suspend, resume, terminate).'),
106
+ at('exchange', 'instance', 'Accept, reject, verify or otherwise exchange about an active control record.'),
107
+ at('capture', 'instance', 'Take in information about an active control record from outside.'),
108
+ at('execute', 'instance', 'Run an automated task on an active control record or one of its behaviour qualifiers.'),
109
+ at('request', 'instance', 'Ask for a task on an active control record that needs a decision or manual work.'),
110
+ at('grant', 'instance', 'Give an authorization or permission against an active control record.'),
111
+ at('retrieve', 'read', 'Get details of one or more control records, without changing them.'),
112
+ at('notify', 'read', 'Subscribe to changes of control records, without changing them.'),
113
+ ]);
114
+
115
+ /** The behaviour qualifier types, as BIAN v14.0 rationalised them (release notes, section 7.2). */
116
+ const QUALIFIER_TYPES = Object.freeze([
117
+ 'Fulfillment', 'Term', 'Duty', 'Aspect', 'Clause', 'Algorithm', 'Task', 'Property', 'Routine', 'Criteria',
118
+ 'Deliverable', 'Indicator', 'Topic', 'Workstep', 'Event', 'Goal', 'Function', 'Test', 'Item',
119
+ ]);
120
+
121
+ /** The action terms that act on an existing control record or behaviour qualifier, or read one. */
122
+ const ON_INSTANCE = Object.freeze(['instance', 'read']);
123
+
124
+ export const BianNotation = {
125
+ /** Every functional pattern, in the guide's order. */
126
+ functionalPatterns() { return [...FUNCTIONAL_PATTERNS.values()]; },
127
+ /** A functional pattern by name, or null. */
128
+ functionalPattern(name) { return FUNCTIONAL_PATTERNS.get(name) ?? FUNCTIONAL_PATTERNS.get(PATTERN_ALIASES.get(name)) ?? null; },
129
+ /** A pattern by its name as written anywhere: any case, extra spaces, a former name ('analyse'); null for none. */
130
+ findPattern(text) {
131
+ const word = String(text ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
132
+ if (!word) return null;
133
+ const alias = [...PATTERN_ALIASES].find(([old]) => old.toLowerCase() === word)?.[1];
134
+ return [...FUNCTIONAL_PATTERNS.values()].find(p => p.name.toLowerCase() === word || p.name === alias) ?? null;
135
+ },
136
+ /**
137
+ * The pattern a control record's name says it has, by the artifact it ends
138
+ * with ('CurrentAccountFacility' or 'Current Account Facility': Fulfill;
139
+ * a former artifact name too), the longest match first; null for none.
140
+ */
141
+ patternOfControlRecord(name) {
142
+ const words = String(name ?? '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').toLowerCase().split(/\s+/).filter(Boolean).join(' ');
143
+ const ends = [...FUNCTIONAL_PATTERNS.values()]
144
+ .flatMap(p => [p.artifact, ...p.formerArtifacts].map(a => [a.toLowerCase(), p]))
145
+ .sort(([a], [b]) => b.length - a.length);
146
+ return ends.find(([a]) => words === a || words.endsWith(` ${a}`))?.[1] ?? null;
147
+ },
148
+ /** Every action term. */
149
+ actionTerms() { return [...ACTION_TERMS.values()]; },
150
+ actionTerm(name) { return ACTION_TERMS.get(name) ?? null; },
151
+ /** The behaviour qualifier types. */
152
+ qualifierTypes() { return [...QUALIFIER_TYPES]; },
153
+ isQualifierType(type) { return QUALIFIER_TYPES.includes(type); },
154
+
155
+ /**
156
+ * A service domain's default service operations for a functional pattern
157
+ * (the starting point BIAN gives; a deployment may drop or add some): the
158
+ * service-domain-wide terms on the service domain; the pattern's creating
159
+ * term on the control record and on each behaviour qualifier; and each
160
+ * instance and read term on the control record and each behaviour qualifier.
161
+ * @param {string} patternName
162
+ * @param {{serviceDomain: string, controlRecord: string, qualifiers?: string[]}} names
163
+ * 'Current Account', 'Current Account Arrangement', ['Interest', 'Payments']
164
+ * @returns {Array<{actionTerm: string, target: string, qualifier: string|null, name: string}>}
165
+ */
166
+ serviceOperations(patternName, { serviceDomain, controlRecord, qualifiers = [] }) {
167
+ const pattern = FUNCTIONAL_PATTERNS.get(patternName);
168
+ if (!pattern) return [];
169
+ const op = (term, target, qualifier = null) => ({
170
+ actionTerm: term, target, qualifier,
171
+ name: `${term} ${target}${qualifier ? ` | ${qualifier}` : ''}`,
172
+ });
173
+ const terms = (group) => [...ACTION_TERMS.values()].filter(t => group(t.group)).map(t => t.name);
174
+ const onInstance = terms(g => ON_INSTANCE.includes(g));
175
+ return [
176
+ ...terms(g => g === 'service-domain').map(t => op(t, serviceDomain)),
177
+ op(pattern.creates, controlRecord),
178
+ ...onInstance.map(t => op(t, controlRecord)),
179
+ ...qualifiers.flatMap(q => [op(pattern.creates, controlRecord, q), ...onInstance.map(t => op(t, controlRecord, q))]),
180
+ ];
181
+ },
182
+ };
@@ -8,6 +8,7 @@ import { getEdgeMarker } from './edgeMarkers/index.js';
8
8
  import { PopupMenu } from './PopupMenu.js';
9
9
  import { TextStyle, TEXT_STYLE_KEYS } from './TextStyle.js';
10
10
  import { FormDialog } from './FormDialog.js';
11
+ import { ModelCheckDialog } from './ModelCheckDialog.js';
11
12
  import { NoteTemplate } from './templates/NoteTemplate.js';
12
13
  import { parseShapeText, placeShapes } from './textToShapes.js';
13
14
  import { ViewFit } from './ViewFit.js';
@@ -914,33 +915,11 @@ export class DiagramEditor {
914
915
  const checker = this.model?.checker?.(this.checkerOptions());
915
916
  if (!checker) return [];
916
917
  const findings = checker.run();
917
- let dialog = null;
918
- const list = el({
919
- style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '420px' },
920
- children: findings.length ? findings.map(finding => ({
921
- style: { display: 'flex', gap: '4px', alignItems: 'stretch' },
922
- children: [
923
- {
924
- tag: 'button',
925
- attrs: { type: 'button', title: 'Show it', 'data-code': finding.code },
926
- style: { flex: '1', textAlign: 'left', padding: '4px 6px', border: '1px solid #ddd', borderRadius: '3px', background: finding.severity === 'warning' ? '#fff6e5' : '#f5f8fc', cursor: 'pointer' },
927
- text: `${finding.severity === 'warning' ? '⚠' : 'ℹ'} ${finding.message}`,
928
- on: { click: () => { dialog?.close(); this.revealFinding(finding); } },
929
- },
930
- ...this.findingActions(finding).map(act => ({
931
- tag: 'button', attrs: { type: 'button', 'data-action': act.label }, text: act.label,
932
- style: { padding: '4px 8px', cursor: 'pointer' },
933
- on: { click: () => { dialog?.close(); act.action(); } },
934
- })),
935
- ],
936
- })) : [{ text: 'Nothing found: the model passes every check.' }],
937
- }, this.document);
938
- dialog = new FormDialog({
939
- title: `Model check: ${findings.length} finding${findings.length === 1 ? '' : 's'}`,
940
- fields: [{ key: 'findings', label: '', type: 'custom', element: list, read: () => null }],
941
- submitLabel: 'Close',
918
+ new ModelCheckDialog({
919
+ findings,
920
+ onReveal: (finding) => this.revealFinding(finding),
921
+ actionsFor: (finding) => this.findingActions(finding),
942
922
  document: this.document ?? document,
943
- onApply: () => {},
944
923
  }).open();
945
924
  return findings;
946
925
  }
@@ -224,10 +224,16 @@ export const DIAGRAM_TYPES = {
224
224
  'A person or legal actor that can act or hold rights: person, organisation, government body. Every business process involves actors.'),
225
225
  shape('role', 'Role', StadiumTemplate, { width: 120, height: 44, fillColor: C.purple },
226
226
  'The part a party (or resource) plays in a context: customer, supplier, employee, owner, custodian. A party has many roles, and they change over time.'),
227
- shape('arrangement', 'Arrangement', DocumentTemplate, { width: 130, height: 70, fillColor: C.yellow },
228
- 'A formalised relationship between parties, or parties and resources: contract, agreement, subscription, membership, policy, lease. Business interactions are structured by agreements.'),
227
+ shape('agreement', 'Agreement', DocumentTemplate, { width: 130, height: 70, fillColor: C.yellow },
228
+ 'The contract parties sign: the promises they agree on as one unit. It governs the arrangements made under it (BIAN keeps the two apart).'),
229
+ shape('arrangement', 'Arrangement', RoundedRectangleTemplate, { width: 130, height: 60, fillColor: C.orange },
230
+ 'The terms a product or service is provided on, under an agreement: a subscription, an account, a loan, a lease, a membership.'),
229
231
  shape('product', 'Product', PackageTemplate, { width: 120, height: 70, fillColor: C.green },
230
232
  'What the business offers and sells, under terms: a plan, a policy, a loan, a service bundle. Not the things it holds (resources), but the offer made of them.'),
233
+ shape('feature', 'Product Feature', ParallelogramTemplate, { width: 130, height: 50, fillColor: C.green },
234
+ 'A feature a product is made up of and can be offered with: interest, payments, an overdraft, a reward scheme.'),
235
+ shape('condition', 'Condition', TrapezoidTemplate, { width: 120, height: 55, fillColor: C.red },
236
+ 'A fee, rate, limit or eligibility rule a product or an arrangement is subject to.'),
231
237
  shape('resource', 'Resource', BoxTemplate, { width: 110, height: 80, fillColor: C.blue },
232
238
  'A physical or digital thing of value or use: product, asset, document, file, device. Businesses manage things.'),
233
239
  shape('event', 'Event', EllipseTemplate, { width: 120, height: 60, fillColor: C.green },
@@ -0,0 +1,63 @@
1
+ import { el } from '../workspace/dom.js';
2
+ import { FormDialog } from './FormDialog.js';
3
+
4
+ /**
5
+ * ModelCheckDialog — a model check's findings (ModelChecker#run), listed:
6
+ * each a button (⚠ a warning, ℹ for a look) that shows what it names
7
+ * (`onReveal`), with what can be done about it beside (`actionsFor`), or
8
+ * "nothing found". Any editor or part that checks a model shows it this way.
9
+ */
10
+ export class ModelCheckDialog {
11
+ /**
12
+ * @param {Object} options
13
+ * @param {Array<Object>} options.findings
14
+ * @param {(finding: Object) => void} [options.onReveal] - Show what a finding names
15
+ * @param {(finding: Object) => Array<{label: string, action: Function}>} [options.actionsFor]
16
+ * @param {Document} [options.document]
17
+ */
18
+ constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document }) {
19
+ this.findings = findings;
20
+ this.onReveal = onReveal;
21
+ this.actionsFor = actionsFor;
22
+ this.document = doc;
23
+ }
24
+
25
+ /** The dialog's title: how many findings. */
26
+ get title() {
27
+ const n = this.findings.length;
28
+ return `Model check: ${n} finding${n === 1 ? '' : 's'}`;
29
+ }
30
+
31
+ /** Open it; returns the FormDialog. */
32
+ open() {
33
+ let dialog = null;
34
+ const list = el({
35
+ style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '420px' },
36
+ children: this.findings.length ? this.findings.map(finding => ({
37
+ style: { display: 'flex', gap: '4px', alignItems: 'stretch' },
38
+ children: [
39
+ {
40
+ tag: 'button',
41
+ attrs: { type: 'button', title: 'Show it', 'data-code': finding.code },
42
+ style: { flex: '1', textAlign: 'left', padding: '4px 6px', border: '1px solid #ddd', borderRadius: '3px', background: finding.severity === 'warning' ? '#fff6e5' : '#f5f8fc', cursor: 'pointer' },
43
+ text: `${finding.severity === 'warning' ? '⚠' : 'ℹ'} ${finding.message}`,
44
+ on: { click: () => { dialog?.close(); this.onReveal(finding); } },
45
+ },
46
+ ...this.actionsFor(finding).map(act => ({
47
+ tag: 'button', attrs: { type: 'button', 'data-action': act.label }, text: act.label,
48
+ style: { padding: '4px 8px', cursor: 'pointer' },
49
+ on: { click: () => { dialog?.close(); act.action(); } },
50
+ })),
51
+ ],
52
+ })) : [{ text: 'Nothing found: the model passes every check.' }],
53
+ }, this.document);
54
+ dialog = new FormDialog({
55
+ title: this.title,
56
+ fields: [{ key: 'findings', label: '', type: 'custom', element: list, read: () => null }],
57
+ submitLabel: 'Close',
58
+ document: this.document,
59
+ onApply: () => {},
60
+ });
61
+ return dialog.open();
62
+ }
63
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * ModelChecker — checks over a model: what is wrong or worth a look in it.
3
+ * Each check is a method listed by `checks()`, run in that order, so a
4
+ * subclass adds, drops or reorders them; `run()` gives every finding. The
5
+ * base of ViewSetChecker (models of views) and DataModelChecker (data
6
+ * models). DOM-free and advisory: findings are shown, never enforced.
7
+ *
8
+ * Each finding: `{ code, severity: 'warning'|'info', message, kind, ids }`,
9
+ * `kind` saying what the ids name (a view, an entity, ...).
10
+ */
11
+ export class ModelChecker {
12
+ /** @param {Object} model */
13
+ constructor(model) {
14
+ this.model = model;
15
+ }
16
+
17
+ /** The checks to run, in the order their findings are listed. None here. */
18
+ checks() {
19
+ return [];
20
+ }
21
+
22
+ /** Every finding. @returns {Array<Object>} */
23
+ run() {
24
+ return this.checks().flatMap(check => check.call(this));
25
+ }
26
+ }
@@ -1,81 +1,94 @@
1
- import { el } from '../workspace/dom.js';
2
- import { PopupMenu } from './PopupMenu.js';
3
-
4
- /**
5
- * ViewTabStrip — a row (or column) of tabs, one per view, with an optional
6
- * close button and a right-click menu per tab. Stateless: render() builds the
7
- * strip from the items given.
8
- */
9
- export class ViewTabStrip {
10
- /**
11
- * @param {Object} options
12
- * @param {'horizontal'|'vertical'} [options.orientation]
13
- * @param {(id: string) => void} options.onSelect
14
- * @param {(id: string) => void} [options.onClose] - Shows a × on each tab
15
- * @param {(id: string) => Array} [options.menuItems] - PopupMenu items for a tab
16
- * @param {Document} [options.document]
17
- */
18
- constructor({ orientation = 'horizontal', onSelect, onClose = null, menuItems = () => [], document: doc = document }) {
19
- this.orientation = orientation;
20
- this.onSelect = onSelect;
21
- this.onClose = onClose;
22
- this.menuItems = menuItems;
23
- this.doc = doc;
24
- }
25
-
26
- /**
27
- * @param {Array<{id: string, title: string}>} items
28
- * @param {string} activeId
29
- * @returns {HTMLElement}
30
- */
31
- render(items, activeId) {
32
- const vertical = this.orientation === 'vertical';
33
- return el({
34
- attrs: { role: 'tablist', 'aria-orientation': this.orientation },
35
- style: {
36
- flex: vertical ? '0 0 150px' : '0 0 auto', display: 'flex', flexDirection: vertical ? 'column' : 'row',
37
- gap: '2px', background: '#f0f0f0', padding: '4px 8px', fontSize: '12px',
38
- borderRight: vertical ? '1px solid #ccc' : 'none', borderBottom: vertical ? 'none' : '1px solid #ccc',
39
- overflowX: vertical ? 'visible' : 'auto', overflowY: vertical ? 'auto' : 'visible',
40
- },
41
- children: items.map(({ id, title }) => this._tab(id, title, id === activeId, vertical)),
42
- }, this.doc);
43
- }
44
-
45
- _tab(id, title, active, vertical) {
46
- const menuItems = this.menuItems(id);
47
- return {
48
- attrs: { role: 'tab', tabindex: '0', 'aria-selected': String(active), 'data-view-id': id },
49
- style: {
50
- display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 12px', border: '1px solid #ccc',
51
- cursor: 'pointer', fontSize: '12px', whiteSpace: 'nowrap', userSelect: 'none',
52
- borderRadius: vertical ? '3px' : '3px 3px 0 0',
53
- background: active ? '#fff' : '#e8e8e8', fontWeight: active ? 'bold' : 'normal',
54
- },
55
- on: {
56
- click: () => this.onSelect(id),
57
- keydown: (e) => {
58
- if (e.key === 'Enter' || e.key === ' ') {
59
- e.preventDefault();
60
- this.onSelect(id);
61
- }
62
- },
63
- contextmenu: (e) => {
64
- if (!menuItems.length) return;
65
- e.preventDefault();
66
- PopupMenu.show(e.clientX, e.clientY, menuItems, this.doc);
67
- },
68
- },
69
- children: [
70
- { tag: 'span', text: title },
71
- this.onClose && {
72
- tag: 'button',
73
- text: '×',
74
- attrs: { type: 'button', 'aria-label': `Close ${title}`, title: 'Close tab' },
75
- style: { border: 'none', background: 'none', padding: '0', cursor: 'pointer', fontSize: '14px', lineHeight: '1', color: '#888' },
76
- on: { click: (e) => { e.stopPropagation(); this.onClose(id); } },
77
- },
78
- ].filter(Boolean),
79
- };
80
- }
81
- }
1
+ import { el } from '../workspace/dom.js';
2
+ import { PopupMenu } from './PopupMenu.js';
3
+
4
+ /**
5
+ * ViewTabStrip — a row (or column) of tabs, one per open view, as a browser
6
+ * has them: an optional × to close a tab (`onClose`; `canClose` says which may
7
+ * be), a + after them to add one (`onAdd`), and a right-click menu per tab.
8
+ * Stateless: render() builds the strip from the items given.
9
+ */
10
+ export class ViewTabStrip {
11
+ /**
12
+ * @param {Object} options
13
+ * @param {'horizontal'|'vertical'} [options.orientation]
14
+ * @param {(id: string) => void} options.onSelect
15
+ * @param {(id: string) => void} [options.onClose] - Shows a × on each tab
16
+ * @param {(id: string) => boolean} [options.canClose] - Which tabs get the × (default all)
17
+ * @param {(event: Event) => void} [options.onAdd] - Shows a + after the tabs
18
+ * @param {(id: string) => Array} [options.menuItems] - PopupMenu items for a tab
19
+ * @param {Document} [options.document]
20
+ */
21
+ constructor({ orientation = 'horizontal', onSelect, onClose = null, canClose = () => true, onAdd = null, menuItems = () => [], document: doc = document }) {
22
+ this.orientation = orientation;
23
+ this.onSelect = onSelect;
24
+ this.onClose = onClose;
25
+ this.canClose = canClose;
26
+ this.onAdd = onAdd;
27
+ this.menuItems = menuItems;
28
+ this.doc = doc;
29
+ }
30
+
31
+ /**
32
+ * @param {Array<{id: string, title: string}>} items
33
+ * @param {string} activeId
34
+ * @returns {HTMLElement}
35
+ */
36
+ render(items, activeId) {
37
+ const vertical = this.orientation === 'vertical';
38
+ return el({
39
+ attrs: { role: 'tablist', 'aria-orientation': this.orientation },
40
+ style: {
41
+ flex: vertical ? '0 0 150px' : '0 0 auto', display: 'flex', flexDirection: vertical ? 'column' : 'row',
42
+ gap: '2px', background: '#f0f0f0', padding: '4px 8px', fontSize: '12px',
43
+ borderRight: vertical ? '1px solid #ccc' : 'none', borderBottom: vertical ? 'none' : '1px solid #ccc',
44
+ overflowX: vertical ? 'visible' : 'auto', overflowY: vertical ? 'auto' : 'visible',
45
+ },
46
+ children: [
47
+ ...items.map(({ id, title }) => this._tab(id, title, id === activeId, vertical)),
48
+ this.onAdd && {
49
+ tag: 'button', text: '+',
50
+ attrs: { type: 'button', 'aria-label': 'New view', title: 'New view', 'data-tab-add': '' },
51
+ style: { border: '1px solid transparent', background: 'none', cursor: 'pointer', fontSize: '15px', lineHeight: '1', color: '#555', padding: '2px 8px' },
52
+ on: { click: (e) => this.onAdd(e) },
53
+ },
54
+ ].filter(Boolean),
55
+ }, this.doc);
56
+ }
57
+
58
+ _tab(id, title, active, vertical) {
59
+ const menuItems = this.menuItems(id);
60
+ return {
61
+ attrs: { role: 'tab', tabindex: '0', 'aria-selected': String(active), 'data-view-id': id },
62
+ style: {
63
+ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 12px', border: '1px solid #ccc',
64
+ cursor: 'pointer', fontSize: '12px', whiteSpace: 'nowrap', userSelect: 'none',
65
+ borderRadius: vertical ? '3px' : '3px 3px 0 0',
66
+ background: active ? '#fff' : '#e8e8e8', fontWeight: active ? 'bold' : 'normal',
67
+ },
68
+ on: {
69
+ click: () => this.onSelect(id),
70
+ keydown: (e) => {
71
+ if (e.key === 'Enter' || e.key === ' ') {
72
+ e.preventDefault();
73
+ this.onSelect(id);
74
+ }
75
+ },
76
+ contextmenu: (e) => {
77
+ if (!menuItems.length) return;
78
+ e.preventDefault();
79
+ PopupMenu.show(e.clientX, e.clientY, menuItems, this.doc);
80
+ },
81
+ },
82
+ children: [
83
+ { tag: 'span', text: title },
84
+ this.onClose && this.canClose(id) && {
85
+ tag: 'button',
86
+ text: '×',
87
+ attrs: { type: 'button', 'aria-label': `Close ${title}`, title: 'Close tab' },
88
+ style: { border: 'none', background: 'none', padding: '0', cursor: 'pointer', fontSize: '14px', lineHeight: '1', color: '#888' },
89
+ on: { click: (e) => { e.stopPropagation(); this.onClose(id); } },
90
+ },
91
+ ].filter(Boolean),
92
+ };
93
+ }
94
+ }