@simai-studio/view 0.0.0-stage → 1.1.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.
Files changed (127) hide show
  1. package/LICENSE.md +183 -0
  2. package/README.md +75 -2
  3. package/dist/package-receipt.json +972 -0
  4. package/dist/set.json +141 -0
  5. package/dist/smart/rule.js +278 -0
  6. package/dist/smart/rule.js.gz +0 -0
  7. package/dist/smart/view/aliases.json +129 -0
  8. package/dist/smart/view/aliases.json.gz +0 -0
  9. package/dist/smart/view/component.json +1628 -0
  10. package/dist/smart/view/component.json.gz +0 -0
  11. package/dist/smart/view/css/view.css +208 -0
  12. package/dist/smart/view/css/view.css.gz +0 -0
  13. package/dist/smart/view/css/view.min.css +2 -0
  14. package/dist/smart/view/css/view.min.css.gz +0 -0
  15. package/dist/smart/view/examples.json +668 -0
  16. package/dist/smart/view/examples.json.gz +0 -0
  17. package/dist/smart/view/js/view.js +3955 -0
  18. package/dist/smart/view/js/view.js.gz +0 -0
  19. package/dist/smart/view/js/view.min.js +1 -0
  20. package/dist/smart/view/js/view.min.js.gz +0 -0
  21. package/dist/smart/view/lang/en.json +61 -0
  22. package/dist/smart/view/lang/en.json.gz +0 -0
  23. package/dist/smart/view/lang/ru.json +61 -0
  24. package/dist/smart/view/lang/ru.json.gz +0 -0
  25. package/dist/smart/view/probe.json +165 -0
  26. package/dist/smart/view/probe.json.gz +0 -0
  27. package/dist/smart/view/smart.manifest.json +1577 -0
  28. package/dist/smart/view/smart.manifest.json.gz +0 -0
  29. package/dist/smart/view/static/default/embedded.html +96 -0
  30. package/dist/smart/view/static/default/embedded.html.gz +0 -0
  31. package/dist/smart/view/static/default/open-record.html +242 -0
  32. package/dist/smart/view/static/default/open-record.html.gz +0 -0
  33. package/dist/smart/view/static/default/reference.html +242 -0
  34. package/dist/smart/view/static/default/reference.html.gz +0 -0
  35. package/dist/smart/view/static/sheets.json +282 -0
  36. package/dist/smart/view/static/sheets.json.gz +0 -0
  37. package/dist/smart/view-properties/aliases.json +174 -0
  38. package/dist/smart/view-properties/aliases.json.gz +0 -0
  39. package/dist/smart/view-properties/component.json +1906 -0
  40. package/dist/smart/view-properties/component.json.gz +0 -0
  41. package/dist/smart/view-properties/css/view-properties.css +1158 -0
  42. package/dist/smart/view-properties/css/view-properties.css.gz +0 -0
  43. package/dist/smart/view-properties/css/view-properties.min.css +2 -0
  44. package/dist/smart/view-properties/css/view-properties.min.css.gz +0 -0
  45. package/dist/smart/view-properties/examples.json +841 -0
  46. package/dist/smart/view-properties/examples.json.gz +0 -0
  47. package/dist/smart/view-properties/js/view-properties.js +10551 -0
  48. package/dist/smart/view-properties/js/view-properties.js.gz +0 -0
  49. package/dist/smart/view-properties/js/view-properties.min.js +1392 -0
  50. package/dist/smart/view-properties/js/view-properties.min.js.gz +0 -0
  51. package/dist/smart/view-properties/lang/en.json +89 -0
  52. package/dist/smart/view-properties/lang/en.json.gz +0 -0
  53. package/dist/smart/view-properties/lang/ru.json +89 -0
  54. package/dist/smart/view-properties/lang/ru.json.gz +0 -0
  55. package/dist/smart/view-properties/probe.json +188 -0
  56. package/dist/smart/view-properties/probe.json.gz +0 -0
  57. package/dist/smart/view-properties/skeleton.html +13 -0
  58. package/dist/smart/view-properties/skeleton.html.gz +0 -0
  59. package/dist/smart/view-properties/smart.manifest.json +1593 -0
  60. package/dist/smart/view-properties/smart.manifest.json.gz +0 -0
  61. package/dist/smart/view-properties/static/default/full.html +2 -0
  62. package/dist/smart/view-properties/static/default/full.html.gz +0 -0
  63. package/dist/smart/view-properties/static/default/panel-closed.html +4 -0
  64. package/dist/smart/view-properties/static/default/panel-closed.html.gz +0 -0
  65. package/dist/smart/view-properties/static/default/panel.html +4 -0
  66. package/dist/smart/view-properties/static/default/panel.html.gz +0 -0
  67. package/dist/smart/view-properties/static/default/reference.html +2 -0
  68. package/dist/smart/view-properties/static/default/reference.html.gz +0 -0
  69. package/dist/smart/view-properties/static/sheets.json +426 -0
  70. package/dist/smart/view-properties/static/sheets.json.gz +0 -0
  71. package/dist/smart/view-properties/template/default.css +992 -0
  72. package/dist/smart/view-properties/template/default.css.gz +0 -0
  73. package/dist/smart/view-properties/template/default.js +1658 -0
  74. package/dist/smart/view-properties/template/default.js.gz +0 -0
  75. package/dist/smart/view-table/aliases.json +448 -0
  76. package/dist/smart/view-table/aliases.json.gz +0 -0
  77. package/dist/smart/view-table/component.json +2746 -0
  78. package/dist/smart/view-table/component.json.gz +0 -0
  79. package/dist/smart/view-table/css/view-table.css +3658 -0
  80. package/dist/smart/view-table/css/view-table.css.gz +0 -0
  81. package/dist/smart/view-table/css/view-table.min.css +2 -0
  82. package/dist/smart/view-table/css/view-table.min.css.gz +0 -0
  83. package/dist/smart/view-table/examples.json +499 -0
  84. package/dist/smart/view-table/examples.json.gz +0 -0
  85. package/dist/smart/view-table/js/view-table.js +14166 -0
  86. package/dist/smart/view-table/js/view-table.js.gz +0 -0
  87. package/dist/smart/view-table/js/view-table.min.js +1440 -0
  88. package/dist/smart/view-table/js/view-table.min.js.gz +0 -0
  89. package/dist/smart/view-table/lang/en.json +132 -0
  90. package/dist/smart/view-table/lang/en.json.gz +0 -0
  91. package/dist/smart/view-table/lang/ru.json +132 -0
  92. package/dist/smart/view-table/lang/ru.json.gz +0 -0
  93. package/dist/smart/view-table/probe.json +275 -0
  94. package/dist/smart/view-table/probe.json.gz +0 -0
  95. package/dist/smart/view-table/smart.manifest.json +2531 -0
  96. package/dist/smart/view-table/smart.manifest.json.gz +0 -0
  97. package/dist/smart/view-table/static/default/current.html +141 -0
  98. package/dist/smart/view-table/static/default/current.html.gz +0 -0
  99. package/dist/smart/view-table/static/default/current.skeleton.html +141 -0
  100. package/dist/smart/view-table/static/default/current.skeleton.html.gz +0 -0
  101. package/dist/smart/view-table/static/default/empty.html +149 -0
  102. package/dist/smart/view-table/static/default/empty.html.gz +0 -0
  103. package/dist/smart/view-table/static/default/loading.html +180 -0
  104. package/dist/smart/view-table/static/default/loading.html.gz +0 -0
  105. package/dist/smart/view-table/static/default/many.html +180 -0
  106. package/dist/smart/view-table/static/default/many.html.gz +0 -0
  107. package/dist/smart/view-table/static/default/reference.html +136 -0
  108. package/dist/smart/view-table/static/default/reference.html.gz +0 -0
  109. package/dist/smart/view-table/static/default/reference.skeleton.html +136 -0
  110. package/dist/smart/view-table/static/default/reference.skeleton.html.gz +0 -0
  111. package/dist/smart/view-table/static/default/typed-cells.html +229 -0
  112. package/dist/smart/view-table/static/default/typed-cells.html.gz +0 -0
  113. package/dist/smart/view-table/static/default/typed-cells.skeleton.html +229 -0
  114. package/dist/smart/view-table/static/default/typed-cells.skeleton.html.gz +0 -0
  115. package/dist/smart/view-table/static/sheets.json +480 -0
  116. package/dist/smart/view-table/static/sheets.json.gz +0 -0
  117. package/dist/smart/view-table/studio.fixture.json +17 -0
  118. package/dist/smart/view-table/studio.fixture.json.gz +0 -0
  119. package/dist/smart/view-table/studio.scenario.json +30 -0
  120. package/dist/smart/view-table/studio.scenario.json.gz +0 -0
  121. package/dist/smart/view-table/template/default.css +1301 -0
  122. package/dist/smart/view-table/template/default.css.gz +0 -0
  123. package/dist/smart/view-table/template/default.js +1215 -0
  124. package/dist/smart/view-table/template/default.js.gz +0 -0
  125. package/dist/smart/view-table/template/link-cell-contract.js +48 -0
  126. package/dist/smart/view-table/template/link-cell-contract.js.gz +0 -0
  127. package/package.json +29 -4
@@ -0,0 +1,1658 @@
1
+ import {html, nothing} from 'lit';
2
+ import "./default.css";
3
+ import {renderViewField, renderEditField} from '../../../field-renderers/index.js';
4
+
5
+ // The arrangement, drawn in the component's own light DOM. Not in a shadow root:
6
+ // the field controls are Smart elements whose stylesheets are document-level, and
7
+ // a document stylesheet does not cross a shadow boundary. sf-view slots its
8
+ // children for exactly this reason and says so beside the slot; the first version
9
+ // of this composite built them inside a shadow root and every control came out
10
+ // with its markup and none of its CSS.
11
+
12
+ function renderFieldValue(context, field, editing, {disabled = false} = {}) {
13
+ const key = String(field.key ?? '');
14
+ const editHost = {
15
+ setFieldValue: (target, value) => context.component.changeField(target, value),
16
+ // A control that knows its entry is not a value yet says so here (port
17
+ // 1.16.0: a masked string that does not fill its mask); the view holds
18
+ // both saves until it is completed or cleared.
19
+ setFieldError: (target, message) => context.component.reportFieldError(target, message),
20
+ ...(context.canUploadFiles
21
+ ? {
22
+ getFieldUploads: (target) => context.component.getFieldUploads(target),
23
+ startFieldUpload: (target, items) => context.component.startFieldUpload(target, items),
24
+ retryFieldUpload: (target, index) => context.component.retryFieldUpload(target, index),
25
+ }
26
+ : {}),
27
+ ...(context.canPickRecords
28
+ ? {
29
+ getFieldOptionsPage: (target) => context.component.getFieldOptionsPage(target),
30
+ getFieldOptionSearch: (target) => context.component.getFieldOptionSearch(target),
31
+ setFieldOptionSearch: (target, text) => context.component.setFieldOptionSearch(target, text),
32
+ loadFieldOptions: (target, request) => context.component.loadFieldOptions(target, request),
33
+ }
34
+ : {}),
35
+ };
36
+ // A datetime and a time of day are the renderer set's: sf-input-date in
37
+ // mode datetime and time, one control each (SIMAI Framework duplication
38
+ // audit A-14). This view drew its own pair -- the day in a datepicker and
39
+ // the time in a masked field -- which cut the day short in a half-width
40
+ // field and held half-typed entries of its own.
41
+ const content = editing
42
+ ? renderEditField(
43
+ field,
44
+ context.changed.has(key) ? context.changed.get(key) : field.value,
45
+ editHost,
46
+ // A field its condition turns off is drawn disabled where the set
47
+ // draws disabled apart from read-only (a choice as buttons).
48
+ {size: context.size, ...(disabled ? {disabled: true} : {})},
49
+ )
50
+ : renderViewField(field, field.value, context.viewHost, {size: context.size});
51
+
52
+ const reference = ['record', 'relation', 'user'].includes(field.propertyType || field.type)
53
+ && field.value && typeof field.value === 'object';
54
+ if (!editing && reference && content) {
55
+ return html`<span
56
+ class="sf-property-view-reference"
57
+ @click=${(event) => {
58
+ event.preventDefault();
59
+ // And no further: the field in view is a button that opens
60
+ // the group for editing, so a press that reached it put the
61
+ // whole group into edit behind the record that had just
62
+ // opened (owner, 2026-10-08). Following a reference is one
63
+ // intent, not two.
64
+ event.stopPropagation();
65
+ context.component.openReference(field, field.value);
66
+ }}
67
+ >${content}</span>`;
68
+ }
69
+
70
+ // An edit control the set cannot build is not an empty value. record and
71
+ // relation have no editor until the picker's host methods exist, and the
72
+ // first version showed «не заполнено» over a field that had a value -- a
73
+ // consumer read it as the value having been lost. Fall back to the view, so
74
+ // the value is shown and plainly not editable yet.
75
+ if (editing && (content === null || typeof content === 'undefined' || content === nothing)) {
76
+ const shown = renderViewField(field, field.value, context.viewHost, {size: context.size});
77
+ if (shown !== null && typeof shown !== 'undefined' && shown !== nothing) {
78
+ return html`<span class="sf-property-view-readonly color-on-surface-variant" data-readonly="true">${shown}</span>`;
79
+ }
80
+ }
81
+
82
+ if (content === null || typeof content === 'undefined' || content === nothing) {
83
+ return html`<span class="sf-property-view-empty color-on-surface-variant">${context.emptyText}</span>`;
84
+ }
85
+
86
+ return content;
87
+ }
88
+
89
+ // While the view is being configured every entry is listed, hidden and rare ones
90
+ // included: a hidden field a person cannot see is a field they cannot show again,
91
+ // and «показать редкие поля» is a reading choice rather than part of the
92
+ // arrangement being edited.
93
+ function listed(context, entries) {
94
+ return context.configuring ? (entries ?? []) : context.visible(entries);
95
+ }
96
+
97
+ // The chooser names the layers this person may write and nothing else. A layer
98
+ // they may not write is drawn disabled with the host's own reason on it rather
99
+ // than left out, because a «Для всех» that is simply absent reads as a feature
100
+ // the product does not have.
101
+ // The controls of the arranging mode live in the bar at the foot (owner,
102
+ // 2026-10-08), where saving, cancelling and deleting live when a group is being
103
+ // edited: the foot is where a mode's own answers belong, and a band of them
104
+ // under the head pushed the record down by a row it did not need.
105
+ function renderConfigureBar(context) {
106
+ if (!context.canConfigureView) return nothing;
107
+ // Not configuring: nothing is drawn. Entering is the «⋯» menu's, as the mockup
108
+ // has it -- a standalone «Настроить вид» link stood as its own band under the
109
+ // head and was one of the two empty-looking rows the owner saw.
110
+ if (!context.configuring) return nothing;
111
+
112
+ // One row of controls and nothing else: the view being arranged and the
113
+ // way out. Whose copy is written follows from the view itself -- a view
114
+ // for everyone is written for everyone, a personal one for its person --
115
+ // so the «Персональный / Для всех» toggle that stood beside the chooser
116
+ // said the same thing twice and is gone (owner, 2026-10-14).
117
+
118
+ // Which named view is being arranged, chosen, made, renamed and removed in
119
+ // the Framework's profile picker (owner, 2026-10-14) -- the one the admin
120
+ // menu's profiles use.
121
+ const writable = context.editableLayers ?? [];
122
+ const chooser = html`
123
+ <sf-profile-picker
124
+ size=${context.size}
125
+ data-action="view-profile"
126
+ label=${context.viewProfileLabel}
127
+ current=${context.viewProfile ?? ''}
128
+ .profiles=${(context.viewProfiles ?? []).map((profile) => ({
129
+ id: profile.id, label: profile.label, shared: Boolean(profile.shared),
130
+ editable: writable.includes(profile.shared ? 'shared' : 'personal')
131
+ && (!profile.shared || context.canDeleteViewProfile(profile.id)),
132
+ }))}
133
+ .rights=${{create: {personal: writable.includes('personal'), shared: writable.includes('shared')}}}
134
+ @sf-profile-picker-select=${(event) => context.component.selectViewProfile(event.detail.id)}
135
+ @sf-profile-picker-create=${(event) => context.component.createViewProfile(event.detail.label, {shared: event.detail.shared})}
136
+ @sf-profile-picker-rename=${(event) => context.component.renameViewProfile(event.detail.id, event.detail.label)}
137
+ @sf-profile-picker-delete=${(event) => context.component.deleteViewProfile(event.detail.id)}
138
+ ></sf-profile-picker>`;
139
+
140
+ return html`
141
+ <div class="sf-property-view-actions sf-property-view-configure-bar flex items-cross-center gap-2 flex-wrap"
142
+ data-configuring="true" role="group"
143
+ aria-label=${context.configureText}>
144
+ ${chooser}
145
+ <span class="grow"></span>
146
+ ${context.resetAsked
147
+ ? html`
148
+ <span class="sf-property-view-reset-ask color-error">${context.resetViewAskText}</span>
149
+ <sf-button
150
+ appearance="link"
151
+ size=${context.size}
152
+ status="error"
153
+ text=${context.resetViewText}
154
+ data-action="reset-view-confirm"
155
+ @click=${() => context.component.resetView()}
156
+ ></sf-button>
157
+ <sf-button
158
+ appearance="outline"
159
+ size=${context.size}
160
+ text=${context.cancelConfiguringText}
161
+ data-action="reset-view-cancel"
162
+ @click=${() => context.component.cancelReset()}
163
+ ></sf-button>`
164
+ : html`
165
+ <!-- Reset, cancel, done: the further a control reaches, the
166
+ quieter it is drawn. Reset takes the whole layer away
167
+ and asks first; cancel puts back the arrangement this
168
+ mode was entered with; done keeps what is there. -->
169
+ <sf-button
170
+ appearance="link"
171
+ size=${context.size}
172
+ text=${context.resetViewText}
173
+ data-action="reset-view"
174
+ @click=${() => context.component.askReset()}
175
+ ></sf-button>
176
+ <sf-button
177
+ appearance="outline"
178
+ size=${context.size}
179
+ text=${context.cancelConfiguringText}
180
+ data-action="cancel-configuring"
181
+ @click=${() => context.component.cancelConfiguringView()}
182
+ ></sf-button>
183
+ <sf-button
184
+ appearance="default"
185
+ size=${context.size}
186
+ text=${context.doneConfiguringText}
187
+ data-action="done-configuring"
188
+ @click=${() => context.component.stopConfiguringView()}
189
+ ></sf-button>`}
190
+ </div>
191
+ `;
192
+ }
193
+
194
+ // Width, template variant and hint sit behind the field's own settings rather
195
+ // than under every field at once. Drawn under each field they were four controls
196
+ // per row, so a group of eight fields was thirty-two controls and the
197
+ // arrangement -- the thing being edited -- could no longer be read.
198
+ function renderFieldSettings(context, field, key) {
199
+ const variants = Array.isArray(field.variantOptions) ? field.variantOptions : [];
200
+
201
+ return html`
202
+ <sf-popover placement="bottom-end" width="16rem" label=${context.fieldSettingsText}
203
+ data-settings-for=${key}>
204
+ <sf-button-icon
205
+ slot="trigger"
206
+ size="1/2"
207
+ appearance="link"
208
+ scheme="secondary"
209
+ icon="settings"
210
+ data-action="field-settings"
211
+ title=${context.fieldSettingsText}
212
+ aria-label=${context.fieldSettingsText}
213
+ ></sf-button-icon>
214
+ <div slot="content" class="flex flex-col gap-1 p-1">
215
+ <label class="sf-property-view-settings-row sf-property-view-settings-row--stacked flex flex-col gap-1/4">
216
+ <span class="sf-property-view-settings-label color-on-surface-variant">${context.fieldLabelText}</span>
217
+ <sf-input
218
+ size="1/2"
219
+ appearance="bordered"
220
+ data-action="field-label"
221
+ aria-label=${context.fieldLabelText}
222
+ placeholder=${String(field.label ?? key)}
223
+ value=${String(field.label ?? '')}
224
+ @change=${(event) => context.component.setFieldView(key, {
225
+ label: String(event.target?.value ?? '').trim() || null,
226
+ })}
227
+ ></sf-input>
228
+ </label>
229
+ <label class="sf-property-view-settings-row sf-property-view-settings-row--stacked flex flex-col gap-1/4">
230
+ <span class="sf-property-view-settings-label color-on-surface-variant">${context.hintText}</span>
231
+ <sf-input
232
+ size="1/2"
233
+ appearance="bordered"
234
+ data-action="hint"
235
+ aria-label=${context.hintText}
236
+ placeholder=${context.hintText}
237
+ value=${String(field.hint ?? '')}
238
+ @change=${(event) => context.component.setFieldView(key, {hint: event.target?.value ?? ''})}
239
+ ></sf-input>
240
+ </label>
241
+ ${variants.length
242
+ ? html`
243
+ <label class="sf-property-view-settings-row flex items-cross-center justify-between gap-1">
244
+ <span class="sf-property-view-settings-label color-on-surface-variant">${context.variantText}</span>
245
+ <sf-dropdown
246
+ size="1/2"
247
+ appearance="outlined"
248
+ mode="select"
249
+ search="false"
250
+ data-action="variant"
251
+ aria-label=${context.variantText}
252
+ value=${String(field.variant ?? '')}
253
+ .options=${variants.map((variant) => ({
254
+ type: 'text',
255
+ text: variant.label ?? variant.text ?? String(variant.value ?? variant),
256
+ value: String(variant.value ?? variant),
257
+ selected: String(field.variant ?? '') === String(variant.value ?? variant),
258
+ }))}
259
+ @change=${(event) => context.component.setFieldView(key, {variant: event.detail?.value ?? null})}
260
+ ></sf-dropdown>
261
+ </label>`
262
+ : nothing}
263
+ <label class="sf-property-view-settings-row flex items-cross-center justify-between gap-1">
264
+ <span class="sf-property-view-settings-label color-on-surface-variant">${context.widthText}</span>
265
+ <sf-switch
266
+ size="1/2"
267
+ data-action="width"
268
+ ?checked=${field.width === 'full'}
269
+ @change=${(event) => context.component.setFieldView(key, {
270
+ width: event.target?.checked ? 'full' : 'half',
271
+ })}
272
+ ></sf-switch>
273
+ </label>
274
+ </div>
275
+ </sf-popover>
276
+ `;
277
+ }
278
+
279
+ // The two controls a field carries while the view is arranged: taking it out of
280
+ // this area and where everything else about it lives. They sit at the end of the
281
+ // field, on the ladder the rest of the panel uses.
282
+ //
283
+ // None of them is red (owner, 2026-10-08). Red is the record's own delete, in
284
+ // the bar at the foot; a mode whose rows each carry a red mark reads as a list
285
+ // of warnings, and taking a field out of an area is an ordinary act of
286
+ // arranging that the «+» beside it undoes.
287
+ //
288
+ // Taking out rather than hiding (owner, 2026-10-07): a field removed here joins
289
+ // the unplaced, where the list behind the «+» offers it back, so removing is
290
+ // visible and undoable in the same place. Hiding is still what the port carries
291
+ // and what a shared layer does to a field for everyone -- a field hidden above
292
+ // this layer is drawn with its reason and cannot be taken out from here.
293
+ function renderFieldActions(context, field, key) {
294
+ const locked = !context.canChangeVisibility(field);
295
+
296
+ return html`
297
+ <div class="sf-property-view-field-actions flex items-cross-center gap-1/4">
298
+ <sf-button-icon
299
+ size="1/2"
300
+ appearance="link"
301
+ scheme="secondary"
302
+ icon=${field.hidden ? 'visibility_off' : 'close'}
303
+ data-action="remove-field"
304
+ ?disabled=${locked}
305
+ title=${locked ? (context.layerDenied('shared') || context.hiddenText) : context.removeFieldText}
306
+ aria-label=${context.removeFieldText}
307
+ @click=${() => context.component.moveFieldToUnplaced(key)}
308
+ ></sf-button-icon>
309
+ ${renderFieldSettings(context, field, key)}
310
+ </div>
311
+ `;
312
+ }
313
+
314
+ // A name that can be changed, where it stands. In the arranging mode the title
315
+ // is the control: pressing it, or the pencil beside it, turns it into a field
316
+ // with a tick and a cross -- the two answers a person has to a name they are
317
+ // typing. Nothing is written until the tick, so an area keeps its name while
318
+ // somebody is still deciding on another.
319
+ function renderEntryName(context, kind, key, label, titleClass) {
320
+ const renaming = context.renaming?.kind === kind && context.renaming?.key === String(key);
321
+
322
+ if (!context.configuring) {
323
+ return html`<h3 class=${titleClass}>${label}</h3>`;
324
+ }
325
+
326
+ if (!renaming) {
327
+ // The pencil stands right after the name rather than at the end of the
328
+ // row: at the end it read as another of the area's controls, and what
329
+ // it edits is the name beside it (owner, 2026-10-08). The name takes
330
+ // only the room it needs, and the space that was taken from it goes to
331
+ // the spacer, which keeps the controls at the end of the row.
332
+ return html`
333
+ <h3 class=${titleClass}>
334
+ <button type="button" class="sf-property-view-rename-trigger text-start cursor-pointer"
335
+ data-action="rename"
336
+ title=${context.renameText}
337
+ @click=${() => context.component.startRenaming(kind, key)}
338
+ >${label}</button>
339
+ </h3>
340
+ <sf-button-icon
341
+ size="1/2"
342
+ appearance="link"
343
+ scheme="secondary"
344
+ icon="edit"
345
+ data-action="rename"
346
+ title=${context.renameText}
347
+ aria-label=${context.renameText}
348
+ @click=${() => context.component.startRenaming(kind, key)}
349
+ ></sf-button-icon>
350
+ <span class="grow"></span>
351
+ `;
352
+ }
353
+
354
+ const commit = (event) => {
355
+ const input = event.target?.closest?.('.sf-property-view-rename')?.querySelector('sf-input');
356
+ context.component.renameEntry(kind, key, input?.value ?? label);
357
+ };
358
+
359
+ return html`
360
+ <span class="sf-property-view-rename flex items-cross-center gap-1/4 grow min-w-0">
361
+ <sf-input
362
+ size="1/2"
363
+ appearance="bordered"
364
+ autofocus
365
+ root-class="flex-auto min-w-0"
366
+ data-action="rename-input"
367
+ aria-label=${context.renameText}
368
+ value=${label}
369
+ @keydown=${(event) => {
370
+ if (event.key === 'Enter') commit(event);
371
+ if (event.key === 'Escape') context.component.cancelRenaming();
372
+ }}
373
+ ></sf-input>
374
+ <sf-button-icon
375
+ size="1/2"
376
+ appearance="link"
377
+ scheme="on-surface"
378
+ icon="check"
379
+ data-action="rename-save"
380
+ title=${context.saveNameText}
381
+ aria-label=${context.saveNameText}
382
+ @click=${commit}
383
+ ></sf-button-icon>
384
+ <sf-button-icon
385
+ size="1/2"
386
+ appearance="link"
387
+ scheme="on-surface"
388
+ icon="close"
389
+ data-action="rename-cancel"
390
+ title=${context.cancelNameText}
391
+ aria-label=${context.cancelNameText}
392
+ @click=${() => context.component.cancelRenaming()}
393
+ ></sf-button-icon>
394
+ </span>
395
+ `;
396
+ }
397
+
398
+ // A value is a way in. Reading a record and changing it are the same screen in the
399
+ // mockup: a person presses the value and its group opens for editing, with a pen
400
+ // showing that it can be. Only a readonly field, a field already being edited and
401
+ // a field being arranged are not buttons -- each already means something else.
402
+ // What a press inside a field's value may land on that acts on its own.
403
+ const INNER_CONTROL = 'a[href], button, input, select, textarea, [role="button"], [role="link"]';
404
+
405
+ function renderField(context, field, editing, sectionKey = '', groupKey = '') {
406
+ // A field as its conditions make it now (port 1.14.0): required or not,
407
+ // enabled or drawn disabled as a readonly field is. Not while the view is
408
+ // arranged: arranging places fields, it does not fill them in.
409
+ const disabled = !context.configuring && Boolean(field.when) && context.fieldDisabled(field);
410
+ if (!context.configuring && field.when) field = context.conditionedField(field);
411
+ const key = String(field.key ?? '');
412
+ // A message the field's own control draws under itself (port 1.16.0: an
413
+ // incomplete masked string) is not drawn a second time here; it still
414
+ // counts among the errors beside Save, and still holds the save.
415
+ const error = context.controlErrors?.has(key) ? null : context.errors.get(key);
416
+
417
+ const arrangementError = context.configuring ? context.entryError('field', key) : null;
418
+
419
+ return html`
420
+ <div class="sf-property-view-field flex flex-col gap-1 min-w-0 ${field.width === 'full' ? 'sf-property-view-field--full' : ''}"
421
+ data-field=${key}
422
+ data-sf-sortable-item=${context.configuring ? key : nothing}
423
+ data-arranging=${context.configuring ? 'true' : nothing}
424
+ data-hidden=${context.configuring && field.hidden ? 'true' : nothing}>
425
+ ${context.configuring
426
+ ? html`<i class="sf-icon sf-property-view-grip items-cross-center" data-sf-sortable-handle
427
+ aria-hidden="true">drag_indicator</i>`
428
+ : nothing}
429
+ <div class="sf-property-view-field-label flex items-cross-center gap-1/3 color-on-surface-variant">
430
+ ${String(field.label ?? key)}
431
+ ${field.required && editing
432
+ ? html`<span class="sf-property-view-required color-error" aria-hidden="true">*</span>`
433
+ : nothing}
434
+ ${field.hint
435
+ // The hint is the Framework's own affordance for a hint,
436
+ // sf-help-mark: a button that opens a short explanation on
437
+ // a press, by touch, by pointer and by keyboard alike, and
438
+ // announces it politely. It was a glyph carrying the words
439
+ // in a native `title`, which needs a hover -- so on a touch
440
+ // device the hint could not be read at all (Larena storage
441
+ // QA, 2026-10-22). The accessible name stays the hint
442
+ // itself, as it was on the glyph's aria-label: `label` names
443
+ // the button, `text` is the explanation it opens.
444
+ ? html`<sf-help-mark class="sf-property-view-hint"
445
+ label=${String(field.hint)}
446
+ text=${String(field.hint)}></sf-help-mark>`
447
+ : nothing}
448
+ ${field.rare ? html`<span class="sf-property-view-rare color-on-surface-variant">${context.rareText}</span>` : nothing}
449
+ ${field.inherited && !editing
450
+ ? html`<span class="sf-property-view-inherited color-on-surface-variant">${context.inheritedText}</span>`
451
+ : nothing}
452
+ </div>
453
+ ${editing || context.configuring || field.readonly === true || !context.mayEdit || context.openAll
454
+ ? html`<div class="sf-property-view-field-value min-w-0 flex items-cross-center gap-2 flex-wrap">${renderFieldValue(context, field, editing, {disabled})}</div>`
455
+ : html`
456
+ <button
457
+ type="button"
458
+ class="sf-property-view-field-value sf-property-view-field-open min-w-0 flex items-cross-center gap-1 text-start cursor-pointer"
459
+ data-open-group=${groupKey}
460
+ title=${context.editText}
461
+ @click=${(event) => {
462
+ // A control inside the value -- a file's download
463
+ // button, a URL, an address -- does its own thing
464
+ // and nothing more: downloading a stored file also
465
+ // opened the group for editing (simai.main,
466
+ // 2026-10-28). sf-file-preview's download is a
467
+ // button, not a link, so every control between the
468
+ // press and this button counts.
469
+ const path = event.composedPath();
470
+ const inner = path.slice(0, Math.max(0, path.indexOf(event.currentTarget)));
471
+ if (inner.some((node) => node.matches?.(INNER_CONTROL))) return;
472
+ context.component.editGroup(sectionKey, groupKey);
473
+ }}
474
+ >
475
+ ${renderFieldValue(context, field, editing)}
476
+ <i class="sf-icon sf-icon--size-1/2 sf-property-view-pen" aria-hidden="true">edit</i>
477
+ </button>`}
478
+ ${context.configuring ? renderFieldActions(context, field, key) : nothing}
479
+ ${error || arrangementError
480
+ ? html`<div class="sf-property-view-field-error color-error" role="alert">${error || arrangementError}</div>`
481
+ : nothing}
482
+ </div>
483
+ `;
484
+ }
485
+
486
+ // A group of related records: the component composes a data view in it and the
487
+ // view asks the host for its own port. The records never come through the
488
+ // property-view seam -- rows, columns, sorting and paging are the data-view
489
+ // port's vocabulary, and relaying them through this one would duplicate all of it.
490
+ //
491
+ // `chrome=""` leaves out the search box and the page row: the list stands inside a
492
+ // group that already has a heading, and a page row under four rows of a capped
493
+ // list would offer paging through records the cap is not showing anyway.
494
+ //
495
+ // The table is slotted in, which is how a data view is composed -- its own page
496
+ // markup slots one too. An earlier version left it out and relied on the fallback
497
+ // inside the data view's content slot instead, to satisfy a fence that said a
498
+ // component rendering another one has to fetch it. That traded one defect for a
499
+ // worse one: fallback content lives in the shadow tree, where the Framework's
500
+ // page-level stylesheets do not reach, so a consumer got a list whose sort buttons
501
+ // read «unfold_more» as text and whose reference cells read «arrow_forward». The
502
+ // fence was right and the clever fix was wrong: this component does render a table,
503
+ // so it fetches cl-view-table.
504
+ function renderRelatedList(context, group, groupKey) {
505
+ return html`
506
+ <sf-view
507
+ class="sf-property-view-related"
508
+ chrome=""
509
+ row-cap=${String(group.rowCap ?? 5)}
510
+ port-owner="property-view"
511
+ port-record=${context.recordId || ''}
512
+ port-group=${groupKey}
513
+ aria-label=${String(group.label ?? groupKey)}
514
+ >
515
+ <sf-view-table
516
+ slot="content"
517
+ toolbar="false"
518
+ aria-label=${String(group.label ?? groupKey)}
519
+ ></sf-view-table>
520
+ </sf-view>
521
+ `;
522
+ }
523
+
524
+ // Placing a field in this group without dragging it. Dragging is the gesture the
525
+ // mockup asks for and it stays, but it is a poor way to reach a field that is
526
+ // three screens away, and no way at all without a pointer -- and a collection
527
+ // can have hundreds of fields, which no list anybody scrolls will answer.
528
+ //
529
+ // So: a search and a set of ticks. What is ticked is what this area shows, which
530
+ // makes unticking the way a field leaves it, and each row says where the field
531
+ // is now, because a field lives in one place and taking it here takes it from
532
+ // there. The surface is anchored by hand because sf-dropdown brings its own
533
+ // trigger and the trigger here is the «+».
534
+ function renderAddField(context, groupKey) {
535
+ const options = context.fieldChoices(groupKey).map((option) => ({
536
+ type: 'checkbox',
537
+ size: '1/2',
538
+ text: option.label,
539
+ value: option.key,
540
+ group: option.group,
541
+ selected: option.selected,
542
+ checked: option.selected,
543
+ ariaLabel: option.label,
544
+ }));
545
+ const placed = options.filter((option) => option.selected).map((option) => option.value);
546
+
547
+ // The list is sf-dropdown's: it carries the search, the groups, the
548
+ // multiple selection and the surface, and takes the «+» as its trigger.
549
+ // What is acted on is the option the change names, not the whole value the
550
+ // dropdown now holds: the component moves a field rather than setting a
551
+ // list, and reading the whole list as the new truth once took five fields
552
+ // out of an area because one was ticked.
553
+ return html`
554
+ <sf-dropdown
555
+ class="sf-property-view-add"
556
+ size="1/2"
557
+ multiple
558
+ search="true"
559
+ search-placeholder=${context.addFieldText}
560
+ aria-label=${context.addFieldText}
561
+ data-add-for=${groupKey}
562
+ value=${placed.join(',')}
563
+ .options=${options}
564
+ @change=${(event) => {
565
+ const key = String(event.detail?.value ?? '');
566
+ if (!key) return;
567
+ const values = Array.isArray(event.detail?.values)
568
+ ? event.detail.values.map(String)
569
+ : [];
570
+ context.component.toggleFieldInGroup(key, groupKey, values.includes(key));
571
+ }}
572
+ >
573
+ <sf-button-icon
574
+ slot="trigger"
575
+ size="1/2"
576
+ appearance="link"
577
+ scheme="secondary"
578
+ icon="add"
579
+ data-action="add-field"
580
+ title=${context.addFieldText}
581
+ aria-label=${context.addFieldText}
582
+ ></sf-button-icon>
583
+ </sf-dropdown>
584
+ `;
585
+ }
586
+
587
+ function renderGroup(context, section, group) {
588
+ const groupKey = String(group.key ?? '');
589
+ // On a surface open all at once (edit, create) every group is open; a
590
+ // group the host keeps closed stays shown.
591
+ const editing = (context.openAll && group.kind !== 'related' && group.editable !== false)
592
+ || (context.editing?.group === groupKey
593
+ && context.editing?.section === String(section.key ?? ''));
594
+ const fields = listed(context, group.fields);
595
+ const configuring = context.configuring;
596
+ const groupError = configuring ? context.entryError('group', groupKey) : null;
597
+ const groupLocked = configuring && !context.canChangeVisibility(group);
598
+ // The field list is a sortable list and nothing more: the drop is reported by
599
+ // sf-sortable and applied or refused by the component, which is the seam that
600
+ // mechanism already publishes. Its id carries the kind and the container, so
601
+ // one listener can tell a field dropped into a group from a group dropped into
602
+ // a section.
603
+ // A group read by itself (port 1.15.0, group-load) and not read yet holds
604
+ // no fields but says how many: it is drawn as that many skeleton lines, and
605
+ // busy, until its read answers -- never as an empty group.
606
+ const waiting = !Array.isArray(group.fields) || group.fields.length === 0
607
+ ? Number.isInteger(group.field_count) && group.field_count > 0 ? group.field_count : 0
608
+ : 0;
609
+ const reading = waiting > 0 || Boolean(context.component.isGroupReading?.(groupKey));
610
+ const rows = waiting > 0
611
+ ? html`${Array.from({length: waiting}, (_, at) => html`<div class="sf-skeleton sf-skeleton--animation-pulse" aria-hidden="true">
612
+ <div class="sf-skeleton-text sf-skeleton-text--size-1 ${SKELETON_WIDTHS[at % SKELETON_WIDTHS.length]}"></div></div>`)}`
613
+ : html`${fields.map((field) => renderField(context, field, editing, String(section.key ?? ''), groupKey))}`;
614
+
615
+ return html`
616
+ <section class="sf-property-view-group bg-surface-0 flex flex-col gap-2 ${editing ? 'sf-property-view-group--editing' : ''}"
617
+ data-group=${groupKey}
618
+ aria-busy=${reading ? 'true' : nothing}
619
+ data-sf-sortable-item=${configuring ? groupKey : nothing}
620
+ data-hidden=${configuring && group.hidden ? 'true' : nothing}
621
+ data-columns=${group.columns === 2 ? '2' : '1'}>
622
+ <header class="sf-property-view-group-header flex items-cross-center justify-between gap-2">
623
+ ${configuring
624
+ ? html`<i class="sf-icon sf-property-view-grip items-cross-center" data-sf-sortable-handle
625
+ aria-hidden="true">drag_indicator</i>`
626
+ : nothing}
627
+ ${renderEntryName(context, 'group', groupKey, String(group.label ?? groupKey),
628
+ 'sf-property-view-group-title')}
629
+ ${group.rare ? html`<span class="sf-property-view-rare color-on-surface-variant">${context.rareText}</span>` : nothing}
630
+ ${configuring
631
+ ? html`
632
+ <span class="flex items-cross-center gap-1/4">
633
+ ${renderAddField(context, groupKey)}
634
+ <sf-button-icon
635
+ size="1/2"
636
+ appearance="link"
637
+ scheme="secondary"
638
+ icon="keyboard_arrow_up"
639
+ data-action="group-up"
640
+ ?disabled=${context.groupAt(groupKey) === 0}
641
+ title=${context.moveUpText}
642
+ aria-label=${context.moveUpText}
643
+ @click=${() => context.component.moveGroupBy(groupKey, -1)}
644
+ ></sf-button-icon>
645
+ <sf-button-icon
646
+ size="1/2"
647
+ appearance="link"
648
+ scheme="secondary"
649
+ icon="keyboard_arrow_down"
650
+ data-action="group-down"
651
+ ?disabled=${context.groupAt(groupKey) === context.groupCount - 1}
652
+ title=${context.moveDownText}
653
+ aria-label=${context.moveDownText}
654
+ @click=${() => context.component.moveGroupBy(groupKey, 1)}
655
+ ></sf-button-icon>
656
+ <sf-button-icon
657
+ size="1/2"
658
+ appearance="link"
659
+ scheme="secondary"
660
+ icon=${group.hidden ? 'visibility_off' : 'delete'}
661
+ data-action="remove-group"
662
+ ?disabled=${groupLocked}
663
+ title=${groupLocked ? (context.layerDenied('shared') || context.hiddenText) : context.removeGroupText}
664
+ aria-label=${context.removeGroupText}
665
+ @click=${() => context.component.removeGroup(groupKey)}
666
+ ></sf-button-icon>
667
+ </span>`
668
+ : nothing}
669
+ ${!configuring && !editing && context.mayEdit && !context.openAll && group.kind !== 'related' && group.editable !== false
670
+ ? html`
671
+ <sf-button
672
+ appearance="link"
673
+ size=${context.size}
674
+ text=${context.editText}
675
+ data-action="edit"
676
+ @click=${() => context.component.editGroup(String(section.key ?? ''), groupKey)}
677
+ ></sf-button>`
678
+ : nothing}
679
+ </header>
680
+ ${groupError
681
+ ? html`<div class="sf-property-view-group-error" role="alert">${groupError}</div>`
682
+ : nothing}
683
+ ${group.kind === 'related'
684
+ ? html`<div class="sf-property-view-group-related">${renderRelatedList(context, group, groupKey)}</div>`
685
+ : configuring
686
+ ? html`
687
+ <sf-sortable
688
+ class="sf-property-view-group-fields min-w-0"
689
+ group="field"
690
+ accepts="field"
691
+ id=${`field:${groupKey}`}
692
+ label=${String(group.label ?? groupKey)}
693
+ >${rows}</sf-sortable>`
694
+ : html`<div class="sf-property-view-group-fields min-w-0">${rows}</div>`}
695
+ </section>
696
+ `;
697
+ }
698
+
699
+ // The phone's first screen: the sections as a list, each one leading to its own
700
+ // screen. Drawn only when there is more than one, for the same reason the desktop
701
+ // list is: a list of one is a control nobody can use, and it would put a screen
702
+ // between a person and the only groups there are.
703
+ function renderPhoneSectionList(context, sections) {
704
+ return html`
705
+ <nav class="sf-property-view-phone-sections flex flex-col" aria-label=${context.sectionsLabel}>
706
+ ${sections.map((section) => html`
707
+ <button
708
+ type="button"
709
+ class="sf-property-view-phone-section flex items-cross-center justify-between gap-1"
710
+ data-phone-section=${String(section.key)}
711
+ @click=${() => context.component.openPhoneSection(String(section.key))}
712
+ >
713
+ <span class="sf-property-view-phone-section-label">${String(section.label ?? section.key)}</span>
714
+ <i class="sf-icon" aria-hidden="true">chevron_right</i>
715
+ </button>
716
+ `)}
717
+ </nav>
718
+ `;
719
+ }
720
+
721
+ // The second screen's head: back to the list, and the name of where you are.
722
+ function renderPhoneSectionHead(context) {
723
+ return html`
724
+ <div class="sf-property-view-phone-head flex items-cross-center gap-2">
725
+ <sf-button-icon
726
+ size=${context.size}
727
+ appearance="link"
728
+ icon="arrow_back"
729
+ data-action="phone-back"
730
+ aria-label=${context.backLabel}
731
+ @click=${() => context.component.closePhoneSection()}
732
+ ></sf-button-icon>
733
+ <h2 class="sf-property-view-phone-title">${context.phoneSectionLabel}</h2>
734
+ </div>
735
+ `;
736
+ }
737
+
738
+ function renderSectionList(context, sections) {
739
+ // One section is drawn as its groups alone: a list of one is a control nobody
740
+ // can use, and the consumer's request asks for the groups to stand alone.
741
+ if (sections.length < 2 && !context.configuring) return nothing;
742
+
743
+ return html`
744
+ <nav class="sf-property-view-sections self-cross-stretch flex flex-col min-w-0" aria-label=${context.sectionsLabel}>
745
+ ${context.configuring
746
+ ? html`
747
+ ${renderSectionTabsArranged(context, sections)}
748
+ <div class="sf-property-view-additions flex">${renderAddSection(context)}</div>`
749
+ : renderSectionTree(context, sections)}
750
+ </nav>
751
+ `;
752
+ }
753
+
754
+ // Reading: the sections are a tree, which carries the keyboard walk and the
755
+ // selection a list of destinations needs.
756
+ //
757
+ // The items are given as data rather than as children: sf-tree reads its child
758
+ // items once and removes them, so a section renamed while the view is being
759
+ // arranged kept its old name in this list.
760
+ function renderSectionTree(context, sections) {
761
+ const items = sections.map((section) => ({
762
+ label: sectionName(context, section),
763
+ value: String(section.key),
764
+ selected: String(section.key) === context.selectedSection,
765
+ openIcon: section.icon ? String(section.icon) : '',
766
+ closedIcon: section.icon ? String(section.icon) : '',
767
+ }));
768
+
769
+ // selection "one": sf-tree 2.0's name for a single choice; its manifest
770
+ // declares it since sf-tree 2.0.1 ("single" is a former value until
771
+ // 2026-12-01).
772
+ return html`
773
+ <sf-tree
774
+ selection="one"
775
+ .items=${items}
776
+ @sf-tree-select=${(event) => {
777
+ // sf-tree 2.0 (standard 1.3.8 §8): select carries the entry's
778
+ // id, which is the section key given as its value.
779
+ const id = event.detail?.id;
780
+ if (id) context.component.selectSection(String(id));
781
+ }}
782
+ ></sf-tree>
783
+ `;
784
+ }
785
+
786
+ // Arranging: the same tabs, in an order a drag can change, and each one still a
787
+ // place to drop a field or an area. A tree cannot be reordered by a pointer --
788
+ // it takes its items once and owns their rows -- so while the view is arranged
789
+ // the tabs are rows of this component's own, which is also what lets a tab carry
790
+ // a handle.
791
+ function renderSectionTabsArranged(context, sections) {
792
+ return html`
793
+ <sf-sortable
794
+ class="sf-property-view-section-tabs flex flex-col"
795
+ group="section"
796
+ accepts="section"
797
+ id="section:all"
798
+ label=${context.sectionsLabel}
799
+ >
800
+ ${sections.map((section) => html`
801
+ <button
802
+ type="button"
803
+ class="sf-property-view-section-tab flex items-cross-center gap-1/3 min-w-0 text-start cursor-pointer"
804
+ data-section-tab=${String(section.key)}
805
+ data-sf-sortable-item=${String(section.key)}
806
+ aria-current=${String(section.key) === context.selectedSection ? 'true' : nothing}
807
+ @click=${() => context.component.selectSection(String(section.key))}
808
+ >
809
+ <i class="sf-icon sf-property-view-section-grip color-on-surface-variant" data-sf-sortable-handle
810
+ aria-hidden="true">drag_indicator</i>
811
+ <i class="sf-icon" aria-hidden="true">${section.icon ? String(section.icon) : 'folder'}</i>
812
+ <span class="sf-property-view-section-tab-label flex-auto min-w-0">${sectionName(context, section)}</span>
813
+ </button>
814
+ `)}
815
+ </sf-sortable>
816
+ `;
817
+ }
818
+
819
+ // A rare section says so in its own name. A tree row takes a label string and
820
+ // draws it as text -- markup put into sf-tree-item once printed lit's own
821
+ // markers into the list -- so a trailing mark of its own is a gap in that
822
+ // component rather than something to work around here.
823
+ function sectionName(context, section) {
824
+ const label = String(section.label ?? section.key);
825
+ return section.rare ? `${label} · ${context.rareText}` : label;
826
+ }
827
+
828
+ // The bar at the foot of the panel: what is being done to the record right now,
829
+ // and what can be done to the record itself. Arranging the view is neither, so
830
+ // while the view is being arranged there is no bar -- its own band carries that
831
+ // mode's actions, and two bars at once was the state the owner saw.
832
+ //
833
+ // The count of changed fields is gone. It named a number nobody acts on, while
834
+ // the two controls that matter stood after it; what is still said is what stops
835
+ // a save -- errors and a conflict.
836
+ // Sections as a row of tabs rather than a list beside the fields. The same
837
+ // sections either way -- what changes is where they are read, not what they are
838
+ // -- so a section is still selected the same way and a group still moves to one
839
+ // by being dropped on it.
840
+ // sf-tabs reads its set from an attribute, through a parser that splits on `|`
841
+ // and reads `::` as flags; it has no declarative way to take richer items, so a
842
+ // section's icon cannot travel with it here. A label carrying either sequence
843
+ // would be read as structure, so it is spelled out of the way.
844
+ function tabLabel(context, section) {
845
+ return sectionName(context, section).replace(/\|/gu, '\u2502').replace(/::/gu, ':\u2236');
846
+ }
847
+
848
+ // The tab row only. The groups are drawn under it by the layout, as they are
849
+ // beside the list, so a section's content is in one place whichever way the
850
+ // sections are read. Slotting empty panels in here drew empty tabs: a slot for
851
+ // a tab replaces its label.
852
+ function renderSectionTabs(context, sections) {
853
+ const at = Math.max(0, sections.findIndex((section) => String(section.key) === context.selectedSection));
854
+ return html`
855
+ <sf-tabs
856
+ class="sf-property-view-tabs"
857
+ size=${context.size}
858
+ active-index=${String(at)}
859
+ aria-label=${context.sectionsLabel}
860
+ items=${sections.map((section) => tabLabel(context, section)).join('|')}
861
+ @sf-tabs-change=${(event) => {
862
+ const section = sections[Number(event.detail?.current)];
863
+ if (section) context.component.selectSection(String(section.key));
864
+ }}
865
+ ></sf-tabs>
866
+ `;
867
+ }
868
+
869
+ // The type of a view at 1/2: a step down from 1 in every role, so the record
870
+ // reads at the table's size. Variables rather than a class: the panel is drawn
871
+ // in a drawer moved to the page's end, outside the element.
872
+ function sizeStyle(context) {
873
+ if (context.size !== '1/2') return '';
874
+ return ' --sf-property-view--title-size: var(--sf-heading-5--size); --sf-property-view--title-height: var(--sf-heading-5--height);'
875
+ + ' --sf-property-view--group-size: var(--sf-text-medium--size); --sf-property-view--group-height: var(--sf-text-medium--height);'
876
+ + ' --sf-property-view--label-size: var(--sf-label-medium--size); --sf-property-view--label-height: var(--sf-label-medium--height);'
877
+ + ' --sf-property-view--value-size: var(--sf-text-small--size); --sf-property-view--value-height: var(--sf-text-small--height);';
878
+ }
879
+
880
+ // A save the host makes only once confirmed (port 1.14.0): its words, and the
881
+ // two answers, in the bar where save was pressed -- the same place and the same
882
+ // arrangement as the view's reset question. Going back sends nothing and keeps
883
+ // what was entered.
884
+ function renderConfirmation(context) {
885
+ return html`
886
+ <div class="sf-property-view-actions flex items-cross-center gap-2 flex-wrap" role="group"
887
+ data-confirming="true">
888
+ <span class="sf-property-view-confirm-text" role="alert">${context.confirmation.text}</span>
889
+ <span class="grow"></span>
890
+ <sf-button
891
+ appearance="default"
892
+ size=${context.size}
893
+ text=${context.confirmSaveText}
894
+ data-action="confirm-save"
895
+ @click=${() => context.component.confirmSave()}
896
+ ></sf-button>
897
+ <sf-button
898
+ appearance="outline"
899
+ size=${context.size}
900
+ text=${context.keepEditingText}
901
+ data-action="keep-editing"
902
+ @click=${() => context.component.cancelConfirmation()}
903
+ ></sf-button>
904
+ </div>
905
+ `;
906
+ }
907
+
908
+ // The foot of a surface open all at once: Create or Save, and Cancel, with
909
+ // what went wrong beside them.
910
+ function renderOpenAllBar(context) {
911
+ if (context.confirmation) return renderConfirmation(context);
912
+ const creating = context.mode === 'create';
913
+ return html`
914
+ <div class="sf-property-view-actions flex items-cross-center gap-2 flex-wrap" role="group">
915
+ <sf-button
916
+ appearance="default"
917
+ size=${context.size}
918
+ text=${creating ? context.createText : context.saveText}
919
+ data-action=${creating ? 'create' : 'save'}
920
+ ?disabled=${(!creating && context.changed.size === 0) || context.inputsInvalid}
921
+ @click=${() => context.component.saveAll()}
922
+ ></sf-button>
923
+ <sf-button
924
+ appearance="outline"
925
+ size=${context.size}
926
+ text=${context.cancelText}
927
+ data-action="cancel"
928
+ @click=${() => context.component.cancelEdit()}
929
+ ></sf-button>
930
+ ${context.errors.size
931
+ ? html`<span class="sf-property-view-errors color-error">${context.errorsText}: ${context.errors.size}</span>`
932
+ : nothing}
933
+ ${context.conflict
934
+ ? html`<span class="sf-property-view-conflict color-error" role="alert">${context.conflict}</span>`
935
+ : nothing}
936
+ </div>
937
+ `;
938
+ }
939
+
940
+ function renderActions(context, {panel = false} = {}) {
941
+ // A surface placed to show the object has no footer of its own (owner,
942
+ // 2026-10-14): a reader sees values, not controls that would refuse.
943
+ if (context.mode === 'view') return nothing;
944
+ if (context.openAll) return renderOpenAllBar(context);
945
+ const removable = context.mode !== 'create' && context.recordActions.includes('record.delete');
946
+ // While the view is arranged the foot carries that mode's controls instead:
947
+ // saving a record and arranging the view it is read in are two modes, and a
948
+ // bar holding both would offer a save for something nobody was editing.
949
+ if (context.configuring) return renderConfigureBar(context);
950
+ if (context.editing && context.confirmation) return renderConfirmation(context);
951
+ // A panel's foot holds every action on the record -- the host's own, slotted
952
+ // as `footer`, and the delete -- in one bar under the fields (owner,
953
+ // 2026-10-14). They were drawn in the scrolling body, the delete as one more
954
+ // section of it.
955
+ const hosted = panel && context.hasHostSlot('footer');
956
+ if (!context.editing && !removable && !hosted) return nothing;
957
+
958
+ return html`
959
+ <div class="sf-property-view-actions flex items-cross-center gap-2 flex-wrap" role="group">
960
+ ${context.editing
961
+ ? html`
962
+ <sf-button
963
+ appearance="default"
964
+ size=${context.size}
965
+ text=${context.saveText}
966
+ data-action="save"
967
+ ?disabled=${context.changed.size === 0 || context.inputsInvalid}
968
+ @click=${() => context.component.saveGroup()}
969
+ ></sf-button>
970
+ <sf-button
971
+ appearance="outline"
972
+ size=${context.size}
973
+ text=${context.cancelText}
974
+ data-action="cancel"
975
+ @click=${() => context.component.cancelEdit()}
976
+ ></sf-button>`
977
+ : nothing}
978
+ ${context.errors.size
979
+ ? html`<span class="sf-property-view-errors color-error">${context.errorsText}: ${context.errors.size}</span>`
980
+ : nothing}
981
+ ${context.conflict
982
+ ? html`<span class="sf-property-view-conflict color-error" role="alert">${context.conflict}</span>`
983
+ : nothing}
984
+ <span class="grow"></span>
985
+ ${hosted ? html`<div class="sf-property-view-host-footer flex flex-wrap items-cross-center gap-2">${context.hostSlot('footer')}</div>` : nothing}
986
+ ${removable
987
+ ? html`
988
+ <sf-button-icon
989
+ appearance="link"
990
+ status="error"
991
+ size=${context.size}
992
+ icon="delete"
993
+ data-action="delete"
994
+ title=${context.deleteText}
995
+ aria-label=${context.deleteText}
996
+ @click=${() => context.component.deleteRecord()}
997
+ ></sf-button-icon>`
998
+ : nothing}
999
+ </div>
1000
+ `;
1001
+ }
1002
+
1003
+ // An unplaced field is being arranged, not read: it shows its name and a handle
1004
+ // and nothing else. Drawn like a placed one it carried its value, so the area
1005
+ // read as a group of fields that happen to be last rather than as the fields
1006
+ // this view does not show.
1007
+ function renderUnplacedField(context, field) {
1008
+ const key = String(field.key ?? '');
1009
+ return html`
1010
+ <div class="sf-property-view-field sf-property-view-field--unplaced flex flex-col gap-1 min-w-0"
1011
+ data-field=${key}
1012
+ data-sf-sortable-item=${key}
1013
+ data-arranging="true">
1014
+ <i class="sf-icon sf-property-view-grip items-cross-center" data-sf-sortable-handle aria-hidden="true">drag_indicator</i>
1015
+ <div class="sf-property-view-field-label flex items-cross-center gap-1/3 color-on-surface-variant">
1016
+ ${String(field.label ?? key)}
1017
+ ${field.rare ? html`<span class="sf-property-view-rare color-on-surface-variant">${context.rareText}</span>` : nothing}
1018
+ </div>
1019
+ </div>
1020
+ `;
1021
+ }
1022
+
1023
+ // Fields the view places nowhere. Drawn only while the view is being arranged,
1024
+ // because they are not part of what a person reads -- and drawn even when there
1025
+ // are none, so that dragging a field out of every group has somewhere to land
1026
+ // and a person can see that taking a field out does not destroy it.
1027
+ function renderUnplaced(context) {
1028
+ if (!context.configuring) return nothing;
1029
+
1030
+ const fields = context.unplaced;
1031
+ // The Framework's own accordion rather than a fold built here: the owner
1032
+ // asked for it by name, and a second thing that opens and closes is a
1033
+ // second thing to keep looking right (owner, 2026-10-09).
1034
+ //
1035
+ // The accordion owns whether it is open -- its script claims any
1036
+ // .sf-accordion the document gains and remembers the state -- so the panel
1037
+ // does not bind it. What the panel does instead is press the trigger when a
1038
+ // drag starts, the way a person would, and press it back when the drag
1039
+ // ends. One controller, and a drop target that is visible exactly when
1040
+ // something is in the air.
1041
+ //
1042
+ // The hint paragraph is gone: it described in two lines what the «+» beside
1043
+ // every area does, under a heading that already says what the bin is.
1044
+ return html`
1045
+ <article
1046
+ class="sf-accordion sf-accordion--outline sf-property-view-group--unplaced"
1047
+ data-unplaced="true"
1048
+ >
1049
+ <h3 class="sf-accordion-heading">
1050
+ <button
1051
+ type="button"
1052
+ class="sf-accordion-trigger"
1053
+ data-action="toggle-unplaced"
1054
+ aria-expanded="false"
1055
+ >
1056
+ <span class="sf-accordion-title flex items-cross-center gap-1/3">
1057
+ ${context.unplacedTitle}
1058
+ ${fields.length
1059
+ ? html`<sf-tag
1060
+ kind="default"
1061
+ size="1/3"
1062
+ appearance="surface"
1063
+ text=${String(fields.length)}
1064
+ ></sf-tag>`
1065
+ : nothing}
1066
+ </span>
1067
+ <sf-icon icon="chevron_right" size="1/2"></sf-icon>
1068
+ </button>
1069
+ </h3>
1070
+ <div class="sf-accordion-content" role="region" hidden>
1071
+ <sf-sortable
1072
+ class="sf-property-view-group-fields sf-property-view-unplaced"
1073
+ group="field"
1074
+ accepts="field"
1075
+ id=${`unplaced:${context.recordId || 'record'}`}
1076
+ label=${context.unplacedTitle}
1077
+ >${fields.map((field) => renderUnplacedField(context, field))}</sf-sortable>
1078
+ </div>
1079
+ </article>
1080
+ `;
1081
+ }
1082
+
1083
+ // The three controls a panel carries, and none of them invents anything. Close is
1084
+ // the component's own business; copying and opening need an address, and the
1085
+ // address is the host's -- a composite that constructed a URL would be guessing at
1086
+ // the consumer's routing and would be wrong in the first consumer whose routing
1087
+ // did not match the guess. No address, no two controls.
1088
+ // The panel's head, as the mockup draws it: the collection on its own line above,
1089
+ // then the record's name with its favourite star, the host's own actions, and a
1090
+ // «⋯» for what belongs to the view rather than to the record. One block, so there
1091
+ // are no empty bands between the title and the first card -- the view this replaces
1092
+ // had two of about sixty pixels, which is what the owner saw first.
1093
+ // What belongs to the view rather than to the record: arranging it, resetting it
1094
+ // and whether the rare entries are shown. It is a menu because there is more than
1095
+ // one of them; pressing «⋯» used to run the first item straight away, so the
1096
+ // second never had anywhere to be.
1097
+ function renderMenuControl(context) {
1098
+ const items = context.panelMenuItems;
1099
+ const host = context.hasHostSlot('menu') ? context.hostSlot('menu') : null;
1100
+ // The views are chosen from this menu too, so more than one of them is a
1101
+ // reason for it to stand even with nothing else in it.
1102
+ const views = !context.configuring && (context.viewProfiles?.length ?? 0) > 1;
1103
+ if (!items.length && !host && !views) return nothing;
1104
+
1105
+ // Always the menu, never the one item drawn in its place. The place is the
1106
+ // panel's own: what belongs in it depends on the task, and a consumer that
1107
+ // slots its items into `menu` finds the same «\u2026» wherever it looks --
1108
+ // which it could not when one item meant a different glyph in that corner
1109
+ // (owner, 2026-10-08).
1110
+ // The «⋯» opens the Framework's standard menu (owner, 2026-10-12), and so
1111
+ // do the items a host slots into `menu` (owner, 2026-10-14): they were a
1112
+ // popover of link buttons, the one menu of the panel drawn otherwise. The
1113
+ // slotted elements stay in the page, hidden, and a row of the menu stands
1114
+ // for each -- a link row for a link, a press on the element for the rest.
1115
+ return html`
1116
+ <sf-button-icon
1117
+ appearance="link"
1118
+ scheme="on-surface"
1119
+ size=${context.size}
1120
+ icon="more_horiz"
1121
+ data-action="menu"
1122
+ aria-haspopup="menu"
1123
+ aria-expanded="false"
1124
+ aria-label=${context.menuLabel}
1125
+ @click=${(event) => context.component.openPanelMenu(event.currentTarget.querySelector('button') ?? event.currentTarget)}
1126
+ ></sf-button-icon>
1127
+ ${host ? html`<div class="sf-property-view-host-menu" data-host-menu hidden>${host}</div>` : nothing}
1128
+ `;
1129
+ }
1130
+
1131
+ function renderPanelHead(context) {
1132
+ return html`
1133
+ <header class="sf-property-view-head flex flex-col gap-1">
1134
+ <div class="sf-property-view-title-row flex items-cross-center gap-1 min-w-0">
1135
+ <h2 class="sf-property-view-title min-w-0 overflow-hidden">${context.panelLabel || context.recordLabel || context.recordId || ''}</h2>
1136
+ <!-- One glyph, filled when the record is a favourite and outlined
1137
+ when it is not: a star that changes shape as well as fill
1138
+ says two things where one will do (owner, 2026-10-08). The
1139
+ press raises the component's own event; whether the record
1140
+ is a favourite stays the host's to decide and to answer.
1141
+ A record being created is not one yet and cannot be a
1142
+ favourite: no star until it has an id (Larena storage
1143
+ review, 2026-10-28). -->
1144
+ ${context.recordId && context.mode !== 'create' ? html`<sf-button-icon
1145
+ appearance="link"
1146
+ size=${context.size}
1147
+ icon="star"
1148
+ ?filled=${context.favourite}
1149
+ data-action="favourite"
1150
+ aria-pressed=${context.favourite ? 'true' : 'false'}
1151
+ aria-label=${context.favouriteLabel}
1152
+ @click=${() => context.component.toggleFavourite()}
1153
+ ></sf-button-icon>` : nothing}
1154
+ <span class="grow"></span>
1155
+ ${context.hasHostSlot('actions') ? context.hostSlot('actions') : nothing}
1156
+ ${renderMenuControl(context)}
1157
+ </div>
1158
+ </header>
1159
+ `;
1160
+ }
1161
+
1162
+ // The edge controls stand on a raised surface, elevation 3 as a menu does: its
1163
+ // own fill and shadow, and the text role that goes with it. On --sf-surface-0
1164
+ // they were the page's darkest colour on a dimmed page in the dark theme and
1165
+ // could hardly be found (owner, 2026-10-13). Inline, because the button's own
1166
+ // appearance rule outranks any selector this sheet could write.
1167
+ const EDGE_SURFACE = '--sf-icon-button--background-color: var(--sf-elevation-3--surface); --sf-on-surface-variant: var(--sf-on-elevation-3--variant); box-shadow: var(--sf-elevation-3--shadow)';
1168
+
1169
+ // Round, and on the panel's outer edge rather than inside its head: in a stack
1170
+ // each panel keeps its own set visible past the one above it, which is what the
1171
+ // mockup shows and what a row inside the head cannot do.
1172
+ //
1173
+ // A panel filling the screen has no outside, and the drawer brings the strip in
1174
+ // along the top of it, at its inline start. There the controls stand in a row,
1175
+ // as the drawer lays its own edge out, and the head keeps their room
1176
+ // (panelFills): a column there stood over the record's name and the first rows
1177
+ // on a phone (docs review, round 4, 2026-10-20).
1178
+ function panelFills(context) {
1179
+ return !context.wide || context.panelSize === 'full';
1180
+ }
1181
+
1182
+ function renderPanelControls(context) {
1183
+ return html`
1184
+ <div class="sf-property-view-edge flex ${panelFills(context) ? 'flex-row' : 'flex-col'} gap-1/2" slot="edge"
1185
+ aria-label=${context.closeLabel}>
1186
+ <sf-button-icon
1187
+ appearance="tonal"
1188
+ scheme="on-surface"
1189
+ root-style=${EDGE_SURFACE}
1190
+ size=${context.size}
1191
+ icon="close"
1192
+ data-action="close"
1193
+ aria-label=${context.closeLabel}
1194
+ title=${context.closeLabel}
1195
+ @click=${() => context.component.closePanel('close')}
1196
+ ></sf-button-icon>
1197
+ ${context.address
1198
+ ? html`
1199
+ <sf-button-copy
1200
+ appearance="tonal"
1201
+ root-style=${EDGE_SURFACE}
1202
+ size=${context.size}
1203
+ icon="link"
1204
+ value=${context.addressUrl}
1205
+ label=${context.copyLinkLabel}
1206
+ success-text=${context.linkCopiedLabel}
1207
+ duration=${String(context.copyFeedbackMs)}
1208
+ data-action="copy-link"
1209
+ .propertyView=${context.component}
1210
+ @sf-copy-button-copied=${() => context.component.announceAddressCopy(true)}
1211
+ @sf-copy-button-copy-failed=${() => context.component.announceAddressCopy(false)}
1212
+ ></sf-button-copy>`
1213
+ : nothing}
1214
+ ${context.canOpenFullPage
1215
+ ? html`
1216
+ <sf-button-icon
1217
+ appearance="tonal"
1218
+ scheme="on-surface"
1219
+ root-style=${EDGE_SURFACE}
1220
+ size=${context.size}
1221
+ icon="open_in_full"
1222
+ aria-label=${context.openFullLabel}
1223
+ title=${context.openFullLabel}
1224
+ data-action="open-full"
1225
+ @click=${() => context.component.openFullPage()}
1226
+ ></sf-button-icon>`
1227
+ : nothing}
1228
+ </div>
1229
+ `;
1230
+ }
1231
+
1232
+ // The phone's list screen: below the breakpoint, with more than one section and
1233
+ // none of them opened. Named once rather than written out at each of the three
1234
+ // places that ask it -- the first version repeated the expression and the third
1235
+ // copy was the one that forgot to suppress the groups, so the list screen drew the
1236
+ // list with a section's groups underneath it.
1237
+ function onPhoneSectionList(context, sections) {
1238
+ return !context.wide && sections.length > 1 && context.phoneSection === null;
1239
+ }
1240
+
1241
+ // The section being arranged, named where its areas are. The section list is
1242
+ // drawn by sf-tree, whose rows take a label as a string and nothing else
1243
+ // (sf-tree 2.0 draws class markup, not sf-tree-item elements), so neither a pencil nor a field can stand in the list
1244
+ // itself -- and a section shown as a tab has no list at all. The name of the
1245
+ // place you are arranging belongs above the things you are arranging anyway.
1246
+ function renderSectionBar(context, section, count) {
1247
+ if (!section) return nothing;
1248
+
1249
+ return html`
1250
+ <div class="sf-property-view-section-bar flex items-cross-center gap-1/4"
1251
+ data-section=${String(section.key)}>
1252
+ ${context.configuring
1253
+ ? renderSectionIcon(context, section)
1254
+ : html`<i class="sf-icon" aria-hidden="true">${section.icon ? String(section.icon) : 'folder'}</i>`}
1255
+ ${renderEntryName(context, 'section', String(section.key),
1256
+ String(section.label ?? section.key), 'sf-property-view-section-title min-w-0')}
1257
+ <sf-button-icon
1258
+ size="1/2"
1259
+ appearance="link"
1260
+ scheme="secondary"
1261
+ icon="delete"
1262
+ data-action="remove-section"
1263
+ ?disabled=${count < 2}
1264
+ title=${context.removeSectionText}
1265
+ aria-label=${context.removeSectionText}
1266
+ @click=${() => context.component.removeSection(String(section.key))}
1267
+ ></sf-button-icon>
1268
+ </div>
1269
+ `;
1270
+ }
1271
+
1272
+ // The glyph a section is marked with, chosen from a grid of them. A column of a
1273
+ // hundred icons is a column nobody scrolls to the end of, so the dropdown lays
1274
+ // its options out several to a row and searches by the name of the glyph, which
1275
+ // is the only thing about an icon a person can type.
1276
+ function renderSectionIcon(context, section) {
1277
+ const current = section.icon ? String(section.icon) : '';
1278
+ const options = context.sectionIconChoices.map((icon) => ({
1279
+ kind: 'icon',
1280
+ icon,
1281
+ text: icon,
1282
+ value: icon,
1283
+ ariaLabel: icon,
1284
+ selected: icon === current,
1285
+ }));
1286
+
1287
+ // The chooser is the dropdown laid out as a grid: a hundred and twenty
1288
+ // glyphs in a column is a column nobody scrolls to the end of, and the
1289
+ // search is the only thing about a glyph a person can type. The trigger is
1290
+ // the glyph itself, because a field spelling out the name of what is drawn
1291
+ // beside it says the same thing twice.
1292
+ return html`
1293
+ <sf-dropdown
1294
+ class="sf-property-view-section-icon"
1295
+ size="1/2"
1296
+ layout="grid"
1297
+ columns="6"
1298
+ search="true"
1299
+ search-placeholder=${context.sectionIconText}
1300
+ aria-label=${context.sectionIconText}
1301
+ data-icon-for=${String(section.key)}
1302
+ value=${current}
1303
+ .options=${options}
1304
+ @change=${(event) => context.component.setSectionIcon(
1305
+ String(section.key), event.detail?.value ?? '')}
1306
+ >
1307
+ <sf-button-icon
1308
+ slot="trigger"
1309
+ size="1/2"
1310
+ appearance="link"
1311
+ scheme="on-surface"
1312
+ icon=${current || 'folder'}
1313
+ data-action="section-icon"
1314
+ title=${context.sectionIconText}
1315
+ aria-label=${context.sectionIconText}
1316
+ ></sf-button-icon>
1317
+ </sf-dropdown>
1318
+ `;
1319
+ }
1320
+
1321
+ // Making something new, at the end of the arrangement, where a person who has
1322
+ // run out of places to put a field looks for one (owner, 2026-10-07). An area
1323
+ // and a section are the two boxes this view has: one holds fields, the other
1324
+ // holds areas. Both are named the moment they are made -- the new name opens
1325
+ // for typing -- because an area called «Новая область» among four real ones is
1326
+ // a box nobody can tell from the others.
1327
+ //
1328
+ // Both stand the width of what they add to, in the neutral scheme and with no
1329
+ // glyph: making a box is not the primary act of this mode, and a full-width
1330
+ // outlined button with a plus in it read as the main thing to do on the screen.
1331
+ // «Add an area» takes the ladder's size 1, the size the panel's own controls
1332
+ // use; «add a section» stays a step below it, which is where it sits in the
1333
+ // rail (owner, 2026-10-09).
1334
+ // Keeping the list of named views, under the arrangement they belong to --
1335
+ // the same place a new area and a new section are made, because making a box is
1336
+ // one act whatever the box is (owner, 2026-10-09).
1337
+ //
1338
+ // A view is named the moment it is made, like an area and a section: a view
1339
+ // called «Новый вид» among four real ones is one nobody can tell from the
1340
+ // others. Removing is offered per view and not at all for the last shared one:
1341
+ // a scope with no shared view has nothing for a person who has made none of
1342
+ // their own, and the host refuses it anyway.
1343
+ function renderViewProfiles(context) {
1344
+ const profiles = context.viewProfiles ?? [];
1345
+ if (!profiles.length) return nothing;
1346
+
1347
+ return html`
1348
+ <section class="sf-property-view-profiles flex flex-col gap-1/3">
1349
+ <h3 class="sf-property-view-profiles-title">${context.viewProfileLabel}</h3>
1350
+ ${profiles.map((profile) => html`
1351
+ <div class="sf-property-view-profile flex items-cross-center gap-1/3" data-profile=${profile.id}>
1352
+ <sf-input
1353
+ size="1/2"
1354
+ appearance="bordered"
1355
+ class="flex-auto min-w-0"
1356
+ value=${profile.label}
1357
+ aria-label=${profile.label}
1358
+ data-action="rename-profile"
1359
+ @change=${(event) => context.component.renameViewProfile(
1360
+ profile.id, event.detail?.value ?? event.target?.value ?? '')}
1361
+ ></sf-input>
1362
+ <span class="sf-property-view-profile-scope color-on-surface-variant">
1363
+ ${profile.shared ? context.sharedLayerText : context.personalLayerText}
1364
+ </span>
1365
+ <sf-button-icon
1366
+ size="1/2"
1367
+ appearance="link"
1368
+ scheme="secondary"
1369
+ icon="close"
1370
+ data-action="delete-profile"
1371
+ ?disabled=${!context.canDeleteViewProfile(profile.id)}
1372
+ title=${context.canDeleteViewProfile(profile.id)
1373
+ ? context.deleteViewText
1374
+ : context.lastSharedViewText}
1375
+ aria-label=${context.deleteViewText}
1376
+ @click=${() => context.component.deleteViewProfile(profile.id)}
1377
+ ></sf-button-icon>
1378
+ </div>
1379
+ `)}
1380
+ ${context.namingView ? renderNewViewName(context) : html`
1381
+ <sf-button
1382
+ appearance="outline"
1383
+ scheme="on-surface"
1384
+ size="1/2"
1385
+ text=${context.addViewText}
1386
+ data-action="add-profile"
1387
+ @click=${() => context.component.startNamingView()}
1388
+ ></sf-button>`}
1389
+ </section>
1390
+ `;
1391
+ }
1392
+
1393
+ // The name of a view about to be made, in the row the rename of an area uses.
1394
+ // Nothing is written until it is confirmed with a name.
1395
+ function renderNewViewName(context) {
1396
+ const commit = (event) => {
1397
+ const input = event.target?.closest?.('.sf-property-view-rename')?.querySelector('sf-input');
1398
+ context.component.createViewProfile(input?.value ?? '');
1399
+ };
1400
+
1401
+ return html`
1402
+ <span class="sf-property-view-rename flex items-cross-center gap-1/4 min-w-0" data-action="new-profile">
1403
+ <sf-input
1404
+ size="1/2"
1405
+ appearance="bordered"
1406
+ autofocus
1407
+ root-class="flex-auto min-w-0"
1408
+ data-action="new-profile-input"
1409
+ aria-label=${context.addViewText}
1410
+ placeholder=${context.newViewText}
1411
+ @keydown=${(event) => {
1412
+ if (event.key === 'Enter') commit(event);
1413
+ if (event.key === 'Escape') context.component.cancelNamingView();
1414
+ }}
1415
+ ></sf-input>
1416
+ <sf-button-icon
1417
+ size="1/2"
1418
+ appearance="link"
1419
+ scheme="on-surface"
1420
+ icon="check"
1421
+ data-action="new-profile-save"
1422
+ title=${context.saveNameText}
1423
+ aria-label=${context.saveNameText}
1424
+ @click=${commit}
1425
+ ></sf-button-icon>
1426
+ <sf-button-icon
1427
+ size="1/2"
1428
+ appearance="link"
1429
+ scheme="on-surface"
1430
+ icon="close"
1431
+ data-action="new-profile-cancel"
1432
+ title=${context.cancelNameText}
1433
+ aria-label=${context.cancelNameText}
1434
+ @click=${() => context.component.cancelNamingView()}
1435
+ ></sf-button-icon>
1436
+ </span>
1437
+ `;
1438
+ }
1439
+
1440
+ function renderArrangementAdditions(context, section) {
1441
+ return html`
1442
+ <div class="sf-property-view-additions flex flex-col items-cross-stretch gap-1">
1443
+ <sf-button
1444
+ appearance="outline"
1445
+ scheme="on-surface"
1446
+ size=${context.size}
1447
+ text=${context.addGroupText}
1448
+ data-action="add-group"
1449
+ @click=${() => context.component.addGroup(section ? String(section.key) : '')}
1450
+ ></sf-button>
1451
+ ${sidebarDrawn(context) ? nothing : renderAddSection(context)}
1452
+ ${renderViewProfiles(context)}
1453
+ </div>
1454
+ `;
1455
+ }
1456
+
1457
+ // A new section is made at the end of the sections (owner, 2026-10-08), which is
1458
+ // where a person looks for one: under the list of them. Without a rail there is
1459
+ // no list to put it under, so it stands beside «add an area» as before.
1460
+ function renderAddSection(context) {
1461
+ return html`
1462
+ <sf-button
1463
+ appearance="outline"
1464
+ scheme="on-surface"
1465
+ size="1/2"
1466
+ text=${context.addSectionText}
1467
+ data-action="add-section"
1468
+ @click=${() => context.component.addSection()}
1469
+ ></sf-button>
1470
+ `;
1471
+ }
1472
+
1473
+ // Whether the column of tabs is drawn beside the fields. It holds the tabs and
1474
+ // nothing else (owner, 2026-10-07), so what used to stand above the whole panel
1475
+ // -- the band of the arranging mode -- stands above the fields instead, and the
1476
+ // column reaches the top of the panel the way a rail of tabs does.
1477
+ function sidebarDrawn(context) {
1478
+ const sections = listed(context, context.sections);
1479
+ return Boolean(context.wide && context.sectionsAs === 'list'
1480
+ && (sections.length > 1 || context.configuring));
1481
+ }
1482
+
1483
+ // The record on its way: the arrangement's shape in the Framework's skeleton
1484
+ // -- a sentence in its place said nothing about it (owner, 2026-10-14). Each
1485
+ // group is the card a group read by itself draws until its fields come
1486
+ // (port 1.15.0 group-load, renderGroup's `waiting`): its header with a bar for
1487
+ // the title and a line per field, so the record and its groups arrive in the
1488
+ // boxes this draws (standard simai.smart-component 1.3.8 §5.4, item 25). The
1489
+ // same markup is skeleton.html, which a server prints before any code, so the
1490
+ // element's first render after the printed skeleton changes nothing.
1491
+ const SKELETON_WIDTHS = ['w-1/3', 'w-2/3', 'w-1/2', 'w-3/4', 'w-2/5'];
1492
+ const SKELETON_GROUPS = 3;
1493
+ const SKELETON_FIELDS = 3;
1494
+ function renderLoading(context) {
1495
+ const bar = (at) => html`<div class="sf-skeleton sf-skeleton--animation-pulse" aria-hidden="true"><div class="sf-skeleton-text sf-skeleton-text--size-1 ${SKELETON_WIDTHS[at % SKELETON_WIDTHS.length]}"></div></div>`;
1496
+ // The section tabs are drawn with the skeleton, in the box the tabs take
1497
+ // (simai/ui-pro#3): they came only with the data before, and the record
1498
+ // moved down by their height when it arrived. The stylesheet draws the
1499
+ // strip only where the record has tabs -- sections-as tabs, from the wide
1500
+ // arrangement on -- since a printed skeleton knows no viewport.
1501
+ const tabs = html`<div class="sf-property-view-tabs sf-property-view-tabs--skeleton flex items-cross-center gap-2" aria-hidden="true" data-sf-skeleton="tabs" data-sections-as=${context.sectionsAs}>${[0, 1, 2].map((at) => html`<div class="flex-none" style="inline-size: 6rem">${bar(at + 3)}</div>`)}</div>`;
1502
+ return html`${tabs}<div class="sf-property-view-loading flex flex-col gap-4" data-state="loading">${Array.from({length: SKELETON_GROUPS}, (_, group) => html`<section class="sf-property-view-group bg-surface-0 flex flex-col gap-2" aria-busy="true"><header class="sf-property-view-group-header flex items-cross-center content-main-between gap-2"><div class="flex-1">${bar(group)}</div></header>${Array.from({length: SKELETON_FIELDS}, (_, field) => bar(group + field + 1))}</section>`)}<span class="sr-only" role="status">${context.message}</span></div>`;
1503
+ }
1504
+
1505
+ function renderLoadFailure(context) {
1506
+ const failure = context.loadFailure;
1507
+ return html`<sf-empty-state kind=${failure.kind} size=${context.size === '1/2' ? '1/2' : '1'} heading-level="2"
1508
+ text=${failure.text || nothing}
1509
+ .actions=${failure.kind === 'error' ? null : []}
1510
+ @sf-empty-state-action=${(event) => { if (event.detail?.id === 'retry') context.component.load('retry'); }}></sf-empty-state>`;
1511
+ }
1512
+
1513
+ function renderBody(context, railOutside = false, withActions = true) {
1514
+ if (context.loading) return renderLoading(context);
1515
+ if (context.loadFailure) return renderLoadFailure(context);
1516
+ // Sections are listed the same way fields and groups are while the view is
1517
+ // being configured: a hidden section filtered out here would be a section
1518
+ // nobody could ever show again.
1519
+ const sections = listed(context, context.sections);
1520
+ const current = sections.find((section) => String(section.key) === context.selectedSection)
1521
+ ?? sections[0] ?? null;
1522
+
1523
+ return html`
1524
+ <div class="sf-property-view-status" role="status" aria-live="polite" ?hidden=${!context.message}>
1525
+ ${context.message}
1526
+ </div>
1527
+ ${!context.panel && (context.hasHostSlot('actions') || context.hasHostSlot('menu') || context.panelMenuItems.length)
1528
+ ? html`
1529
+ <div class="sf-property-view-host-actions flex flex-wrap items-cross-center justify-end gap-1">
1530
+ ${context.hasHostSlot('actions') ? context.hostSlot('actions') : nothing}
1531
+ ${renderMenuControl(context)}
1532
+ </div>`
1533
+ : nothing}
1534
+ ${onPhoneSectionList(context, sections) ? renderPhoneSectionList(context, sections) : nothing}
1535
+ ${context.wide && context.sectionsAs === 'tabs' && sections.length > 1
1536
+ ? renderSectionTabs(context, sections)
1537
+ : nothing}
1538
+ <div class="sf-property-view-layout"
1539
+ style=${`--sf-property-view--sections-width: ${context.sectionsWidth};${context.panel ? '' : sizeStyle(context)}`}
1540
+ data-sections=${String(sections.length)}
1541
+ data-screen=${onPhoneSectionList(context, sections) ? 'sections' : 'groups'}>
1542
+ ${!context.wide && sections.length > 1 && context.phoneSection !== null
1543
+ ? renderPhoneSectionHead(context)
1544
+ : nothing}
1545
+ ${sidebarDrawn(context) && !railOutside ? renderSectionList(context, sections) : nothing}
1546
+ ${context.isEmptyRecord && !context.configuring
1547
+ ? html`<p class="sf-property-view-empty-record color-on-surface-variant"
1548
+ role="status">${context.emptyRecordText}</p>`
1549
+ : nothing}
1550
+ ${onPhoneSectionList(context, sections) ? nothing : context.configuring
1551
+ ? html`
1552
+ <div class="sf-property-view-body flex-auto min-w-0 flex flex-col gap-4">
1553
+ ${renderSectionBar(context, current, sections.length)}
1554
+ <sf-sortable
1555
+ class="sf-property-view-groups flex flex-col gap-4"
1556
+ group="group"
1557
+ accepts="group"
1558
+ id=${`group:${current ? String(current.key) : ''}`}
1559
+ label=${String(current?.label ?? current?.key ?? '')}
1560
+ >${listed(context, current?.groups).map((group) => renderGroup(context, current, group))}</sf-sortable>
1561
+ ${renderArrangementAdditions(context, current)}
1562
+ ${renderUnplaced(context)}
1563
+ </div>`
1564
+ : html`
1565
+ <div class="sf-property-view-body flex-auto min-w-0 flex flex-col gap-4">
1566
+ ${context.visible(current?.groups).map((group) => renderGroup(context, current, group))}
1567
+ </div>`}
1568
+ </div>
1569
+ ${withActions && context.hasHostSlot('footer')
1570
+ ? html`<div class="sf-property-view-host-footer flex flex-wrap items-cross-center gap-2">${context.hostSlot('footer')}</div>`
1571
+ : nothing}
1572
+ ${withActions ? renderActions(context) : nothing}
1573
+ `;
1574
+ }
1575
+
1576
+ // A deeper window is narrower by a step, so the one below shows along the
1577
+ // inline-start edge. That is what makes a stack visible rather than merely true
1578
+ // in the DOM, and `width` is a declared input on sf-drawer, so it reaches into
1579
+ // none of that component's internals.
1580
+ //
1581
+ // A window above the stack takes no step in from the one below (owner,
1582
+ // 2026-10-07): the step read as the panel having slipped rather than as a stack,
1583
+ // and the page behind is already dimmed, which says there is something under
1584
+ // this. sf-drawer keeps `stack-offset` for a consumer that wants the step; this
1585
+ // one asks for none.
1586
+ //
1587
+ // The panel lies *over* the page: `overlay` is on, so the page behind is dimmed and
1588
+ // keeps its own layout. It was off before, which is how a consumer's page came to
1589
+ // be squeezed instead of covered -- the first thing the owner pointed at.
1590
+ //
1591
+ // `modal` is on because the drawer draws its overlay only when it is -- the
1592
+ // element is marked hidden unless modal and overlay are both set, which was
1593
+ // measured rather than assumed. Dimming the page is what the mockup asks for, and
1594
+ // a panel that dims the page is modal in the sense that matters. Esc is still
1595
+ // handled here rather than by the drawer, because in a stack it must close the top
1596
+ // window and not every one of them.
1597
+ //
1598
+ // The drawer is given no `title`: the panel draws its own head, with the collection
1599
+ // above the name and the star and the «⋯» beside it, and a drawer title as well
1600
+ // would be the same words twice.
1601
+ export function renderPropertyViewTemplate(context) {
1602
+ if (!context.panel) return renderBody(context);
1603
+
1604
+ // The panel is laid out the way a page is (owner, 2026-10-08): a head across
1605
+ // the top, a foot across the bottom, and what is between them scrolling
1606
+ // under both. The bands were sticky inside the panel's own scroller before,
1607
+ // which held them only while their containing block was on screen -- so a
1608
+ // record scrolled far enough took its own head and foot with it.
1609
+ //
1610
+ // With a rail of tabs what is between them is two columns, each with its own
1611
+ // scrollbar: a long list of sections scrolls without moving the record, and
1612
+ // a long record scrolls without moving the list.
1613
+ const rail = sidebarDrawn(context);
1614
+
1615
+ // `show-close` is off because the panel draws its own close, in the strip
1616
+ // beside copy-link and open-full. With both, a panel carried two close
1617
+ // buttons a step apart, and on a phone neither was reachable: the strip is
1618
+ // placed a gutter's width beyond the panel's inline start, which on a
1619
+ // full-screen drawer is off the screen.
1620
+ return html`
1621
+ <sf-drawer
1622
+ ?open=${context.panelOpen}
1623
+ placement="inline-end"
1624
+ modal="true"
1625
+ overlay="true"
1626
+ size=${context.wide ? context.panelSize : 'full'}
1627
+ width=${context.wide ? (context.drawerWidth || nothing) : nothing}
1628
+ gutter="var(--sf-space-1)"
1629
+ panel-style=${`--sf-drawer-background: var(--sf-surface-1); --sf-property-view--sections-width: ${context.sectionsWidth}; scrollbar-gutter: auto;${sizeStyle(context)}`}
1630
+ show-close="false"
1631
+ close-label=${context.closeLabel}
1632
+ close-on-esc="false"
1633
+ label=${context.panelLabel || context.recordId || ''}
1634
+ >
1635
+ ${renderPanelControls(context)}
1636
+ <div class="sf-property-view-panel relative flex flex-col gap-2${rail ? ' sf-property-view-panel--railed' : ''}${panelFills(context) ? ' sf-property-view-panel--fills' : ''}"
1637
+ style=${panelFills(context)
1638
+ ? `--sf-property-view--edge-room: calc(${context.edgeControlCount} * ${context.edgeControlWidth} + ${context.edgeControlCount - 1} * var(--sf-space-1\\/2));`
1639
+ : nothing}
1640
+ data-record-id=${context.recordId || ''}
1641
+ data-stack-depth=${String(context.stackDepth)}
1642
+ data-stacked=${context.isTopPanel ? 'false' : 'true'}
1643
+ >
1644
+ ${renderPanelHead(context)}
1645
+ <div class="sf-property-view-scroll flex-auto min-h-0 ${rail ? 'flex' : 'flex flex-col'}">
1646
+ ${rail
1647
+ ? html`
1648
+ ${renderSectionList(context, listed(context, context.sections))}
1649
+ <div class="sf-property-view-frame flex-auto min-w-0 flex flex-col gap-2">
1650
+ ${renderBody(context, true, false)}
1651
+ </div>`
1652
+ : renderBody(context, false, false)}
1653
+ </div>
1654
+ ${renderActions(context, {panel: true})}
1655
+ </div>
1656
+ </sf-drawer>
1657
+ `;
1658
+ }