@godxjp/ui 18.4.0 → 18.5.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.
Files changed (131) hide show
  1. package/README.md +16 -1
  2. package/dist/app/theme-axes.d.ts +11 -3
  3. package/dist/app/theme-axes.js +2 -1
  4. package/dist/components/charts/chart-frame.d.ts +17 -3
  5. package/dist/components/charts/chart-frame.js +7 -1
  6. package/dist/components/charts/chart-summary.d.ts +21 -0
  7. package/dist/components/charts/chart-summary.js +32 -5
  8. package/dist/components/charts/compact-bar-trend.d.ts +29 -0
  9. package/dist/components/charts/compact-bar-trend.js +74 -0
  10. package/dist/components/charts/index.d.ts +3 -0
  11. package/dist/components/charts/index.js +2 -0
  12. package/dist/components/data-display/badge.d.ts +8 -0
  13. package/dist/components/data-display/badge.js +7 -1
  14. package/dist/components/data-display/data-table.d.ts +16 -1
  15. package/dist/components/data-display/data-table.js +56 -0
  16. package/dist/components/data-display/index.d.ts +3 -1
  17. package/dist/components/data-display/index.js +10 -1
  18. package/dist/components/data-display/list-row.d.ts +22 -4
  19. package/dist/components/data-display/list-row.js +21 -3
  20. package/dist/components/data-display/service-launcher-card.d.ts +40 -0
  21. package/dist/components/data-display/service-launcher-card.js +97 -0
  22. package/dist/components/data-entry/command-palette.d.ts +37 -0
  23. package/dist/components/data-entry/command-palette.js +125 -0
  24. package/dist/components/data-entry/index.d.ts +2 -0
  25. package/dist/components/data-entry/index.js +2 -0
  26. package/dist/components/feedback/dialog.d.ts +2 -0
  27. package/dist/components/feedback/dialog.js +42 -33
  28. package/dist/components/feedback/index.d.ts +4 -1
  29. package/dist/components/feedback/index.js +6 -2
  30. package/dist/components/feedback/sheet.d.ts +26 -4
  31. package/dist/components/feedback/sheet.js +54 -7
  32. package/dist/components/feedback/two-factor-setup.d.ts +31 -0
  33. package/dist/components/feedback/two-factor-setup.js +97 -0
  34. package/dist/components/general/index.d.ts +1 -1
  35. package/dist/components/general/logo.d.ts +42 -8
  36. package/dist/components/general/logo.js +67 -14
  37. package/dist/components/layout/app-shell.js +21 -5
  38. package/dist/components/layout/auth-divider.d.ts +7 -0
  39. package/dist/components/layout/auth-divider.js +21 -0
  40. package/dist/components/layout/auth-footer.d.ts +5 -0
  41. package/dist/components/layout/auth-footer.js +24 -0
  42. package/dist/components/layout/auth-identity.d.ts +5 -0
  43. package/dist/components/layout/auth-identity.js +17 -0
  44. package/dist/components/layout/auth-shell.d.ts +8 -1
  45. package/dist/components/layout/auth-shell.js +25 -6
  46. package/dist/components/layout/auth-stack.d.ts +4 -0
  47. package/dist/components/layout/auth-stack.js +8 -0
  48. package/dist/components/layout/centered-shell.d.ts +5 -2
  49. package/dist/components/layout/centered-shell.js +2 -1
  50. package/dist/components/layout/index.d.ts +18 -3
  51. package/dist/components/layout/index.js +17 -2
  52. package/dist/components/layout/legal-document-shell.d.ts +34 -0
  53. package/dist/components/layout/legal-document-shell.js +186 -0
  54. package/dist/components/layout/master-detail.d.ts +24 -0
  55. package/dist/components/layout/master-detail.js +48 -0
  56. package/dist/components/layout/org-switcher.d.ts +9 -0
  57. package/dist/components/layout/org-switcher.js +218 -0
  58. package/dist/components/layout/page-container.d.ts +1 -1
  59. package/dist/components/layout/page-container.js +3 -2
  60. package/dist/components/layout/sidebar-link.d.ts +43 -0
  61. package/dist/components/layout/sidebar-link.js +13 -0
  62. package/dist/components/layout/sidebar.d.ts +42 -5
  63. package/dist/components/layout/sidebar.js +170 -85
  64. package/dist/components/navigation/app-setting-picker.js +17 -6
  65. package/dist/components/navigation/filter-bar.d.ts +12 -3
  66. package/dist/components/navigation/filter-bar.js +8 -2
  67. package/dist/components/navigation/index.d.ts +2 -2
  68. package/dist/components/navigation/index.js +3 -1
  69. package/dist/components/navigation/tabs-scroll.d.ts +70 -0
  70. package/dist/components/navigation/tabs-scroll.js +76 -0
  71. package/dist/components/navigation/tabs.js +35 -20
  72. package/dist/email/brand-mark.d.ts +92 -0
  73. package/dist/email/brand-mark.js +83 -0
  74. package/dist/email/color.d.ts +52 -0
  75. package/dist/email/color.js +37 -0
  76. package/dist/email/geometry.d.ts +112 -0
  77. package/dist/email/geometry.js +89 -0
  78. package/dist/email/index.d.ts +78 -0
  79. package/dist/email/index.js +76 -0
  80. package/dist/email/inline-style.d.ts +25 -0
  81. package/dist/email/inline-style.js +21 -0
  82. package/dist/email/tokens.generated.d.ts +147 -0
  83. package/dist/email/tokens.generated.js +142 -0
  84. package/dist/i18n/messages/en.json +21 -2
  85. package/dist/i18n/messages/ja.json +21 -2
  86. package/dist/i18n/messages/vi.json +21 -2
  87. package/dist/inertia/index.d.ts +50 -0
  88. package/dist/inertia/index.js +5 -0
  89. package/dist/props/components/app.prop.d.ts +11 -1
  90. package/dist/props/components/charts.prop.d.ts +40 -1
  91. package/dist/props/components/data-display.prop.d.ts +13 -1
  92. package/dist/props/components/feedback.prop.d.ts +10 -0
  93. package/dist/props/components/index.d.ts +2 -2
  94. package/dist/props/components/layout.prop.d.ts +373 -10
  95. package/dist/props/components/layout.prop.js +1 -0
  96. package/dist/props/components/navigation.prop.d.ts +19 -1
  97. package/dist/props/registry.d.ts +291 -7
  98. package/dist/props/registry.js +341 -7
  99. package/dist/props/vocabulary/index.d.ts +1 -1
  100. package/dist/props/vocabulary/interaction.prop.d.ts +1 -1
  101. package/dist/props/vocabulary/layout.prop.d.ts +18 -0
  102. package/dist/styles/base.css +8 -2
  103. package/dist/styles/card-layout.css +161 -7
  104. package/dist/styles/chart-layout.css +97 -0
  105. package/dist/styles/control.css +48 -2
  106. package/dist/styles/data-display-layout.css +52 -6
  107. package/dist/styles/dialog-layout.css +105 -4
  108. package/dist/styles/fonts.css +15 -4
  109. package/dist/styles/index.css +11 -3
  110. package/dist/styles/layout.css +367 -1
  111. package/dist/styles/logo-layout.css +62 -0
  112. package/dist/styles/navigation-layout.css +43 -0
  113. package/dist/styles/shell-layout.css +488 -40
  114. package/dist/theme/dxs.canonical.css +76 -0
  115. package/dist/tokens/axes.css +39 -0
  116. package/dist/tokens/base.css +5 -0
  117. package/dist/tokens/components/card.css +48 -0
  118. package/dist/tokens/components/chart.css +47 -0
  119. package/dist/tokens/components/control.css +16 -0
  120. package/dist/tokens/components/email.css +66 -0
  121. package/dist/tokens/components/feedback.css +17 -0
  122. package/dist/tokens/components/legal-document.css +67 -0
  123. package/dist/tokens/components/list-row.css +23 -2
  124. package/dist/tokens/components/logo.css +32 -0
  125. package/dist/tokens/components/navigation.css +13 -0
  126. package/dist/tokens/components/sheet.css +19 -0
  127. package/dist/tokens/components/shell.css +138 -0
  128. package/dist/tokens/components/sidebar.css +33 -0
  129. package/dist/tokens/foundation.css +5 -2
  130. package/dist/tokens/semantic/layout.css +44 -2
  131. package/package.json +16 -5
@@ -0,0 +1,70 @@
1
+ import * as React from "react";
2
+ /**
3
+ * Keep-the-active-tab-visible logic for the horizontal `TabsList` scroll strip (gh#204).
4
+ *
5
+ * `TabsList` scrolls its own overflow (gh#175) so long localized labels never clip. That fixed
6
+ * clipping but not the SCROLL POSITION: after a responsive resize / route re-render the strip kept
7
+ * (or shifted) its internal `scrollLeft`, so the ACTIVE trigger — typically the FIRST one — could
8
+ * end up completely outside the scrollport while still carrying `data-state="active"` /
9
+ * `aria-selected="true"`. A tab you cannot see is a tab you cannot use.
10
+ *
11
+ * The fix observes the strip (size changes) and its triggers (`data-state` changes) and scrolls the
12
+ * element that must stay reachable back into view. Deliberate constraints:
13
+ *
14
+ * - **Never fights the user.** The scroll only runs when the target is NOT already fully inside the
15
+ * scrollport, so an intentional manual/touch scroll that leaves the active tab visible is left
16
+ * alone, and a click (target is under the pointer) or arrow-key move (the browser already
17
+ * scrolled focus into view) is a no-op.
18
+ * - **Focus wins over selection.** With `activationMode="manual"` the roving focus can sit on a
19
+ * trigger that is not the selected one; yanking the strip to the selected tab would strand the
20
+ * keyboard user. The focused trigger inside the list is therefore the preferred target.
21
+ * - **Reduced motion.** `prefers-reduced-motion: reduce` downgrades every correction to an instant
22
+ * jump; resize corrections are instant regardless (a smooth animation mid-resize reads as jank).
23
+ * - **RTL / vertical safe.** All geometry is physical viewport rects and the scroll uses
24
+ * `scrollIntoView({ block: "nearest", inline: "nearest" })`, which resolves direction natively —
25
+ * so a first trigger in `dir="rtl"` (visually on the right) and a vertical rail both work
26
+ * without direction branches. `nearest` also means no ancestor/page scroll when the strip is
27
+ * already where it should be.
28
+ */
29
+ /** Minimal rect shape — only the edges the visibility test needs. */
30
+ export type TabsScrollBox = {
31
+ left: number;
32
+ right: number;
33
+ top: number;
34
+ bottom: number;
35
+ };
36
+ /**
37
+ * True when `trigger` sits entirely inside the `scrollport` box. Pure geometry — physical viewport
38
+ * coordinates, so it is direction- and orientation-agnostic (LTR, RTL, vertical rail alike).
39
+ *
40
+ * A zero-area scrollport (unmounted, `display:none`, or an environment without layout such as
41
+ * jsdom) reports `true`: there is no measurable overflow to correct, and scrolling a hidden strip
42
+ * would be meaningless.
43
+ */
44
+ export declare function isTabFullyVisible(scrollport: TabsScrollBox, trigger: TabsScrollBox): boolean;
45
+ /**
46
+ * The trigger the strip must keep reachable: the focused trigger when the roving focus is inside
47
+ * this list (manual activation can park focus off the selected tab), otherwise the active one.
48
+ */
49
+ export declare function resolveTabScrollTarget(list: HTMLElement): HTMLElement | null;
50
+ /** `true` when the user has asked for reduced motion (falsy/unsupported environments say no). */
51
+ export declare function prefersReducedMotion(): boolean;
52
+ /**
53
+ * Scrolls the list's keep-visible target back into the scrollport when — and only when — it is not
54
+ * already fully inside it. Returns the element it scrolled, or `null` when nothing needed doing
55
+ * (which is the common case, and what keeps this from fighting a deliberate user scroll).
56
+ */
57
+ export declare function syncActiveTabIntoView(list: HTMLElement | null, behavior?: ScrollBehavior): HTMLElement | null;
58
+ /**
59
+ * Wires the two signals that can strand the active tab off-screen:
60
+ *
61
+ * - a `ResizeObserver` on the strip — a responsive width change (1440 → 1024 → 390) keeps/shifts
62
+ * the old `scrollLeft`; the observer's initial delivery also covers first mount at a new width;
63
+ * - a `MutationObserver` on descendant `data-state` — the selected tab changing from outside the
64
+ * strip (router/URL state, a controlled parent) must bring the newly-selected trigger into view.
65
+ *
66
+ * Resize corrections are instant; a selection change animates unless reduced motion is requested.
67
+ * Both observers are optional: without them (SSR, ancient runtimes) the component degrades to the
68
+ * pre-fix behaviour instead of throwing.
69
+ */
70
+ export declare function useKeepActiveTabVisible(listRef: React.RefObject<HTMLElement | null>): void;
@@ -0,0 +1,76 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ const VISIBILITY_EPSILON = 1;
4
+ function isTabFullyVisible(scrollport, trigger) {
5
+ const width = scrollport.right - scrollport.left;
6
+ const height = scrollport.bottom - scrollport.top;
7
+ if (width <= 0 && height <= 0) return true;
8
+ return trigger.left >= scrollport.left - VISIBILITY_EPSILON && trigger.right <= scrollport.right + VISIBILITY_EPSILON && trigger.top >= scrollport.top - VISIBILITY_EPSILON && trigger.bottom <= scrollport.bottom + VISIBILITY_EPSILON;
9
+ }
10
+ function resolveTabScrollTarget(list) {
11
+ const focused = list.ownerDocument?.activeElement;
12
+ if (focused instanceof HTMLElement && focused !== list && list.contains(focused) && focused.getAttribute("role") === "tab") {
13
+ return focused;
14
+ }
15
+ return list.querySelector('[role="tab"][data-state="active"]');
16
+ }
17
+ function prefersReducedMotion() {
18
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
19
+ try {
20
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+ function syncActiveTabIntoView(list, behavior = "auto") {
26
+ if (!list) return null;
27
+ const target = resolveTabScrollTarget(list);
28
+ if (!target || typeof target.scrollIntoView !== "function") return null;
29
+ if (isTabFullyVisible(list.getBoundingClientRect(), target.getBoundingClientRect())) return null;
30
+ target.scrollIntoView({
31
+ block: "nearest",
32
+ inline: "nearest",
33
+ behavior: prefersReducedMotion() ? "auto" : behavior
34
+ });
35
+ return target;
36
+ }
37
+ function useKeepActiveTabVisible(listRef) {
38
+ React.useEffect(() => {
39
+ const list = listRef.current;
40
+ if (!list) return void 0;
41
+ syncActiveTabIntoView(list, "auto");
42
+ const cleanups = [];
43
+ if (typeof ResizeObserver !== "undefined") {
44
+ const resizeObserver = new ResizeObserver(() => {
45
+ syncActiveTabIntoView(list, "auto");
46
+ });
47
+ resizeObserver.observe(list);
48
+ cleanups.push(() => {
49
+ resizeObserver.disconnect();
50
+ });
51
+ }
52
+ if (typeof MutationObserver !== "undefined") {
53
+ const mutationObserver = new MutationObserver(() => {
54
+ syncActiveTabIntoView(list, "smooth");
55
+ });
56
+ mutationObserver.observe(list, {
57
+ subtree: true,
58
+ attributes: true,
59
+ attributeFilter: ["data-state"]
60
+ });
61
+ cleanups.push(() => {
62
+ mutationObserver.disconnect();
63
+ });
64
+ }
65
+ return () => {
66
+ for (const cleanup of cleanups) cleanup();
67
+ };
68
+ }, [listRef]);
69
+ }
70
+ export {
71
+ isTabFullyVisible,
72
+ prefersReducedMotion,
73
+ resolveTabScrollTarget,
74
+ syncActiveTabIntoView,
75
+ useKeepActiveTabVisible
76
+ };
@@ -3,6 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import * as TabsPrimitive from "@radix-ui/react-tabs";
5
5
  import { cn } from "../../lib/utils.js";
6
+ import { useKeepActiveTabVisible } from "./tabs-scroll.js";
6
7
  function resolveFallbackTabValue(items, requested) {
7
8
  if (!items || items.length === 0) return requested;
8
9
  if (requested !== void 0) {
@@ -75,26 +76,40 @@ function Tabs({
75
76
  }
76
77
  );
77
78
  }
78
- const TabsList = React.forwardRef(({ className, variant = "default", ...props }, ref) => /* @__PURE__ */ jsx(
79
- TabsPrimitive.List,
80
- {
81
- ref,
82
- "data-slot": "tabs-list",
83
- "data-variant": variant,
84
- className: cn(
85
- // `min-w-0 max-w-full` let the list shrink to (and never exceed) whatever width its
86
- // ancestors actually give it instead of forcing them wider; horizontal orientation then
87
- // scrolls its own overflow rather than clipping/hiding long localized labels in a narrow
88
- // container (gh#175). Hidden scrollbar keeps the strip visually clean while staying
89
- // swipeable on touch and reachable via keyboard (arrow-key roving focus still scrolls the
90
- // newly-focused trigger into view natively). Vertical orientation is untouched — it already
91
- // stacks in a column and is sized by its own `h-*`/`w-*` overrides.
92
- "group/tabs-list text-muted-foreground data-[variant=default]:bg-muted inline-flex w-fit max-w-full min-w-0 items-center justify-center rounded-lg p-1 group-data-[orientation=vertical]/tabs:flex-col data-[orientation=horizontal]:[scrollbar-width:none] data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden data-[variant=line]:gap-1 data-[variant=line]:rounded-none data-[variant=line]:bg-transparent [&[data-orientation=horizontal]::-webkit-scrollbar]:hidden",
93
- className
94
- ),
95
- ...props
96
- }
97
- ));
79
+ const TabsList = React.forwardRef(({ className, variant = "default", ...props }, ref) => {
80
+ const listRef = React.useRef(null);
81
+ const setListRef = React.useCallback(
82
+ (node) => {
83
+ listRef.current = node;
84
+ if (typeof ref === "function") ref(node);
85
+ else if (ref) ref.current = node;
86
+ },
87
+ [ref]
88
+ );
89
+ useKeepActiveTabVisible(listRef);
90
+ return /* @__PURE__ */ jsx(
91
+ TabsPrimitive.List,
92
+ {
93
+ ref: setListRef,
94
+ "data-slot": "tabs-list",
95
+ "data-variant": variant,
96
+ className: cn(
97
+ // `min-w-0 max-w-full` let the list shrink to (and never exceed) whatever width its
98
+ // ancestors actually give it instead of forcing them wider; horizontal orientation then
99
+ // scrolls its own overflow rather than clipping/hiding long localized labels in a narrow
100
+ // container (gh#175). Hidden scrollbar keeps the strip visually clean while staying
101
+ // swipeable on touch and reachable via keyboard (arrow-key roving focus still scrolls the
102
+ // newly-focused trigger into view natively); `useKeepActiveTabVisible` above re-pins the
103
+ // active trigger when a resize would otherwise strand it off-strip (gh#204). Vertical
104
+ // orientation is untouched — it already stacks in a column and is sized by its own
105
+ // `h-*`/`w-*` overrides.
106
+ "group/tabs-list text-muted-foreground data-[variant=default]:bg-muted inline-flex w-fit max-w-full min-w-0 items-center justify-center rounded-lg p-1 group-data-[orientation=vertical]/tabs:flex-col data-[orientation=horizontal]:[scrollbar-width:none] data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden data-[variant=line]:gap-1 data-[variant=line]:rounded-none data-[variant=line]:bg-transparent [&[data-orientation=horizontal]::-webkit-scrollbar]:hidden",
107
+ className
108
+ ),
109
+ ...props
110
+ }
111
+ );
112
+ });
98
113
  TabsList.displayName = TabsPrimitive.List.displayName;
99
114
  const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
100
115
  TabsPrimitive.Trigger,
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The canonical GoDX brand mark, as email-safe markup.
3
+ *
4
+ * The mark is an emerald CAPSULE with an internal light GLYPH bar — the same artwork the React
5
+ * `<Logo mark="godx" />` paints, at the same 32×32 viewBox and the same coordinates (a test asserts
6
+ * the capsule path is byte-identical to the component's, so the two can never diverge). A capsule
7
+ * WITHOUT the internal glyph is the incomplete mark and must not ship.
8
+ *
9
+ * Nothing here references an external or relative asset: an email template has no base URL it can
10
+ * trust, remote images are blocked by default in most clients, and a `cid:` attachment needs
11
+ * transport support. So the mark is delivered three ways, in order of fidelity:
12
+ *
13
+ * 1. `svg` — inline `<svg>`. Apple Mail, iOS Mail, Thunderbird, Samsung Mail. Best fidelity.
14
+ * 2. `dataUri` — the same SVG as a `data:` URL for `<img src>`. No network fetch.
15
+ * 3. `tableHtml` — bulletproof `<table>`/`<div>` markup with background-color + border-radius.
16
+ * Renders everywhere; degrades to a square emerald block with a light bar in Outlook's Word
17
+ * renderer (which drops `border-radius`) — still recognisably the mark, never a broken image.
18
+ *
19
+ * Colours come from `EMAIL_COLORS` (derived from `--success` / `--success-foreground`, the roles
20
+ * `--logo-godx-color` already points at), so a re-themed identity role re-tints the mark.
21
+ */
22
+ import { type EmailHex } from "./color.js";
23
+ /** A rounded rectangle in the mark's 32×32 coordinate space. */
24
+ export interface EmailBrandMarkRect {
25
+ readonly x: number;
26
+ readonly y: number;
27
+ readonly width: number;
28
+ readonly height: number;
29
+ readonly radius: number;
30
+ }
31
+ /** Intrinsic coordinate space of the mark artwork. */
32
+ export declare const EMAIL_BRAND_MARK_VIEWBOX = "0 0 32 32";
33
+ /** The emerald capsule. */
34
+ export declare const EMAIL_BRAND_MARK_CAPSULE: EmailBrandMarkRect;
35
+ /** The internal light glyph bar inside the capsule. */
36
+ export declare const EMAIL_BRAND_MARK_GLYPH: EmailBrandMarkRect;
37
+ /**
38
+ * Path data for a rounded rectangle, in the exact serialisation the canonical mark uses — so the
39
+ * generated capsule string equals the artwork shipped by `<Logo mark="godx" />` character for
40
+ * character.
41
+ */
42
+ export declare function roundedRectPath({ x, y, width, height, radius }: EmailBrandMarkRect): string;
43
+ /** Options for rendering the mark. Every default comes from the token contract. */
44
+ export interface EmailBrandMarkOptions {
45
+ /** Rendered width in px. Default `EMAIL_BRAND_MARK.widthPx` (32). */
46
+ width?: number;
47
+ /** Rendered height in px. Default `EMAIL_BRAND_MARK.heightPx` (32). */
48
+ height?: number;
49
+ /** Capsule fill. Default `EMAIL_COLORS.brand`. */
50
+ color?: EmailHex;
51
+ /** Internal glyph fill. Default `EMAIL_COLORS.brandForeground`. */
52
+ glyphColor?: EmailHex;
53
+ /**
54
+ * Accessible name. Pass `""` for a decorative mark that sits next to a readable wordmark — the
55
+ * markup then carries `alt=""` / `aria-hidden` instead of a redundant announcement.
56
+ */
57
+ label?: string;
58
+ }
59
+ /**
60
+ * Inline `<svg>` markup for the mark — self-contained, no external or relative asset reference.
61
+ * The `xmlns` attribute is required: an email body is not always parsed as HTML5.
62
+ */
63
+ export declare function emailBrandMarkSvg(o?: EmailBrandMarkOptions): string;
64
+ /**
65
+ * The mark as a `data:image/svg+xml` URL for `<img src>`. Percent-encoded (not base64) so it stays
66
+ * readable in a template and needs no `Buffer`/`btoa`. Still zero network requests.
67
+ */
68
+ export declare function emailBrandMarkDataUri(o?: EmailBrandMarkOptions): string;
69
+ /**
70
+ * Bulletproof `<table>` markup for clients with no SVG support. Uses only `background-color`,
71
+ * `border-radius`, fixed cell sizes and `font-size:0` — the intersection every client renders.
72
+ */
73
+ export declare function emailBrandMarkTableHtml(o?: EmailBrandMarkOptions): string;
74
+ /** The canonical mark at its default size and colours, in all three delivery forms. */
75
+ export interface EmailBrandMark {
76
+ readonly viewBox: string;
77
+ readonly width: string;
78
+ readonly widthPx: number;
79
+ readonly height: string;
80
+ readonly heightPx: number;
81
+ readonly capsule: EmailBrandMarkRect;
82
+ readonly glyph: EmailBrandMarkRect;
83
+ readonly capsulePath: string;
84
+ readonly glyphPath: string;
85
+ /** Inline `<svg>` — highest fidelity. */
86
+ readonly svg: string;
87
+ /** `data:` URL of the same SVG, for `<img src>`. */
88
+ readonly dataUri: string;
89
+ /** `<table>` fallback for clients with no SVG support. */
90
+ readonly tableHtml: string;
91
+ }
92
+ export declare const EMAIL_BRAND_MARK: EmailBrandMark;
@@ -0,0 +1,83 @@
1
+ import { EMAIL_COLORS } from "./color.js";
2
+ import { EMAIL_GEOMETRY_SOURCE } from "./tokens.generated.js";
3
+ const EMAIL_BRAND_MARK_VIEWBOX = "0 0 32 32";
4
+ const EMAIL_BRAND_MARK_CAPSULE = Object.freeze({
5
+ x: 1,
6
+ y: 7,
7
+ width: 30,
8
+ height: 18,
9
+ radius: 7
10
+ });
11
+ const EMAIL_BRAND_MARK_GLYPH = Object.freeze({
12
+ x: 7,
13
+ y: 13,
14
+ width: 18,
15
+ height: 6,
16
+ radius: 1
17
+ });
18
+ const sep = (value) => value < 0 ? String(value) : ` ${value}`;
19
+ const arc = (r, dx, dy) => `a${r} ${r} 0 0 1${sep(dx)}${sep(dy)}`;
20
+ function roundedRectPath({ x, y, width, height, radius }) {
21
+ const h = width - 2 * radius;
22
+ const v = height - 2 * radius;
23
+ return `M${x + radius} ${y}h${h}${arc(radius, radius, radius)}v${v}${arc(radius, -radius, radius)}H${x + radius}${arc(radius, -radius, -radius)}v-${v}${arc(radius, radius, -radius)}Z`;
24
+ }
25
+ const CAPSULE_PATH = roundedRectPath(EMAIL_BRAND_MARK_CAPSULE);
26
+ const GLYPH_PATH = roundedRectPath(EMAIL_BRAND_MARK_GLYPH);
27
+ const DEFAULT_WIDTH = Number.parseFloat(EMAIL_GEOMETRY_SOURCE["--email-mark-width"]);
28
+ const DEFAULT_HEIGHT = Number.parseFloat(EMAIL_GEOMETRY_SOURCE["--email-mark-height"]);
29
+ function options(o = {}) {
30
+ return {
31
+ width: o.width ?? DEFAULT_WIDTH,
32
+ height: o.height ?? DEFAULT_HEIGHT,
33
+ color: o.color ?? EMAIL_COLORS.brand,
34
+ glyphColor: o.glyphColor ?? EMAIL_COLORS.brandForeground,
35
+ label: o.label ?? "GoDX"
36
+ };
37
+ }
38
+ const attr = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
39
+ function emailBrandMarkSvg(o = {}) {
40
+ const { width, height, color, glyphColor, label } = options(o);
41
+ const a11y = label ? ` role="img" aria-label="${attr(label)}"` : ` role="presentation" aria-hidden="true"`;
42
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${EMAIL_BRAND_MARK_VIEWBOX}" focusable="false"${a11y} style="display:block;border:0;outline:none;text-decoration:none"><path fill="${color}" d="${CAPSULE_PATH}"/><path fill="${glyphColor}" d="${GLYPH_PATH}"/></svg>`;
43
+ }
44
+ function emailBrandMarkDataUri(o = {}) {
45
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(emailBrandMarkSvg(o))}`;
46
+ }
47
+ function emailBrandMarkTableHtml(o = {}) {
48
+ const { width, height, color, glyphColor, label } = options(o);
49
+ const scale = width / 32;
50
+ const box = (rect) => ({
51
+ width: Math.round(rect.width * scale),
52
+ height: Math.round(rect.height * scale * height / width),
53
+ radius: Math.round(rect.radius * scale)
54
+ });
55
+ const capsule = box(EMAIL_BRAND_MARK_CAPSULE);
56
+ const glyph = box(EMAIL_BRAND_MARK_GLYPH);
57
+ const a11y = label ? ` role="img" aria-label="${attr(label)}"` : ` role="presentation" aria-hidden="true"`;
58
+ return `<table${a11y} border="0" cellpadding="0" cellspacing="0" width="${capsule.width}" style="border-collapse:separate;border-spacing:0;width:${capsule.width}px"><tr><td width="${capsule.width}" height="${capsule.height}" align="center" valign="middle" style="width:${capsule.width}px;height:${capsule.height}px;line-height:${capsule.height}px;font-size:0;mso-line-height-rule:exactly;background-color:${color};border-radius:${capsule.radius}px"><div style="width:${glyph.width}px;height:${glyph.height}px;line-height:${glyph.height}px;font-size:0;background-color:${glyphColor};border-radius:${glyph.radius}px">&#8203;</div></td></tr></table>`;
59
+ }
60
+ const EMAIL_BRAND_MARK = Object.freeze({
61
+ viewBox: EMAIL_BRAND_MARK_VIEWBOX,
62
+ width: EMAIL_GEOMETRY_SOURCE["--email-mark-width"],
63
+ widthPx: DEFAULT_WIDTH,
64
+ height: EMAIL_GEOMETRY_SOURCE["--email-mark-height"],
65
+ heightPx: DEFAULT_HEIGHT,
66
+ capsule: EMAIL_BRAND_MARK_CAPSULE,
67
+ glyph: EMAIL_BRAND_MARK_GLYPH,
68
+ capsulePath: CAPSULE_PATH,
69
+ glyphPath: GLYPH_PATH,
70
+ svg: emailBrandMarkSvg(),
71
+ dataUri: emailBrandMarkDataUri(),
72
+ tableHtml: emailBrandMarkTableHtml()
73
+ });
74
+ export {
75
+ EMAIL_BRAND_MARK,
76
+ EMAIL_BRAND_MARK_CAPSULE,
77
+ EMAIL_BRAND_MARK_GLYPH,
78
+ EMAIL_BRAND_MARK_VIEWBOX,
79
+ emailBrandMarkDataUri,
80
+ emailBrandMarkSvg,
81
+ emailBrandMarkTableHtml,
82
+ roundedRectPath
83
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Email colour contract — semantic web roles resolved to LITERAL hex.
3
+ *
4
+ * HTML email has no cascade worth trusting: Gmail strips <style>, Outlook ignores custom
5
+ * properties, and every colour must appear inline as `#rrggbb` on the element that paints it. The
6
+ * web tokens, meanwhile, are HSL channel triplets (`--primary: 204 100% 39%`) so they can be
7
+ * alpha-composed. This module bridges the two — the triplets are GENERATED from
8
+ * src/tokens/foundation.css (scripts/gen-email-tokens.mjs) and converted here, at module load, by
9
+ * the same `hslToHex` a consumer can call on its own brand roles. There is no hex literal in this
10
+ * directory, so an email palette that drifts from the web palette is structurally impossible.
11
+ */
12
+ import { EMAIL_COLOR_SOURCE } from "./tokens.generated.js";
13
+ /**
14
+ * The email palette slots. Each maps 1:1 to a semantic role in `src/tokens/foundation.css`
15
+ * (`EMAIL_COLOR_SOURCE[token].cssVar` names it), so a re-themed role re-tints the email.
16
+ */
17
+ export type EmailColorToken = keyof typeof EMAIL_COLOR_SOURCE;
18
+ /** A `#rrggbb` string — the only colour form every email client renders identically. */
19
+ export type EmailHex = `#${string}`;
20
+ /**
21
+ * Convert a CSS HSL channel triplet (`"204 100% 39%"` — the shape every `@godxjp/ui` colour role
22
+ * uses) into the `#rrggbb` literal an email client needs. Rounds each channel the way a browser
23
+ * does, so the hex is exactly what `hsl(var(--role))` paints on the web.
24
+ *
25
+ * Consumers with a custom brand role can call this directly:
26
+ * `hslToHex(getComputedStyle(el).getPropertyValue("--primary"))`.
27
+ *
28
+ * @throws if the input is not a bare `H S% L%` triplet (a hex or `hsl(...)` wrapper is rejected
29
+ * rather than silently mis-parsed).
30
+ */
31
+ export declare function hslToHex(triplet: string): EmailHex;
32
+ /**
33
+ * The canonical (light-scheme) transactional-email palette, as literal hex.
34
+ *
35
+ * | slot | web role | used for |
36
+ * | ----------------------------- | --------------------------------- | --------------------------------- |
37
+ * | `background` | `--background` | the page wrapper behind the card |
38
+ * | `foreground` | `--foreground` | body copy |
39
+ * | `surface` / `surfaceForeground`| `--card` / `--card-foreground` | the 480px card |
40
+ * | `muted` / `mutedForeground` | `--muted` / `--muted-foreground` | quiet bands, legal copy |
41
+ * | `border` | `--border` | card edge, footer hairline |
42
+ * | `primary` / `primaryForeground`| `--primary` / `--primary-foreground` | the one CTA |
43
+ * | `focus` | `--ring` | focus affordance in webmail panes |
44
+ * | `brand` / `brandForeground` | `--success` / `--success-foreground` | the GoDX capsule + its glyph |
45
+ */
46
+ export declare const EMAIL_COLORS: Readonly<Record<EmailColorToken, EmailHex>>;
47
+ /**
48
+ * The dark-scheme palette, derived from the same roles under `.dark` /
49
+ * `:root[data-theme="dark"]`. Use it inside `@media (prefers-color-scheme: dark)` for the clients
50
+ * that honour it — never as the only palette, since most clients do not.
51
+ */
52
+ export declare const EMAIL_COLORS_DARK: Readonly<Record<EmailColorToken, EmailHex>>;
@@ -0,0 +1,37 @@
1
+ import { EMAIL_COLOR_SOURCE, EMAIL_COLOR_SOURCE_DARK } from "./tokens.generated.js";
2
+ const TRIPLET = /^\s*(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%\s*$/;
3
+ const channel = (n) => Math.round(Math.min(255, Math.max(0, n * 255))).toString(16).padStart(2, "0");
4
+ function hslToHex(triplet) {
5
+ const parsed = TRIPLET.exec(triplet);
6
+ if (!parsed) {
7
+ throw new TypeError(
8
+ `hslToHex expects a bare HSL channel triplet like "204 100% 39%", received "${triplet}".`
9
+ );
10
+ }
11
+ const hue = (Number(parsed[1]) % 360 + 360) % 360 / 60;
12
+ const saturation = Number(parsed[2]) / 100;
13
+ const lightness = Number(parsed[3]) / 100;
14
+ const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
15
+ const second = chroma * (1 - Math.abs(hue % 2 - 1));
16
+ const base = lightness - chroma / 2;
17
+ const [r, g, b] = hue < 1 ? [chroma, second, 0] : hue < 2 ? [second, chroma, 0] : hue < 3 ? [0, chroma, second] : hue < 4 ? [0, second, chroma] : hue < 5 ? [second, 0, chroma] : [chroma, 0, second];
18
+ return `#${channel(r + base)}${channel(g + base)}${channel(b + base)}`;
19
+ }
20
+ function resolve(source) {
21
+ const out = {};
22
+ for (const [token, entry] of Object.entries(source)) {
23
+ out[token] = hslToHex(entry.hsl);
24
+ }
25
+ return Object.freeze(out);
26
+ }
27
+ const EMAIL_COLORS = resolve(
28
+ EMAIL_COLOR_SOURCE
29
+ );
30
+ const EMAIL_COLORS_DARK = resolve(
31
+ EMAIL_COLOR_SOURCE_DARK
32
+ );
33
+ export {
34
+ EMAIL_COLORS,
35
+ EMAIL_COLORS_DARK,
36
+ hslToHex
37
+ };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Email geometry + typography contract — the `--email-*` component tokens resolved to the two
3
+ * forms an email template actually needs: a CSS string (`"480px"`, for `style="width:480px"`) and a
4
+ * bare number (`480`, for the legacy HTML attribute form `<table width="480">` that Outlook's Word
5
+ * renderer still prefers). Both come from src/tokens/components/email.css via
6
+ * scripts/gen-email-tokens.mjs — nothing here is hand-typed.
7
+ */
8
+ import { EMAIL_GEOMETRY_SOURCE } from "./tokens.generated.js";
9
+ /** Every `--email-*` token name declared in the component token tier. */
10
+ export type EmailTokenName = keyof typeof EMAIL_GEOMETRY_SOURCE;
11
+ /** The 480px transactional shell — the card and the page gutter around it. */
12
+ export interface EmailShellTokens {
13
+ /** Card width as a CSS length. */
14
+ readonly width: string;
15
+ /** Card width as a number, for `<table width="…">`. */
16
+ readonly widthPx: number;
17
+ /** Width of the column INSIDE the card padding (`width − 2 × padding`). */
18
+ readonly contentWidth: string;
19
+ readonly contentWidthPx: number;
20
+ /** Card inset on desktop. */
21
+ readonly padding: string;
22
+ readonly paddingPx: number;
23
+ /** Gutter between the client viewport and the card. */
24
+ readonly pagePadding: string;
25
+ readonly pagePaddingPx: number;
26
+ /** Card edge hairline. */
27
+ readonly borderWidth: string;
28
+ readonly borderWidthPx: number;
29
+ /** Card corner radius (ignored by Outlook's Word renderer — degrade to square, never to an image). */
30
+ readonly radius: string;
31
+ readonly radiusPx: number;
32
+ /** Vertical rhythm between blocks. */
33
+ readonly gap: string;
34
+ readonly gapPx: number;
35
+ /** Tight rhythm (heading ↔ lede). */
36
+ readonly gapSm: string;
37
+ readonly gapSmPx: number;
38
+ /**
39
+ * Height the canonical invitation reference card measures at this geometry (480×407). A
40
+ * visual-regression target, NOT a layout input — never set a fixed height on an email card.
41
+ */
42
+ readonly referenceHeight: string;
43
+ readonly referenceHeightPx: number;
44
+ }
45
+ export declare const EMAIL_SHELL: EmailShellTokens;
46
+ /** Type ramp. Sizes are literal px — no email client resolves the rem scale reliably. */
47
+ export interface EmailTypographyTokens {
48
+ /** Email-safe font stack. Web-font `@font-face` is unavailable in most clients — never rely on it. */
49
+ readonly fontFamily: string;
50
+ readonly bodyFontSize: string;
51
+ readonly bodyFontSizePx: number;
52
+ readonly bodyLineHeight: number;
53
+ readonly bodyFontWeight: number;
54
+ readonly headingFontSize: string;
55
+ readonly headingFontSizePx: number;
56
+ readonly headingLineHeight: number;
57
+ readonly headingFontWeight: number;
58
+ }
59
+ export declare const EMAIL_TYPOGRAPHY: EmailTypographyTokens;
60
+ /** The single primary call-to-action. */
61
+ export interface EmailCtaTokens {
62
+ readonly height: string;
63
+ readonly heightPx: number;
64
+ /** Equal to the height so the label centres without flexbox (Outlook has none). */
65
+ readonly lineHeight: string;
66
+ readonly lineHeightPx: number;
67
+ readonly paddingX: string;
68
+ readonly paddingXPx: number;
69
+ readonly radius: string;
70
+ readonly radiusPx: number;
71
+ readonly fontSize: string;
72
+ readonly fontSizePx: number;
73
+ readonly fontWeight: number;
74
+ }
75
+ export declare const EMAIL_CTA: EmailCtaTokens;
76
+ /** The legal band under the body — quiet type, a hairline, and the inter-link spacing. */
77
+ export interface EmailFooterTokens {
78
+ readonly fontSize: string;
79
+ readonly fontSizePx: number;
80
+ readonly lineHeight: number;
81
+ /** Horizontal separation between adjacent footer links. */
82
+ readonly linkGap: string;
83
+ readonly linkGapPx: number;
84
+ readonly paddingTop: string;
85
+ readonly paddingTopPx: number;
86
+ readonly borderWidth: string;
87
+ readonly borderWidthPx: number;
88
+ }
89
+ export declare const EMAIL_FOOTER: EmailFooterTokens;
90
+ /** Focus affordance for the webmail preview panes that DO honour `:focus` (WCAG 2.4.7/2.4.11). */
91
+ export interface EmailFocusTokens {
92
+ readonly borderWidth: string;
93
+ readonly borderWidthPx: number;
94
+ }
95
+ export declare const EMAIL_FOCUS: EmailFocusTokens;
96
+ /** Narrow-viewport reflow — apply under `@media (max-width: …)` and via `width="100%"` fallbacks. */
97
+ export interface EmailMobileTokens {
98
+ /** The reflow breakpoint (card width + both gutters). */
99
+ readonly maxWidth: string;
100
+ readonly maxWidthPx: number;
101
+ /** The card goes fluid below the breakpoint. */
102
+ readonly width: string;
103
+ readonly padding: string;
104
+ readonly paddingPx: number;
105
+ readonly pagePadding: string;
106
+ readonly pagePaddingPx: number;
107
+ readonly headingFontSize: string;
108
+ readonly headingFontSizePx: number;
109
+ /** The CTA goes full-bleed so the tap target spans the card. */
110
+ readonly ctaWidth: string;
111
+ }
112
+ export declare const EMAIL_MOBILE: EmailMobileTokens;