@agent-native/core-corpus 0.213.1 → 0.214.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 (22) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/community/qr/package.json +1 -1
  3. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +15 -1
  4. package/corpus/templates/content/CHANGELOG.md +5 -0
  5. package/corpus/templates/content/actions/_database-view-setup.ts +305 -11
  6. package/corpus/templates/content/actions/_property-utils.ts +3 -0
  7. package/corpus/templates/content/actions/update-content-database-view.ts +18 -2
  8. package/corpus/templates/content/actions/view-screen.ts +12 -0
  9. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +91 -16
  10. package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +410 -0
  11. package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +426 -0
  12. package/corpus/templates/content/app/components/editor/database/ListView.tsx +136 -18
  13. package/corpus/templates/content/app/components/editor/database/navigation-state.ts +3 -0
  14. package/corpus/templates/content/app/components/editor/database/settings.tsx +3 -3
  15. package/corpus/templates/content/app/components/editor/database/view-config.ts +4 -0
  16. package/corpus/templates/content/app/global.css +13 -0
  17. package/corpus/templates/content/app/i18n-data.ts +32 -0
  18. package/corpus/templates/content/changelog/2026-10-19-list-rows-can-be-customized.md +6 -0
  19. package/corpus/templates/content/changelog/2026-10-19-list-views-fit-phone-screens.md +6 -0
  20. package/corpus/templates/content/shared/api.ts +18 -0
  21. package/corpus/templates/content/shared/database-item-layout.ts +591 -0
  22. package/package.json +1 -1
@@ -0,0 +1,410 @@
1
+ import type {
2
+ ContentDatabaseItemLayout,
3
+ ContentDatabaseItemLeadingKind,
4
+ ContentDatabaseItemTitleWeight,
5
+ DocumentProperty,
6
+ } from "@shared/api";
7
+ import {
8
+ CONTENT_DATABASE_ITEM_LAYOUT_SLOTS,
9
+ contentDatabaseItemLayoutPresets,
10
+ itemLayoutLeadingCandidates,
11
+ itemLayoutLine2Candidates,
12
+ itemLayoutMetaCandidates,
13
+ matchingContentDatabaseItemLayoutPreset,
14
+ MAX_ITEM_LAYOUT_META_PROPERTIES,
15
+ startingContentDatabaseItemLayout,
16
+ toggleContentDatabaseItemLayoutMetaProperty,
17
+ type ContentDatabaseItemLayoutPresetId,
18
+ type ContentDatabaseItemLayoutSlot,
19
+ } from "@shared/database-item-layout";
20
+ import { IconCheck, IconPencil } from "@tabler/icons-react";
21
+
22
+ import { cn } from "@/lib/utils";
23
+
24
+ import { dbText } from "./DatabaseView";
25
+
26
+ const SLOT_LABEL_KEYS = {
27
+ leading: "listRowsStart",
28
+ title: "listRowsTitle",
29
+ line2: "listRowsLine2",
30
+ line3: "listRowsLine3",
31
+ trailing: "listRowsEnd",
32
+ } as const satisfies Record<
33
+ ContentDatabaseItemLayoutSlot,
34
+ Parameters<typeof dbText>[0]
35
+ >;
36
+
37
+ const PRESET_LABEL_KEYS = {
38
+ default: "listRowsPresetDefault",
39
+ checklist: "listRowsPresetChecklist",
40
+ reminders: "listRowsPresetReminders",
41
+ tracker: "listRowsPresetTracker",
42
+ digest: "listRowsPresetDigest",
43
+ } as const satisfies Record<
44
+ ContentDatabaseItemLayoutPresetId,
45
+ Parameters<typeof dbText>[0]
46
+ >;
47
+
48
+ export function listItemLayoutSlotLabel(slot: ContentDatabaseItemLayoutSlot) {
49
+ return dbText(SLOT_LABEL_KEYS[slot]);
50
+ }
51
+
52
+ function Segmented<T extends string | number>({
53
+ label,
54
+ value,
55
+ options,
56
+ onChange,
57
+ }: {
58
+ label: string;
59
+ value: T;
60
+ options: Array<{ value: T; label: string; disabled?: boolean }>;
61
+ onChange: (value: T) => void;
62
+ }) {
63
+ return (
64
+ <div role="group" aria-label={label} className="flex flex-wrap gap-1">
65
+ {options.map((option) => (
66
+ <button
67
+ key={String(option.value)}
68
+ type="button"
69
+ disabled={option.disabled}
70
+ aria-pressed={value === option.value}
71
+ className={cn(
72
+ "h-7 rounded-md border px-2 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
73
+ value === option.value
74
+ ? "border-database-blue bg-database-blue/5 text-database-blue"
75
+ : "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
76
+ )}
77
+ // Pressing the current choice again changes nothing, so Start keeps
78
+ // the checkbox or status field it shows.
79
+ onClick={() => {
80
+ if (option.value !== value) onChange(option.value);
81
+ }}
82
+ >
83
+ {option.label}
84
+ </button>
85
+ ))}
86
+ </div>
87
+ );
88
+ }
89
+
90
+ function PropertyChoices({
91
+ label,
92
+ properties,
93
+ selectedIds: savedIds,
94
+ multiple,
95
+ noneLabel,
96
+ onChange,
97
+ }: {
98
+ label: string;
99
+ properties: DocumentProperty[];
100
+ selectedIds: string[];
101
+ multiple: boolean;
102
+ noneLabel?: string;
103
+ onChange: (ids: string[]) => void;
104
+ }) {
105
+ // A saved slot can name a property this menu doesn't offer: one hidden, or
106
+ // one since deleted. It isn't shown, so it doesn't count toward the limit.
107
+ // In Line 3 and End it stays saved for when it's shown again.
108
+ const offeredIds = properties.map((property) => property.definition.id);
109
+ const selectedIds = savedIds.filter((id) => offeredIds.includes(id));
110
+ const rows = [
111
+ ...(noneLabel ? [{ id: "", name: noneLabel }] : []),
112
+ ...properties.map((property) => ({
113
+ id: property.definition.id,
114
+ name: property.definition.name,
115
+ })),
116
+ ];
117
+ return (
118
+ <div
119
+ role="group"
120
+ aria-label={label}
121
+ className="grid max-h-56 gap-0.5 overflow-y-auto"
122
+ >
123
+ {rows.map((row) => {
124
+ const selected = row.id
125
+ ? selectedIds.includes(row.id)
126
+ : selectedIds.length === 0;
127
+ const position = row.id ? selectedIds.indexOf(row.id) : -1;
128
+ const full =
129
+ multiple &&
130
+ !selected &&
131
+ !!row.id &&
132
+ selectedIds.length >= MAX_ITEM_LAYOUT_META_PROPERTIES;
133
+ return (
134
+ <button
135
+ key={row.id || "none"}
136
+ type="button"
137
+ aria-pressed={selected}
138
+ disabled={full}
139
+ className="flex h-8 items-center gap-2 rounded px-2 text-left text-sm enabled:hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
140
+ onClick={() => {
141
+ if (!multiple) return onChange(row.id ? [row.id] : []);
142
+ onChange(
143
+ toggleContentDatabaseItemLayoutMetaProperty(
144
+ savedIds,
145
+ offeredIds,
146
+ row.id || null,
147
+ ),
148
+ );
149
+ }}
150
+ >
151
+ <span className="flex size-4 shrink-0 items-center justify-center">
152
+ {selected ? (
153
+ <IconCheck className="size-4 text-database-blue" />
154
+ ) : null}
155
+ </span>
156
+ <span className="min-w-0 flex-1 truncate">{row.name}</span>
157
+ {multiple && position >= 0 ? (
158
+ <span className="text-xs text-muted-foreground">
159
+ {position + 1}
160
+ </span>
161
+ ) : null}
162
+ </button>
163
+ );
164
+ })}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ /** The controls for one slot, shared by the Layout panel and the row popovers. */
170
+ export function ListItemLayoutSlotEditor({
171
+ slot,
172
+ layout,
173
+ properties,
174
+ onChange,
175
+ }: {
176
+ slot: ContentDatabaseItemLayoutSlot;
177
+ layout: ContentDatabaseItemLayout;
178
+ properties: DocumentProperty[];
179
+ onChange: (layout: ContentDatabaseItemLayout) => void;
180
+ }) {
181
+ const label = listItemLayoutSlotLabel(slot);
182
+ if (slot === "leading") {
183
+ const checkboxes = itemLayoutLeadingCandidates(properties, "checkbox");
184
+ const statuses = itemLayoutLeadingCandidates(properties, "status");
185
+ const kind = layout.leading.kind;
186
+ const candidates =
187
+ kind === "checkbox" ? checkboxes : kind === "status" ? statuses : [];
188
+ return (
189
+ <div className="grid gap-2">
190
+ <Segmented<ContentDatabaseItemLeadingKind>
191
+ label={label}
192
+ value={kind}
193
+ options={[
194
+ { value: "none", label: dbText("none") },
195
+ { value: "page_icon", label: dbText("listRowsPageIcon") },
196
+ {
197
+ value: "checkbox",
198
+ label: dbText("listRowsCheckbox"),
199
+ disabled: checkboxes.length === 0,
200
+ },
201
+ {
202
+ value: "status",
203
+ label: dbText("listRowsStatus"),
204
+ disabled: statuses.length === 0,
205
+ },
206
+ ]}
207
+ onChange={(next) =>
208
+ onChange({
209
+ ...layout,
210
+ leading: {
211
+ kind: next,
212
+ propertyId:
213
+ next === "checkbox"
214
+ ? (checkboxes[0]?.definition.id ?? null)
215
+ : next === "status"
216
+ ? (statuses[0]?.definition.id ?? null)
217
+ : null,
218
+ },
219
+ })
220
+ }
221
+ />
222
+ {candidates.length > 1 ? (
223
+ <PropertyChoices
224
+ label={label}
225
+ properties={candidates}
226
+ selectedIds={
227
+ layout.leading.propertyId ? [layout.leading.propertyId] : []
228
+ }
229
+ multiple={false}
230
+ onChange={([id]) =>
231
+ onChange({
232
+ ...layout,
233
+ leading: { kind, propertyId: id ?? null },
234
+ })
235
+ }
236
+ />
237
+ ) : null}
238
+ </div>
239
+ );
240
+ }
241
+ if (slot === "title") {
242
+ return (
243
+ <Segmented<ContentDatabaseItemTitleWeight>
244
+ label={label}
245
+ value={layout.titleWeight}
246
+ options={[
247
+ { value: "regular", label: dbText("listRowsRegular") },
248
+ { value: "medium", label: dbText("listRowsMedium") },
249
+ { value: "bold", label: dbText("listRowsBold") },
250
+ ]}
251
+ onChange={(titleWeight) => onChange({ ...layout, titleWeight })}
252
+ />
253
+ );
254
+ }
255
+ if (slot === "line2") {
256
+ return (
257
+ <div className="grid gap-2">
258
+ <PropertyChoices
259
+ label={label}
260
+ properties={itemLayoutLine2Candidates(properties)}
261
+ selectedIds={layout.line2.propertyId ? [layout.line2.propertyId] : []}
262
+ multiple={false}
263
+ noneLabel={dbText("listRowsNothing")}
264
+ onChange={([id]) =>
265
+ onChange({
266
+ ...layout,
267
+ line2: { ...layout.line2, propertyId: id ?? null },
268
+ })
269
+ }
270
+ />
271
+ {layout.line2.propertyId ? (
272
+ <Segmented<1 | 2 | 3>
273
+ label={label}
274
+ value={layout.line2.lines}
275
+ options={[
276
+ { value: 1, label: dbText("listRowsOneLine") },
277
+ { value: 2, label: dbText("listRowsLineCount", { count: 2 }) },
278
+ { value: 3, label: dbText("listRowsLineCount", { count: 3 }) },
279
+ ]}
280
+ onChange={(lines) =>
281
+ onChange({ ...layout, line2: { ...layout.line2, lines } })
282
+ }
283
+ />
284
+ ) : null}
285
+ </div>
286
+ );
287
+ }
288
+ return (
289
+ <div className="grid gap-1.5">
290
+ <PropertyChoices
291
+ label={label}
292
+ properties={itemLayoutMetaCandidates(properties)}
293
+ selectedIds={layout[slot]}
294
+ multiple
295
+ noneLabel={dbText("listRowsNothing")}
296
+ onChange={(ids) => onChange({ ...layout, [slot]: ids })}
297
+ />
298
+ {slot === "trailing" ? (
299
+ <div className="text-xs text-muted-foreground">
300
+ {dbText("listRowsEndHint")}
301
+ </div>
302
+ ) : null}
303
+ </div>
304
+ );
305
+ }
306
+
307
+ export function ListItemLayoutPresets({
308
+ layout,
309
+ properties,
310
+ onChange,
311
+ }: {
312
+ layout: ContentDatabaseItemLayout | null;
313
+ properties: DocumentProperty[];
314
+ onChange: (layout: ContentDatabaseItemLayout | null) => void;
315
+ }) {
316
+ const active = matchingContentDatabaseItemLayoutPreset(layout, properties);
317
+ return (
318
+ <div className="flex flex-wrap gap-1.5">
319
+ {contentDatabaseItemLayoutPresets(properties).map((preset) => {
320
+ const unavailable = preset.missingPropertyType
321
+ ? dbText(
322
+ preset.missingPropertyType === "checkbox"
323
+ ? "listRowsNeedsCheckbox"
324
+ : "listRowsNeedsStatus",
325
+ )
326
+ : undefined;
327
+ return (
328
+ <button
329
+ key={preset.id}
330
+ type="button"
331
+ disabled={!!unavailable}
332
+ title={unavailable}
333
+ aria-pressed={active === preset.id}
334
+ className={cn(
335
+ "h-7 rounded-full border px-3 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
336
+ active === preset.id
337
+ ? "border-database-blue bg-database-blue text-database-blue-foreground"
338
+ : "border-border text-foreground hover:bg-muted",
339
+ )}
340
+ onClick={() => onChange(preset.layout)}
341
+ >
342
+ {dbText(PRESET_LABEL_KEYS[preset.id])}
343
+ </button>
344
+ );
345
+ })}
346
+ </div>
347
+ );
348
+ }
349
+
350
+ /** The Rows section of a list view's Layout panel. */
351
+ export function DatabaseListRowsSetting({
352
+ itemLayout,
353
+ properties,
354
+ visibleProperties,
355
+ editingOnList,
356
+ onChange,
357
+ onEditOnList,
358
+ }: {
359
+ itemLayout: ContentDatabaseItemLayout | null;
360
+ properties: DocumentProperty[];
361
+ visibleProperties: DocumentProperty[];
362
+ editingOnList: boolean;
363
+ onChange: (layout: ContentDatabaseItemLayout | null) => void;
364
+ onEditOnList: () => void;
365
+ }) {
366
+ const working =
367
+ itemLayout ?? startingContentDatabaseItemLayout(visibleProperties);
368
+ return (
369
+ <div className="grid gap-3 border-t border-border pt-4">
370
+ <div className="flex items-start justify-between gap-2">
371
+ <div className="grid gap-0.5">
372
+ <div className="text-sm font-medium text-foreground">
373
+ {dbText("listRows")}
374
+ </div>
375
+ <div className="text-xs text-muted-foreground">
376
+ {dbText("listRowsDescription")}
377
+ </div>
378
+ </div>
379
+ {editingOnList ? null : (
380
+ <button
381
+ type="button"
382
+ className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-database-blue hover:bg-database-blue/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
383
+ onClick={onEditOnList}
384
+ >
385
+ <IconPencil className="size-3.5" />
386
+ {dbText("listRowsEditOnList")}
387
+ </button>
388
+ )}
389
+ </div>
390
+ <ListItemLayoutPresets
391
+ layout={itemLayout}
392
+ properties={properties}
393
+ onChange={onChange}
394
+ />
395
+ {CONTENT_DATABASE_ITEM_LAYOUT_SLOTS.map((slot) => (
396
+ <div key={slot} className="grid gap-1.5">
397
+ <div className="text-xs text-muted-foreground">
398
+ {listItemLayoutSlotLabel(slot)}
399
+ </div>
400
+ <ListItemLayoutSlotEditor
401
+ slot={slot}
402
+ layout={working}
403
+ properties={properties}
404
+ onChange={onChange}
405
+ />
406
+ </div>
407
+ ))}
408
+ </div>
409
+ );
410
+ }