@apliteni/apliteni-ui 0.64.0 → 0.66.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.64.0",
3
+ "version": "0.66.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
package/react/README.md CHANGED
@@ -29,7 +29,7 @@ import '@apliteni/apliteni-ui/react/css'; // React components' shell styles (mo
29
29
  import { DataTable, Modal, Button } from '@apliteni/apliteni-ui/react';
30
30
  ```
31
31
 
32
- Components: `Checkbox`, `Switch`, `EmptyState`, `DataTable`, `Pagination`, `StatBand`, `Modal`, `Confirm`, `Drawer`, `CommandPalette`, `Dropdown`, `BackLink`, `Tooltip`, `Snippet`, `Tabs`, `Button`, `Badge`, `Pill`, `StatusDot`, `Card`, `Callout`, `Icon`.
32
+ Components: `Success`, `SuccessPanel`, `SuccessCheck`, `Checkbox`, `Switch`, `EmptyState`, `DataTable`, `Pagination`, `StatBand`, `Modal`, `Confirm`, `Drawer`, `CommandPalette`, `Dropdown`, `BackLink`, `Tooltip`, `Snippet`, `Tabs`, `Button`, `Badge`, `Pill`, `StatusDot`, `Card`, `Callout`, `Icon`.
33
33
 
34
34
  `Pill` renders metadata with the existing pill spacing. Omit `variant` for neutral
35
35
  metadata or use `live` or `soon`. Its children accept React content.
@@ -553,6 +553,52 @@ Tooltip accepts text and inline children, without nested controls. Hovering or f
553
553
  <Tooltip text="Updated daily">Balance</Tooltip>
554
554
  ```
555
555
 
556
+ ## Success
557
+
558
+ `SuccessPanel` confirms an outcome inside the current page with `title` and optional
559
+ `sub`, takes the same `check` as `Success`, and forwards a ref to its root div. `SuccessCheck` is the bare mark both
560
+ confirmations draw, matching the kit's `successCheck()`, and forwards a ref to its
561
+ `svg`. It carries no size or box of its own: to compose it alone, put it in a wrapper
562
+ that sets both, such as the kit's `ui-success__check`, and name the outcome in text
563
+ next to it.
564
+
565
+ ```tsx
566
+ <div className="ui-success__check"><SuccessCheck /></div>
567
+ ```
568
+
569
+ The mark comes in two: `line` (the default) is the bare Lucide check in the success
570
+ colour, which strokes itself on and takes its size from the layout it lands in;
571
+ `circled` is Lucide `circle-check-big` at 20px, the kit's label size, drawn at rest and
572
+ the same size everywhere — in every `Success` layout and in `SuccessPanel`. Pick it with `check` on `Success` or `variant` on
573
+ `SuccessCheck`. Guidelines / Iconography reserves a circled glyph for a state and a
574
+ bare one for an action, and a confirmation reports a state — so `circled` is the mark
575
+ that rule asks for, and `line` is the default because it carries the moment at page
576
+ size. Neither has a filled disc behind it.
577
+
578
+ `Success` provides `hero`, `split`, and `compact` layouts on a plain elevated card;
579
+ there is no backdrop layer and no `backdrop` prop. Pass `title`, one short `body`
580
+ line, and React `actions`. There is no `eyebrow` prop: a confirmation carries one
581
+ title and at most one line under it, so put the outcome in the title rather than in a
582
+ label above it. A kit `Button` carries the focus ring already; a router link or
583
+ a plain `<a>` must also take `className="ui-focusable"`, or it falls back to the
584
+ browser's own focus outline. An omitted or empty `actions`
585
+ leaves out the actions row. Hero and split default to h1; compact defaults to h2.
586
+ `level` overrides the heading rank, and a value outside 1–6 falls back to that layout
587
+ default. `confetti` enables the existing decorative animation. All three components
588
+ use the kit CSS and its reduced-motion rules.
589
+
590
+ ```tsx
591
+ <Success title="Changes saved" check="circled"
592
+ countdown={waiting ? { seconds: 5, label: 'Continuing' } : null}
593
+ onCountdownEnd={continueToNextPage}
594
+ actions={<Button onClick={() => setWaiting(false)}>Stay here</Button>} />
595
+ ```
596
+
597
+ Removing `countdown` or unmounting cancels the timer. Changing its duration restarts
598
+ it; changing its label or callback does not. Durations are whole seconds rounded
599
+ down, with five seconds used for omitted, non-finite, or sub-one values. Completion
600
+ fires once per countdown, including in StrictMode. Navigation belongs to the caller.
601
+
556
602
  ## Checkbox and Switch
557
603
 
558
604
  `Checkbox` wraps a native checkbox and its visible `label`. Set `type="radio"`
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { InputHTMLAttributes, ReactNode, ButtonHTMLAttributes, RefAttributes, ReactElement, AnchorHTMLAttributes, HTMLAttributes, RefObject, MouseEvent, ElementType, ComponentPropsWithoutRef, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react';
2
+ import { InputHTMLAttributes, ReactNode, ButtonHTMLAttributes, RefAttributes, ReactElement, AnchorHTMLAttributes, HTMLAttributes, RefObject, MouseEvent, ElementType, ComponentPropsWithoutRef, SelectHTMLAttributes, TextareaHTMLAttributes, SVGAttributes } from 'react';
3
3
 
4
4
  declare function Icon({ name }: {
5
5
  name: string;
@@ -679,6 +679,73 @@ type TooltipProps = {
679
679
  };
680
680
  declare function Tooltip({ text, children }: TooltipProps): react.JSX.Element;
681
681
 
682
+ type SuccessMark = 'line' | 'circled';
683
+ type SuccessCheckProps = Omit<SVGAttributes<SVGSVGElement>, 'children'> & {
684
+ /** `line` is the bare Lucide check and draws itself on; `circled` is Lucide
685
+ * circle-check-big at 20px, the kit's label size, drawn at rest. */
686
+ variant?: SuccessMark;
687
+ };
688
+ /** Decorative mark. Supply the outcome in adjacent text. */
689
+ declare const SuccessCheck: react.ForwardRefExoticComponent<Omit<SVGAttributes<SVGSVGElement>, "children"> & {
690
+ /** `line` is the bare Lucide check and draws itself on; `circled` is Lucide
691
+ * circle-check-big at 20px, the kit's label size, drawn at rest. */
692
+ variant?: SuccessMark;
693
+ } & react.RefAttributes<SVGSVGElement>>;
694
+ type SuccessPanelProps = Omit<HTMLAttributes<HTMLDivElement>, 'title' | 'children'> & {
695
+ title?: string;
696
+ sub?: string;
697
+ /** Which check mark to draw. See SuccessCheck. */
698
+ check?: SuccessMark;
699
+ };
700
+ declare const SuccessPanel: react.ForwardRefExoticComponent<Omit<HTMLAttributes<HTMLDivElement>, "children" | "title"> & {
701
+ title?: string;
702
+ sub?: string;
703
+ /** Which check mark to draw. See SuccessCheck. */
704
+ check?: SuccessMark;
705
+ } & react.RefAttributes<HTMLDivElement>>;
706
+ type SuccessCountdown = {
707
+ seconds?: number;
708
+ label?: string;
709
+ };
710
+ type SuccessProps = Omit<HTMLAttributes<HTMLDivElement>, 'title' | 'children'> & {
711
+ layout?: 'hero' | 'split' | 'compact';
712
+ level?: 1 | 2 | 3 | 4 | 5 | 6;
713
+ /** Which check mark to draw. See SuccessCheck. */
714
+ check?: SuccessMark;
715
+ title?: string;
716
+ /** One short line under the title, or nothing. There is no eyebrow tier.
717
+ * why: docs/specification.md#success-confirmations */
718
+ body?: string;
719
+ /** Removed: a confirmation carries one title and at most one line. Typed as
720
+ * `never` so a caller still passing it is told where it went rather than
721
+ * having it spread silently onto the root as an unknown DOM attribute. */
722
+ eyebrow?: never;
723
+ actions?: ReactNode;
724
+ confetti?: boolean;
725
+ /** Set to null to cancel; changing seconds starts a new countdown. */
726
+ countdown?: SuccessCountdown | null;
727
+ onCountdownEnd?: () => void;
728
+ };
729
+ declare const Success: react.ForwardRefExoticComponent<Omit<HTMLAttributes<HTMLDivElement>, "children" | "title"> & {
730
+ layout?: "hero" | "split" | "compact";
731
+ level?: 1 | 2 | 3 | 4 | 5 | 6;
732
+ /** Which check mark to draw. See SuccessCheck. */
733
+ check?: SuccessMark;
734
+ title?: string;
735
+ /** One short line under the title, or nothing. There is no eyebrow tier.
736
+ * why: docs/specification.md#success-confirmations */
737
+ body?: string;
738
+ /** Removed: a confirmation carries one title and at most one line. Typed as
739
+ * `never` so a caller still passing it is told where it went rather than
740
+ * having it spread silently onto the root as an unknown DOM attribute. */
741
+ eyebrow?: never;
742
+ actions?: ReactNode;
743
+ confetti?: boolean;
744
+ /** Set to null to cancel; changing seconds starts a new countdown. */
745
+ countdown?: SuccessCountdown | null;
746
+ onCountdownEnd?: () => void;
747
+ } & react.RefAttributes<HTMLDivElement>>;
748
+
682
749
  type SidebarNavBadge = string | number | {
683
750
  text: string | number;
684
751
  tone?: 'neutral' | 'accent' | 'live' | 'danger';
@@ -732,4 +799,4 @@ declare const SidebarNav: react.ForwardRefExoticComponent<Omit<Omit<react.Detail
732
799
  declare const PAGE_SIZES: readonly number[];
733
800
  declare const DEFAULT_PAGE_SIZE: number;
734
801
 
735
- export { AppShell, type AppShellProps, type AppShellSection, BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, BusyRegion, type BusyRegionProps, Button, type ButtonLinkProps, type ButtonProps, Callout, type CalloutProps, type CalloutVariant, Card, Checkbox, type CheckboxProps, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, Confirm, type ConfirmProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, DeltaValue, type DeltaValueProps, Denied, type DeniedProps, Drawer, type DrawerProps, DrawerSection, type DrawerSectionProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, EmptyState, type EmptyStateProps, type EmptyStateVariant, Field, type FieldControlProps, type FieldProps, FileField, type FileFieldProps, type Filter, FilterBar, type FilterBarProps, Icon, KeyValueList, type KeyValueListProps, type KeyValueRow, Modal, type ModalProps, type NativeButtonProps, NumericValue, type NumericValueProps, PAGE_SIZES, Pagination, type PaginationProps, Pill, type PillProps, RowIdentity, type RowIdentityProps, type SegmentOption, Segmented, type SegmentedProps, SelectField, type SelectFieldProps, SidebarNav, type SidebarNavBadge, type SidebarNavItem, type SidebarNavLeaf, type SidebarNavProps, type SidebarNavSection, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, Snippet, type SnippetProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, StatusDot, type StatusDotProps, Switch, type SwitchProps, THEME_INIT_SCRIPT, type TabItem, type TableSort, Tabs, type TabsProps, TextArea, type TextAreaProps, TextField, type TextFieldProps, ThemeToggle, type ThemeToggleProps, Timeline, type TimelineEvent, type TimelineEventKind, type TimelineProps, Toast, type ToastNotice, type ToastProps, Tooltip, type TooltipProps, sortTableRows, useToast };
802
+ export { AppShell, type AppShellProps, type AppShellSection, BackLink, type BackLinkOwnProps, type BackLinkProps, Badge, BusyRegion, type BusyRegionProps, Button, type ButtonLinkProps, type ButtonProps, Callout, type CalloutProps, type CalloutVariant, Card, Checkbox, type CheckboxProps, type Column, type CommandGroup, type CommandItem, CommandPalette, type CommandPaletteProps, Confirm, type ConfirmProps, DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, DeltaValue, type DeltaValueProps, Denied, type DeniedProps, Drawer, type DrawerProps, DrawerSection, type DrawerSectionProps, Dropdown, type DropdownBadge, type DropdownEntry, type DropdownItem, type DropdownProps, type DropdownRowProps, type DropdownSearch, type DropdownSection, type DropdownSeparator, EmptyState, type EmptyStateProps, type EmptyStateVariant, Field, type FieldControlProps, type FieldProps, FileField, type FileFieldProps, type Filter, FilterBar, type FilterBarProps, Icon, KeyValueList, type KeyValueListProps, type KeyValueRow, Modal, type ModalProps, type NativeButtonProps, NumericValue, type NumericValueProps, PAGE_SIZES, Pagination, type PaginationProps, Pill, type PillProps, RowIdentity, type RowIdentityProps, type SegmentOption, Segmented, type SegmentedProps, SelectField, type SelectFieldProps, SidebarNav, type SidebarNavBadge, type SidebarNavItem, type SidebarNavLeaf, type SidebarNavProps, type SidebarNavSection, Skeleton, type SkeletonProps, SkeletonTable, type SkeletonTableProps, Snippet, type SnippetProps, StatBand, type StatBandProps, type StatDelta, type StatFigure, type StatTone, type StatVariant, StatusDot, type StatusDotProps, Success, SuccessCheck, type SuccessCheckProps, type SuccessCountdown, SuccessPanel, type SuccessPanelProps, type SuccessProps, Switch, type SwitchProps, THEME_INIT_SCRIPT, type TabItem, type TableSort, Tabs, type TabsProps, TextArea, type TextAreaProps, TextField, type TextFieldProps, ThemeToggle, type ThemeToggleProps, Timeline, type TimelineEvent, type TimelineEventKind, type TimelineProps, Toast, type ToastNotice, type ToastProps, Tooltip, type TooltipProps, sortTableRows, useToast };
@@ -2868,6 +2868,129 @@ function ThemeToggle({ labelled = false }) {
2868
2868
  ] }) : button;
2869
2869
  }
2870
2870
 
2871
+ // src/Success.tsx
2872
+ import { forwardRef as forwardRef8, useEffect as useEffect14, useRef as useRef17, useState as useState13 } from "react";
2873
+ import { jsx as jsx34, jsxs as jsxs29 } from "react/jsx-runtime";
2874
+ var SuccessCheck = forwardRef8(function SuccessCheck2({ variant = "line", className, ...rest }, ref) {
2875
+ const circled = variant === "circled";
2876
+ return /* @__PURE__ */ jsxs29(
2877
+ "svg",
2878
+ {
2879
+ ...rest,
2880
+ ref,
2881
+ className: ["ui-sx__check", `ui-sx__check--${circled ? "circled" : "line"}`, className].filter(Boolean).join(" "),
2882
+ width: "24",
2883
+ height: "24",
2884
+ viewBox: "0 0 24 24",
2885
+ "aria-hidden": "true",
2886
+ focusable: "false",
2887
+ children: [
2888
+ circled && /* @__PURE__ */ jsx34("path", { className: "ui-sx__circle", d: "M22 11.08V12a10 10 0 1 1-5.93-9.14" }),
2889
+ /* @__PURE__ */ jsx34("path", { className: "ui-sx__tick", d: circled ? "M22 4L12 14.01l-3-3" : "M20 6L9 17l-5-5" })
2890
+ ]
2891
+ }
2892
+ );
2893
+ });
2894
+ var SuccessPanel = forwardRef8(function SuccessPanel2({ title = "Done", sub, check = "line", className, ...rest }, ref) {
2895
+ const circled = check === "circled";
2896
+ return /* @__PURE__ */ jsxs29("div", { ...rest, ref, className: ["ui-success", className].filter(Boolean).join(" "), children: [
2897
+ /* @__PURE__ */ jsx34("div", { className: ["ui-success__check", circled && "ui-success__check--circled"].filter(Boolean).join(" "), children: /* @__PURE__ */ jsx34(SuccessCheck, { variant: circled ? "circled" : "line" }) }),
2898
+ /* @__PURE__ */ jsx34("div", { className: "ui-success__title", children: title }),
2899
+ sub && /* @__PURE__ */ jsx34("div", { className: "ui-success__sub", children: sub })
2900
+ ] });
2901
+ });
2902
+ var pieces = [
2903
+ [8, 0, -24, "a", 1],
2904
+ [20, 0.14, 40, "g", 0.8],
2905
+ [31, 0.06, 12, "c", 1.1],
2906
+ [42, 0.2, -52, "p", 0.9],
2907
+ [50, 0.02, 28, "a", 1],
2908
+ [58, 0.18, -16, "k", 0.75],
2909
+ [67, 0.09, 60, "g", 1.05],
2910
+ [76, 0.24, -36, "c", 0.85],
2911
+ [85, 0.05, 20, "a", 1],
2912
+ [92, 0.16, -48, "p", 0.9],
2913
+ [14, 0.3, 44, "k", 0.8],
2914
+ [37, 0.34, -28, "g", 1],
2915
+ [62, 0.28, 52, "a", 0.9],
2916
+ [80, 0.36, -20, "c", 1.05]
2917
+ ];
2918
+ function Countdown({ seconds, label = "Redirecting", onDone }) {
2919
+ const [remaining, setRemaining] = useState13(seconds);
2920
+ const callback = useRef17(onDone);
2921
+ useEffect14(() => {
2922
+ callback.current = onDone;
2923
+ }, [onDone]);
2924
+ useEffect14(() => {
2925
+ let count = seconds;
2926
+ const timer = setInterval(() => {
2927
+ count -= 1;
2928
+ setRemaining(count);
2929
+ if (count === 0) {
2930
+ clearInterval(timer);
2931
+ callback.current?.();
2932
+ }
2933
+ }, 1e3);
2934
+ return () => clearInterval(timer);
2935
+ }, [seconds]);
2936
+ return /* @__PURE__ */ jsxs29("div", { className: "ui-sx__count", "data-sx-count": "", style: { "--sx-secs": `${seconds}s` }, children: [
2937
+ /* @__PURE__ */ jsx34("span", { className: "ui-sx__count-ring", "aria-hidden": "true" }),
2938
+ /* @__PURE__ */ jsxs29("span", { className: "ui-sx__count-text", children: [
2939
+ label,
2940
+ " in ",
2941
+ /* @__PURE__ */ jsx34("b", { "data-sx-num": "", children: remaining }),
2942
+ "s"
2943
+ ] })
2944
+ ] });
2945
+ }
2946
+ var Success = forwardRef8(function Success2({
2947
+ layout = "hero",
2948
+ level,
2949
+ check = "line",
2950
+ title = "All done",
2951
+ body,
2952
+ actions,
2953
+ confetti = false,
2954
+ countdown,
2955
+ onCountdownEnd,
2956
+ className,
2957
+ // Destructured only to keep it out of `rest`; see SuccessProps.eyebrow.
2958
+ eyebrow: _removedEyebrow,
2959
+ ...rest
2960
+ }, ref) {
2961
+ void _removedEyebrow;
2962
+ const rank = [1, 2, 3, 4, 5, 6].includes(Number(level)) ? Number(level) : layout === "compact" ? 2 : 1;
2963
+ const Heading = `h${rank}`;
2964
+ const hasActions = Array.isArray(actions) ? actions.length > 0 : Boolean(actions);
2965
+ const seconds = countdown?.seconds;
2966
+ const duration = seconds != null && Number.isFinite(seconds) && seconds >= 1 ? Math.floor(seconds) : 5;
2967
+ const mark = check === "circled" ? "circled" : "line";
2968
+ return /* @__PURE__ */ jsxs29(
2969
+ "div",
2970
+ {
2971
+ role: "status",
2972
+ "aria-live": "polite",
2973
+ ...rest,
2974
+ ref,
2975
+ className: ["ui-sx", `ui-sx--${layout}`, `ui-sx--check-${mark}`, confetti && "ui-sx--confetti", className].filter(Boolean).join(" "),
2976
+ children: [
2977
+ confetti && /* @__PURE__ */ jsx34("div", { className: "ui-sx__confetti", "aria-hidden": "true", children: pieces.map(
2978
+ ([x, d, r, t, s], index) => /* @__PURE__ */ jsx34("i", { className: `ui-sx__piece ui-sx__piece--${t}`, style: { left: `${x}%`, "--sx-d": `${d}s`, "--sx-r": `${r}deg`, "--sx-s": s } }, index)
2979
+ ) }),
2980
+ /* @__PURE__ */ jsxs29("div", { className: "ui-sx__inner", children: [
2981
+ /* @__PURE__ */ jsx34("div", { className: "ui-sx__visual", children: /* @__PURE__ */ jsx34(SuccessCheck, { variant: mark }) }),
2982
+ /* @__PURE__ */ jsxs29("div", { className: "ui-sx__content", children: [
2983
+ /* @__PURE__ */ jsx34(Heading, { className: "ui-sx__title", children: title }),
2984
+ body && /* @__PURE__ */ jsx34("p", { className: "ui-sx__body", children: body }),
2985
+ hasActions && /* @__PURE__ */ jsx34("div", { className: "ui-sx__actions", children: actions }),
2986
+ countdown && /* @__PURE__ */ jsx34(Countdown, { seconds: duration, label: countdown.label, onDone: onCountdownEnd }, duration)
2987
+ ] })
2988
+ ] })
2989
+ ]
2990
+ }
2991
+ );
2992
+ });
2993
+
2871
2994
  // src/index.ts
2872
2995
  var PAGE_SIZES = KIT_PAGE_SIZES;
2873
2996
  var DEFAULT_PAGE_SIZE3 = KIT_DEFAULT_PAGE_SIZE;
@@ -2909,6 +3032,9 @@ export {
2909
3032
  Snippet,
2910
3033
  StatBand,
2911
3034
  StatusDot,
3035
+ Success,
3036
+ SuccessCheck,
3037
+ SuccessPanel,
2912
3038
  Switch,
2913
3039
  THEME_INIT_SCRIPT,
2914
3040
  Tabs,
@@ -264,11 +264,13 @@ export function toast({
264
264
  // a card. Two strings, no options; styles are .ui-success in styles/callout.css.
265
265
  //
266
266
  // The kit also publishes success() from components/success.js, which is the same
267
- // idea at page size: layouts, a backdrop, follow-up buttons and an auto-redirect
268
- // countdown. Pick by how much of the screen the confirmation owns. The two share
269
- // the glowing check; their layout and content remain independent.
270
- export function successPanel({ title = 'Done', sub = '' } = {}) {
271
- return `<div class="ui-success"><div class="ui-success__check">${successCheck()}</div><div class="ui-success__title">${esc(title)}</div>${sub ? `<div class="ui-success__sub">${esc(sub)}</div>` : ''}</div>`;
267
+ // idea at page size: layouts, follow-up buttons and an auto-redirect countdown.
268
+ // Pick by how much of the screen the confirmation owns. The two share the check;
269
+ // their layout and content remain independent.
270
+ export function successPanel({ title = 'Done', sub = '', check = 'line' } = {}) {
271
+ // The circled mark is one status size everywhere, so the box narrows for it.
272
+ const box = check === 'circled' ? 'ui-success__check ui-success__check--circled' : 'ui-success__check';
273
+ return `<div class="ui-success"><div class="${box}">${successCheck(check)}</div><div class="ui-success__title">${esc(title)}</div>${sub ? `<div class="ui-success__sub">${esc(sub)}</div>` : ''}</div>`;
272
274
  }
273
275
 
274
276
  // ---- Empty state ---------------------------------------------------------
@@ -1,26 +1,26 @@
1
- // Success / confirmation surface. One factory, three layouts and three
2
- // backdrops, an SVG check that draws itself in, optional confetti and an
3
- // optional auto-redirect countdown. Every motion path is reduced-motion safe.
4
- // Accent-aware, but the success mark stays on the --green family.
1
+ // Success / confirmation surface. One factory, three layouts, two check marks,
2
+ // optional confetti and an optional auto-redirect countdown, every motion path
3
+ // reduced-motion safe. Accent-aware, but the mark stays on the --green family.
5
4
  //
6
5
  // container.innerHTML = success({ title, body, actions: [{ label, variant }] });
7
6
  //
8
- // The check animation is pure CSS, so string-rendered markup animates on its own
9
- // once mounted. A live countdown is opt-in via wireSuccess(); the markup alone
10
- // shows the ring sweep and a static number.
7
+ // The check animation is pure CSS, so string-rendered markup animates once
8
+ // mounted. A live countdown is opt-in via wireSuccess().
11
9
  import { esc, button } from './index.js';
12
10
 
13
- // Self-drawing check: a faint track disc, a filled accent disc that springs in,
14
- // the tick that strokes on via dash-offset, and a burst ring that expands and
15
- // fades. viewBox 0 0 52 52; the tick path length is ~34 (dasharray tuned to it).
16
- export function successCheck() {
17
- // width/height attributes keep the base svg:where(:not([width]):not([height]))
18
- // fallback from sizing us; the real size is set per-layout in success.css.
19
- return `<svg class="ui-sx__check" width="52" height="52" viewBox="0 0 52 52" aria-hidden="true" focusable="false">
20
- <circle class="ui-sx__ring" cx="26" cy="26" r="24"/>
21
- <circle class="ui-sx__disc" cx="26" cy="26" r="24"/>
22
- <path class="ui-sx__tick" d="M15 27l7.5 7.5L37 18"/>
23
- </svg>`;
11
+ // 'line' is the bare Lucide `check`, 'circled' is Lucide circle-check-big. Which
12
+ // is the default, and why that departs from Guidelines / Iconography:
13
+ // why: docs/specification.md#success-confirmations
14
+ // Each is written whole because the icon-sizing and glyph-stroke gates find their
15
+ // subjects by scanning source for a class on an `<svg …>`: a tag split across
16
+ // string pieces leaves both silent rather than failing.
17
+ const MARKS = {
18
+ line: '<svg class="ui-sx__check ui-sx__check--line" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ui-sx__tick" d="M20 6L9 17l-5-5"/></svg>',
19
+ circled: '<svg class="ui-sx__check ui-sx__check--circled" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ui-sx__circle" d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path class="ui-sx__tick" d="M22 4L12 14.01l-3-3"/></svg>',
20
+ };
21
+
22
+ export function successCheck(variant = 'line') {
23
+ return Object.hasOwn(MARKS, variant) ? MARKS[variant] : MARKS.line;
24
24
  }
25
25
 
26
26
  // A deterministic confetti field (opt-in). Decorative + aria-hidden; each piece
@@ -60,21 +60,26 @@ function countdownEl({ seconds = 5, label = 'Redirecting' } = {}) {
60
60
  // For a confirmation that stays inside the page the user is already on, the kit
61
61
  // publishes successPanel() from components/index.js — a check, a title and one
62
62
  // line of sub, and nothing to configure. Pick by how much of the screen the
63
- // confirmation owns. The two share the glowing check, while their layout
64
- // and content remain independent.
63
+ // confirmation owns. The two share the check, while their layout and content
64
+ // remain independent.
65
+ //
66
+ // Both carry one title and at most one short line under it. There is no eyebrow
67
+ // tier: a confirmation stacking a label, a headline and a paragraph reads as
68
+ // three competing voices for one outcome.
69
+ // why: docs/specification.md#success-confirmations
65
70
  export function success({
66
71
  layout = 'hero', // 'hero' | 'split' | 'compact'
67
72
  level, // heading level of the title; see the note below
68
- backdrop = 'aurora', // 'aurora' | 'glow' | 'flat'
69
- eyebrow = '',
73
+ check = 'line', // 'line' | 'circled' — see successCheck() above
70
74
  title = 'All done',
71
- body = '',
75
+ body = '', // one short line, or nothing; see the note below
72
76
  actions = [], // [{ label, variant, href, icon, iconRight, size }]
73
77
  confetti = false, // opt-in particle burst (reduced-motion safe)
74
78
  countdown = null, // { seconds, label } | null
75
79
  className = '',
76
80
  } = {}) {
77
- const cls = ['ui-sx', `ui-sx--${layout}`, `ui-sx--bd-${backdrop}`, confetti && 'ui-sx--confetti', className]
81
+ const mark = check === 'circled' ? 'circled' : 'line';
82
+ const cls = ['ui-sx', `ui-sx--${layout}`, `ui-sx--check-${mark}`, confetti && 'ui-sx--confetti', className]
78
83
  .filter(Boolean).join(' ');
79
84
 
80
85
  // The title's rank follows the layout, because the layout is the question
@@ -87,16 +92,6 @@ export function success({
87
92
  const rank = [1, 2, 3, 4, 5, 6].includes(Number(level)) ? Number(level) : (layout === 'compact' ? 2 : 1);
88
93
  const h = `h${rank}`;
89
94
 
90
- const bd = backdrop === 'aurora'
91
- ? `<div class="ui-sx__aurora" aria-hidden="true">
92
- <span class="ui-sx__glow ui-sx__glow--a"></span>
93
- <span class="ui-sx__glow ui-sx__glow--b"></span>
94
- </div>`
95
- : backdrop === 'glow'
96
- ? `<span class="ui-glow ui-glow--green ui-sx__bg-glow" aria-hidden="true"></span>`
97
- : '';
98
-
99
- const eyebrowEl = eyebrow ? `<div class="ui-sx__eyebrow">${esc(eyebrow)}</div>` : '';
100
95
  const bodyEl = body ? `<p class="ui-sx__body">${esc(body)}</p>` : '';
101
96
  const actionsEl = actions.length
102
97
  ? `<div class="ui-sx__actions">${actions.map((a) => button({ size: 'md', ...a })).join('')}</div>`
@@ -104,12 +99,10 @@ export function success({
104
99
  const countEl = countdown ? countdownEl(countdown) : '';
105
100
 
106
101
  return `<div class="${esc(cls)}" role="status" aria-live="polite">
107
- ${bd}
108
102
  ${confetti ? confettiField() : ''}
109
103
  <div class="ui-sx__inner">
110
- <div class="ui-sx__visual">${successCheck()}</div>
104
+ <div class="ui-sx__visual">${successCheck(mark)}</div>
111
105
  <div class="ui-sx__content">
112
- ${eyebrowEl}
113
106
  <${h} class="ui-sx__title">${esc(title)}</${h}>
114
107
  ${bodyEl}
115
108
  ${actionsEl}
package/src/index.d.ts CHANGED
@@ -82,7 +82,7 @@ export declare function deckTextSwitch(active?: string): string;
82
82
  export declare function hlShell(raw: string): string;
83
83
  export declare function pill(label: string, variant?: string): string;
84
84
  export declare function statusDot(live?: boolean): string;
85
- export declare function successCheck(): string;
85
+ export declare function successCheck(variant?: 'line' | 'circled'): string;
86
86
  export declare function themeIcon(theme: string): string;
87
87
  export declare function themeName(theme: string): string;
88
88
  export declare function themeToggle(theme?: string): string;
@@ -123,9 +123,15 @@
123
123
  }
124
124
  .ui-success__check {
125
125
  --sx-green: var(--chip-success-ink);
126
- width: 48px; height: 48px;
126
+ width: 28px; height: 28px;
127
127
  margin: 0 auto 14px;
128
128
  }
129
129
  .ui-success__check .ui-sx__check { width: 100%; height: 100%; }
130
+ /* `circled` is one status size everywhere, this panel included: the box narrows to
131
+ the 20px success() draws it at, rather than stretching a status mark to fill the
132
+ line mark's box. The mark fills the box, so the box is what decides. The modifier
133
+ is written by successPanel(), as success() writes ui-sx--check-circled on its root.
134
+ why: docs/specification.md#success-confirmations */
135
+ .ui-success__check--circled { width: 20px; height: 20px; }
130
136
  .ui-success__title { color: var(--strong); font-size: var(--text-lg); font-weight: var(--weight-semibold); margin-bottom: 5px; }
131
137
  .ui-success__sub { color: var(--text); font-size: var(--text-base); }
@@ -1,21 +1,16 @@
1
1
  /* ============================================================================
2
2
  * Success / confirmation surface — .ui-sx*
3
- * The emotional high point of a flow, with craft: a self-drawing SVG check
4
- * (ring burst + spring), an aurora / glow backdrop, follow-up actions, an
5
- * optional confetti field and an optional auto-redirect countdown. Three
6
- * layouts (hero / split / compact) and three backdrops (aurora / glow / flat).
3
+ * The end of a flow: a check mark, the outcome in words, somewhere to go
4
+ * next, an optional confetti field and an optional auto-redirect countdown.
5
+ * Three layouts (hero / split / compact) and two check marks.
7
6
  *
8
- * Accent-aware via the kit tokens; the success mark stays on the --green
9
- * family (green = success), the surrounds follow --accent so it re-themes with
10
- * the sub-theme. Every motion path is reduced-motion safe — see the block at
11
- * the foot. Markup comes from success() in components/index (re-exported).
7
+ * The card is a plain elevated surface — no blurred glows or drifting blobs
8
+ * behind it. Accent-aware via the kit tokens; the mark stays on the --green
9
+ * family (green = success). Every motion path is reduced-motion safe — see
10
+ * the block at the foot. Markup comes from success() in components/index
11
+ * (re-exported).
12
12
  * ========================================================================== */
13
13
 
14
- .ui-sx, .ui-success__check {
15
- --sx-tint: color-mix(in srgb, var(--green) 18%, transparent);
16
- --sx-track: color-mix(in srgb, var(--green) 32%, transparent);
17
- }
18
-
19
14
  .ui-sx {
20
15
  --sx-green: var(--green);
21
16
  position: relative;
@@ -26,41 +21,9 @@
26
21
  isolation: isolate;
27
22
  }
28
23
 
29
- /* ---- Backdrops ----------------------------------------------------------- */
30
- /* Aurora: two soft blurred blobs — a green wash under the check + an accent
31
- blob for identity — so the success matches the rest of the kit's warmth. */
32
- .ui-sx__aurora { position: absolute; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
33
- .ui-sx__glow {
34
- position: absolute; border-radius: 50%; filter: blur(70px); opacity: 0.9;
35
- aspect-ratio: 1; will-change: transform;
36
- }
37
- .ui-sx__glow--a {
38
- width: 58%; top: -14%; left: 50%; translate: -50% 0;
39
- background: radial-gradient(circle at center, var(--sx-tint), transparent 68%);
40
- animation: ui-sx-float 14s ease-in-out infinite alternate; /* motion: ambient — a background glow that drifts on its own clock; `alternate` needs a symmetric curve, which var(--ease) is not */
41
- }
42
- .ui-sx__glow--b {
43
- width: 52%; bottom: -20%; left: 12%;
44
- background: radial-gradient(circle at center,
45
- color-mix(in srgb, var(--accent) 30%, transparent), transparent 68%);
46
- animation: ui-sx-float 18s ease-in-out -6s infinite alternate; /* motion: ambient — the second glow, offset in period and phase so the pair never lines up */
47
- }
48
- :root[data-theme="light"] .ui-sx__glow { opacity: 0.7; }
49
- @keyframes ui-sx-float {
50
- from { transform: translate(-2%, -2%) scale(1); }
51
- to { transform: translate(3%, 3%) scale(1.14); }
52
- }
53
- /* Glow backdrop: reuse the shared .ui-glow--green, parked behind the check. */
54
- .ui-sx__bg-glow { top: -40%; left: 50%; translate: -50% 0; width: 360px; height: 360px; }
55
-
56
24
  /* ---- Layout shell -------------------------------------------------------- */
57
25
  .ui-sx__inner { position: relative; z-index: 1; }
58
26
  .ui-sx__content { min-width: 0; }
59
- .ui-sx__eyebrow {
60
- /* rank: label */
61
- color: var(--green); font-size: var(--text-sm); font-weight: var(--weight-medium);
62
- margin-bottom: 10px;
63
- }
64
27
  .ui-sx__title {
65
28
  color: var(--strong); font-size: var(--text-xl); font-weight: var(--weight-semibold);
66
29
  letter-spacing: -.02em; line-height: 1.2; margin: 0;
@@ -76,7 +39,7 @@
76
39
  .ui-sx--hero .ui-sx__visual { margin-bottom: 22px; }
77
40
  .ui-sx--hero .ui-sx__body { margin-left: auto; margin-right: auto; }
78
41
  .ui-sx--hero .ui-sx__actions { justify-content: center; }
79
- .ui-sx--hero .ui-sx__check { width: 96px; height: 96px; }
42
+ .ui-sx--hero .ui-sx__check { width: 56px; height: 56px; }
80
43
 
81
44
  /* split — visual panel beside the copy */
82
45
  .ui-sx--split .ui-sx__inner {
@@ -87,14 +50,14 @@
87
50
  background: color-mix(in srgb, var(--green) 8%, transparent); /* ring-gap: inherit — decorative illustration has no nested focus target. */
88
51
  border-right: 1px solid var(--border);
89
52
  }
90
- .ui-sx--split .ui-sx__check { width: 120px; height: 120px; }
53
+ .ui-sx--split .ui-sx__check { width: 72px; height: 72px; }
91
54
  .ui-sx--split .ui-sx__content { padding: 40px 36px; align-self: center; }
92
55
 
93
56
  /* compact — inline, panel/toast-adjacent */
94
57
  .ui-sx--compact { padding: 18px 20px; }
95
58
  .ui-sx--compact .ui-sx__inner { display: flex; align-items: center; gap: 16px; }
96
59
  .ui-sx--compact .ui-sx__visual { flex: none; }
97
- .ui-sx--compact .ui-sx__check { width: 44px; height: 44px; }
60
+ .ui-sx--compact .ui-sx__check { width: 28px; height: 28px; }
98
61
  .ui-sx--compact .ui-sx__title { font-size: calc(var(--text-md) + 0.5px); }
99
62
  .ui-sx--compact .ui-sx__body { font-size: var(--text-sm); margin-top: 3px; }
100
63
  .ui-sx--compact .ui-sx__actions { margin-top: 0; margin-left: auto; flex: none; }
@@ -102,38 +65,29 @@
102
65
  .ui-sx--compact .ui-sx__content > div:first-child,
103
66
  .ui-sx--compact .ui-sx__title { min-width: 0; }
104
67
 
105
- /* ---- The self-drawing check --------------------------------------------- */
106
- .ui-sx__check { display: block; overflow: visible; }
107
- /* Soft tinted disc that springs in behind the tick. */
108
- .ui-sx__disc {
109
- fill: var(--sx-tint); stroke: var(--sx-track); stroke-width: 1.5;
110
- transform-origin: center; animation: ui-sx-pop .5s cubic-bezier(.22,1,.3,1) both; /* motion: choreographed — first beat of the check: disc, then tick at .28s, then ring at .2s */
111
- }
112
- /* The tick, stroked on via dash-offset (path length ~34). */
113
- .ui-sx__tick {
114
- fill: none; stroke: var(--sx-green); stroke-width: 5;
68
+ /* ---- The check mark ------------------------------------------------------ */
69
+ /* Two marks, both unmodified Lucide paths at Lucide's own stroke width, in the
70
+ success colour. `line` is the bare check (Lucide `check`), the default. It
71
+ strokes itself on via dash-offset; the path is 22.6 units long, which is what
72
+ the dasharray is tuned to. `circled` is Lucide `circle-check-big` at 20px —
73
+ the kit's label size — for a confirmation that should stay quiet. It is drawn
74
+ at rest, because a 20px mark has nothing to choreograph. */
75
+ .ui-sx__check { display: block; }
76
+ .ui-sx__tick, .ui-sx__circle {
77
+ fill: none; stroke: var(--sx-green, var(--green)); stroke-width: 2;
115
78
  stroke-linecap: round; stroke-linejoin: round;
116
- stroke-dasharray: 34; stroke-dashoffset: 34;
117
- animation: ui-sx-draw .5s cubic-bezier(.6,0,.35,1) .28s forwards; /* motion: choreographed — the tick draws while the disc is still settling */
118
- filter: drop-shadow(0 0 5px color-mix(in srgb, var(--green) 55%, transparent));
119
- }
120
- /* Burst ring that expands and fades once, framing the moment. */
121
- .ui-sx__ring {
122
- fill: none; stroke: var(--sx-green); stroke-width: 2;
123
- transform-origin: center; opacity: 0;
124
- animation: ui-sx-burst .7s cubic-bezier(.3,.7,.4,1) .2s forwards; /* motion: choreographed — the ring outlives the tick so the moment ends on the frame */
125
79
  }
126
-
127
- @keyframes ui-sx-pop {
128
- 0% { transform: scale(.4); opacity: 0; }
129
- 60% { transform: scale(1.06); opacity: 1; }
130
- 100% { transform: scale(1); }
80
+ .ui-sx__check--line .ui-sx__tick {
81
+ stroke-dasharray: 23; stroke-dashoffset: 23;
82
+ animation: ui-sx-draw var(--dur-slow) var(--ease-out) forwards;
131
83
  }
132
84
  @keyframes ui-sx-draw { to { stroke-dashoffset: 0; } }
133
- @keyframes ui-sx-burst {
134
- 0% { transform: scale(.55); opacity: .7; }
135
- 100% { transform: scale(1.7); opacity: 0; }
136
- }
85
+
86
+ /* circled — one small size in every layout, so it reads as a status mark
87
+ rather than an illustration. The split panel narrows to match it. */
88
+ .ui-sx--check-circled .ui-sx__check { width: 20px; height: 20px; }
89
+ .ui-sx--check-circled.ui-sx--hero .ui-sx__visual { margin-bottom: 12px; }
90
+ .ui-sx--check-circled.ui-sx--split .ui-sx__inner { grid-template-columns: minmax(120px, 160px) 1fr; }
137
91
 
138
92
  /* ---- Confetti (opt-in) --------------------------------------------------- */
139
93
  .ui-sx__confetti { position: absolute; inset: 0; z-index: 2; pointer-events: none; overflow: hidden; }
@@ -171,12 +125,9 @@
171
125
  .ui-sx__count-text { color: var(--text); font-size: var(--text-sm); }
172
126
  .ui-sx__count-text b { color: var(--text); font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; }
173
127
 
174
- /* ---- Reduced motion — static check, no burst / confetti / sweep ---------- */
128
+ /* ---- Reduced motion — static check, no confetti / sweep ------------------ */
175
129
  @media (prefers-reduced-motion: reduce) {
176
- .ui-sx__glow--a, .ui-sx__glow--b { animation: none; }
177
- .ui-sx__disc { animation: none; }
178
- .ui-sx__tick { animation: none; stroke-dashoffset: 0; }
179
- .ui-sx__ring { display: none; }
130
+ .ui-sx__check--line .ui-sx__tick { animation: none; stroke-dashoffset: 0; }
180
131
  .ui-sx__confetti { display: none; }
181
132
  .ui-sx__count-ring { animation: none; background: var(--surface-2); /* ring-gap: inherit — decorative or text-only surface has no nested focus target. */ }
182
133
  }
@@ -184,6 +135,7 @@
184
135
  /* ---- Narrow — split stacks -------------------------------------------- */
185
136
  @media (max-width: 560px) {
186
137
  .ui-sx--split .ui-sx__inner { grid-template-columns: 1fr; }
138
+ .ui-sx--check-circled.ui-sx--split .ui-sx__inner { grid-template-columns: 1fr; }
187
139
  .ui-sx--split .ui-sx__visual { border-right: 0; border-bottom: 1px solid var(--border); padding: 32px 24px; }
188
140
  .ui-sx--split .ui-sx__content { padding: 28px 26px; text-align: center; }
189
141
  .ui-sx--split .ui-sx__actions { justify-content: center; }
@@ -30,6 +30,13 @@
30
30
  .ui-table td.ui-table__act { padding-right: 0; text-align: right; }
31
31
  .ui-table tr.is-dead td { /* muted-ink: state — revoked row */ color: var(--muted); }
32
32
  .ui-table tr.is-dead td.ui-table__title { text-decoration: line-through; }
33
+ /* The strike is painted by the cell across its in-flow inline descendants, and an atomic
34
+ inline-level box is not one — so a linked name in a revoked row needs it struck itself.
35
+ On hover it carries both lines: the cell-link hover rule below is a whole `text-decoration`,
36
+ which would otherwise replace the strike with the underline.
37
+ held by: src/styles/table-focus.test.js */
38
+ .ui-table tr.is-dead td.ui-table__title a { text-decoration: line-through; }
39
+ .ui-table tr.is-dead td.ui-table__title a:hover { text-decoration-line: underline line-through; }
33
40
  .ui-table tbody tr { transition: background var(--dur-fast) var(--ease); }
34
41
 
35
42
  /* Row hover: an inset, rounded fill so the highlight never sits flush against a
@@ -89,6 +96,24 @@
89
96
  color: var(--strong);
90
97
  text-decoration: underline;
91
98
  }
99
+ /* A plain link — one carrying no class — is the link these rules are about. An inline-block
100
+ box, so a title link that wraps paints one ring instead of one per line, and the kit's small
101
+ corner for that ring to follow. Anchors the kit already styles (`.ui-btn`, `.ui-identity`,
102
+ `.ui-dropdown__item`, `.ui-nav__item`) keep the box and the corner their own component sets;
103
+ a denylist of them went stale twice before this said what it meant (#510).
104
+ held by: src/styles/table-focus.test.js */
105
+ .ui-table :where(td, th, caption) a:not([class]) {
106
+ display: inline-block;
107
+ border-radius: var(--radius-xs);
108
+ }
109
+ /* And on keyboard focus every link in a table takes the kit ring, at whatever corner it
110
+ carries, so none falls back to the browser's own outline. `.ui-btn` has the ring already.
111
+ why: guidelines/state-set.md#focus-rings
112
+ held by: src/styles/table-focus.test.js */
113
+ .ui-table a:not(.ui-btn):focus-visible {
114
+ outline: 2px solid transparent;
115
+ box-shadow: var(--ring);
116
+ }
92
117
 
93
118
  .ui-table__num { text-align: right; font-variant-numeric: tabular-nums; }
94
119
  .ui-table th.ui-table__num { text-align: right; }