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