@kernhq/module-quire 0.9.1 → 0.9.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-quire",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Kern Quire: collaborative documents, spaces and page trees",
5
5
  "homepage": "https://github.com/KernAIO/module-quire#readme",
6
6
  "license": "AGPL-3.0-only",
@@ -114,7 +114,7 @@ async function remove(commentId: string) {
114
114
  {/if}
115
115
  <RichTextEditor bind:value={draft} placeholder={t('comment_placeholder')} minRows={2} />
116
116
  <div class="actions">
117
- <Button size="sm" variant="secondary" onclick={() => onPendingHandled?.()}>{t('common.cancel')}</Button>
117
+ <Button size="sm" variant="secondary" onclick={() => onPendingHandled?.()}>{t('cancel')}</Button>
118
118
  <Button size="sm" disabled={busy || empty(draft)} onclick={() => submit(null, draft)}>
119
119
  {t('comment_post')}
120
120
  </Button>
@@ -169,7 +169,7 @@ async function remove(commentId: string) {
169
169
  icon="trash-2"
170
170
  size={22}
171
171
  variant="ghost"
172
- label={t('common.delete')}
172
+ label={t('delete')}
173
173
  onclick={() => remove(comment.id)}
174
174
  />
175
175
  {/if}
@@ -183,7 +183,7 @@ async function remove(commentId: string) {
183
183
  {#if replyTo === thread.id}
184
184
  <RichTextEditor bind:value={replyDraft} placeholder={t('comment_reply')} minRows={1} />
185
185
  <div class="actions">
186
- <Button size="sm" variant="secondary" onclick={() => (replyTo = null)}>{t('common.cancel')}</Button>
186
+ <Button size="sm" variant="secondary" onclick={() => (replyTo = null)}>{t('cancel')}</Button>
187
187
  <Button
188
188
  size="sm"
189
189
  disabled={busy || empty(replyDraft)}
@@ -122,8 +122,8 @@ async function submit() {
122
122
  </div>
123
123
 
124
124
  {#snippet footer()}
125
- <Button variant="secondary" onclick={() => (open = false)}>{t('common.cancel')}</Button>
126
- <Button disabled={!valid || saving} onclick={submit}>{t('common.create')}</Button>
125
+ <Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
126
+ <Button disabled={!valid || saving} onclick={submit}>{t('create')}</Button>
127
127
  {/snippet}
128
128
  </Dialog>
129
129
 
@@ -168,7 +168,7 @@ async function createPage(parentId: string | null) {
168
168
  void treeQuery.refetch()
169
169
  }}
170
170
  >
171
- {t('common.retry')}
171
+ {t('retry')}
172
172
  </Button>
173
173
  {/snippet}
174
174
  </EmptyState>
@@ -67,9 +67,9 @@ const kindLabel = (v: PageVersion) =>
67
67
  {#each [1, 2, 3, 4] as n (n)}<Skeleton height="56px" />{/each}
68
68
  </div>
69
69
  {:else if query.isError}
70
- <EmptyState icon="triangle-alert" title={t('history_error')} description={t('common.retry')}>
70
+ <EmptyState icon="triangle-alert" title={t('history_error')} description={t('retry')}>
71
71
  {#snippet actions()}
72
- <Button variant="secondary" onclick={() => void query.refetch()}>{t('common.retry')}</Button>
72
+ <Button variant="secondary" onclick={() => void query.refetch()}>{t('retry')}</Button>
73
73
  {/snippet}
74
74
  </EmptyState>
75
75
  {:else if versions.length === 0}
@@ -299,7 +299,8 @@ const laneRule = (lane: Lane) =>
299
299
  color: var(--kern-ink-900);
300
300
  }
301
301
  .fields {
302
- margin: 10px 5px 0 0;
302
+ margin-block-start: 10px;
303
+ margin-inline-end: 5px;
303
304
  padding: 0;
304
305
  display: flex;
305
306
  flex-direction: column;
@@ -67,6 +67,19 @@ const api = getQuireApi()
67
67
  const core = coreApi<CoreApi>()
68
68
  const client = useQueryClient()
69
69
 
70
+ /** What a view with nothing configured looks like — the shape `ViewConfig` guarantees. */
71
+ const BLANK_CONFIG: ViewConfig = {
72
+ filters: [],
73
+ filterMode: 'and',
74
+ sorts: [],
75
+ groupBy: null,
76
+ dateProperty: null,
77
+ visibleProperties: null,
78
+ columnWidths: {},
79
+ cardSize: 'medium',
80
+ coverProperty: null,
81
+ }
82
+
70
83
  const PAGE_SIZE = 50
71
84
  /** What a non-paging view will show before it says it has stopped. */
72
85
  const CAP = 500
@@ -155,10 +168,27 @@ $effect(() => {
155
168
  if (inspecting && rows.length > 0 && !rows.some((r) => r.id === inspecting)) inspecting = null
156
169
  })
157
170
 
171
+ /**
172
+ * The configuration on screen: the last one *written*, not the last one read back.
173
+ *
174
+ * Two rapid edits both merge from `view.config`, and until the refetch lands that is the value from
175
+ * before either of them — so the second silently undoes the first. Holding the pending
176
+ * configuration here makes successive edits compose, and shows each one immediately rather than a
177
+ * round trip later.
178
+ */
179
+ let pending = $state.raw<{ viewId: string; config: ViewConfig; seq: number } | null>(null)
180
+ let writes = 0
181
+
182
+ const liveConfig = $derived<ViewConfig>(
183
+ pending && pending.viewId === view?.id ? pending.config : (view?.config ?? BLANK_CONFIG),
184
+ )
185
+ /** The view as the screen should draw it: the stored one, wearing whatever was written last. */
186
+ const liveView = $derived<View | null>(view ? { ...view, config: liveConfig } : null)
187
+
158
188
  const properties = $derived(database ? orderedProperties(database) : [])
159
189
  const relations = $derived(properties.filter((p) => p.type === 'relation'))
160
190
  const groupProperty = $derived(
161
- view?.config.groupBy ? (properties.find((p) => p.key === view.config.groupBy) ?? null) : null,
191
+ liveConfig.groupBy ? (properties.find((p) => p.key === liveConfig.groupBy) ?? null) : null,
162
192
  )
163
193
 
164
194
  // ---- writing ------------------------------------------------------------------------------
@@ -170,36 +200,72 @@ const refreshSchema = async () => {
170
200
  const refreshRows = () => client.invalidateQueries({ queryKey: ['quire', 'row', workspaceId, databaseId] })
171
201
 
172
202
  /**
173
- * One place that reports a failure, and one flag that stops a second click.
203
+ * One place that reports a failure, and one guard that stops a second click.
204
+ *
205
+ * `disabled={busy}` alone does not stop it: the attribute reaches the button on the next render and
206
+ * two quick clicks are one render apart, so the guard is read here, in the same tick as the click.
174
207
  *
175
- * `disabled={busy}` alone does not: the attribute reaches the button on the next render and two
176
- * quick clicks are one render apart, so the guard is read here in the same tick as the click.
208
+ * The guard is **per action**, not global. A single in-flight flag looks like the same thing and is
209
+ * not: choosing a filter's column, then its operator, then its value is three writes a few hundred
210
+ * milliseconds apart, and a global flag drops the second and the third on the floor — the panel
211
+ * fills in, the table never changes, and nothing on screen says why.
177
212
  */
178
- async function act(work: () => Promise<unknown>, after: () => Promise<unknown>) {
179
- if (busy) return
213
+ const running = new Set<string>()
214
+
215
+ async function act(work: () => Promise<unknown>, after: () => Promise<unknown>, guard?: string) {
216
+ if (guard && running.has(guard)) return
217
+ if (guard) running.add(guard)
180
218
  busy = true
181
219
  try {
182
220
  await work()
183
221
  await after()
184
222
  } catch (err) {
185
- toast.error(err instanceof Error && err.message ? err.message : t('common.error'))
223
+ toast.error(err instanceof Error && err.message ? err.message : t('error'))
186
224
  } finally {
187
- busy = false
225
+ if (guard) running.delete(guard)
226
+ busy = running.size > 0
188
227
  }
189
228
  }
190
229
 
191
230
  const patchView = (patch: Partial<ViewConfig>) => {
192
231
  if (!view) return
193
- // `updateView` replaces `config` wholesale, so a partial write deletes the rest of the view.
194
- const config = mergeConfig(view.config, patch)
195
- void act(() => api.databases.updateView({ workspaceId, viewId: view.id, config }), refreshSchema)
232
+ /**
233
+ * `updateView` replaces `config` wholesale, so the merged whole always goes — a partial write
234
+ * deletes the rest of the view. And it is snapshotted: the merge carries arrays straight out of
235
+ * the query cache, which are `$state` proxies, and a proxy cannot be `structuredClone`d — which is
236
+ * what the API layer does, so the request throws before it is sent and the edit never appears.
237
+ */
238
+ const config = $state.snapshot(mergeConfig(liveConfig, patch)) as ViewConfig
239
+ const viewId = view.id
240
+ const mine = ++writes
241
+ pending = { viewId, config, seq: mine }
242
+ /**
243
+ * Filtering, sorting and grouping decide **which rows** the server returns, and the rows query is
244
+ * keyed by the view's *id* — which does not change when its configuration does. Without this the
245
+ * filter panel fills in and the table underneath keeps showing everything, until something else
246
+ * happens to invalidate it.
247
+ */
248
+ const changesRows =
249
+ patch.filters !== undefined || patch.filterMode !== undefined || patch.sorts !== undefined
250
+ void act(
251
+ () => api.databases.updateView({ workspaceId, viewId, config }),
252
+ async () => {
253
+ await refreshSchema()
254
+ if (changesRows) await refreshRows()
255
+ // Only the newest write hands control back to the server's answer.
256
+ if (pending?.seq === mine) pending = null
257
+ },
258
+ )
196
259
  }
197
260
 
198
- const writeCell = (row: Row, property: Property, value: unknown) =>
199
- act(
200
- () => api.databases.updateRow({ workspaceId, rowId: row.id, props: { [property.key]: value } }),
261
+ const writeCell = (row: Row, property: Property, value: unknown) => {
262
+ // A multi-select or relation cell hands back an array built from proxied state.
263
+ const next = $state.snapshot(value)
264
+ return act(
265
+ () => api.databases.updateRow({ workspaceId, rowId: row.id, props: { [property.key]: next } }),
201
266
  refreshRows,
202
267
  )
268
+ }
203
269
 
204
270
  const writeTitle = (row: Row, title: string) => {
205
271
  if (title === row.title) return Promise.resolve()
@@ -219,6 +285,7 @@ const addRow = (seed: Record<string, unknown> = {}) =>
219
285
  await refreshRows()
220
286
  await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
221
287
  },
288
+ 'add-row',
222
289
  )
223
290
 
224
291
  const duplicateRow = (row: Row) =>
@@ -231,6 +298,7 @@ const duplicateRow = (row: Row) =>
231
298
  props: $state.snapshot(row.props),
232
299
  }),
233
300
  refreshRows,
301
+ `duplicate-${row.id}`,
234
302
  )
235
303
 
236
304
  const openPage = (row: Row) =>
@@ -255,7 +323,7 @@ const moveOnBoard = (row: Row, laneId: string) => {
255
323
  }
256
324
 
257
325
  const setDate = (row: Row, iso: string | null) => {
258
- const key = view?.config.dateProperty
326
+ const key = liveConfig.dateProperty
259
327
  if (!key) return Promise.resolve()
260
328
  return act(
261
329
  () => api.databases.updateRow({ workspaceId, rowId: row.id, props: { [key]: iso } }),
@@ -299,19 +367,19 @@ const hideProperty = (property: Property) =>
299
367
 
300
368
  const sortBy = (property: Property, direction: 'asc' | 'desc' | null) => {
301
369
  if (!view) return
302
- const rest = view.config.sorts.filter((s) => s.propertyKey !== property.key)
370
+ const rest = liveConfig.sorts.filter((s) => s.propertyKey !== property.key)
303
371
  patchView({ sorts: direction ? [{ propertyKey: property.key, direction }, ...rest] : rest })
304
372
  }
305
373
 
306
374
  const sortDirectionOf = (key: string) =>
307
- view?.config.sorts.find((s) => s.propertyKey === key)?.direction ?? null
375
+ liveConfig.sorts.find((s) => s.propertyKey === key)?.direction ?? null
308
376
 
309
377
  function filterBy(property: Property) {
310
378
  if (!view) return
311
- const already = view.config.filters.some((f) => f.propertyKey === property.key)
379
+ const already = liveConfig.filters.some((f) => f.propertyKey === property.key)
312
380
  if (!already) {
313
381
  const operator = descriptorFor(property.type).operators[0] ?? 'equals'
314
- patchView({ filters: [...view.config.filters, { propertyKey: property.key, operator, value: null }] })
382
+ patchView({ filters: [...liveConfig.filters, { propertyKey: property.key, operator, value: null }] })
315
383
  }
316
384
  filterOpen = true
317
385
  }
@@ -343,6 +411,7 @@ function confirmed() {
343
411
  await refreshSchema()
344
412
  await refreshRows()
345
413
  },
414
+ `delete-property-${target.property.id}`,
346
415
  )
347
416
  else if (target.kind === 'view')
348
417
  void act(
@@ -352,6 +421,7 @@ function confirmed() {
352
421
  if (chosenViewId === target.view.id) chosenViewId = null
353
422
  await refreshSchema()
354
423
  },
424
+ `delete-view-${target.view.id}`,
355
425
  )
356
426
  else
357
427
  void act(
@@ -362,6 +432,7 @@ function confirmed() {
362
432
  await refreshRows()
363
433
  await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
364
434
  },
435
+ `delete-row-${target.row.id}`,
365
436
  )
366
437
  }
367
438
 
@@ -389,7 +460,7 @@ const confirmBody = $derived(
389
460
  const groupItems = $derived<MenuItem[]>([
390
461
  {
391
462
  type: 'radio',
392
- value: view?.config.groupBy ?? '',
463
+ value: liveConfig.groupBy ?? '',
393
464
  options: [
394
465
  { value: '', label: t('db_group_none') },
395
466
  ...properties
@@ -469,7 +540,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
469
540
  void rowsQuery.refetch()
470
541
  }}
471
542
  >
472
- {t('common.retry')}
543
+ {t('retry')}
473
544
  </Button>
474
545
  {/snippet}
475
546
  </EmptyState>
@@ -500,7 +571,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
500
571
  <Toolbar>
501
572
  <FilterMenu
502
573
  {database}
503
- config={view?.config ?? { filters: [], filterMode: 'and', sorts: [], groupBy: null, dateProperty: null, visibleProperties: null, columnWidths: {}, cardSize: 'medium', coverProperty: null }}
574
+ config={liveConfig}
504
575
  {workspaceId}
505
576
  {people}
506
577
  {canEdit}
@@ -510,16 +581,16 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
510
581
  />
511
582
  <SortMenu
512
583
  {database}
513
- config={view?.config ?? { filters: [], filterMode: 'and', sorts: [], groupBy: null, dateProperty: null, visibleProperties: null, columnWidths: {}, cardSize: 'medium', coverProperty: null }}
584
+ config={liveConfig}
514
585
  {canEdit}
515
586
  open={sortOpen}
516
587
  onOpenChange={(o) => (sortOpen = o)}
517
588
  onchange={patchView}
518
589
  />
519
- {#if view?.kind === 'board'}
590
+ {#if liveView?.kind === 'board'}
520
591
  <DropdownMenu items={groupItems} align="start">
521
592
  {#snippet trigger(props: Record<string, unknown>)}
522
- <ToolbarButton {...props} prefix={t('db_by')} active={Boolean(view?.config.groupBy)}>
593
+ <ToolbarButton {...props} prefix={t('db_by')} active={Boolean(liveConfig.groupBy)}>
523
594
  {groupProperty?.name ?? t('db_group_none')}
524
595
  </ToolbarButton>
525
596
  {/snippet}
@@ -561,7 +632,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
561
632
  {/if}
562
633
  <TableView
563
634
  {database}
564
- {view}
635
+ view={liveView}
565
636
  {rows}
566
637
  {people}
567
638
  {workspaceId}
@@ -591,14 +662,14 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
591
662
  disabled={rowsQuery.isFetchingNextPage}
592
663
  onclick={() => void rowsQuery.fetchNextPage()}
593
664
  >
594
- {rowsQuery.isFetchingNextPage ? t('common.loading') : t('db_load_more')}
665
+ {rowsQuery.isFetchingNextPage ? t('loading') : t('db_load_more')}
595
666
  </Button>
596
667
  </div>
597
668
  {/if}
598
669
  {:else if view.kind === 'board'}
599
670
  <BoardView
600
671
  {database}
601
- {view}
672
+ view={liveView}
602
673
  {rows}
603
674
  {people}
604
675
  {workspaceId}
@@ -619,7 +690,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
619
690
  {:else if view.kind === 'gallery'}
620
691
  <GalleryView
621
692
  {database}
622
- {view}
693
+ view={liveView}
623
694
  {rows}
624
695
  {people}
625
696
  {workspaceId}
@@ -631,7 +702,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
631
702
  {:else if view.kind === 'list'}
632
703
  <ListView
633
704
  {database}
634
- {view}
705
+ view={liveView}
635
706
  {rows}
636
707
  {people}
637
708
  {workspaceId}
@@ -643,7 +714,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
643
714
  {:else}
644
715
  <CalendarView
645
716
  {database}
646
- {view}
717
+ view={liveView}
647
718
  {rows}
648
719
  {canEdit}
649
720
  onOpenRow={(row) => (inspecting = row.id)}
@@ -707,8 +778,8 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
707
778
  >
708
779
  <p class="confirm">{confirmBody}</p>
709
780
  {#snippet footer()}
710
- <Button variant="secondary" onclick={() => (confirming = null)}>{t('common.cancel')}</Button>
711
- <Button variant="danger" disabled={busy} onclick={confirmed}>{t('common.delete')}</Button>
781
+ <Button variant="secondary" onclick={() => (confirming = null)}>{t('cancel')}</Button>
782
+ <Button variant="danger" disabled={busy} onclick={confirmed}>{t('delete')}</Button>
712
783
  {/snippet}
713
784
  </Dialog>
714
785
 
@@ -143,7 +143,7 @@ const menu = (row: Row): MenuItem[] => [
143
143
  color: var(--kern-ink-900);
144
144
  }
145
145
  .fields {
146
- margin: 0 5px 0 0;
146
+ margin-inline-end: 5px;
147
147
  padding: 0;
148
148
  display: flex;
149
149
  flex-direction: column;
@@ -87,7 +87,7 @@ const formulaError = $derived.by(() => {
87
87
  parseFormula(expression)
88
88
  return null
89
89
  } catch (err) {
90
- return err instanceof FormulaError || err instanceof Error ? err.message : t('common.error')
90
+ return err instanceof FormulaError || err instanceof Error ? err.message : t('error')
91
91
  }
92
92
  })
93
93
 
@@ -314,7 +314,8 @@ $effect(() => {
314
314
  {#if relations.length === 0}
315
315
  <p class="hint">{t('db_rollup_needs_relation')}</p>
316
316
  {/if}
317
- <Field label={t('db_rollup_target')}>
317
+ <!-- Disabled *with a reason*: there is nothing to gather until a relation is chosen. -->
318
+ <Field label={t('db_rollup_target')} hint={viaDatabaseId ? undefined : t('db_rollup_target_hint')}>
318
319
  {#snippet children(id: string)}
319
320
  <Select
320
321
  {id}
@@ -362,9 +363,9 @@ $effect(() => {
362
363
  </div>
363
364
 
364
365
  {#snippet footer()}
365
- <Button variant="secondary" onclick={onClose}>{t('common.cancel')}</Button>
366
+ <Button variant="secondary" onclick={onClose}>{t('cancel')}</Button>
366
367
  <Button disabled={!valid || busy || submitting} onclick={submit}>
367
- {property ? t('common.save') : t('common.add')}
368
+ {property ? t('save') : t('add')}
368
369
  </Button>
369
370
  {/snippet}
370
371
  </Dialog>
@@ -172,9 +172,9 @@ $effect(() => {
172
172
  </div>
173
173
 
174
174
  {#snippet footer()}
175
- <Button variant="secondary" onclick={onClose}>{t('common.cancel')}</Button>
175
+ <Button variant="secondary" onclick={onClose}>{t('cancel')}</Button>
176
176
  <Button disabled={!valid || busy || submitting} onclick={submit}>
177
- {view ? t('common.save') : t('common.create')}
177
+ {view ? t('save') : t('create')}
178
178
  </Button>
179
179
  {/snippet}
180
180
  </Dialog>
@@ -108,7 +108,7 @@ const unlink = (id: string) => onchange(ids.filter((v) => v !== id))
108
108
  {#if search.isLoading}
109
109
  <div class="state"><Spinner size={16} /></div>
110
110
  {:else if search.isError}
111
- <p class="state">{t('common.error')}</p>
111
+ <p class="state">{t('error')}</p>
112
112
  {:else if results.length === 0}
113
113
  <p class="state">{t('db_relation_none')}</p>
114
114
  {:else}
@@ -325,6 +325,16 @@ export const en: Record<string, Message> = {
325
325
  'quire.db_views': 'Views',
326
326
  'quire.db_visible_properties': 'Columns shown',
327
327
  'quire.db_widen': 'Widen',
328
+ 'quire.add': 'Add',
329
+ 'quire.cancel': 'Cancel',
330
+ 'quire.create': 'Create',
331
+ 'quire.delete': 'Delete',
332
+ 'quire.error': 'Something went wrong. Try again.',
333
+ 'quire.loading': 'Loading…',
334
+ 'quire.retry': 'Try again',
335
+ 'quire.save': 'Save',
336
+ 'quire.db_rollup_target_hint':
337
+ 'Choose the relation first; the column comes from the database on its other side.',
328
338
  }
329
339
 
330
340
  export type QuireMessageKey = keyof typeof en
@@ -657,6 +667,15 @@ const ar: Record<string, Message> = {
657
667
  'quire.db_views': 'طرق العرض',
658
668
  'quire.db_visible_properties': 'الأعمدة المعروضة',
659
669
  'quire.db_widen': 'توسيع',
670
+ 'quire.add': 'إضافة',
671
+ 'quire.cancel': 'إلغاء',
672
+ 'quire.create': 'إنشاء',
673
+ 'quire.delete': 'حذف',
674
+ 'quire.error': 'حدث خطأ ما. حاول مرة أخرى.',
675
+ 'quire.loading': 'جارٍ التحميل…',
676
+ 'quire.retry': 'أعد المحاولة',
677
+ 'quire.save': 'حفظ',
678
+ 'quire.db_rollup_target_hint': 'اختر العلاقة أولًا؛ يأتي العمود من قاعدة البيانات على الطرف الآخر.',
660
679
  }
661
680
 
662
681
  const de: Record<string, Message> = {
@@ -978,6 +997,16 @@ const de: Record<string, Message> = {
978
997
  'quire.db_views': 'Ansichten',
979
998
  'quire.db_visible_properties': 'Sichtbare Spalten',
980
999
  'quire.db_widen': 'Verbreitern',
1000
+ 'quire.add': 'Hinzufügen',
1001
+ 'quire.cancel': 'Abbrechen',
1002
+ 'quire.create': 'Erstellen',
1003
+ 'quire.delete': 'Löschen',
1004
+ 'quire.error': 'Etwas ist schiefgelaufen. Versuche es erneut.',
1005
+ 'quire.loading': 'Wird geladen…',
1006
+ 'quire.retry': 'Erneut versuchen',
1007
+ 'quire.save': 'Speichern',
1008
+ 'quire.db_rollup_target_hint':
1009
+ 'Wähle zuerst die Beziehung; die Spalte kommt aus der Datenbank auf ihrer anderen Seite.',
981
1010
  }
982
1011
 
983
1012
  const fa: Record<string, Message> = {
@@ -1290,6 +1319,15 @@ const fa: Record<string, Message> = {
1290
1319
  'quire.db_views': 'نماها',
1291
1320
  'quire.db_visible_properties': 'ستون‌های نمایان',
1292
1321
  'quire.db_widen': 'پهن‌تر',
1322
+ 'quire.add': 'افزودن',
1323
+ 'quire.cancel': 'انصراف',
1324
+ 'quire.create': 'ساختن',
1325
+ 'quire.delete': 'حذف',
1326
+ 'quire.error': 'چیزی درست پیش نرفت. دوباره تلاش کنید.',
1327
+ 'quire.loading': 'در حال بارگذاری…',
1328
+ 'quire.retry': 'دوباره تلاش کنید',
1329
+ 'quire.save': 'ذخیره',
1330
+ 'quire.db_rollup_target_hint': 'نخست پیوند را برگزینید؛ ستون از پایگاه‌دادهٔ آن سوی پیوند می‌آید.',
1293
1331
  }
1294
1332
 
1295
1333
  const tr: Record<string, Message> = {
@@ -1604,6 +1642,15 @@ const tr: Record<string, Message> = {
1604
1642
  'quire.db_views': 'Görünümler',
1605
1643
  'quire.db_visible_properties': 'Görünen sütunlar',
1606
1644
  'quire.db_widen': 'Genişlet',
1645
+ 'quire.add': 'Ekle',
1646
+ 'quire.cancel': 'Vazgeç',
1647
+ 'quire.create': 'Oluştur',
1648
+ 'quire.delete': 'Sil',
1649
+ 'quire.error': 'Bir şeyler ters gitti. Yeniden deneyin.',
1650
+ 'quire.loading': 'Yükleniyor…',
1651
+ 'quire.retry': 'Yeniden dene',
1652
+ 'quire.save': 'Kaydet',
1653
+ 'quire.db_rollup_target_hint': 'Önce ilişkiyi seçin; sütun onun diğer ucundaki veritabanından gelir.',
1607
1654
  }
1608
1655
 
1609
1656
  /** In the shape `defineClientModule().messages` expects. */
@@ -281,6 +281,17 @@ export function createMockQuireApi() {
281
281
  throw notFound('View')
282
282
  }
283
283
 
284
+ /**
285
+ * A copy on the way out, because a real API answers with fresh JSON every time.
286
+ *
287
+ * Handing back the live object makes the mock *look* right and behave wrongly in one specific
288
+ * way: TanStack's structural sharing compares the new answer with the cached one, finds the same
289
+ * object, and keeps the old reference — so `query.data` never changes identity, no `$derived`
290
+ * re-runs, and every schema change (a new column, a deleted view) is applied to the data and
291
+ * invisible on screen. Rows never showed it, because `toDatabaseRow` already built a new object.
292
+ */
293
+ const copy = <T>(value: T): T => structuredClone(value)
294
+
284
295
  const toDatabaseRow = (row: Row): DatabaseRow => ({
285
296
  id: row.id,
286
297
  databaseId: row._databaseId ?? '',
@@ -558,9 +569,12 @@ export function createMockQuireApi() {
558
569
  .filter((d) => d.spaceId === spaceId)
559
570
  .map((d) => ({ id: d.id, pageId: d.pageId, name: d.name })),
560
571
 
561
- get: async ({ databaseId }: { databaseId: string }) => theDatabase(databaseId),
572
+ get: async ({ databaseId }: { databaseId: string }) => copy(theDatabase(databaseId)),
562
573
 
563
- forPage: async ({ pageId }: { pageId: string }) => databases.find((d) => d.pageId === pageId) ?? null,
574
+ forPage: async ({ pageId }: { pageId: string }) => {
575
+ const db = databases.find((d) => d.pageId === pageId)
576
+ return db ? copy(db) : null
577
+ },
564
578
 
565
579
  create: async (input: { spaceId: string; pageId: string; name?: string; inline?: boolean }) => {
566
580
  const db: Database = {
@@ -589,7 +603,7 @@ export function createMockQuireApi() {
589
603
  for (const view of db.views) view.databaseId = db.id
590
604
  databases.push(db)
591
605
  found(input.pageId).kind = 'database'
592
- return db
606
+ return copy(db)
593
607
  },
594
608
 
595
609
  rows: async (input: {
@@ -702,7 +716,7 @@ export function createMockQuireApi() {
702
716
  hidden: false,
703
717
  }
704
718
  db.properties.push(property)
705
- return property
719
+ return copy(property)
706
720
  },
707
721
 
708
722
  updateProperty: async (input: {
@@ -717,7 +731,7 @@ export function createMockQuireApi() {
717
731
  if (input.type !== undefined) property.type = input.type
718
732
  if (input.config !== undefined) property.config = input.config
719
733
  if (input.hidden !== undefined) property.hidden = input.hidden
720
- return property
734
+ return copy(property)
721
735
  },
722
736
 
723
737
  moveProperty: async (input: { propertyId: string; afterId?: string | null }) => {
@@ -732,7 +746,7 @@ export function createMockQuireApi() {
732
746
  db.properties.forEach((p, index) => {
733
747
  p.position = String(index).padStart(4, '0')
734
748
  })
735
- return property
749
+ return copy(property)
736
750
  },
737
751
 
738
752
  removeProperty: async (input: { propertyId: string }) => {
@@ -759,7 +773,7 @@ export function createMockQuireApi() {
759
773
  isDefault: db.views.length === 0,
760
774
  }
761
775
  db.views.push(view)
762
- return view
776
+ return copy(view)
763
777
  },
764
778
 
765
779
  updateView: async (input: {
@@ -773,7 +787,7 @@ export function createMockQuireApi() {
773
787
  if (input.kind !== undefined) view.kind = input.kind
774
788
  // Replaced wholesale, exactly as the service does it — the client must send the merged whole.
775
789
  if (input.config !== undefined) view.config = { ...BLANK_VIEW_CONFIG, ...input.config }
776
- return view
790
+ return copy(view)
777
791
  },
778
792
 
779
793
  removeView: async (input: { viewId: string }) => {
@@ -189,35 +189,42 @@ async function trash() {
189
189
  {:else if query.isError}
190
190
  <EmptyState icon="triangle-alert" title={t('page_error')} description={t('page_error_desc')}>
191
191
  {#snippet actions()}
192
- <Button variant="secondary" onclick={() => void query.refetch()}>{t('common.retry')}</Button>
192
+ <Button variant="secondary" onclick={() => void query.refetch()}>{t('retry')}</Button>
193
193
  {/snippet}
194
194
  </EmptyState>
195
195
  {:else if !doc}
196
196
  <EmptyState icon="circle-help" title={t('page_missing')} description={t('page_missing_desc')} />
197
197
  {:else}
198
198
  <div class="head">
199
- {#if editable}
200
- <input
201
- bind:this={titleEl}
202
- class="title"
203
- value={title}
204
- placeholder={t('untitled')}
205
- aria-label={t('page_title')}
206
- oninput={(e) => {
207
- title = (e.currentTarget as HTMLInputElement).value
208
- dirty = true
209
- }}
210
- onblur={saveTitle}
211
- onkeydown={(e) => {
212
- if (e.key === 'Enter') {
213
- e.preventDefault()
214
- ;(e.currentTarget as HTMLInputElement).blur()
215
- }
216
- }}
217
- />
218
- {:else}
219
- <h1 class="title">{doc.title.trim() || t('untitled')}</h1>
220
- {/if}
199
+ <!--
200
+ The editable title is *inside* the h1 rather than instead of it. A page whose only title is
201
+ an `<input>` has no level-1 heading at all — which is what a screen reader looks for first,
202
+ and what `ux.spec.ts` fails a route on.
203
+ -->
204
+ <h1 class="title">
205
+ {#if editable}
206
+ <input
207
+ bind:this={titleEl}
208
+ class="title-field"
209
+ value={title}
210
+ placeholder={t('untitled')}
211
+ aria-label={t('page_title')}
212
+ oninput={(e) => {
213
+ title = (e.currentTarget as HTMLInputElement).value
214
+ dirty = true
215
+ }}
216
+ onblur={saveTitle}
217
+ onkeydown={(e) => {
218
+ if (e.key === 'Enter') {
219
+ e.preventDefault()
220
+ ;(e.currentTarget as HTMLInputElement).blur()
221
+ }
222
+ }}
223
+ />
224
+ {:else}
225
+ {doc.title.trim() || t('untitled')}
226
+ {/if}
227
+ </h1>
221
228
 
222
229
  <DropdownMenu
223
230
  items={[
@@ -356,12 +363,18 @@ async function trash() {
356
363
  line-height: 1.2;
357
364
  color: var(--kern-ink-900);
358
365
  margin: 0;
366
+ }
367
+ .title-field {
368
+ width: 100%;
369
+ min-width: 0;
370
+ color: inherit;
371
+ font: inherit;
372
+ margin: 0;
359
373
  border: 0;
360
374
  background: none;
361
375
  padding: 0;
362
- font-family: inherit;
363
376
  }
364
- .title:focus {
377
+ .title-field:focus {
365
378
  outline: none;
366
379
  }
367
380
  .byline {
@@ -54,7 +54,7 @@ function open(key: string) {
54
54
  {:else if spacesQuery.isError}
55
55
  <EmptyState icon="triangle-alert" title={t('spaces_error')} description={t('spaces_error_desc')}>
56
56
  {#snippet actions()}
57
- <Button variant="secondary" onclick={() => void spacesQuery.refetch()}>{t('common.retry')}</Button>
57
+ <Button variant="secondary" onclick={() => void spacesQuery.refetch()}>{t('retry')}</Button>
58
58
  {/snippet}
59
59
  </EmptyState>
60
60
  {:else if spaceList.length === 0}