@lovett/ui 0.2.0 → 0.2.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/src/styles.css CHANGED
@@ -956,7 +956,13 @@
956
956
  display: flex;
957
957
  flex-direction: column;
958
958
  gap: var(--space-2);
959
- min-block-size: 96px;
959
+ /* 96px is the OUTER floor, and `min-block-size` is a border-box one — so it
960
+ carries the block-start compensation below, which lives outside the border
961
+ box. Without the `+ --space-1` an empty lane in a content-sized board
962
+ (the only layout where this floor binds at all; a stretched board takes
963
+ its height from the grid row) drew its frame 4px shorter than before.
964
+ Measured: column 154px -> 150px, with the empty message itself unmoved. */
965
+ min-block-size: calc(96px + var(--space-1));
960
966
  /* Overridable by the consuming surface. Capping it is what produces the
961
967
  next-card peek instead of an infinitely tall column. Declared here rather
962
968
  than spelled as a `var(--x, fallback)` so the token-shape guard can still
@@ -968,6 +974,52 @@
968
974
  max-block-size: var(--board-column-max-block-size);
969
975
  flex: 1 1 auto;
970
976
  overflow-y: auto;
977
+
978
+ /* THE LANE IS A CLIP, AND A CARD PAINTS OUTSIDE ITSELF.
979
+ *
980
+ * `overflow-y: auto` here does not clip one axis. A `visible` companion is
981
+ * coerced to `auto` — the same rule `.ds-board-scroller` above declares
982
+ * `overflow-y: hidden` to dodge — so this box clips INLINE as well. That is
983
+ * why the reserve below is four-sided and not a top inset: with zero padding
984
+ * the clip edge WAS the card's own border box, so every card lost shadow
985
+ * left and right, the top one lost its hover lift into the header gap, and
986
+ * the last one lost its shadow at the scroll end. Owner, in a browser, 2026-
987
+ * 09-09: the lift is sliced. Nothing typecheck, lint, build or the suite
988
+ * reads can see a sliced shadow, and jsdom has no layout to measure one.
989
+ *
990
+ * `scroll-padding-block-end` below is NOT this and never was. It positions
991
+ * where `scrollIntoView` lands. It reserves no paint room whatsoever.
992
+ *
993
+ * The reserve is READ OFF the shadow tokens, not guessed. A shadow paints
994
+ * `|offset| + spread + blur/2` past the border box, so the hover step
995
+ * --shadow-md reaches furthest in the theme where it is a plain
996
+ * `0 4px 12px`: 6px inline, 2px block-start, 10px block-end. The hover
997
+ * `translateY(-1px)` lifts all of that one pixel, taking block-start to 3.
998
+ * Rounded up onto the spacing scale: 8 / 4 / 12. A value off the scale would
999
+ * mean the scale is wrong; these are on it.
1000
+ *
1001
+ * Inline and block-start are pulled straight back by an equal NEGATIVE
1002
+ * margin, so this box grows outward into the column's own --space-2 padding
1003
+ * and not one card moves. That is the entire requirement — paint room
1004
+ * without repositioning the lane's resting composition. The side effect is
1005
+ * an improvement: an overlay vertical scrollbar now floats over the reserve
1006
+ * instead of over the cards.
1007
+ *
1008
+ * Block-end takes no such margin, deliberately, and this is where the
1009
+ * scroller's note at ~:839 is respected rather than overruled. 12px exceeds
1010
+ * the column's 8px of bottom padding, so pulling back would drive this box
1011
+ * through the column's border AND consume the 8px that note keeps clear for
1012
+ * an overlay HORIZONTAL scrollbar to float over — the one thing it asks for.
1013
+ * Uncompensated trailing padding on a scroll container costs nothing at
1014
+ * rest, because `flex: 1 1 auto` already makes this box taller than its
1015
+ * content; it surfaces only at the bottom of a scrolled lane, where it reads
1016
+ * as the end of the list rather than as a gap. */
1017
+ padding-inline: var(--space-2);
1018
+ margin-inline: calc(var(--space-2) * -1);
1019
+ padding-block-start: var(--space-1);
1020
+ margin-block-start: calc(var(--space-1) * -1);
1021
+ padding-block-end: var(--space-3);
1022
+
971
1023
  scroll-padding-block-end: var(--space-6);
972
1024
  }
973
1025
 
package/src/theme-v2.css CHANGED
@@ -158,6 +158,19 @@
158
158
  against its surface, under the 3:1 WCAG 2.2 SC 2.4.11 asks of a focus
159
159
  indicator. See the note on --ring-focus in tokens.css. */
160
160
  --ring-focus: 0 0 0 2px rgb(var(--accent));
161
+ /* The SAME ring, painted inward. An outset box-shadow is clipped by ANY
162
+ ancestor with a non-visible overflow, and when that happens the focus
163
+ indicator does not degrade — it disappears, which is a WCAG 2.4.7 failure
164
+ rather than a cosmetic one.
165
+ Use this at a clipping boundary: inside a scroll container, inside the
166
+ `overflow-hidden` a grid-rows reveal needs, inside a Card that keeps its
167
+ default clip. It cannot be clipped by an ancestor because it paints inside
168
+ the element's own border box.
169
+ Found the long way on 2026-09-09: the reply composer, the reaction chips
170
+ and the GIF picker all lost their ring, and `thread.tsx` had already been
171
+ forced to override a Card's `overflow-hidden` to `overflow-visible` to keep
172
+ one. That override is the cost this token removes. */
173
+ --ring-focus-inset: inset 0 0 0 2px rgb(var(--accent));
161
174
  --ring-error: 0 0 0 2px rgb(var(--destructive));
162
175
  }
163
176
 
@@ -225,4 +238,17 @@
225
238
  --inset-highlight: 255 255 255 / 0.05;
226
239
  --modal-overlay: 0 0 0 / 0.7;
227
240
  --ring-focus: 0 0 0 2px rgb(var(--accent));
241
+ /* The SAME ring, painted inward. An outset box-shadow is clipped by ANY
242
+ ancestor with a non-visible overflow, and when that happens the focus
243
+ indicator does not degrade — it disappears, which is a WCAG 2.4.7 failure
244
+ rather than a cosmetic one.
245
+ Use this at a clipping boundary: inside a scroll container, inside the
246
+ `overflow-hidden` a grid-rows reveal needs, inside a Card that keeps its
247
+ default clip. It cannot be clipped by an ancestor because it paints inside
248
+ the element's own border box.
249
+ Found the long way on 2026-09-09: the reply composer, the reaction chips
250
+ and the GIF picker all lost their ring, and `thread.tsx` had already been
251
+ forced to override a Card's `overflow-hidden` to `overflow-visible` to keep
252
+ one. That override is the cost this token removes. */
253
+ --ring-focus-inset: inset 0 0 0 2px rgb(var(--accent));
228
254
  }
@@ -740,3 +740,191 @@ describe('Thread — MEDIUM 4: the comments are a real list', () => {
740
740
  expect(commentBox('c-many').style.paddingInlineStart).toBe('')
741
741
  })
742
742
  })
743
+
744
+ /**
745
+ * The per-comment capability seam.
746
+ *
747
+ * 0.2.0 gated Edit and Delete on "a callback exists and this is not a
748
+ * tombstone" and nothing else, so a host that wired both drew both on every
749
+ * live comment — including ones the viewer did not write, where the server's
750
+ * `AND author_id = ?` answers 404 and always will.
751
+ *
752
+ * These assert at the rendered MENU ITEM, never at the prop. A capability that
753
+ * arrives and changes nothing is the same defect one layer further in.
754
+ */
755
+ describe('Thread — canEdit and canDelete', () => {
756
+ /** Two comments by two people; only the first is the viewer's. */
757
+ const AUTHORSHIP: readonly ThreadComment[] = [
758
+ {
759
+ id: 'c-mine',
760
+ author: { id: 'usr_ada', name: 'Ada Whitfield' },
761
+ bodyMd: 'I wrote this one.',
762
+ createdAt: FIXTURE_NOW - 60_000,
763
+ canEdit: true,
764
+ canDelete: true,
765
+ },
766
+ {
767
+ id: 'c-theirs',
768
+ author: { id: 'usr_lee', name: 'Lee Ndiaye' },
769
+ bodyMd: 'And this one is not mine.',
770
+ createdAt: FIXTURE_NOW - 30_000,
771
+ canEdit: false,
772
+ canDelete: false,
773
+ },
774
+ ]
775
+
776
+ function openMenu(commentId: string, name: string): void {
777
+ fireEvent.click(
778
+ within(commentBox(commentId)).getByRole('button', {
779
+ name: `More actions for ${name}'s comment`,
780
+ }),
781
+ )
782
+ }
783
+
784
+ it('draws Edit and Delete on a comment the viewer authored', () => {
785
+ render(
786
+ <Thread
787
+ comments={AUTHORSHIP}
788
+ now={FIXTURE_NOW}
789
+ locale="en-GB"
790
+ defaultSort="new"
791
+ onEdit={vi.fn()}
792
+ onDelete={vi.fn()}
793
+ />,
794
+ )
795
+ openMenu('c-mine', 'Ada Whitfield')
796
+ expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeTruthy()
797
+ expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeTruthy()
798
+ })
799
+
800
+ it('draws NEITHER on a comment the viewer did not author', () => {
801
+ render(
802
+ <Thread
803
+ comments={AUTHORSHIP}
804
+ now={FIXTURE_NOW}
805
+ locale="en-GB"
806
+ defaultSort="new"
807
+ onEdit={vi.fn()}
808
+ onDelete={vi.fn()}
809
+ // A third item, so the menu still has a reason to exist and the two
810
+ // that matter are provably absent FROM it rather than absent because
811
+ // the whole trigger vanished. Both cases are asserted; this is the one
812
+ // where the viewer can see the menu they are not being offered.
813
+ onCopyLink={vi.fn()}
814
+ />,
815
+ )
816
+ openMenu('c-theirs', 'Lee Ndiaye')
817
+ expect(screen.getByRole('menuitem', { name: 'Copy link' })).toBeTruthy()
818
+ // Absent, not disabled. A greyed row still claims the action exists for
819
+ // somebody, which is the lie in a smaller font.
820
+ expect(screen.queryByRole('menuitem', { name: 'Edit' })).toBeNull()
821
+ expect(screen.queryByRole('menuitem', { name: 'Delete' })).toBeNull()
822
+ })
823
+
824
+ it('separates the two predicates — an admin may remove what it may not rewrite', () => {
825
+ const MODERATOR: readonly ThreadComment[] = [
826
+ {
827
+ id: 'c-theirs',
828
+ author: { id: 'usr_lee', name: 'Lee Ndiaye' },
829
+ bodyMd: 'Somebody else wrote this.',
830
+ createdAt: FIXTURE_NOW - 30_000,
831
+ canEdit: false,
832
+ canDelete: true,
833
+ },
834
+ ]
835
+ render(
836
+ <Thread
837
+ comments={MODERATOR}
838
+ now={FIXTURE_NOW}
839
+ locale="en-GB"
840
+ defaultSort="new"
841
+ onEdit={vi.fn()}
842
+ onDelete={vi.fn()}
843
+ />,
844
+ )
845
+ openMenu('c-theirs', 'Lee Ndiaye')
846
+ expect(screen.queryByRole('menuitem', { name: 'Edit' })).toBeNull()
847
+ expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeTruthy()
848
+ })
849
+
850
+ it('still reports the id from a comment that kept its controls', () => {
851
+ const onDelete = vi.fn()
852
+ render(
853
+ <Thread
854
+ comments={AUTHORSHIP}
855
+ now={FIXTURE_NOW}
856
+ locale="en-GB"
857
+ defaultSort="new"
858
+ onDelete={onDelete}
859
+ />,
860
+ )
861
+ openMenu('c-mine', 'Ada Whitfield')
862
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' }))
863
+ expect(onDelete).toHaveBeenCalledWith('c-mine')
864
+ })
865
+
866
+ it('draws both when the flags are ABSENT — every 0.2.0 host is here', () => {
867
+ // The uncomfortable half, asserted rather than assumed: absent means TRUE,
868
+ // so a host that upgrades without mapping the fields keeps exactly the
869
+ // render it has, defect and all. The alternative silently strips the edit
870
+ // control off the viewer's own comments and nothing errors.
871
+ renderThread({ onEdit: vi.fn(), onDelete: vi.fn() })
872
+ openMenu('c-one-word', 'Lee Ndiaye')
873
+ expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeTruthy()
874
+ expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeTruthy()
875
+ })
876
+
877
+ it('offers no overflow at all when the flags are the only thing left in it', () => {
878
+ // Both callbacks wired, both refused, no clipboard under jsdom and no
879
+ // `onCopyLink`: `CommentActions` has nothing to put in a menu and draws no
880
+ // trigger. An empty menu behind a button is the greyed row again.
881
+ render(
882
+ <Thread
883
+ comments={AUTHORSHIP}
884
+ now={FIXTURE_NOW}
885
+ locale="en-GB"
886
+ defaultSort="new"
887
+ onEdit={vi.fn()}
888
+ onDelete={vi.fn()}
889
+ />,
890
+ )
891
+ expect(
892
+ within(commentBox('c-theirs')).queryByRole('button', {
893
+ name: "More actions for Lee Ndiaye's comment",
894
+ }),
895
+ ).toBeNull()
896
+ })
897
+
898
+ it('keeps the tombstone rule — a deleted comment offers neither, flags or not', () => {
899
+ const TOMB: readonly ThreadComment[] = [
900
+ {
901
+ id: 'c-tomb',
902
+ author: { id: 'usr_ada', name: 'Ada Whitfield' },
903
+ bodyMd: '',
904
+ createdAt: FIXTURE_NOW - 60_000,
905
+ deletedAt: FIXTURE_NOW - 30_000,
906
+ canEdit: true,
907
+ canDelete: true,
908
+ },
909
+ ]
910
+ render(
911
+ <Thread
912
+ comments={TOMB}
913
+ now={FIXTURE_NOW}
914
+ locale="en-GB"
915
+ defaultSort="new"
916
+ onEdit={vi.fn()}
917
+ onDelete={vi.fn()}
918
+ onCopyLink={vi.fn()}
919
+ />,
920
+ )
921
+ fireEvent.click(
922
+ within(commentBox('c-tomb')).getByRole('button', {
923
+ name: "More actions for [deleted]'s comment",
924
+ }),
925
+ )
926
+ expect(screen.getByRole('menuitem', { name: 'Copy link' })).toBeTruthy()
927
+ expect(screen.queryByRole('menuitem', { name: 'Edit' })).toBeNull()
928
+ expect(screen.queryByRole('menuitem', { name: 'Delete' })).toBeNull()
929
+ })
930
+ })
@@ -592,7 +592,7 @@ export function GifPicker({ search, onPick, className }: GifPickerProps) {
592
592
  onClick={() => {
593
593
  onPick(gif)
594
594
  }}
595
- className="block overflow-hidden border transition-[border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:border-[rgb(var(--border-strong))] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)] motion-reduce:transition-none"
595
+ className="block overflow-hidden border transition-[border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:border-[rgb(var(--border-strong))] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus-inset)] motion-reduce:transition-none"
596
596
  style={{
597
597
  borderRadius: 'var(--radius-sm)',
598
598
  borderColor: 'rgb(var(--border))',
@@ -171,6 +171,14 @@ export interface ThreadRenderContext extends RichComposerAttachmentProps {
171
171
  */
172
172
  readonly onCopyLink?: ((commentId: string) => void) | undefined
173
173
  readonly onCopyText?: ((commentId: string, ok: boolean) => void) | undefined
174
+ /**
175
+ * Edit and delete are gated TWICE, and the second gate is the per-comment
176
+ * one: the callback here says the host wired the mutation, and `canEdit` /
177
+ * `canDelete` on the row say whether this viewer may use it on THAT comment
178
+ * (see `ThreadComment`). Wiring the callback alone draws the item on every
179
+ * live comment, which is what 0.2.0 did and what the server then refused.
180
+ * Absent flags keep the item, so nothing a 0.2.0 host draws disappears.
181
+ */
174
182
  readonly onEdit?: ((commentId: string) => void) | undefined
175
183
  readonly onDelete?: ((commentId: string) => void) | undefined
176
184
  /**
@@ -453,6 +461,44 @@ export function CommentItem({
453
461
  // rather than trusted as a domain value (D6).
454
462
  const tint = asAvatarTint(author?.tint)
455
463
  const canEngage = !deleted && state === 'sent'
464
+ // The two capability gates, computed once and read only by CommentActions.
465
+ //
466
+ // Three conditions each, and each is a different question. `ctx.onEdit`
467
+ // asks whether the HOST wired the mutation at all; `deleted` asks whether
468
+ // there is anything left to act on; `node.canEdit` asks whether THIS viewer
469
+ // may act on THIS comment. 0.2.0 asked only the first two, so a host that
470
+ // wired both mutations drew both on everybody's comments and the server
471
+ // answered 404 forever.
472
+ //
473
+ // `!== false` rather than `=== true`: absent means the control is drawn,
474
+ // which is the render every 0.2.0 host already has (types.ts states the
475
+ // trade this makes and why the safer-looking default is worse). Only an
476
+ // explicit `false` takes an item away.
477
+ //
478
+ // Neither is an authorization decision. The server re-checks authorship on
479
+ // the edit and rank on the moderated delete; this decides what is DRAWN.
480
+ //
481
+ // `onReact` AND `onRetry` WERE CHECKED FOR THE SAME DEFECT AND DO NOT HAVE
482
+ // IT, which is worth stating so the next reader does not re-derive it and
483
+ // add two flags nothing needs.
484
+ //
485
+ // • React is not an authored act. Acknowledging somebody else's remark is
486
+ // the entire point of a reaction, so authorship is not a predicate any
487
+ // host's reaction route can sensibly carry, and there is no live comment
488
+ // on which the picker is drawn and cannot work. `canEngage` already
489
+ // withholds it from a tombstone and from a send still in flight — the
490
+ // two comments this RENDER refuses to offer it on.
491
+ //
492
+ // • Retry is unreachable on anybody else's comment, resting on an
493
+ // invariant rather than on a check. It is drawn only for
494
+ // `state === 'failed'`, and both non-`sent` delivery states are
495
+ // viewer-local: a comment that never reached the server, exists in one
496
+ // browser, and has no other viewer. There is no authorship question to
497
+ // ask about a row nobody else can see. That invariant is load-bearing
498
+ // enough to skip a flag, so it is stated on `CommentDeliveryState`
499
+ // where a host will actually read it — not only here.
500
+ const canEdit = ctx.onEdit !== undefined && !deleted && node.canEdit !== false
501
+ const canDelete = ctx.onDelete !== undefined && !deleted && node.canDelete !== false
456
502
  const indentClampDepth = ctx.indentClampDepth ?? DEFAULT_INDENT_CLAMP_DEPTH
457
503
 
458
504
  // A reply draws the segment of the connector that spans its own box.
@@ -613,14 +659,14 @@ export function CommentItem({
613
659
  ctx.onCopyLink?.(node.id)
614
660
  },
615
661
  })}
616
- {...(ctx.onEdit === undefined || deleted
662
+ {...(!canEdit
617
663
  ? {}
618
664
  : {
619
665
  onEdit: () => {
620
666
  ctx.onEdit?.(node.id)
621
667
  },
622
668
  })}
623
- {...(ctx.onDelete === undefined || deleted
669
+ {...(!canDelete
624
670
  ? {}
625
671
  : {
626
672
  onDelete: () => {
@@ -189,7 +189,10 @@ export function ThreadComposer({
189
189
  'min-w-0 flex-1 overflow-hidden border',
190
190
  'transition-[border-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
191
191
  'motion-reduce:transition-none',
192
- 'focus-within:border-[rgb(var(--accent))] focus-within:[box-shadow:var(--ring-focus)]',
192
+ // INSET, because this composer is mounted inside the `overflow-hidden` that
193
+ // `comment.tsx`'s grid-rows reply reveal requires. An outset ring is
194
+ // clipped there on three sides and the indicator vanishes.
195
+ 'focus-within:border-[rgb(var(--accent))] focus-within:[box-shadow:var(--ring-focus-inset)]',
193
196
  )}
194
197
  style={{
195
198
  // Concentric: the host panel declares --thread-inner-radius as its
@@ -117,7 +117,8 @@ const CHIP_BASE = cn(
117
117
  'inline-flex items-center justify-center gap-[var(--space-1)] border px-[var(--space-2)] text-[12px] font-semibold tabular-nums',
118
118
  'transition-[background-color,border-color,color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
119
119
  'motion-reduce:transition-none',
120
- 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
120
+ // INSET: chips sit inside clipping ancestors on both hosts.
121
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus-inset)]',
121
122
  'active:scale-[0.96] motion-reduce:active:scale-100',
122
123
  'disabled:cursor-not-allowed disabled:opacity-60',
123
124
  )
@@ -42,6 +42,16 @@ export interface ThreadReactionCount {
42
42
  * ADR-147 D17 — optimistic send is a first-class state, not a nicety.
43
43
  * A comment silently lost to a bad connection is the worst thing this surface
44
44
  * can do to someone, and it is the most likely.
45
+ *
46
+ * `pending` AND `failed` ARE VIEWER-LOCAL, and that is an invariant a host has
47
+ * to hold rather than a thing this type can enforce. Both describe a comment
48
+ * that has not landed: it exists in one browser, no other viewer can see it,
49
+ * and no read should ever return either value. A host that puts `failed` on a
50
+ * row it fetched is describing somebody ELSE's comment as the viewer's own
51
+ * failed send, and the retry drawn beside it — the one control on this surface
52
+ * with no capability flag, because it never needed one — becomes the same lie
53
+ * `canEdit` exists to stop. Absent means `sent`, which is what every read
54
+ * should leave it as.
45
55
  */
46
56
  export type CommentDeliveryState = 'sent' | 'pending' | 'failed'
47
57
 
@@ -163,6 +173,91 @@ export interface ThreadComment {
163
173
  * sees a word change.
164
174
  */
165
175
  readonly moderated?: boolean | undefined
176
+ /**
177
+ * Whether to DRAW the edit control on THIS comment.
178
+ *
179
+ * 0.2.0 forwarded `onEdit` and `onDelete` through to the thread and gated
180
+ * both menu items on nothing but "a callback exists, and this is not a
181
+ * tombstone". There was no authorship check anywhere in the render, so a
182
+ * host that wired the two mutations drew them on every live comment,
183
+ * including the ones the viewer did not write. Every server this was pointed
184
+ * at refused correctly, which is exactly what makes it the wrong kind of
185
+ * bug. It is not a hole; it is a LIE. A menu item that exists and can never
186
+ * work tells somebody they may do a thing they may not, and it is worse than
187
+ * the absent item, because the absent item at least agrees with the server.
188
+ *
189
+ * TWO flags rather than one `viewerIsAuthor`, and the argument for the
190
+ * second one is already in this interface. `moderated` exists because a
191
+ * comment can be removed by somebody OTHER than its author (FU-0031,
192
+ * FU-0032) — so a surface that can produce that tombstone has a delete
193
+ * predicate that is NOT authorship, while its edit stays author-only. This
194
+ * module cannot say what any host's predicates ARE, and does not try to; it
195
+ * only has to be able to spell two that differ, and `moderated` is already
196
+ * proof that they do. One consumer's arrangement is the existence proof
197
+ * rather than the rule: a moderated delete on a route of its own behind an
198
+ * admin rank, and the author-gated edit deliberately left alone, because the
199
+ * rank that removes somebody's words is not the rank that rewrites them.
200
+ * A single `viewerIsAuthor` cannot express that pair, and the day it has to,
201
+ * the fix is a breaking rename rather than a field.
202
+ *
203
+ * (Naming a consumer here is a narrower act than the one the TOMBSTONE
204
+ * docblock in `comment.tsx` rules out. That one would have told every host
205
+ * how to DERIVE a value — a schema stated as this module's fact. This one
206
+ * cites an arrangement to show a shape is reachable, and constrains nobody:
207
+ * a host whose two predicates are identical sets both flags the same way and
208
+ * never notices the seam has two halves.)
209
+ *
210
+ * A per-comment field rather than a predicate in the render context, because
211
+ * `ThreadLinkPreview.canHide` is already that shape and a second shape for
212
+ * the same idea is a second thing to learn. It also means nothing between
213
+ * here and the control needs new plumbing: `<Thread>` and `<ActivityPane>`
214
+ * forward `comments` verbatim, so the capability arrives with the row it
215
+ * describes.
216
+ *
217
+ * The SHAPE is `canHide`'s; the DEFAULT is its opposite, and that is worth
218
+ * saying out loud before somebody reasons from the precedent and guesses
219
+ * backwards. `canHide` is REQUIRED and therefore fails closed — a card whose
220
+ * host says nothing draws no dismiss control. These two are OPTIONAL and
221
+ * fail open. The difference is not principle, it is history: link previews
222
+ * were new when `canHide` landed, so requiring it cost no host anything,
223
+ * whereas edit and delete already render for everyone and a required field
224
+ * would be a breaking change that silently removes controls until it is
225
+ * supplied. "ABSENT MEANS TRUE" below is the whole of that argument.
226
+ *
227
+ * Deriving it here from `author.id` against a viewer id was the other
228
+ * candidate and is rejected twice over: a derived rule recomputed on the
229
+ * client is a second copy waiting to disagree with the server's, and
230
+ * `author.id` is nullable by D5 — `ON DELETE SET NULL` would quietly turn
231
+ * "the author is gone" into "you wrote this".
232
+ *
233
+ * ABSENT MEANS TRUE, and that is the uncomfortable half of this. A host on
234
+ * 0.2.0 that wired `onEdit` and upgrades without touching its data keeps
235
+ * precisely the render it has today, defect included. The alternative —
236
+ * absent means false — fixes that host by silently taking the edit control
237
+ * off the viewer's OWN comments until it maps two new fields: a release in
238
+ * which nobody can edit anything, nothing errors, no test in the host fails,
239
+ * and it survives to production for the same reason FU-0019 did. Trading a
240
+ * known lie for a new silent regression is not a trade, so the default
241
+ * preserves the render and this docblock is the notice. The seam is one
242
+ * field wide; a host that means it should set it.
243
+ *
244
+ * RENDERING ONLY, and never an authorization decision — the same words
245
+ * `canHide` is written in, for the same reason. The server re-checks
246
+ * authorship on every edit and rank on every moderated delete; a client that
247
+ * flips this to `true` earns a 404, not an edit.
248
+ */
249
+ readonly canEdit?: boolean | undefined
250
+ /**
251
+ * Whether to DRAW the delete control on THIS comment. Everything above
252
+ * applies, including that absent means true and that this decides RENDERING
253
+ * only — the server remains the gate and refuses on its own authority.
254
+ *
255
+ * It is separate from `canEdit` so the two predicates can differ, which on a
256
+ * surface with moderation they already do: an admin may remove a comment
257
+ * they may not rewrite, so `canDelete: true` with `canEdit: false` is the
258
+ * shape that describes them and there is no way to spell it with one flag.
259
+ */
260
+ readonly canDelete?: boolean | undefined
166
261
  readonly reactions?: readonly ThreadReactionCount[] | undefined
167
262
  readonly attachments?: readonly ThreadAttachment[] | undefined
168
263
  /**
package/src/tokens.css CHANGED
@@ -402,6 +402,19 @@
402
402
  Both resolve through the theme-aware tokens, so the light block needs no
403
403
  override — var() resolves at use time. */
404
404
  --ring-focus: 0 0 0 2px rgb(var(--accent));
405
+ /* The SAME ring, painted inward. An outset box-shadow is clipped by ANY
406
+ ancestor with a non-visible overflow, and when that happens the focus
407
+ indicator does not degrade — it disappears, which is a WCAG 2.4.7 failure
408
+ rather than a cosmetic one.
409
+ Use this at a clipping boundary: inside a scroll container, inside the
410
+ `overflow-hidden` a grid-rows reveal needs, inside a Card that keeps its
411
+ default clip. It cannot be clipped by an ancestor because it paints inside
412
+ the element's own border box.
413
+ Found the long way on 2026-09-09: the reply composer, the reaction chips
414
+ and the GIF picker all lost their ring, and `thread.tsx` had already been
415
+ forced to override a Card's `overflow-hidden` to `overflow-visible` to keep
416
+ one. That override is the cost this token removes. */
417
+ --ring-focus-inset: inset 0 0 0 2px rgb(var(--accent));
405
418
  --ring-error: 0 0 0 2px rgb(var(--destructive));
406
419
 
407
420
  --radius-xs: 6px;