@refraction-ui/react 0.10.0 → 0.12.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/dist/index.js CHANGED
@@ -2731,6 +2731,7 @@ function createConversation(config = {}) {
2731
2731
  const messagesByConv = /* @__PURE__ */ new Map();
2732
2732
  let activeConversationId = config.activeConversationId ?? null;
2733
2733
  let openThreadRootId = null;
2734
+ let replyTarget = null;
2734
2735
  let status = "idle";
2735
2736
  let error = null;
2736
2737
  let abortController = null;
@@ -2752,6 +2753,7 @@ function createConversation(config = {}) {
2752
2753
  activeConversationId,
2753
2754
  messages: activeConversationId ? messagesByConv.get(activeConversationId) ?? [] : [],
2754
2755
  openThreadRootId,
2756
+ replyTarget,
2755
2757
  threadingMode,
2756
2758
  status,
2757
2759
  error
@@ -2848,6 +2850,7 @@ function createConversation(config = {}) {
2848
2850
  newConversation(opts) {
2849
2851
  const conversation = createConversationInternal(opts ?? {});
2850
2852
  openThreadRootId = null;
2853
+ replyTarget = null;
2851
2854
  emit();
2852
2855
  return conversation;
2853
2856
  },
@@ -2855,6 +2858,7 @@ function createConversation(config = {}) {
2855
2858
  if (!conversations.has(conversationId) || activeConversationId === conversationId) return;
2856
2859
  activeConversationId = conversationId;
2857
2860
  openThreadRootId = null;
2861
+ replyTarget = null;
2858
2862
  emit();
2859
2863
  },
2860
2864
  deleteConversation(conversationId) {
@@ -2864,6 +2868,7 @@ function createConversation(config = {}) {
2864
2868
  if (activeConversationId === conversationId) {
2865
2869
  activeConversationId = orderedConversations()[0]?.id ?? null;
2866
2870
  openThreadRootId = null;
2871
+ replyTarget = null;
2867
2872
  }
2868
2873
  emit();
2869
2874
  },
@@ -2901,6 +2906,7 @@ function createConversation(config = {}) {
2901
2906
  const next = list.filter((m) => !removeIds.has(m.id));
2902
2907
  messagesByConv.set(activeConversationId, next);
2903
2908
  if (openThreadRootId && removeIds.has(openThreadRootId)) openThreadRootId = null;
2909
+ if (replyTarget && removeIds.has(replyTarget)) replyTarget = openThreadRootId;
2904
2910
  emit();
2905
2911
  },
2906
2912
  react(messageId, emoji) {
@@ -2931,6 +2937,7 @@ function createConversation(config = {}) {
2931
2937
  const conversationId = ensureActiveConversation(opts);
2932
2938
  const list = messagesByConv.get(conversationId);
2933
2939
  const parentId = opts?.replyTo ? rootIdOf(list, opts.replyTo) : void 0;
2940
+ const replyToId = opts?.replyTo;
2934
2941
  const isFirstRoot = !parentId && selectRoots(list).length === 0;
2935
2942
  const userMsg = {
2936
2943
  id: generateId("rfr-msg"),
@@ -2941,6 +2948,7 @@ function createConversation(config = {}) {
2941
2948
  timestamp: /* @__PURE__ */ new Date(),
2942
2949
  status: "sent",
2943
2950
  parentId,
2951
+ replyToId,
2944
2952
  attachments: opts?.attachments,
2945
2953
  metadata: opts?.metadata
2946
2954
  };
@@ -2997,11 +3005,20 @@ function createConversation(config = {}) {
2997
3005
  },
2998
3006
  openThread(rootId) {
2999
3007
  openThreadRootId = rootId;
3008
+ replyTarget = rootId;
3009
+ emit();
3010
+ },
3011
+ replyTo(messageId) {
3012
+ if (!activeConversationId) return;
3013
+ const list = messagesByConv.get(activeConversationId);
3014
+ openThreadRootId = rootIdOf(list, messageId);
3015
+ replyTarget = messageId;
3000
3016
  emit();
3001
3017
  },
3002
3018
  closeThread() {
3003
- if (openThreadRootId === null) return;
3019
+ if (openThreadRootId === null && replyTarget === null) return;
3004
3020
  openThreadRootId = null;
3021
+ replyTarget = null;
3005
3022
  emit();
3006
3023
  },
3007
3024
  setThreadingMode(mode) {
@@ -3345,6 +3362,7 @@ function useConversation(config) {
3345
3362
  retryLast: api.retryLast,
3346
3363
  stop: api.stop,
3347
3364
  openThread: api.openThread,
3365
+ replyTo: api.replyTo,
3348
3366
  closeThread: api.closeThread,
3349
3367
  setThreadingMode: api.setThreadingMode
3350
3368
  };
@@ -3409,6 +3427,8 @@ function Composer({
3409
3427
  toolbar = true,
3410
3428
  emoji = true,
3411
3429
  attachments = true,
3430
+ error,
3431
+ onRetry,
3412
3432
  onSubmit,
3413
3433
  onStop,
3414
3434
  onSlashCommand,
@@ -3549,148 +3569,154 @@ ${sel}
3549
3569
  submit();
3550
3570
  }
3551
3571
  }
3572
+ const iconBtn = "flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground";
3552
3573
  const toolbarBtn = (label, title, kind) => h(
3553
3574
  "button",
3554
3575
  {
3555
3576
  key: kind,
3556
3577
  type: "button",
3557
3578
  title,
3558
- className: "rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground",
3579
+ className: cn(iconBtn, "text-xs font-medium"),
3559
3580
  onMouseDown: (e) => e.preventDefault(),
3560
3581
  // keep textarea selection
3561
3582
  onClick: () => format(kind)
3562
3583
  },
3563
3584
  label
3564
3585
  );
3565
- return h(
3586
+ const menu = menuOpen ? h(
3566
3587
  "div",
3567
- { className: "border-t border-border" },
3568
- // attachment chips
3569
- pending.length > 0 ? h(
3588
+ {
3589
+ className: "absolute bottom-full left-0 z-20 mb-2 w-72 overflow-hidden rounded-xl border border-border bg-popover shadow-lg",
3590
+ role: "listbox"
3591
+ },
3592
+ h(
3570
3593
  "div",
3571
- { className: "flex flex-wrap gap-2 px-3 pt-2" },
3572
- ...pending.map(
3573
- (a) => h(
3574
- "span",
3575
- { key: a.id, className: "inline-flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs" },
3576
- a.name,
3577
- h(
3578
- "button",
3579
- {
3580
- type: "button",
3581
- className: "text-muted-foreground hover:text-destructive",
3582
- onClick: () => setPending((p) => p.filter((x) => x.id !== a.id))
3583
- },
3584
- "\u2715"
3585
- )
3586
- )
3594
+ { className: "border-b border-border px-3 py-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground" },
3595
+ trigger?.type === "/" ? "Commands" : trigger?.type === "@" ? "Mentions" : "Emoji"
3596
+ ),
3597
+ ...items.map(
3598
+ (it, i) => h(
3599
+ "button",
3600
+ {
3601
+ key: it.key,
3602
+ type: "button",
3603
+ role: "option",
3604
+ "aria-selected": i === active,
3605
+ className: cn(
3606
+ "flex w-full items-center gap-2 px-3 py-2 text-left text-sm",
3607
+ i === active ? "bg-accent" : "hover:bg-accent/50"
3608
+ ),
3609
+ onMouseEnter: () => setActive(i),
3610
+ onMouseDown: (e) => e.preventDefault(),
3611
+ onClick: () => selectItem(i)
3612
+ },
3613
+ it.icon ? h("span", { className: "w-4 text-center text-muted-foreground" }, it.icon) : null,
3614
+ h("span", { className: "flex-1 truncate" }, it.primary),
3615
+ it.secondary ? h("span", { className: "truncate text-xs text-muted-foreground" }, it.secondary) : null
3587
3616
  )
3588
- ) : null,
3589
- // toolbar
3590
- toolbar ? h(
3591
- "div",
3592
- { className: "flex items-center gap-0.5 px-2 pt-2" },
3593
- toolbarBtn("B", "Bold (\u2318B)", "bold"),
3594
- toolbarBtn("\u{1D456}", "Italic (\u2318I)", "italic"),
3595
- toolbarBtn("</>", "Code (\u2318E)", "code"),
3596
- toolbarBtn("\u{1F517}", "Link (\u2318K)", "link"),
3597
- toolbarBtn("\u275D", "Quote", "quote"),
3598
- toolbarBtn("\u2022", "Bulleted list", "ul"),
3599
- toolbarBtn("1.", "Numbered list", "ol")
3600
- ) : null,
3601
- // input row (relative for the popup menu)
3617
+ )
3618
+ ) : null;
3619
+ return h(
3620
+ "div",
3621
+ { className: "p-3" },
3602
3622
  h(
3603
3623
  "div",
3604
- { className: "relative flex items-end gap-2 p-3" },
3605
- menuOpen ? h(
3624
+ { className: "relative" },
3625
+ menu,
3626
+ // unified input card
3627
+ h(
3606
3628
  "div",
3607
3629
  {
3608
- className: "absolute bottom-full left-3 z-20 mb-1 w-72 overflow-hidden rounded-lg border border-border bg-popover shadow-lg",
3609
- role: "listbox"
3630
+ className: "overflow-hidden rounded-2xl border border-border bg-background transition focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/40"
3610
3631
  },
3632
+ // error banner
3633
+ error ? h(
3634
+ "div",
3635
+ { className: "flex items-center gap-2 border-b border-border bg-destructive/5 px-3 py-2 text-xs text-destructive", role: "alert" },
3636
+ h("span", { className: "flex-1 truncate" }, error),
3637
+ onRetry ? h("button", { type: "button", className: "font-medium underline", onClick: () => onRetry() }, "Retry") : null
3638
+ ) : null,
3639
+ // attachment chips
3640
+ pending.length > 0 ? h(
3641
+ "div",
3642
+ { className: "flex flex-wrap gap-2 px-3 pt-3" },
3643
+ ...pending.map(
3644
+ (a) => h(
3645
+ "span",
3646
+ { key: a.id, className: "inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs" },
3647
+ a.name,
3648
+ h(
3649
+ "button",
3650
+ { type: "button", className: "text-muted-foreground hover:text-destructive", onClick: () => setPending((p) => p.filter((x) => x.id !== a.id)) },
3651
+ "\u2715"
3652
+ )
3653
+ )
3654
+ )
3655
+ ) : null,
3656
+ // textarea (borderless)
3657
+ h("textarea", {
3658
+ ref,
3659
+ className: "block max-h-40 w-full resize-none bg-transparent px-3.5 py-3 text-sm placeholder:text-muted-foreground focus:outline-none",
3660
+ rows: 1,
3661
+ value,
3662
+ placeholder,
3663
+ autoFocus,
3664
+ "aria-label": "Message",
3665
+ onChange: (e) => syncFromTextarea(e.target),
3666
+ onClick: (e) => syncFromTextarea(e.currentTarget),
3667
+ onKeyUp: (e) => syncFromTextarea(e.currentTarget),
3668
+ onKeyDown,
3669
+ onBlur: () => setTimeout(() => setTrigger(null), 120)
3670
+ }),
3671
+ // bottom action bar
3611
3672
  h(
3612
3673
  "div",
3613
- { className: "border-b border-border px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground" },
3614
- trigger?.type === "/" ? "Commands" : trigger?.type === "@" ? "Mentions" : "Emoji"
3615
- ),
3616
- ...items.map(
3617
- (it, i) => h(
3674
+ { className: "flex items-center gap-0.5 px-2 pb-2" },
3675
+ attachments ? h(
3676
+ React11.Fragment,
3677
+ null,
3678
+ h("input", {
3679
+ ref: fileRef,
3680
+ type: "file",
3681
+ accept: "image/*",
3682
+ multiple: true,
3683
+ className: "hidden",
3684
+ onChange: (e) => {
3685
+ onFiles(e.target.files);
3686
+ e.target.value = "";
3687
+ }
3688
+ }),
3689
+ h("button", { type: "button", className: iconBtn, "aria-label": "Attach image or GIF", onClick: () => fileRef.current?.click() }, "\u{1F4CE}")
3690
+ ) : null,
3691
+ attachments && toolbar ? h("span", { className: "mx-1 h-5 w-px bg-border" }) : null,
3692
+ toolbar ? h(
3693
+ React11.Fragment,
3694
+ null,
3695
+ toolbarBtn("B", "Bold (\u2318B)", "bold"),
3696
+ toolbarBtn("\u{1D456}", "Italic (\u2318I)", "italic"),
3697
+ toolbarBtn("</>", "Code (\u2318E)", "code"),
3698
+ toolbarBtn("\u{1F517}", "Link (\u2318K)", "link"),
3699
+ toolbarBtn("\u275D", "Quote", "quote"),
3700
+ toolbarBtn("\u2022", "Bulleted list", "ul"),
3701
+ toolbarBtn("1.", "Numbered list", "ol")
3702
+ ) : null,
3703
+ h("div", { className: "flex-1" }),
3704
+ busy ? h(
3705
+ "button",
3706
+ { type: "button", "aria-label": "Stop", className: "flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground", onClick: () => onStop?.() },
3707
+ "\u25A0"
3708
+ ) : h(
3618
3709
  "button",
3619
3710
  {
3620
- key: it.key,
3621
3711
  type: "button",
3622
- role: "option",
3623
- "aria-selected": i === active,
3624
- className: cn(
3625
- "flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm",
3626
- i === active ? "bg-accent" : "hover:bg-accent/50"
3627
- ),
3628
- onMouseEnter: () => setActive(i),
3629
- onMouseDown: (e) => e.preventDefault(),
3630
- onClick: () => selectItem(i)
3712
+ "aria-label": "Send",
3713
+ className: "flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-base font-semibold text-primary-foreground transition disabled:opacity-40",
3714
+ disabled: !value.trim() && pending.length === 0,
3715
+ onClick: submit
3631
3716
  },
3632
- it.icon ? h("span", { className: "w-4 text-center text-muted-foreground" }, it.icon) : null,
3633
- h("span", { className: "flex-1 truncate" }, it.primary),
3634
- it.secondary ? h("span", { className: "truncate text-xs text-muted-foreground" }, it.secondary) : null
3717
+ "\u2191"
3635
3718
  )
3636
3719
  )
3637
- ) : null,
3638
- attachments ? h(
3639
- React11.Fragment,
3640
- null,
3641
- h("input", {
3642
- ref: fileRef,
3643
- type: "file",
3644
- accept: "image/*",
3645
- multiple: true,
3646
- className: "hidden",
3647
- onChange: (e) => {
3648
- onFiles(e.target.files);
3649
- e.target.value = "";
3650
- }
3651
- }),
3652
- h(
3653
- "button",
3654
- {
3655
- type: "button",
3656
- className: "rounded-md border border-border px-2 py-2 text-sm hover:bg-accent",
3657
- "aria-label": "Attach image or GIF",
3658
- onClick: () => fileRef.current?.click()
3659
- },
3660
- "\u{1F4CE}"
3661
- )
3662
- ) : null,
3663
- h("textarea", {
3664
- ref,
3665
- className: "max-h-40 flex-1 resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary",
3666
- rows: 1,
3667
- value,
3668
- placeholder,
3669
- autoFocus,
3670
- "aria-label": "Message",
3671
- onChange: (e) => syncFromTextarea(e.target),
3672
- onClick: (e) => syncFromTextarea(e.currentTarget),
3673
- onKeyUp: (e) => syncFromTextarea(e.currentTarget),
3674
- onKeyDown,
3675
- onBlur: () => setTimeout(() => setTrigger(null), 120)
3676
- }),
3677
- busy ? h(
3678
- "button",
3679
- {
3680
- type: "button",
3681
- className: "rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground",
3682
- onClick: () => onStop?.()
3683
- },
3684
- "Stop"
3685
- ) : h(
3686
- "button",
3687
- {
3688
- type: "button",
3689
- className: "rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50",
3690
- disabled: !value.trim() && pending.length === 0,
3691
- onClick: submit
3692
- },
3693
- "Send"
3694
3720
  )
3695
3721
  )
3696
3722
  );
@@ -3807,7 +3833,7 @@ function HoverActions({
3807
3833
  onToggleEmojis,
3808
3834
  align
3809
3835
  }) {
3810
- const { state, openThread, deleteMessage } = conversation;
3836
+ const { replyTo, deleteMessage } = conversation;
3811
3837
  return h2(
3812
3838
  "div",
3813
3839
  {
@@ -3816,7 +3842,8 @@ function HoverActions({
3816
3842
  align === "end" && "justify-end"
3817
3843
  )
3818
3844
  },
3819
- h2("button", { type: "button", className: "hover:text-foreground", onClick: () => openThread(rootIdOf(state.messages, message.id)) }, "Reply"),
3845
+ // Reply targets this specific message but groups under the originating root.
3846
+ h2("button", { type: "button", className: "hover:text-foreground", onClick: () => replyTo(message.id) }, "Reply"),
3820
3847
  h2("button", { type: "button", className: "hover:text-foreground", onClick: onToggleEmojis }, "React"),
3821
3848
  isOwn ? h2("button", { type: "button", className: "hover:text-foreground", onClick: onEdit }, "Edit") : null,
3822
3849
  isOwn ? h2("button", { type: "button", className: "hover:text-destructive", onClick: () => deleteMessage(message.id) }, "Delete") : null
@@ -3878,7 +3905,7 @@ function MessageRow({
3878
3905
  const inner = h2(
3879
3906
  React11.Fragment,
3880
3907
  null,
3881
- quotedParent ? h2(QuotedParent, { parent: quotedParent, onClick: () => openThread(quotedParent.id) }) : null,
3908
+ quotedParent ? h2(QuotedParent, { parent: quotedParent, onClick: () => openThread(rootIdOf(state.messages, quotedParent.id)) }) : null,
3882
3909
  editing ? h2(EditField, { message, conversation, onDone: () => setEditing(false) }) : isUser ? h2("div", { className: "inline-block rounded-2xl rounded-br-sm bg-primary/10 px-3 py-2 text-left" }, h2(MessageBody, { message })) : h2(MessageBody, { message }),
3883
3910
  h2(Reactions, { message, onReact: (e) => react(message.id, e), align }),
3884
3911
  showThreadAffordance && replyCount > 0 ? h2(
@@ -3978,13 +4005,14 @@ function ThreadPanel({ conversation, currentUserId, composer }) {
3978
4005
  const rootId = state.openThreadRootId;
3979
4006
  if (!rootId) return null;
3980
4007
  const messages = selectThreadMessages(state.messages, rootId);
4008
+ const target = state.replyTarget && state.replyTarget !== rootId ? findMessage(state.messages, state.replyTarget) : void 0;
3981
4009
  return h2(
3982
4010
  "aside",
3983
4011
  { className: "flex w-80 flex-col border-l border-border", "aria-label": "Thread" },
3984
4012
  h2(
3985
4013
  "div",
3986
4014
  { className: "flex items-center justify-between border-b border-border px-3 py-2" },
3987
- h2("span", { className: "text-sm font-semibold" }, "Thread"),
4015
+ h2("span", { className: "text-sm font-semibold" }, `Thread \xB7 ${messages.length - 1} ${messages.length - 1 === 1 ? "reply" : "replies"}`),
3988
4016
  h2("button", { type: "button", className: "text-muted-foreground hover:text-foreground", "aria-label": "Close thread", onClick: () => conversation.closeThread() }, "\u2715")
3989
4017
  ),
3990
4018
  h2(
@@ -3992,6 +4020,12 @@ function ThreadPanel({ conversation, currentUserId, composer }) {
3992
4020
  { className: "flex-1 overflow-y-auto p-1" },
3993
4021
  ...messages.map((m) => h2(MessageRow, { key: m.id, message: m, conversation, currentUserId, showThreadAffordance: false }))
3994
4022
  ),
4023
+ target ? h2(
4024
+ "div",
4025
+ { className: "flex items-center justify-between gap-2 border-t border-border bg-muted/40 px-3 py-1 text-xs text-muted-foreground" },
4026
+ h2("span", { className: "truncate" }, `\u21B3 Replying to ${target.author.name}`),
4027
+ h2("button", { type: "button", className: "hover:text-foreground", onClick: () => conversation.openThread(rootId) }, "Reply to thread instead")
4028
+ ) : null,
3995
4029
  composer
3996
4030
  );
3997
4031
  }
@@ -4030,9 +4064,13 @@ function Chat({
4030
4064
  const timeline = selectMainTimeline(state.messages, state.threadingMode);
4031
4065
  const activeConv = state.conversations.find((c) => c.id === state.activeConversationId);
4032
4066
  const busy = state.status === "sending" || state.status === "streaming";
4067
+ const error = state.status === "error" ? state.error : null;
4068
+ const onRetry = () => void conversation.retryLast();
4033
4069
  const mainComposer = h2(Composer, {
4034
4070
  placeholder,
4035
4071
  busy,
4072
+ error,
4073
+ onRetry,
4036
4074
  slashCommands,
4037
4075
  mentions,
4038
4076
  onSlashCommand,
@@ -4043,11 +4081,13 @@ function Chat({
4043
4081
  const threadComposer = state.openThreadRootId ? h2(Composer, {
4044
4082
  placeholder: "Reply\u2026",
4045
4083
  busy,
4084
+ error,
4085
+ onRetry,
4046
4086
  slashCommands,
4047
4087
  mentions,
4048
4088
  onSlashCommand,
4049
4089
  toolbar: composerToolbar,
4050
- onSubmit: (content, atts) => void sendMessage(content, { replyTo: state.openThreadRootId, attachments: atts }),
4090
+ onSubmit: (content, atts) => void sendMessage(content, { replyTo: state.replyTarget ?? state.openThreadRootId, attachments: atts }),
4051
4091
  onStop: () => conversation.stop()
4052
4092
  }) : null;
4053
4093
  const body = timeline.length === 0 ? h2("div", { className: "flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground" }, emptyState ?? "No messages yet. Say hello \u{1F44B}") : h2(
@@ -4059,8 +4099,10 @@ function Chat({
4059
4099
  message: m,
4060
4100
  conversation,
4061
4101
  currentUserId,
4062
- showThreadAffordance: state.threadingMode === "panel",
4063
- quotedParent: state.threadingMode === "inline" && m.parentId ? findMessage(state.messages, m.parentId) : void 0
4102
+ // Show the "N replies" count on originating messages in BOTH modes.
4103
+ showThreadAffordance: true,
4104
+ // Inline: quote the specific message replied to (falls back to the root).
4105
+ quotedParent: state.threadingMode === "inline" && m.parentId ? findMessage(state.messages, m.replyToId ?? m.parentId) : void 0
4064
4106
  })
4065
4107
  )
4066
4108
  );
@@ -4344,14 +4386,56 @@ function useCookieConsent(config) {
4344
4386
  };
4345
4387
  }
4346
4388
  var h3 = React11.createElement;
4347
- var btnPrimary = "rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90";
4348
- var btnGhost = "rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent";
4349
- var btnLink = "text-sm text-muted-foreground underline hover:text-foreground";
4389
+ var btnBase = "inline-flex items-center justify-center rounded-lg px-3.5 py-2 text-sm font-medium transition-colors";
4390
+ var btnPrimary = cn(btnBase, "bg-primary text-primary-foreground hover:opacity-90");
4391
+ var btnGhost = cn(btnBase, "border border-border hover:bg-accent");
4392
+ var btnLink = "text-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline";
4393
+ function Toggle({
4394
+ checked,
4395
+ disabled,
4396
+ onChange,
4397
+ label
4398
+ }) {
4399
+ if (disabled) {
4400
+ return h3(
4401
+ "span",
4402
+ { className: "rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground" },
4403
+ "Always on"
4404
+ );
4405
+ }
4406
+ return h3(
4407
+ "button",
4408
+ {
4409
+ type: "button",
4410
+ role: "switch",
4411
+ "aria-checked": checked,
4412
+ "aria-label": label,
4413
+ onClick: () => onChange(!checked),
4414
+ className: cn(
4415
+ "relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
4416
+ checked ? "bg-primary" : "bg-muted"
4417
+ )
4418
+ },
4419
+ h3("span", {
4420
+ className: cn(
4421
+ "inline-block h-4 w-4 transform rounded-full bg-background shadow transition-transform",
4422
+ checked ? "translate-x-[1.125rem]" : "translate-x-0.5"
4423
+ )
4424
+ })
4425
+ );
4426
+ }
4427
+ function CookieIcon() {
4428
+ return h3(
4429
+ "div",
4430
+ { className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent text-xl", "aria-hidden": true },
4431
+ "\u{1F36A}"
4432
+ );
4433
+ }
4350
4434
  function CookieConsent({
4351
4435
  consent,
4352
4436
  position = "bottom",
4353
4437
  title = "We use cookies",
4354
- description = "We use cookies to run the site, remember your preferences, and measure traffic. Choose which categories to allow.",
4438
+ description = "We use cookies to run the site, remember your preferences, and measure traffic. Choose which to allow.",
4355
4439
  policyUrl,
4356
4440
  policyLabel = "Cookie policy",
4357
4441
  className
@@ -4359,64 +4443,80 @@ function CookieConsent({
4359
4443
  const { state, acceptAll, rejectAll, savePreferences, setPreference } = consent;
4360
4444
  const [settings, setSettings] = React11.useState(false);
4361
4445
  if (!state.open) return null;
4362
- const wrapper = cn(
4363
- "fixed inset-x-0 z-50 p-4",
4364
- position === "bottom" ? "bottom-0" : "top-0",
4365
- className
4366
- );
4367
- const panel = "mx-auto max-w-3xl rounded-xl border border-border bg-background p-4 shadow-lg";
4368
- const header = h3(
4369
- "div",
4370
- null,
4371
- h3("h2", { className: "text-base font-semibold" }, title),
4372
- h3("p", { className: "mt-1 text-sm text-muted-foreground" }, description),
4373
- policyUrl ? h3("a", { href: policyUrl, target: "_blank", rel: "noreferrer", className: cn(btnLink, "mt-1 inline-block") }, policyLabel) : null
4374
- );
4375
- const promptActions = h3(
4446
+ const wrapper = cn("fixed inset-x-0 z-50 p-4", position === "bottom" ? "bottom-0" : "top-0", className);
4447
+ const panel = "mx-auto max-w-2xl overflow-hidden rounded-2xl border border-border bg-background shadow-lg";
4448
+ const policy = policyUrl ? h3("a", { href: policyUrl, target: "_blank", rel: "noreferrer", className: cn(btnLink, "whitespace-nowrap") }, policyLabel) : null;
4449
+ const promptView = h3(
4376
4450
  "div",
4377
- { className: "mt-3 flex flex-wrap items-center gap-2" },
4378
- h3("button", { type: "button", className: btnPrimary, onClick: () => acceptAll() }, "Accept all"),
4379
- h3("button", { type: "button", className: btnGhost, onClick: () => rejectAll() }, "Reject all"),
4380
- h3("button", { type: "button", className: cn(btnGhost, "ml-auto"), onClick: () => setSettings(true) }, "Manage preferences")
4451
+ { className: "flex flex-col gap-4 p-5 sm:flex-row sm:items-center" },
4452
+ h3(CookieIcon),
4453
+ h3(
4454
+ "div",
4455
+ { className: "min-w-0 flex-1" },
4456
+ h3("p", { className: "text-sm font-semibold" }, title),
4457
+ h3(
4458
+ "p",
4459
+ { className: "mt-0.5 text-sm leading-relaxed text-muted-foreground" },
4460
+ description,
4461
+ policy ? h3(React11.Fragment, null, " ", policy) : null
4462
+ )
4463
+ ),
4464
+ h3(
4465
+ "div",
4466
+ { className: "flex flex-wrap items-center gap-2 sm:shrink-0" },
4467
+ h3("button", { type: "button", className: btnLink, onClick: () => setSettings(true) }, "Customize"),
4468
+ h3("button", { type: "button", className: btnGhost, onClick: () => rejectAll() }, "Reject all"),
4469
+ h3("button", { type: "button", className: btnPrimary, onClick: () => acceptAll() }, "Accept all")
4470
+ )
4381
4471
  );
4382
4472
  const settingsView = h3(
4383
4473
  "div",
4384
- { className: "mt-3 space-y-2" },
4385
- ...state.categories.map(
4386
- (cat) => h3(
4387
- "label",
4388
- {
4389
- key: cat.id,
4390
- className: "flex items-start justify-between gap-3 rounded-md border border-border p-3"
4391
- },
4392
- h3(
4393
- "span",
4394
- { className: "min-w-0" },
4395
- h3("span", { className: "block text-sm font-medium" }, cat.label, cat.required ? " (required)" : ""),
4396
- cat.description ? h3("span", { className: "block text-xs text-muted-foreground" }, cat.description) : null
4397
- ),
4398
- h3("input", {
4399
- type: "checkbox",
4400
- className: "mt-0.5 h-4 w-4 accent-[hsl(var(--primary))]",
4401
- checked: !!state.preferences[cat.id],
4402
- disabled: cat.required,
4403
- "aria-label": cat.label,
4404
- onChange: (e) => setPreference(cat.id, e.target.checked)
4405
- })
4474
+ { className: "p-5" },
4475
+ h3(
4476
+ "div",
4477
+ { className: "flex items-center gap-3" },
4478
+ h3(CookieIcon),
4479
+ h3(
4480
+ "div",
4481
+ null,
4482
+ h3("p", { className: "text-sm font-semibold" }, "Cookie preferences"),
4483
+ h3("p", { className: "text-xs text-muted-foreground" }, "Toggle the categories you want to allow.")
4484
+ )
4485
+ ),
4486
+ h3(
4487
+ "div",
4488
+ { className: "mt-4 space-y-2" },
4489
+ ...state.categories.map(
4490
+ (cat) => h3(
4491
+ "div",
4492
+ { key: cat.id, className: "flex items-center justify-between gap-4 rounded-xl border border-border p-3" },
4493
+ h3(
4494
+ "div",
4495
+ { className: "min-w-0" },
4496
+ h3("p", { className: "text-sm font-medium" }, cat.label),
4497
+ cat.description ? h3("p", { className: "mt-0.5 text-xs text-muted-foreground" }, cat.description) : null
4498
+ ),
4499
+ h3(Toggle, {
4500
+ checked: !!state.preferences[cat.id],
4501
+ disabled: cat.required,
4502
+ label: cat.label,
4503
+ onChange: (v) => setPreference(cat.id, v)
4504
+ })
4505
+ )
4406
4506
  )
4407
4507
  ),
4408
4508
  h3(
4409
4509
  "div",
4410
- { className: "flex flex-wrap items-center gap-2 pt-1" },
4411
- h3("button", { type: "button", className: btnPrimary, onClick: () => savePreferences(state.preferences) }, "Save preferences"),
4412
- h3("button", { type: "button", className: btnGhost, onClick: () => acceptAll() }, "Accept all"),
4413
- h3("button", { type: "button", className: cn(btnLink, "ml-auto"), onClick: () => setSettings(false) }, "Back")
4510
+ { className: "mt-4 flex flex-wrap items-center gap-2" },
4511
+ h3("button", { type: "button", className: btnLink, onClick: () => setSettings(false) }, "\u2190 Back"),
4512
+ h3("button", { type: "button", className: cn(btnGhost, "sm:ml-auto"), onClick: () => acceptAll() }, "Accept all"),
4513
+ h3("button", { type: "button", className: btnPrimary, onClick: () => savePreferences(state.preferences) }, "Save preferences")
4414
4514
  )
4415
4515
  );
4416
4516
  return h3(
4417
4517
  "div",
4418
4518
  { className: wrapper, role: "dialog", "aria-label": "Cookie consent", "aria-modal": false },
4419
- h3("div", { className: panel }, header, settings ? settingsView : promptActions)
4519
+ h3("div", { className: panel }, settings ? settingsView : promptView)
4420
4520
  );
4421
4521
  }
4422
4522