@eifi1/ui-kit 0.39.0 → 0.40.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.
- package/README.md +8 -7
- package/dist/billing/billing-overview.d.ts +12 -2
- package/dist/billing/pay-page.js +1 -1
- package/dist/billing/pay-page.js.map +1 -1
- package/dist/bin/eifi1-pay-page.mjs +1 -1
- package/dist/components/autocomplete.js +5 -1
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/combobox-core.d.ts +14 -1
- package/dist/components/combobox-core.js +4 -0
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.js +9 -3
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +5 -0
- package/dist/components/data-table-labels.js +2 -0
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-mobile.d.ts +36 -1
- package/dist/components/data-table-mobile.js +162 -15
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table.d.ts +39 -12
- package/dist/components/data-table.js +74 -13
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +10 -3
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/picker-sheet.d.ts +18 -1
- package/dist/components/picker-sheet.js +20 -2
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/tabs.d.ts +26 -2
- package/dist/components/tabs.js +41 -2
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/translation-review-parts.d.ts +2 -1
- package/dist/components/translation-review-parts.js +1 -1
- package/dist/components/translation-review-parts.js.map +1 -1
- package/dist/components/ui-labels.js +3 -1
- package/dist/components/ui-labels.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-comment-thread.d.ts +8 -1
- package/dist/feedback/feedback-comment-thread.js +26 -1
- package/dist/feedback/feedback-comment-thread.js.map +1 -1
- package/dist/feedback/feedback-labels.d.ts +2 -1
- package/dist/feedback/feedback-labels.js +1 -0
- package/dist/feedback/feedback-labels.js.map +1 -1
- package/dist/feedback/feedback-menu-labels.js +3 -1
- package/dist/feedback/feedback-menu-labels.js.map +1 -1
- package/dist/feedback/feedback-menu.d.ts +31 -24
- package/dist/feedback/feedback-menu.js +16 -7
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/feedback/feedback-public-labels.d.ts +3 -2
- package/dist/feedback/feedback-public-labels.js +1 -1
- package/dist/feedback/feedback-public-labels.js.map +1 -1
- package/dist/feedback/feedback-public-list.js +1 -0
- package/dist/feedback/feedback-public-list.js.map +1 -1
- package/dist/feedback/feedback-public-types.d.ts +16 -3
- package/dist/feedback/feedback-record.d.ts +20 -3
- package/dist/feedback/feedback-record.js +15 -0
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-replies.d.ts +13 -2
- package/dist/feedback/feedback-replies.js +67 -33
- package/dist/feedback/feedback-replies.js.map +1 -1
- package/dist/feedback/feedback-rework-entry-chip.d.ts +9 -0
- package/dist/feedback/feedback-rework-entry-chip.js +13 -0
- package/dist/feedback/feedback-rework-entry-chip.js.map +1 -0
- package/dist/feedback/feedback-row-detail.d.ts +31 -8
- package/dist/feedback/feedback-row-detail.js +31 -7
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-table-labels.js +1 -1
- package/dist/feedback/feedback-table-labels.js.map +1 -1
- package/dist/feedback/feedback-table.d.ts +5 -2
- package/dist/feedback/feedback-table.js +2 -2
- package/dist/feedback/feedback-table.js.map +1 -1
- package/dist/feedback/feedback-thread.js +3 -1
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/feedback/feedback-vocabulary.d.ts +9 -6
- package/dist/feedback/feedback-vocabulary.js +16 -1
- package/dist/feedback/feedback-vocabulary.js.map +1 -1
- package/dist/hooks/use-long-press.d.ts +30 -0
- package/dist/hooks/use-long-press.js +77 -0
- package/dist/hooks/use-long-press.js.map +1 -0
- package/dist/hooks/use-overlay-history.js +37 -3
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/german.js +13 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +3 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +12 -5
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +13 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +13 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +13 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +13 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +13 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/shared-labels.js +1 -1
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/pay/pay.js +1 -1
- package/dist/shell/app-shell.js +9 -0
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/fill-height.d.ts +38 -0
- package/dist/shell/fill-height.js +12 -0
- package/dist/shell/fill-height.js.map +1 -0
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +3 -0
- package/dist/shell.js.map +1 -1
- package/package.json +1 -1
- package/src/billing/billing-overview.ts +12 -2
- package/src/billing/pay-page.ts +1 -1
- package/src/components/autocomplete.tsx +5 -1
- package/src/components/combobox-core.tsx +20 -1
- package/src/components/combobox.tsx +9 -3
- package/src/components/data-table-labels.ts +7 -0
- package/src/components/data-table-mobile.tsx +193 -9
- package/src/components/data-table.tsx +141 -24
- package/src/components/inline-entity-combobox.tsx +12 -4
- package/src/components/picker-sheet.tsx +38 -1
- package/src/components/tabs.tsx +73 -3
- package/src/components/translation-review-parts.tsx +3 -2
- package/src/components/ui-labels.ts +2 -0
- package/src/feedback/feedback-comment-thread.tsx +39 -1
- package/src/feedback/feedback-labels.ts +3 -1
- package/src/feedback/feedback-menu-labels.ts +2 -0
- package/src/feedback/feedback-menu.tsx +48 -30
- package/src/feedback/feedback-public-labels.ts +4 -3
- package/src/feedback/feedback-public-list.tsx +4 -1
- package/src/feedback/feedback-public-types.ts +17 -3
- package/src/feedback/feedback-record.ts +34 -3
- package/src/feedback/feedback-replies.tsx +100 -31
- package/src/feedback/feedback-rework-entry-chip.tsx +18 -0
- package/src/feedback/feedback-row-detail.tsx +65 -12
- package/src/feedback/feedback-table-labels.ts +1 -1
- package/src/feedback/feedback-table.tsx +7 -4
- package/src/feedback/feedback-thread.tsx +5 -1
- package/src/feedback/feedback-vocabulary.ts +27 -6
- package/src/hooks/use-long-press.ts +126 -0
- package/src/hooks/use-overlay-history.ts +74 -3
- package/src/i18n/german.ts +10 -2
- package/src/i18n/kit-labels.tsx +3 -0
- package/src/i18n/locales/es.ts +10 -2
- package/src/i18n/locales/fr.ts +10 -2
- package/src/i18n/locales/hu.ts +10 -2
- package/src/i18n/locales/it.ts +10 -2
- package/src/i18n/locales/zh.ts +10 -2
- package/src/i18n/shared-labels.ts +1 -1
- package/src/shell/app-shell.tsx +9 -0
- package/src/shell/fill-height.tsx +45 -0
- package/src/shell.ts +3 -0
|
@@ -237,7 +237,8 @@ export function feedbackPageUrl(context: FeedbackContext | null | undefined): st
|
|
|
237
237
|
* keyed by URL, a React key clash keksdose had).
|
|
238
238
|
*/
|
|
239
239
|
export function feedbackAttachmentUrls(
|
|
240
|
-
row: Pick<FeedbackRecord, "screenshot_url" | "attachment_urls" | "body"
|
|
240
|
+
row: Pick<FeedbackRecord, "screenshot_url" | "attachment_urls" | "body"> &
|
|
241
|
+
Partial<Pick<FeedbackRecord, "replies">>,
|
|
241
242
|
): string[] {
|
|
242
243
|
const urls = [
|
|
243
244
|
...(row.screenshot_url ? [row.screenshot_url] : []),
|
|
@@ -245,6 +246,11 @@ export function feedbackAttachmentUrls(
|
|
|
245
246
|
// that predates it (keksdose dev #578).
|
|
246
247
|
...(row.attachment_urls ?? []),
|
|
247
248
|
...splitBodyAttachments(row.body).urls,
|
|
249
|
+
// 0.40 (§10.3): a rework entry's one file, in entry order — where the rework
|
|
250
|
+
// pictures of the body were before.
|
|
251
|
+
...(row.replies ?? []).flatMap((reply) =>
|
|
252
|
+
reply.kind === "rework" && reply.attachment_url ? [reply.attachment_url] : [],
|
|
253
|
+
),
|
|
248
254
|
];
|
|
249
255
|
return [...new Set(urls)];
|
|
250
256
|
}
|
|
@@ -330,8 +336,9 @@ export interface FeedbackRowAccess {
|
|
|
330
336
|
/** The admin on the inbox (`canEdit`): since 0.38 the reply composer (the outcome
|
|
331
337
|
* itself is frozen, §9.7). */
|
|
332
338
|
canEditOutcome: boolean;
|
|
333
|
-
/** "Rework": the
|
|
334
|
-
*
|
|
339
|
+
/** "Rework": the author, on an answered row ({@link FEEDBACK_REWORKABLE_STATUSES}).
|
|
340
|
+
* Since 0.40 (§10.2 decision 6) the author's only — an admin sends a report back with
|
|
341
|
+
* a reply whose status is `REWORK`; the server answers anyone else 403. */
|
|
335
342
|
canRework: boolean;
|
|
336
343
|
/** The status pills: the admin on the inbox; everyone else sees them read-only. */
|
|
337
344
|
canChangeStatus: boolean;
|
|
@@ -356,7 +363,7 @@ export function feedbackRowAccess(
|
|
|
356
363
|
isAuthor,
|
|
357
364
|
canEditDescription: isAuthor && FEEDBACK_AUTHOR_EDITABLE_STATUSES.includes(row.status),
|
|
358
365
|
canEditOutcome: canEdit,
|
|
359
|
-
canRework:
|
|
366
|
+
canRework: isAuthor && FEEDBACK_REWORKABLE_STATUSES.includes(row.status),
|
|
360
367
|
canChangeStatus: canEdit,
|
|
361
368
|
};
|
|
362
369
|
}
|
|
@@ -396,11 +403,22 @@ export type FeedbackUpload = (file: File) => Promise<string>;
|
|
|
396
403
|
|
|
397
404
|
/* ── Send for rework (§4.4.5) ─────────────────────────────────────────────── */
|
|
398
405
|
|
|
406
|
+
/** 0.40 (§10.3): `POST /feedback/{id}/rework` — the trimmed note and the uploaded file's
|
|
407
|
+
* URL. Return the promise: the section closes when it resolves and keeps the note when
|
|
408
|
+
* it rejects (the toast is the app's: the server's `detail`, else
|
|
409
|
+
* `feedbackToast.updateFailed`). */
|
|
410
|
+
export type FeedbackReworkSend = (note: string, attachmentUrl?: string) => void | Promise<unknown>;
|
|
411
|
+
|
|
399
412
|
export interface FeedbackReworkSectionProps {
|
|
400
413
|
/** The row being sent back: its id, and the body the round is appended to. */
|
|
401
414
|
row: Pick<FeedbackRecord, "id" | "body">;
|
|
402
|
-
/**
|
|
403
|
-
onUpdate
|
|
415
|
+
/** 0.40: the rework as an entry of its own — see {@link FeedbackReworkSend}. With it,
|
|
416
|
+
* `onUpdate` is not called. */
|
|
417
|
+
onRework?: FeedbackReworkSend;
|
|
418
|
+
/** The PATCH — see {@link FeedbackDetailUpdate}. Called with `{ id, body }` only, and
|
|
419
|
+
* only without `onRework`: the legacy append (§3.4), which server-kit 0.13 still
|
|
420
|
+
* turns into an entry for one release. Optional since 0.40; pass one of the two. */
|
|
421
|
+
onUpdate?: FeedbackDetailUpdate;
|
|
404
422
|
/** The PATCH is in flight (`update.isPending`). The upload adds its own. */
|
|
405
423
|
pending?: boolean;
|
|
406
424
|
/** The upload. Left out, the note goes without a file field. */
|
|
@@ -422,7 +440,7 @@ export interface FeedbackReworkSectionProps {
|
|
|
422
440
|
}
|
|
423
441
|
|
|
424
442
|
/**
|
|
425
|
-
* The rework editor (§4.4.5): the submitter
|
|
443
|
+
* The rework editor (§4.4.5): the submitter sends an answered report
|
|
426
444
|
* back with a note and at most one file, and the server reopens it (§3.4, keksdose
|
|
427
445
|
* `submitRework`, `feedback-page.tsx:350`).
|
|
428
446
|
*
|
|
@@ -432,6 +450,9 @@ export interface FeedbackReworkSectionProps {
|
|
|
432
450
|
* uploaded FIRST and awaited: a body naming a file that never arrived would be a
|
|
433
451
|
* rework whose evidence is missing, and the upload is the half that fails on its
|
|
434
452
|
* own. If it fails nothing is sent and the toast says so; the note stays in the box.
|
|
453
|
+
* - **0.40: an entry of its own** (§10.3) — with `onRework`, the note and the file's URL
|
|
454
|
+
* go to `POST /feedback/{id}/rework` and the body is not touched. Without it, the
|
|
455
|
+
* legacy append below, which server-kit 0.13 still converts (one minor).
|
|
435
456
|
* - **The body alone goes in the PATCH** — {@link appendRework}'s strict extension of
|
|
436
457
|
* the current body, which is how the server recognises the append and reopens the
|
|
437
458
|
* row. Never a `status` (kastlan's client sent `OPEN`; an author may not write one,
|
|
@@ -443,6 +464,7 @@ export interface FeedbackReworkSectionProps {
|
|
|
443
464
|
*/
|
|
444
465
|
export function FeedbackReworkSection({
|
|
445
466
|
row,
|
|
467
|
+
onRework,
|
|
446
468
|
onUpdate,
|
|
447
469
|
pending = false,
|
|
448
470
|
onUpload,
|
|
@@ -456,6 +478,7 @@ export function FeedbackReworkSection({
|
|
|
456
478
|
const labels = useFeedbackDetailLabels(labelsProp);
|
|
457
479
|
const toasts = useFeedbackToastLabels();
|
|
458
480
|
const [uploading, setUploading] = useState(false);
|
|
481
|
+
const [sending, setSending] = useState(false);
|
|
459
482
|
// The body as it is NOW, not as it was when Send was pressed: the list can refresh
|
|
460
483
|
// while the file uploads, and an append to a stale body is no longer a strict
|
|
461
484
|
// extension of the stored one — the server would read it as an edit, and refuse it.
|
|
@@ -490,6 +513,21 @@ export function FeedbackReworkSection({
|
|
|
490
513
|
setUploading(false);
|
|
491
514
|
}
|
|
492
515
|
}
|
|
516
|
+
if (onRework) {
|
|
517
|
+
// 0.40 (§10.3): the entry, not the body — the server checks the URL (a 422 outside
|
|
518
|
+
// the app's attachment prefix).
|
|
519
|
+
setSending(true);
|
|
520
|
+
try {
|
|
521
|
+
await onRework(note, url ?? undefined);
|
|
522
|
+
} catch {
|
|
523
|
+
return; // The app's toast; the note stays in the box.
|
|
524
|
+
} finally {
|
|
525
|
+
setSending(false);
|
|
526
|
+
}
|
|
527
|
+
onSent?.();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (!onUpdate) return;
|
|
493
531
|
let body: string | null;
|
|
494
532
|
try {
|
|
495
533
|
body = appendRework(latestBody.current, note, url);
|
|
@@ -511,7 +549,7 @@ export function FeedbackReworkSection({
|
|
|
511
549
|
initial=""
|
|
512
550
|
required
|
|
513
551
|
commit={commit}
|
|
514
|
-
pending={pending || uploading}
|
|
552
|
+
pending={pending || uploading || sending}
|
|
515
553
|
onSave={(value, file) => void send(value, file)}
|
|
516
554
|
onCancel={onCancel}
|
|
517
555
|
saveLabel={labels.reworkSend}
|
|
@@ -593,6 +631,15 @@ export interface FeedbackRowDetailProps {
|
|
|
593
631
|
onReply?: (body: string, status?: FeedbackStatus) => void | Promise<unknown>;
|
|
594
632
|
/** 0.38: the reply is in flight. */
|
|
595
633
|
replying?: boolean;
|
|
634
|
+
/**
|
|
635
|
+
* 0.40 (§10.3): `POST /feedback/{id}/rework` — the author's rework as an entry of its
|
|
636
|
+
* own (see {@link FeedbackReworkSend}). The **Rework** button shows only to the author
|
|
637
|
+
* on an answered row, and only where the reply composer is not shown (§10.2 decision 6:
|
|
638
|
+
* an admin answering a report sends it back with a reply whose status is `REWORK`).
|
|
639
|
+
* Left out, the rework goes through `onUpdate` as the legacy body append — for one
|
|
640
|
+
* minor, while the server converts it.
|
|
641
|
+
*/
|
|
642
|
+
onRework?: FeedbackReworkSend;
|
|
596
643
|
/** 0.38: the report's comments — the app's `FeedbackCommentThread` — in a section of
|
|
597
644
|
* their own under the replies. A thread here draws the comments alone (0.38.1): the
|
|
598
645
|
* Replies section above has the replies, so whatever `replies` the thread is handed
|
|
@@ -647,6 +694,7 @@ export function FeedbackRowDetail({
|
|
|
647
694
|
showSubject = true,
|
|
648
695
|
onReply,
|
|
649
696
|
replying = false,
|
|
697
|
+
onRework,
|
|
650
698
|
comments,
|
|
651
699
|
onPublicChange,
|
|
652
700
|
labels: labelsProp,
|
|
@@ -687,7 +735,11 @@ export function FeedbackRowDetail({
|
|
|
687
735
|
// 0.38, whose frozen outcome is shown as it is, read-only.
|
|
688
736
|
const replies = row.replies;
|
|
689
737
|
const commentLabels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS);
|
|
690
|
-
|
|
738
|
+
// 0.40 (§10.2 decision 6): whoever has the reply composer answers with it — REWORK is
|
|
739
|
+
// one of its statuses — so the Rework button is for an author without it.
|
|
740
|
+
const composerShown = Boolean(replies && access.canEditOutcome && onReply);
|
|
741
|
+
const canRework = access.canRework && !composerShown;
|
|
742
|
+
const showRework = canRework && reworking;
|
|
691
743
|
|
|
692
744
|
return (
|
|
693
745
|
<FeedbackDetail className={className}>
|
|
@@ -791,7 +843,7 @@ export function FeedbackRowDetail({
|
|
|
791
843
|
) : (
|
|
792
844
|
<FeedbackProse>{row.outcome || undefined}</FeedbackProse>
|
|
793
845
|
)}
|
|
794
|
-
{
|
|
846
|
+
{composerShown && replies && onReply && (
|
|
795
847
|
<FeedbackReplyComposer
|
|
796
848
|
key={row.id}
|
|
797
849
|
status={row.status}
|
|
@@ -801,9 +853,9 @@ export function FeedbackRowDetail({
|
|
|
801
853
|
commit={commit}
|
|
802
854
|
/>
|
|
803
855
|
)}
|
|
804
|
-
{((
|
|
856
|
+
{((canRework && !reworking) || pageHref) && (
|
|
805
857
|
<div className="flex flex-wrap gap-2">
|
|
806
|
-
{
|
|
858
|
+
{canRework && !reworking && (
|
|
807
859
|
<Button
|
|
808
860
|
type="button"
|
|
809
861
|
variant="secondary"
|
|
@@ -836,6 +888,7 @@ export function FeedbackRowDetail({
|
|
|
836
888
|
{showRework && (
|
|
837
889
|
<FeedbackReworkSection
|
|
838
890
|
row={row}
|
|
891
|
+
onRework={onRework}
|
|
839
892
|
onUpdate={onUpdate}
|
|
840
893
|
pending={pending}
|
|
841
894
|
onUpload={onUpload}
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
type FeedbackCategoryLabels,
|
|
29
29
|
type FeedbackStatusLabels,
|
|
30
30
|
} from "./feedback-labels";
|
|
31
|
-
import { FEEDBACK_AWAITING_STATUSES,
|
|
31
|
+
import { FEEDBACK_AWAITING_STATUSES, feedbackReworkCount, type FeedbackContext, type FeedbackRecord } from "./feedback-record";
|
|
32
32
|
import type { FeedbackStatusChange } from "./feedback-status-undo";
|
|
33
33
|
import { feedbackPageHref, feedbackPagePath } from "./feedback-row-detail";
|
|
34
34
|
import type { CommitScope } from "../components/write-lock";
|
|
@@ -70,7 +70,8 @@ import { DEFAULT_FEEDBACK_PUBLIC_LABELS } from "./feedback-public-labels";
|
|
|
70
70
|
* `de-CH.json`, ss never ß).
|
|
71
71
|
*/
|
|
72
72
|
export interface FeedbackPageLabels {
|
|
73
|
-
/** The admin inbox's page title, `/feedback
|
|
73
|
+
/** The admin inbox's page title, `/feedback` — the menu's "Feedback inbox" (0.40,
|
|
74
|
+
* §10.2 decision 11; "Feedback" before). de-CH: "Feedback-Eingang" */
|
|
74
75
|
title: string;
|
|
75
76
|
/** The user's own list, `/my-feedback`. de-CH: "Mein Feedback" */
|
|
76
77
|
myTitle: string;
|
|
@@ -268,7 +269,9 @@ function instant(value: string | null | undefined): number | null {
|
|
|
268
269
|
/* ── The subject: title + environment chip + rework chip ───────────────────── */
|
|
269
270
|
|
|
270
271
|
export interface FeedbackSubjectProps {
|
|
271
|
-
|
|
272
|
+
/** `replies` (0.40): the chip counts the rework entries, else the body's blocks —
|
|
273
|
+
* {@link feedbackReworkCount}. */
|
|
274
|
+
row: Pick<FeedbackRecord, "title" | "body" | "context"> & Partial<Pick<FeedbackRecord, "replies">>;
|
|
272
275
|
/**
|
|
273
276
|
* `false` (default): one line — the title truncates and the chips stay whole, as a
|
|
274
277
|
* table cell wants. `true`: the title wraps and the chips flow after its last word, as
|
|
@@ -292,7 +295,7 @@ export interface FeedbackSubjectProps {
|
|
|
292
295
|
export function FeedbackSubject({ row, wrap = false, labels: labelsProp, className }: FeedbackSubjectProps) {
|
|
293
296
|
const labels = useFeedbackPageLabels(labelsProp);
|
|
294
297
|
const env = feedbackEnvironment(row.context);
|
|
295
|
-
const rounds =
|
|
298
|
+
const rounds = feedbackReworkCount(row);
|
|
296
299
|
const chipClass = wrap ? "ms-1.5 align-middle" : "shrink-0";
|
|
297
300
|
const chips = (
|
|
298
301
|
<>
|
|
@@ -852,7 +852,9 @@ export function ChatComposer({
|
|
|
852
852
|
}
|
|
853
853
|
}}
|
|
854
854
|
/>
|
|
855
|
-
|
|
855
|
+
{/* The `data-chat-composer-*` marks are for a host that lines its slot up with the
|
|
856
|
+
send button — FeedbackReplyComposer's status Select (#417). */}
|
|
857
|
+
<div data-chat-composer-actions="" className="flex flex-wrap items-end justify-between gap-2">
|
|
856
858
|
{config || slot != null ? (
|
|
857
859
|
<div className="flex min-w-0 flex-wrap items-end gap-2">
|
|
858
860
|
{config && (
|
|
@@ -878,11 +880,13 @@ export function ChatComposer({
|
|
|
878
880
|
<div className="ms-auto flex items-center gap-2">
|
|
879
881
|
<span
|
|
880
882
|
id={hintId}
|
|
883
|
+
data-chat-composer-hint=""
|
|
881
884
|
className={size === "sm" ? "sr-only" : "text-xs text-placeholder"}
|
|
882
885
|
>
|
|
883
886
|
{sendOn === "enter" ? labels.sendHintEnter : labels.sendHint(modifier)}
|
|
884
887
|
</span>
|
|
885
888
|
<Button
|
|
889
|
+
data-chat-composer-send=""
|
|
886
890
|
onClick={send}
|
|
887
891
|
disabled={!canSend}
|
|
888
892
|
commit={commit}
|
|
@@ -11,12 +11,13 @@ import {
|
|
|
11
11
|
MoreHorizontal,
|
|
12
12
|
OctagonAlert,
|
|
13
13
|
PauseCircle,
|
|
14
|
+
RotateCcw,
|
|
14
15
|
Wrench,
|
|
15
16
|
} from "lucide-react";
|
|
16
17
|
import type { LucideIcon } from "lucide-react";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
* The feedback **vocabulary**: the
|
|
20
|
+
* The feedback **vocabulary**: the nine statuses and five categories, the glyph and tone
|
|
20
21
|
* of each, their orders, and the policy for how a report moves from status to status.
|
|
21
22
|
*
|
|
22
23
|
* Data and plain functions, no components. Until 0.34 this lived at the top of
|
|
@@ -27,16 +28,18 @@ import type { LucideIcon } from "lucide-react";
|
|
|
27
28
|
* feedback-inbox.tsx re-exports every name here, so nothing an app imports has moved.
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
|
-
/** The
|
|
31
|
+
/** The nine states a report can be in.
|
|
31
32
|
*
|
|
32
33
|
* Shared value for value across the apps on purpose: it is what lets one habit,
|
|
33
34
|
* and one agent prompt, work on any repo. Five are a chain and three sit off
|
|
34
35
|
* it — see {@link visibleFeedbackStatuses} for what that buys. `READY` joined in 0.31
|
|
35
36
|
* (feedback contract §8.2): OPEN is "filed, not yet triaged", READY "released for
|
|
36
|
-
* implementation", and nobody works an OPEN row.
|
|
37
|
+
* implementation", and nobody works an OPEN row. `REWORK` joined in 0.40 (§10.2
|
|
38
|
+
* decisions 2, 4): "sent back, released for work again" — a READY that came back. */
|
|
37
39
|
export type FeedbackStatus =
|
|
38
40
|
| "OPEN"
|
|
39
41
|
| "READY"
|
|
42
|
+
| "REWORK"
|
|
40
43
|
| "IN_PROGRESS"
|
|
41
44
|
| "IN_EVALUATION"
|
|
42
45
|
| "NEEDS_LIVE_TEST"
|
|
@@ -76,6 +79,16 @@ export const FEEDBACK_STATUS_META: Record<
|
|
|
76
79
|
activeBg: "bg-brand-soft",
|
|
77
80
|
activeText: "text-brand",
|
|
78
81
|
},
|
|
82
|
+
// Sent back by its author or an admin and released for work again (0.40, feedback
|
|
83
|
+
// contract §10.2 decision 4): the Rework button's `RotateCcw`, in the danger tint so the
|
|
84
|
+
// queue shows reworks apart from new reports. Not IN_PROGRESS's warning tint — that is
|
|
85
|
+
// where a REWORK row moves next — and WONT_DO, the only other red, sits at the far end
|
|
86
|
+
// with `Ban`.
|
|
87
|
+
REWORK: {
|
|
88
|
+
icon: RotateCcw,
|
|
89
|
+
activeBg: "bg-danger-soft",
|
|
90
|
+
activeText: "text-danger",
|
|
91
|
+
},
|
|
79
92
|
IN_PROGRESS: {
|
|
80
93
|
icon: Wrench,
|
|
81
94
|
activeBg: "bg-warning-soft",
|
|
@@ -142,8 +155,11 @@ const OFF_CHAIN: FeedbackStatus[] = ["NEEDS_LIVE_TEST", "POSTPONED", "WONT_DO"];
|
|
|
142
155
|
const FORWARD_CHAIN: FeedbackStatus[] = ["OPEN", "READY", "IN_PROGRESS", "IN_EVALUATION", "DONE"];
|
|
143
156
|
|
|
144
157
|
/** The next status along the chain, or null at the end (or off it, as `WONT_DO`
|
|
145
|
-
* is). What a swipe-to-advance gesture commits.
|
|
158
|
+
* is). What a swipe-to-advance gesture commits. `REWORK` (0.40) is not in the chain —
|
|
159
|
+
* "advance" from READY still goes to IN_PROGRESS — but it leads there too: a reworked
|
|
160
|
+
* row is READY's twin. */
|
|
146
161
|
export function nextFeedbackStatus(current: FeedbackStatus): FeedbackStatus | null {
|
|
162
|
+
if (current === "REWORK") return "IN_PROGRESS";
|
|
147
163
|
const at = FORWARD_CHAIN.indexOf(current);
|
|
148
164
|
return at !== -1 && at < FORWARD_CHAIN.length - 1 ? FORWARD_CHAIN[at + 1] : null;
|
|
149
165
|
}
|
|
@@ -152,7 +168,7 @@ export function nextFeedbackStatus(current: FeedbackStatus): FeedbackStatus | nu
|
|
|
152
168
|
* The steps that make sense from where a row currently stands.
|
|
153
169
|
*
|
|
154
170
|
* For the compact control in a table cell: it is a glanceable triage affordance,
|
|
155
|
-
* and a row of
|
|
171
|
+
* and a row of nine icons in a cell is noise. One step forward, one step back
|
|
156
172
|
* (so an item can be sent back for rework), and any of the three off-chain
|
|
157
173
|
* verdicts — which is what makes those usable as verdicts at all.
|
|
158
174
|
*
|
|
@@ -165,7 +181,12 @@ export function nextFeedbackStatus(current: FeedbackStatus): FeedbackStatus | nu
|
|
|
165
181
|
export function visibleFeedbackStatuses(current: FeedbackStatus): FeedbackStatus[] {
|
|
166
182
|
const set = new Set<FeedbackStatus>([current]);
|
|
167
183
|
const at = FORWARD_CHAIN.indexOf(current);
|
|
168
|
-
if (
|
|
184
|
+
if (current === "REWORK") {
|
|
185
|
+
// READY's twin (0.40, §10.2 decision 4): the step that picks the work up, and the
|
|
186
|
+
// verdicts a released row can still get.
|
|
187
|
+
set.add("IN_PROGRESS");
|
|
188
|
+
for (const off of OFF_CHAIN) set.add(off);
|
|
189
|
+
} else if (at !== -1) {
|
|
169
190
|
if (at < FORWARD_CHAIN.length - 1) set.add(FORWARD_CHAIN[at + 1]);
|
|
170
191
|
if (at > 0) set.add(FORWARD_CHAIN[at - 1]);
|
|
171
192
|
for (const off of OFF_CHAIN) set.add(off);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, type PointerEvent as ReactPointerEvent } from "react";
|
|
2
|
+
|
|
3
|
+
// The press-and-hold that puts DataTable's phone cards into selection mode (0.40,
|
|
4
|
+
// keksdose live #421). Taken over from keksdose's register list
|
|
5
|
+
// (frontend/src/shared/hooks/use-long-press.ts, its feedback #153) so the kit's cards
|
|
6
|
+
// and kk's register answer the same gesture the same way: 500 ms, an 8 px dead zone,
|
|
7
|
+
// cancelled by a scroll, the trailing click swallowed.
|
|
8
|
+
|
|
9
|
+
const LONG_PRESS_MS = 500;
|
|
10
|
+
/** The same 8 px dead zone `useRowSwipe` locks its axis at: the press is dead by the
|
|
11
|
+
* time the swipe hook takes pointer capture on its (ancestor) layer. */
|
|
12
|
+
const SLOP_PX = 8;
|
|
13
|
+
|
|
14
|
+
const movedBeyondSlop = (from: { x: number; y: number }, x: number, y: number) =>
|
|
15
|
+
Math.hypot(x - from.x, y - from.y) > SLOP_PX;
|
|
16
|
+
|
|
17
|
+
export interface LongPressResult {
|
|
18
|
+
handlers: {
|
|
19
|
+
onPointerDown: (e: ReactPointerEvent) => void;
|
|
20
|
+
onPointerMove: (e: ReactPointerEvent) => void;
|
|
21
|
+
onContextMenu: (e: { preventDefault: () => void }) => void;
|
|
22
|
+
};
|
|
23
|
+
/** True exactly once after a long press fired: call it from an `onClickCapture` to
|
|
24
|
+
* swallow the click the browser still sends when the finger lifts. */
|
|
25
|
+
consumeClick: () => boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A press-and-hold on a touch row that may ALSO be swipeable.
|
|
30
|
+
*
|
|
31
|
+
* - The cancel listeners live on `window`: `useRowSwipe` captures the pointer on an
|
|
32
|
+
* ancestor once a drag resolves to the x axis, after which the pressed element's
|
|
33
|
+
* own pointer handlers stop firing, and a timer cancelled only there would fire
|
|
34
|
+
* mid-swipe.
|
|
35
|
+
* - A scroll cancels too (`touch-action: pan-y` hands vertical panning to the
|
|
36
|
+
* browser, which sends `pointercancel`; the capture-phase `scroll` is the backstop).
|
|
37
|
+
* - `contextmenu` is suppressed while enabled — Android fires it at ~500 ms. Pair it
|
|
38
|
+
* with `select-none [-webkit-touch-callout:none]` on the element: iOS's callout
|
|
39
|
+
* only yields to that CSS.
|
|
40
|
+
*/
|
|
41
|
+
export function useLongPress(
|
|
42
|
+
onLongPress: () => void,
|
|
43
|
+
{ enabled = true, delay = LONG_PRESS_MS }: { enabled?: boolean; delay?: number } = {},
|
|
44
|
+
): LongPressResult {
|
|
45
|
+
const timer = useRef<number | null>(null);
|
|
46
|
+
const start = useRef({ x: 0, y: 0 });
|
|
47
|
+
const fired = useRef(false);
|
|
48
|
+
const teardown = useRef<(() => void) | null>(null);
|
|
49
|
+
const cbRef = useRef(onLongPress);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
cbRef.current = onLongPress;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const cancel = useCallback(() => {
|
|
55
|
+
if (timer.current !== null) {
|
|
56
|
+
window.clearTimeout(timer.current);
|
|
57
|
+
timer.current = null;
|
|
58
|
+
}
|
|
59
|
+
teardown.current?.();
|
|
60
|
+
teardown.current = null;
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const onPointerDown = useCallback(
|
|
64
|
+
(e: ReactPointerEvent) => {
|
|
65
|
+
// Cleared above every early return: a press whose trailing click never arrives
|
|
66
|
+
// (the finger lifted over another row) would otherwise leave the latch armed,
|
|
67
|
+
// and it would eat the next real tap once the card has switched modes.
|
|
68
|
+
fired.current = false;
|
|
69
|
+
if (!enabled) return;
|
|
70
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
71
|
+
cancel();
|
|
72
|
+
start.current = { x: e.clientX, y: e.clientY };
|
|
73
|
+
|
|
74
|
+
const onEnd = () => cancel();
|
|
75
|
+
const onMove = (ev: PointerEvent) => {
|
|
76
|
+
if (movedBeyondSlop(start.current, ev.clientX, ev.clientY)) cancel();
|
|
77
|
+
};
|
|
78
|
+
window.addEventListener("pointerup", onEnd);
|
|
79
|
+
window.addEventListener("pointercancel", onEnd);
|
|
80
|
+
window.addEventListener("pointermove", onMove);
|
|
81
|
+
window.addEventListener("scroll", onEnd, { capture: true, passive: true });
|
|
82
|
+
teardown.current = () => {
|
|
83
|
+
window.removeEventListener("pointerup", onEnd);
|
|
84
|
+
window.removeEventListener("pointercancel", onEnd);
|
|
85
|
+
window.removeEventListener("pointermove", onMove);
|
|
86
|
+
window.removeEventListener("scroll", onEnd, { capture: true });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
timer.current = window.setTimeout(() => {
|
|
90
|
+
cancel();
|
|
91
|
+
fired.current = true;
|
|
92
|
+
// The swipe stages' haptic vocabulary; absent on iOS Safari.
|
|
93
|
+
navigator.vibrate?.(20);
|
|
94
|
+
cbRef.current();
|
|
95
|
+
}, delay);
|
|
96
|
+
},
|
|
97
|
+
[cancel, delay, enabled],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Also on the element: it still cancels if an ancestor stops the native event
|
|
101
|
+
// before it reaches `window`.
|
|
102
|
+
const onPointerMove = useCallback(
|
|
103
|
+
(e: ReactPointerEvent) => {
|
|
104
|
+
if (timer.current === null) return;
|
|
105
|
+
if (movedBeyondSlop(start.current, e.clientX, e.clientY)) cancel();
|
|
106
|
+
},
|
|
107
|
+
[cancel],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const onContextMenu = useCallback(
|
|
111
|
+
(e: { preventDefault: () => void }) => {
|
|
112
|
+
if (enabled) e.preventDefault();
|
|
113
|
+
},
|
|
114
|
+
[enabled],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const consumeClick = useCallback(() => {
|
|
118
|
+
if (!fired.current) return false;
|
|
119
|
+
fired.current = false;
|
|
120
|
+
return true;
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
useEffect(() => cancel, [cancel]);
|
|
124
|
+
|
|
125
|
+
return { handlers: { onPointerDown, onPointerMove, onContextMenu }, consumeClick };
|
|
126
|
+
}
|
|
@@ -48,6 +48,16 @@ import { useEffect, useRef } from "react";
|
|
|
48
48
|
* {@link noteAddress}: an address the overlay LIVED THROUGH (a filter the table wrote
|
|
49
49
|
* while its sheet was up) is still unwound, because the user has acted since.
|
|
50
50
|
*
|
|
51
|
+
* ## Not undoing the owner's replaces either
|
|
52
|
+
*
|
|
53
|
+
* An address the overlay lived through is unwound as an ENTRY, never as an address
|
|
54
|
+
* (keksdose live #412). A report that writes each tick of its payee sheet into the query
|
|
55
|
+
* with `setSearchParams(…, { replace: true })` rewrites the overlay's own entry, and the
|
|
56
|
+
* page entry beneath still carries the address from before the sheet opened — so the
|
|
57
|
+
* traversal that took our entry away also took every tick with it. See {@link carryOnto}:
|
|
58
|
+
* whichever way the entry goes (the unwind after a press, or a Back press), the address it
|
|
59
|
+
* last showed is carried onto the entry it lands on, and the router is told.
|
|
60
|
+
*
|
|
51
61
|
* ## The traversal is deferred
|
|
52
62
|
*
|
|
53
63
|
* And it is *scheduled* rather than issued, because `history.go()` lands a task
|
|
@@ -99,6 +109,10 @@ interface PushedEntry {
|
|
|
99
109
|
key: string | undefined;
|
|
100
110
|
/** {@link routeOf} the address the marker was placed at. */
|
|
101
111
|
route: string;
|
|
112
|
+
/** The entry's address as last OBSERVED, after the user's last gesture had run its
|
|
113
|
+
* handlers — what a Back press must carry down (see {@link carryOnto}). `href` is
|
|
114
|
+
* the snapshot BEFORE the gesture, which the owner's-close check needs instead. */
|
|
115
|
+
seen: string;
|
|
102
116
|
}
|
|
103
117
|
const pushed: PushedEntry[] = [];
|
|
104
118
|
|
|
@@ -197,10 +211,41 @@ function runUnwind(): void {
|
|
|
197
211
|
if (taggedSentinel() === null) buryUnder(job.entries);
|
|
198
212
|
return;
|
|
199
213
|
}
|
|
214
|
+
const bottom = job.entries[0];
|
|
215
|
+
carry = { href: job.href, idx: bottom.idx, key: bottom.key };
|
|
200
216
|
pendingProgrammatic += 1;
|
|
201
217
|
window.history.go(-job.entries.length);
|
|
202
218
|
}
|
|
203
219
|
|
|
220
|
+
/** The address an unwind in flight must carry onto the entry it lands on. */
|
|
221
|
+
let carry: { href: string; idx: number | undefined; key: string | undefined } | null = null;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Put `href` — the address the overlay's entry last showed — onto the entry a traversal
|
|
225
|
+
* has just landed on, and tell the router (keksdose live #412).
|
|
226
|
+
*
|
|
227
|
+
* Only the query and the hash can differ: an address naming another page is a
|
|
228
|
+
* navigation, never carried. And only onto the page entry the overlay was opened over,
|
|
229
|
+
* which our push copied the router's `{idx, key}` from — a traversal that came to rest
|
|
230
|
+
* anywhere else is not ours to rewrite.
|
|
231
|
+
*
|
|
232
|
+
* The router heard the traversal land on the OLD address in this same event, so a
|
|
233
|
+
* `replaceState` alone would leave it showing the old query under the new one. A
|
|
234
|
+
* synthetic `popstate` makes it read the address again; both reads happen in one event
|
|
235
|
+
* dispatch, so React commits only the second. Ours absorbs it as programmatic.
|
|
236
|
+
*/
|
|
237
|
+
function carryOnto(href: string, mark: { idx: number | undefined; key: string | undefined }): void {
|
|
238
|
+
const here = currentHref();
|
|
239
|
+
if (here === href || routeOf(here) !== routeOf(href)) return;
|
|
240
|
+
const now = routerMark();
|
|
241
|
+
if (mark.idx !== undefined && now.idx !== undefined && mark.idx !== now.idx) return;
|
|
242
|
+
if (mark.key !== undefined && now.key !== undefined && mark.key !== now.key) return;
|
|
243
|
+
const state: unknown = window.history.state;
|
|
244
|
+
window.history.replaceState(state, "", href);
|
|
245
|
+
pendingProgrammatic += 1;
|
|
246
|
+
window.dispatchEvent(new PopStateEvent("popstate", { state }));
|
|
247
|
+
}
|
|
248
|
+
|
|
204
249
|
function currentHref(): string {
|
|
205
250
|
return typeof window === "undefined" ? "" : window.location.href;
|
|
206
251
|
}
|
|
@@ -416,7 +461,25 @@ function noteAddress(): void {
|
|
|
416
461
|
if (!top || top.dead || !stack.some((e) => e.id === top.id)) return;
|
|
417
462
|
const tag = taggedSentinel();
|
|
418
463
|
// Read without `currentSentinel`'s repair: an observation must not write history.
|
|
419
|
-
if (tag === top.id || (tag === null && standing === top.id && isStillOn(top)))
|
|
464
|
+
if (tag === top.id || (tag === null && standing === top.id && isStillOn(top))) {
|
|
465
|
+
top.href = currentHref();
|
|
466
|
+
top.seen = top.href;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** After the user's gesture has run its handlers: the address its entry now shows —
|
|
471
|
+
* what a Back press must carry down (keksdose live #412). Again a little later, for a
|
|
472
|
+
* handler that writes the address a task after the event. */
|
|
473
|
+
function noteSeen(): void {
|
|
474
|
+
const top = pushed[pushed.length - 1];
|
|
475
|
+
if (!top || top.dead || !stack.some((e) => e.id === top.id)) return;
|
|
476
|
+
const tag = taggedSentinel();
|
|
477
|
+
if (tag === top.id || (tag === null && standing === top.id && isStillOn(top))) top.seen = currentHref();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function noteSeenNowAndLater(): void {
|
|
481
|
+
noteSeen();
|
|
482
|
+
setTimeout(noteSeen, 0);
|
|
420
483
|
}
|
|
421
484
|
|
|
422
485
|
function handlePop() {
|
|
@@ -428,6 +491,9 @@ function handlePop() {
|
|
|
428
491
|
lastSeen = routerMark();
|
|
429
492
|
if (pendingProgrammatic > 0) {
|
|
430
493
|
pendingProgrammatic -= 1;
|
|
494
|
+
const job = carry;
|
|
495
|
+
carry = null;
|
|
496
|
+
if (job) carryOnto(job.href, job);
|
|
431
497
|
return;
|
|
432
498
|
}
|
|
433
499
|
closeOnPop();
|
|
@@ -451,7 +517,10 @@ function closeOnPop() {
|
|
|
451
517
|
// The browser consumed this entry, so the overlay's own cleanup has nothing left
|
|
452
518
|
// to unwind and nothing to record as owed.
|
|
453
519
|
consumed.add(top.id);
|
|
520
|
+
const record = pushed.find((p) => p.id === top.id);
|
|
454
521
|
forgetPushed(top.id);
|
|
522
|
+
// What the page wrote into the address while we were up stays (#412).
|
|
523
|
+
if (record && !record.dead) carryOnto(record.seen, record);
|
|
455
524
|
top.close();
|
|
456
525
|
}
|
|
457
526
|
|
|
@@ -460,6 +529,7 @@ function ensureListening() {
|
|
|
460
529
|
window.addEventListener("popstate", handlePop);
|
|
461
530
|
window.addEventListener("pointerdown", noteAddress, true);
|
|
462
531
|
window.addEventListener("keydown", noteAddress, true);
|
|
532
|
+
for (const type of ["click", "keyup", "change", "input"]) window.addEventListener(type, noteSeenNowAndLater);
|
|
463
533
|
listening = true;
|
|
464
534
|
}
|
|
465
535
|
|
|
@@ -501,7 +571,7 @@ export function useOverlayHistory(open: boolean, onClose: () => void): void {
|
|
|
501
571
|
// The abandoned ones beneath come back owed; ours takes the top slot.
|
|
502
572
|
for (const owed of inherited.slice(0, -1)) pushed.push({ ...owed, dead: true });
|
|
503
573
|
const was = inherited[inherited.length - 1];
|
|
504
|
-
pushed.push({ id, href: currentHref(), dead: false, idx: was.idx, key: was.key, route: was.route });
|
|
574
|
+
pushed.push({ id, href: currentHref(), seen: currentHref(), dead: false, idx: was.idx, key: was.key, route: was.route });
|
|
505
575
|
standing = id;
|
|
506
576
|
} else {
|
|
507
577
|
// A push DESTROYS every entry ahead of the one we are on, so anything we
|
|
@@ -516,7 +586,8 @@ export function useOverlayHistory(open: boolean, onClose: () => void): void {
|
|
|
516
586
|
// the URL is unchanged, so the router treats popping this as a no-op re-render.
|
|
517
587
|
window.history.pushState(marked, "");
|
|
518
588
|
const page = routerMark();
|
|
519
|
-
|
|
589
|
+
const at = currentHref();
|
|
590
|
+
pushed.push({ id, href: at, seen: at, dead: false, idx: page.idx, key: page.key, route: routeOf(at) });
|
|
520
591
|
standing = id;
|
|
521
592
|
}
|
|
522
593
|
return () => {
|