@sincpro/web-ui 0.2.0 → 1.1.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 (132) hide show
  1. package/README.md +3 -3
  2. package/dist/components/activity/ActivityBadge.d.ts +16 -0
  3. package/dist/components/activity/ActivityBadge.js +29 -0
  4. package/dist/components/activity/index.d.ts +1 -0
  5. package/dist/components/activity/index.js +1 -0
  6. package/dist/components/calendar/RecordCalendar.d.ts +48 -0
  7. package/dist/components/calendar/RecordCalendar.js +101 -0
  8. package/dist/components/calendar/index.d.ts +1 -0
  9. package/dist/components/calendar/index.js +1 -0
  10. package/dist/components/form/Sheet.js +5 -1
  11. package/dist/components/gantt/RecordGantt.d.ts +44 -0
  12. package/dist/components/gantt/RecordGantt.js +110 -0
  13. package/dist/components/gantt/index.d.ts +1 -0
  14. package/dist/components/gantt/index.js +1 -0
  15. package/dist/components/index.d.ts +3 -0
  16. package/dist/components/index.js +3 -0
  17. package/dist/components/lines/LineItems.d.ts +5 -2
  18. package/dist/components/lines/LineItems.js +3 -3
  19. package/dist/components/listing/BoardColumns.d.ts +39 -0
  20. package/dist/components/listing/BoardColumns.js +138 -0
  21. package/dist/components/listing/ListingBar.d.ts +5 -0
  22. package/dist/components/listing/ListingBar.js +5 -2
  23. package/dist/components/listing/PivotExplorer.d.ts +34 -0
  24. package/dist/components/listing/PivotExplorer.js +86 -0
  25. package/dist/components/listing/RecordCard.d.ts +6 -8
  26. package/dist/components/listing/RecordCard.js +13 -7
  27. package/dist/components/listing/Row.d.ts +21 -2
  28. package/dist/components/listing/Row.js +29 -13
  29. package/dist/components/listing/index.d.ts +2 -0
  30. package/dist/components/listing/index.js +2 -0
  31. package/dist/components/listing/set.d.ts +1 -1
  32. package/dist/components/navigation/index.d.ts +0 -2
  33. package/dist/components/navigation/index.js +2 -3
  34. package/dist/components/record/RecordAudit.d.ts +17 -0
  35. package/dist/components/record/RecordAudit.js +27 -0
  36. package/dist/components/record/RecordHeader.d.ts +7 -1
  37. package/dist/components/record/RecordHeader.js +30 -18
  38. package/dist/components/record/RecordRibbon.d.ts +13 -0
  39. package/dist/components/record/RecordRibbon.js +22 -0
  40. package/dist/components/record/index.d.ts +2 -0
  41. package/dist/components/record/index.js +2 -0
  42. package/dist/components/search/CriteriaChips.d.ts +6 -1
  43. package/dist/components/search/CriteriaChips.js +71 -20
  44. package/dist/components/search/SearchBar.d.ts +15 -1
  45. package/dist/components/search/SearchBar.js +34 -16
  46. package/dist/components/search/query.d.ts +14 -11
  47. package/dist/components/search/query.js +36 -3
  48. package/dist/components/timeline/RecordTimeline.d.ts +60 -0
  49. package/dist/components/timeline/RecordTimeline.js +214 -0
  50. package/dist/components/timeline/TimelineToolbar.d.ts +30 -0
  51. package/dist/components/timeline/TimelineToolbar.js +31 -0
  52. package/dist/components/timeline/index.d.ts +3 -0
  53. package/dist/components/timeline/index.js +3 -0
  54. package/dist/components/timeline/time.d.ts +43 -0
  55. package/dist/components/timeline/time.js +135 -0
  56. package/dist/foundations/copy.d.ts +110 -14
  57. package/dist/foundations/copy.es.js +85 -9
  58. package/dist/foundations/copy.js +85 -9
  59. package/dist/foundations/grouping.d.ts +3 -0
  60. package/dist/foundations/index.d.ts +1 -1
  61. package/dist/foundations/match.d.ts +23 -0
  62. package/dist/foundations/match.js +63 -0
  63. package/dist/foundations/slots.d.ts +1 -1
  64. package/dist/foundations/slots.js +8 -3
  65. package/dist/foundations/types.d.ts +7 -0
  66. package/dist/foundations/types.js +0 -36
  67. package/dist/primitives/block/index.d.ts +1 -1
  68. package/dist/primitives/block/index.js +1 -1
  69. package/dist/primitives/cell/CellStack.d.ts +7 -1
  70. package/dist/primitives/cell/CellStack.js +5 -2
  71. package/dist/primitives/choice/ComboBox.js +8 -4
  72. package/dist/primitives/choice/KanbanState.d.ts +14 -0
  73. package/dist/primitives/choice/KanbanState.js +37 -0
  74. package/dist/primitives/choice/Priority.d.ts +20 -0
  75. package/dist/primitives/choice/Priority.js +41 -0
  76. package/dist/primitives/choice/Slider.d.ts +6 -1
  77. package/dist/primitives/choice/Slider.js +2 -2
  78. package/dist/primitives/choice/index.d.ts +2 -0
  79. package/dist/primitives/choice/index.js +2 -0
  80. package/dist/primitives/dialog/Dialog.d.ts +9 -3
  81. package/dist/primitives/dialog/Dialog.js +41 -5
  82. package/dist/primitives/dialog/index.d.ts +0 -1
  83. package/dist/primitives/dialog/index.js +0 -2
  84. package/dist/primitives/notice/index.d.ts +1 -1
  85. package/dist/primitives/notice/index.js +1 -1
  86. package/dist/primitives/overlay/Preview.js +13 -4
  87. package/dist/primitives/picker/MatchedText.d.ts +10 -0
  88. package/dist/primitives/picker/MatchedText.js +12 -0
  89. package/dist/primitives/picker/Picker.d.ts +42 -7
  90. package/dist/primitives/picker/Picker.js +107 -29
  91. package/dist/primitives/table/EditableTable.d.ts +6 -1
  92. package/dist/primitives/table/EditableTable.js +70 -9
  93. package/dist/primitives/table/ReportTable.js +2 -1
  94. package/dist/primitives/table/RowActions.js +7 -2
  95. package/dist/primitives/table/Table.d.ts +5 -0
  96. package/dist/primitives/table/Table.js +17 -8
  97. package/dist/primitives/tag/Tags.d.ts +2 -0
  98. package/dist/primitives/tag/Tags.js +2 -1
  99. package/dist/primitives/tag/TagsField.d.ts +17 -0
  100. package/dist/primitives/tag/TagsField.js +46 -11
  101. package/dist/primitives/tag/choices.d.ts +6 -1
  102. package/dist/primitives/tag/choices.js +6 -2
  103. package/dist/primitives/value/Avatar.d.ts +16 -0
  104. package/dist/primitives/value/Avatar.js +91 -0
  105. package/dist/primitives/value/Person.d.ts +6 -51
  106. package/dist/primitives/value/Person.js +9 -148
  107. package/dist/primitives/value/Relation.d.ts +21 -9
  108. package/dist/primitives/value/Relation.js +35 -24
  109. package/dist/primitives/value/contact.d.ts +30 -0
  110. package/dist/primitives/value/contact.js +51 -0
  111. package/dist/primitives/value/index.d.ts +1 -1
  112. package/dist/primitives/value/index.js +1 -1
  113. package/dist/tokens.css +136 -1
  114. package/dist/views/list/List.d.ts +25 -2
  115. package/dist/views/list/List.js +48 -8
  116. package/dist/views/list/ListView.d.ts +1 -1
  117. package/dist/views/list/ListView.js +40 -9
  118. package/dist/views/list/columns.js +8 -1
  119. package/dist/views/list/presentations.d.ts +5 -2
  120. package/dist/views/list/presentations.js +32 -2
  121. package/dist/views/list/types.d.ts +44 -0
  122. package/dist/views/list/useNarrowList.d.ts +10 -0
  123. package/dist/views/list/useNarrowList.js +20 -6
  124. package/dist/views/record/Record.d.ts +14 -0
  125. package/dist/views/record/Record.js +22 -7
  126. package/package.json +11 -4
  127. package/dist/components/navigation/CommandPalette.d.ts +0 -40
  128. package/dist/components/navigation/CommandPalette.js +0 -114
  129. package/dist/components/navigation/commands.d.ts +0 -30
  130. package/dist/components/navigation/commands.js +0 -30
  131. package/dist/primitives/dialog/Drill.d.ts +0 -90
  132. package/dist/primitives/dialog/Drill.js +0 -172
@@ -1,154 +1,15 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useUiCopy } from "../../foundations/copy.js";
4
- import { useSurface } from "../../foundations/surface.js";
5
- import { Button } from "../button/Button.js";
6
4
  import { PopoverTrigger } from "../overlay/PopoverTrigger.js";
7
- import { ArrowRight, Mail, MessageCircle, Phone } from "lucide-react";
5
+ import { Avatar, OVERLAP, SIZE, } from "./Avatar.js";
6
+ import { RelationCard } from "./Relation.js";
8
7
  /* ==================================================================== *
9
- * WHO — the person as a datum, not as a string of text.
10
- *
11
- * «andres.g@sincpro.com.bo» in a cell is a correct and unreadable datum: twelve
12
- * rows of emails get read letter by letter. An initial in a circle is
13
- * recognized without reading, which is what is needed when the question is «who
14
- * does this belong to?» and not «what is their email?».
15
- *
16
- * ── The color comes out of the name and nobody picks it ──────────────
17
- *
18
- * A hash of the name against the tone palette. It is deterministic —the same
19
- * person always has the same color, on every screen— and that is why the color
20
- * gets to mean something. If the consumer picked it, the same person would be
21
- * green in one list and blue in another, and recognition without reading is lost
22
- * entirely.
23
- *
24
- * The color here is NOT a state: it is identity. That is why it uses the tone
25
- * palette but never `bad` nor `warn` — a person is not «in error».
8
+ * WHO — a person as a chip and as a stack. The face itself is `Avatar`; what a person's
9
+ * card says (the face, the role, the ways to reach them, «Abrir») is the `RelationCard` of
10
+ * that person: a person IS a related record, and one card answers for all of them.
26
11
  * ==================================================================== */
27
- const PALETTE = [
28
- "bg-info/15 text-info",
29
- "bg-good/15 text-good",
30
- "bg-accent/25 text-ink",
31
- "bg-primary/12 text-primary",
32
- "bg-ink/10 text-ink",
33
- ];
34
- /** The initials: two, and from the first two WORDS. «Ana María Gutiérrez» gives
35
- * `AG` and not `AM` — the surname distinguishes more than the middle name. */
36
- function initials(name) {
37
- const parts = name
38
- .trim()
39
- .split(/\s+/)
40
- .filter((p) => p.length > 0);
41
- const first = parts[0]?.[0] ?? "?";
42
- const last = parts.length > 1 ? parts[parts.length - 1]?.[0] : undefined;
43
- return `${first}${last ?? ""}`.toUpperCase();
44
- }
45
- /* The same five identities on the dark chrome. The canvas palette there composed its tints
46
- over the navy and left ink letters on a dark disc — 1.61:1, unreadable (measured on the
47
- application bar). Same index, so a person keeps their color across the two surfaces. */
48
- const CHROME_PALETTE = [
49
- "bg-chrome-link/20 text-chrome-link",
50
- "bg-chrome-good/20 text-chrome-good",
51
- "bg-chrome-accent/12 text-chrome-accent",
52
- "bg-chrome-link/14 text-chrome-ink",
53
- "bg-chrome-ink/14 text-chrome-ink",
54
- ];
55
- function toneOf(name, surface = "canvas") {
56
- let h = 0;
57
- for (const ch of name)
58
- h = (h * 31 + ch.codePointAt(0)) % 100000;
59
- const palette = surface === "chrome" ? CHROME_PALETTE : PALETTE;
60
- return palette[h % palette.length];
61
- }
62
- /* The text doesn't go below `micro`: two initials at 10 px can't be read, and an
63
- unreadable initial is a colored circle — which is exactly what we don't want.
64
- What changes between sizes is the circle. */
65
- const SIZE = {
66
- sm: "size-5 text-micro",
67
- md: "size-6 text-micro",
68
- lg: "size-8 text-meta",
69
- /* The head of a contact card: the face is what the card is about. */
70
- xl: "size-12 text-body",
71
- };
72
- /* Two initials, so the overlap stays short of the second letter. The ring is
73
- 1px: a 2px ring of the surface color reads as a gap between people, which
74
- is the thing a stack is not. */
75
- const OVERLAP = {
76
- sm: "-ml-1.5",
77
- md: "-ml-2",
78
- lg: "-ml-2.5",
79
- xl: "-ml-3.5",
80
- };
81
- export function Avatar({ name, size = "md",
82
- /** The photo, if there is one. Without it the initials go, which is the normal
83
- * case here. */
84
- src, }) {
85
- const surface = useSurface();
86
- if (src !== undefined) {
87
- return (
88
- /* The photo lies over the initials, drawn invisible: an image has no text baseline, so
89
- a chip with a photo sat on the image's bottom edge in a row aligned by baseline (a
90
- `DataField`), higher than the label next to it. The hidden initials give the photo
91
- the same baseline the initials disc has. */
92
- _jsxs("span", { title: name, className: `relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-pill ${SIZE[size]}`, children: [_jsx("span", { "aria-hidden": true, className: "invisible", children: initials(name) }), _jsx("img", { src: src, alt: name, className: "absolute inset-0 size-full object-cover" })] }));
93
- }
94
- return (_jsx("span", { title: name, "aria-hidden": true, className: `inline-flex shrink-0 items-center justify-center rounded-pill font-medium select-none ${SIZE[size]} ${toneOf(name, surface)}`, children: initials(name) }));
95
- }
96
- /** The three contact links, built from what people type. `wa.me` takes digits only — a `+`,
97
- * a space or a dash breaks it — and `tel:` takes digits and the leading `+`. */
98
- function contactRows(contact, copy) {
99
- const rows = [];
100
- if (contact.email !== undefined && contact.email !== "") {
101
- rows.push({
102
- key: "email",
103
- icon: Mail,
104
- label: copy.email,
105
- value: contact.email,
106
- href: `mailto:${contact.email}`,
107
- });
108
- }
109
- if (contact.phone !== undefined && contact.phone !== "") {
110
- rows.push({
111
- key: "call",
112
- icon: Phone,
113
- label: copy.call,
114
- value: contact.phone,
115
- href: `tel:${contact.phone.replace(/[^\d+]/g, "")}`,
116
- });
117
- }
118
- if (contact.whatsapp !== undefined && contact.whatsapp !== "") {
119
- rows.push({
120
- key: "whatsapp",
121
- icon: MessageCircle,
122
- label: copy.whatsapp,
123
- value: contact.whatsapp,
124
- href: `https://wa.me/${contact.whatsapp.replace(/\D/g, "")}`,
125
- });
126
- }
127
- return rows;
128
- }
129
- /**
130
- * Who a person is and how to reach them: the face, the name, the role, and one row per way
131
- * of contact — write, call, WhatsApp — then their record.
132
- *
133
- * Context: in an ERP the question after «who is the salesperson of this invoice?» is almost
134
- * always «how do I reach her?». Copying an email out of another screen to paste it into the
135
- * mail client is the detour this card removes. Each way of contact is a ROW and not a bare
136
- * icon: the address and the number are drawn, because often what the person wants is to read
137
- * the number aloud, not to dial it from the browser. Every row is a real link (`mailto:`,
138
- * `tel:`, `wa.me`), so middle-click, «copy address» and the phone's own dialer work.
139
- *
140
- * It is what opens from a `UserChip` with `contact`, and it is exported on its own for the
141
- * side panel of a record, where the same person is shown without a chip to open.
142
- */
143
- export function ContactCard({ name, src, role, contact = {}, onOpen }) {
144
- const copy = useUiCopy().person;
145
- const rows = contactRows(contact, copy);
146
- return (
147
- /* React events bubble through the popover's portal to the React parents of the chip —
148
- a table row that opens on click among them. A click inside the card is not a click
149
- on the row. */
150
- _jsxs("div", { className: "flex w-64 max-w-full flex-col gap-3", onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-3", children: [_jsx("span", { "aria-hidden": true, className: "flex shrink-0", children: _jsx(Avatar, { name: name, size: "xl", ...(src === undefined ? {} : { src }) }) }), _jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate text-lead font-semibold text-ink", title: name, children: name }), role === undefined ? null : (_jsx("span", { className: "line-clamp-2 text-meta text-muted", title: role, children: role }))] })] }), rows.length === 0 ? null : (_jsx("ul", { className: "-mx-1 flex flex-col", children: rows.map(({ key, icon: Icon, label, value, href }) => (_jsx("li", { children: _jsxs("a", { href: href, "data-contact": key, ...(key === "whatsapp" ? { target: "_blank", rel: "noreferrer" } : {}), className: "flex min-w-0 items-center gap-2.5 rounded-control px-1 py-1.5 no-underline outline-primary outline-offset-2 hover:bg-ink/6 focus-visible:outline-2 forced-colors:outline-[Highlight]", children: [_jsx(Icon, { "aria-hidden": true, className: "size-(--size-icon-md) shrink-0 text-primary", strokeWidth: 2 }), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "text-meta font-medium text-ink", children: label }), _jsx("span", { className: "truncate text-micro tabular-nums text-muted", children: value })] })] }) }, key))) })), onOpen === undefined ? null : (_jsx("div", { className: "flex justify-end border-t border-line pt-2", children: _jsx(Button, { role: "ghost", size: "sm", icon: ArrowRight, onClick: onOpen, children: copy.open }) }))] }));
151
- }
12
+ export { Avatar } from "./Avatar.js";
152
13
  /** The chip as the opener of its card. A native button that takes what `PopoverTrigger`
153
14
  * hands its trigger — `ref`, the press handlers, `expanded` and `haspopup` — the same
154
15
  * contract the system's buttons keep. */
@@ -164,7 +25,7 @@ function ChipButton({ expanded, haspopup, children, ref, ...native }) {
164
25
  * a fact of a record.
165
26
  *
166
27
  * Context: a chip is a button ONLY when it has somewhere to go. With `contact` or `onOpen` it
167
- * opens the `ContactCard` (press, Enter or Space — React Aria's `DialogTrigger` through
28
+ * opens the person's `RelationCard` (press, Enter or Space — React Aria's `DialogTrigger` through
168
29
  * `PopoverTrigger`: focus goes into the card, Escape and a click outside close it, and focus
169
30
  * comes back to the chip). Without them it stays text: a button that does nothing is a lie a
170
31
  * keyboard user pays for with one more Tab stop on every row.
@@ -180,7 +41,7 @@ export function UserChip({ name, size = "md", src, detail, role, contact, onOpen
180
41
  if (!reachable) {
181
42
  return _jsx("span", { className: "inline-flex min-w-0 items-center gap-2", children: face });
182
43
  }
183
- return (_jsx(PopoverTrigger, { label: name, trigger: _jsx(ChipButton, { children: face }), children: (close) => (_jsx(ContactCard, { name: name, ...(src === undefined ? {} : { src }), ...(role === undefined ? {} : { role }), ...(contact === undefined ? {} : { contact }), ...(onOpen === undefined
44
+ return (_jsx(PopoverTrigger, { label: name, trigger: _jsx(ChipButton, { children: face }), children: (close) => (_jsx(RelationCard, { name: name, media: { person: true, ...(src === undefined ? {} : { src }) }, ...(role === undefined ? {} : { secondary: role }), ...(contact === undefined ? {} : { contact }), ...(onOpen === undefined
184
45
  ? {}
185
46
  : {
186
47
  onOpen: () => {
@@ -1,5 +1,6 @@
1
- import type { Tone } from "../../foundations/types.js";
1
+ import type { OpenTarget, Tone } from "../../foundations/types.js";
2
2
  import { type FormMode } from "../field/mode.js";
3
+ import { type PersonContact } from "./contact.js";
3
4
  import type { LucideIcon } from "lucide-react";
4
5
  import { type ReactNode } from "react";
5
6
  /** What the floating summary of the related record says. */
@@ -18,16 +19,22 @@ export type RelationSummary = {
18
19
  value: ReactNode;
19
20
  }[];
20
21
  };
22
+ /** The picture of the related record: a photo or an icon on a square tile — or, for a person
23
+ * (`person`), the round face: the photo, or the initials in the color that person has
24
+ * everywhere. */
21
25
  type RelationMedia = {
22
26
  src?: string;
23
27
  alt?: string;
24
28
  icon?: LucideIcon;
29
+ person?: true;
25
30
  };
26
31
  export type RelationCardProps = {
27
32
  name: string;
28
33
  secondary?: ReactNode;
29
34
  media?: RelationMedia;
30
35
  summary?: RelationSummary;
36
+ /** How to reach it — a person, a company: one row per way (write, call, WhatsApp). */
37
+ contact?: PersonContact;
31
38
  /** «Abrir»: go to the related record. */
32
39
  onOpen?: () => void;
33
40
  /** The record's address, when it has one: «Abrir» becomes a real link. */
@@ -39,9 +46,10 @@ export type RelationCardProps = {
39
46
  * Context: it answers the question a person has about the customer of an invoice without
40
47
  * leaving the invoice — «does it owe us?», «what is its NIT?» — and, when the answer is
41
48
  * «I need more», one press takes them to the record. It is what floats from a
42
- * `RelationValue`, and it is exported on its own for a side panel.
49
+ * `RelationValue` and what opens from a `UserChip` (a person is a related record too: round
50
+ * face, role, the ways to reach them), and it is exported on its own for a side panel.
43
51
  */
44
- export declare function RelationCard({ name, secondary, media, summary, onOpen, href, }: RelationCardProps): import("react").JSX.Element;
52
+ export declare function RelationCard({ name, secondary, media, summary, contact, onOpen, href, }: RelationCardProps): import("react").JSX.Element;
45
53
  export type RelationValueProps = {
46
54
  /** The related record's name. Empty, `null` or absent = no record: said, in muted. */
47
55
  name?: string | null;
@@ -51,14 +59,18 @@ export type RelationValueProps = {
51
59
  media?: RelationMedia;
52
60
  /** Go to the related record. The name becomes a link. */
53
61
  onOpen?: () => void;
62
+ /** Go to it in the place the person chose (`OpenTarget`): the name opens in the first of
63
+ * `openTargets` — a modal by default, the form stays behind — and «Abrir en…» beside it offers
64
+ * the rest. Takes over from `onOpen`. */
65
+ onOpenIn?: (to: OpenTarget) => void;
66
+ openTargets?: readonly OpenTarget[];
54
67
  /** The related record's address: the name becomes a real `<a>` (middle-click, copy). */
55
68
  href?: string;
56
- /** Present = an info button next to the name opens the `RelationCard`. */
69
+ /** The facts of the related record. Present = its `RelationCard` floats from the name
70
+ * (hover, focus, long press); from an info button when the name is not a link. */
57
71
  summary?: RelationSummary;
58
- /** Floats the summary from the NAME on hover, focus or long press, instead of from the
59
- * info button. `true` = the `RelationCard` (with `summary` when given); a node = that
60
- * content instead — the product of a line, a consumer's own card. Needs the name to be a
61
- * link (`href` or `onOpen`); without one the content opens from the info button. */
72
+ /** What floats instead of the `RelationCard` — the product of a line, a consumer's own
73
+ * card. `true` floats the card even without `summary`; `false` floats nothing. */
62
74
  preview?: boolean | ReactNode;
63
75
  /** The control that EDITS this relation — a `Picker` — drawn instead in edit mode. */
64
76
  editor?: ReactNode;
@@ -70,5 +82,5 @@ export type RelationValueProps = {
70
82
  * picture, and its summary one press away. Works in a table cell, a list row, a `Record.Facts`
71
83
  * value and a form field (read mode; pass the `Picker` as `editor` for edit mode).
72
84
  */
73
- export declare function RelationValue({ name, secondary, media, onOpen, href, summary, preview, editor, mode, }: RelationValueProps): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
85
+ export declare function RelationValue({ name, secondary, media, onOpen: onOpenOwn, onOpenIn, openTargets, href, summary, preview, editor, mode, }: RelationValueProps): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
74
86
  export {};
@@ -6,10 +6,13 @@ import { Button, ButtonLink, IconButton } from "../button/Button.js";
6
6
  import { CellStack, Thumb } from "../cell/CellStack.js";
7
7
  import { FieldIdsContext } from "../field/context.js";
8
8
  import { useFormMode } from "../field/mode.js";
9
+ import { Menu } from "../menu/Menu.js";
9
10
  import { PopoverTrigger } from "../overlay/PopoverTrigger.js";
10
11
  import { Preview } from "../overlay/Preview.js";
11
12
  import { Tag } from "../tag/Tag.js";
12
- import { ArrowRight, Info } from "lucide-react";
13
+ import { Avatar } from "./Avatar.js";
14
+ import { ContactRows } from "./contact.js";
15
+ import { ArrowRight, Ellipsis, Info } from "lucide-react";
13
16
  import { useContext } from "react";
14
17
  import { Link } from "react-aria-components";
15
18
  /**
@@ -18,22 +21,27 @@ import { Link } from "react-aria-components";
18
21
  * Context: it answers the question a person has about the customer of an invoice without
19
22
  * leaving the invoice — «does it owe us?», «what is its NIT?» — and, when the answer is
20
23
  * «I need more», one press takes them to the record. It is what floats from a
21
- * `RelationValue`, and it is exported on its own for a side panel.
24
+ * `RelationValue` and what opens from a `UserChip` (a person is a related record too: round
25
+ * face, role, the ways to reach them), and it is exported on its own for a side panel.
22
26
  */
23
- export function RelationCard({ name, secondary, media, summary = {}, onOpen, href, }) {
27
+ export function RelationCard({ name, secondary, media, summary = {}, contact, onOpen, href, }) {
24
28
  const copy = useUiCopy();
25
29
  const title = summary.title ?? name;
26
30
  return (
27
31
  /* React events bubble through the popover's portal to the React parents of the link —
28
32
  a table row that opens on click among them. A click inside the card stays here. */
29
- _jsxs("div", { className: "flex w-72 max-w-full flex-col gap-3", onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "flex min-w-0 items-start gap-3", children: [media === undefined ? null : (_jsx(Thumb, { size: "lg", ...(media.src === undefined ? {} : { src: media.src }), ...(media.alt === undefined ? {} : { alt: media.alt }), ...(media.icon === undefined ? {} : { icon: media.icon }) })), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1", children: [_jsx("span", { className: "text-lead leading-tight font-semibold text-ink", children: title }), secondary === undefined ? null : (_jsx("span", { className: "truncate text-micro tabular-nums text-muted", children: secondary })), summary.tag === undefined ? null : (_jsx("span", { className: "flex", children: _jsx(Tag, { tone: summary.tag.tone ?? "neutral", children: summary.tag.label }) }))] })] }), summary.facts === undefined || summary.facts.length === 0 ? null : (_jsx("dl", { className: "grid grid-cols-2 gap-x-4 gap-y-2 border-t border-line pt-3", children: summary.facts.map((fact) => (_jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("dt", { className: "truncate text-micro text-muted", children: fact.label }), _jsx("dd", { className: "truncate text-meta tabular-nums text-ink", children: fact.value })] }, fact.label))) })), href !== undefined ? (_jsx("div", { className: "flex justify-end border-t border-line pt-2", children: _jsx(ButtonLink, { href: href, role: "ghost", size: "sm", icon: ArrowRight, ...(onOpen === undefined ? {} : { onClick: onOpen }), children: copy.relation.open }) })) : onOpen !== undefined ? (_jsx("div", { className: "flex justify-end border-t border-line pt-2", children: _jsx(Button, { role: "ghost", size: "sm", icon: ArrowRight, onClick: onOpen, children: copy.relation.open }) })) : null] }));
33
+ _jsxs("div", { className: "flex w-72 max-w-full flex-col gap-3", onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "flex min-w-0 items-start gap-3", children: [media === undefined ? null : media.person === true ? (
34
+ /* The name is written beside it: the photo's `alt` would say it twice. */
35
+ _jsx("span", { "aria-hidden": true, className: "flex shrink-0", children: _jsx(Avatar, { name: name, size: "xl", ...(media.src === undefined ? {} : { src: media.src }) }) })) : (_jsx(Thumb, { size: "lg", ...(media.src === undefined ? {} : { src: media.src }), ...(media.alt === undefined ? {} : { alt: media.alt }), ...(media.icon === undefined ? {} : { icon: media.icon }) })), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1", children: [_jsx("span", { className: "text-lead leading-tight font-semibold text-ink", children: title }), secondary === undefined ? null : (_jsx("span", { className: "truncate text-micro tabular-nums text-muted", children: secondary })), summary.tag === undefined ? null : (_jsx("span", { className: "flex", children: _jsx(Tag, { tone: summary.tag.tone ?? "neutral", children: summary.tag.label }) }))] })] }), summary.facts === undefined || summary.facts.length === 0 ? null : (_jsx("dl", { className: "grid grid-cols-2 gap-x-4 gap-y-2 border-t border-line pt-3", children: summary.facts.map((fact) => (_jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("dt", { className: "truncate text-micro text-muted", children: fact.label }), _jsx("dd", { className: "truncate text-meta tabular-nums text-ink", children: fact.value })] }, fact.label))) })), contact === undefined ? null : _jsx(ContactRows, { contact: contact }), href !== undefined ? (_jsx("div", { className: "flex justify-end border-t border-line pt-2", children: _jsx(ButtonLink, { href: href, role: "ghost", size: "sm", icon: ArrowRight, ...(onOpen === undefined ? {} : { onClick: onOpen }), children: copy.relation.open }) })) : onOpen !== undefined ? (_jsx("div", { className: "flex justify-end border-t border-line pt-2", children: _jsx(Button, { role: "ghost", size: "sm", icon: ArrowRight, onClick: onOpen, children: copy.relation.open }) })) : null] }));
30
36
  }
31
37
  /**
32
38
  * A many2one as a value: the related record's name as a link, with its secondary line and
33
39
  * picture, and its summary one press away. Works in a table cell, a list row, a `Record.Facts`
34
40
  * value and a form field (read mode; pass the `Picker` as `editor` for edit mode).
35
41
  */
36
- export function RelationValue({ name, secondary, media, onOpen, href, summary, preview, editor, mode, }) {
42
+ export function RelationValue({ name, secondary, media, onOpen: onOpenOwn, onOpenIn, openTargets = ["modal", "peek", "side", "page"], href, summary, preview, editor, mode, }) {
43
+ const first = openTargets[0];
44
+ const onOpen = onOpenIn !== undefined && first !== undefined ? () => onOpenIn(first) : onOpenOwn;
37
45
  const copy = useUiCopy();
38
46
  const formMode = useFormMode(mode);
39
47
  /* Inside a `Field` the value takes the control's height, so a form that goes from
@@ -49,26 +57,29 @@ export function RelationValue({ name, secondary, media, onOpen, href, summary, p
49
57
  const summaryLabel = fillCopy(copy.relationCard.summary, { name });
50
58
  const card = (open) => (_jsx(RelationCard, { name: name, ...(summary === undefined ? {} : { summary }), ...(secondary === undefined ? {} : { secondary }), ...(media === undefined ? {} : { media }), ...(href === undefined ? {} : { href }), ...(open === undefined ? {} : { onOpen: open }) }));
51
59
  /* What floats from the name: the card, or the consumer's own content. */
52
- const previewed = preview === undefined || preview === null || preview === false
60
+ const previewed = preview === false || preview === null
53
61
  ? undefined
54
- : preview === true
55
- ? card(onOpen)
56
- : preview;
62
+ : preview === undefined
63
+ ? summary === undefined
64
+ ? undefined
65
+ : card(onOpen)
66
+ : preview === true
67
+ ? card(onOpen)
68
+ : preview;
57
69
  const link = goes ? (_jsx(Link, { ...(href === undefined ? {} : { href }), ...(onOpen === undefined ? {} : { onPress: onOpen }), className: ({ isFocusVisible }) => cn("cursor-pointer rounded-mark text-primary no-underline underline-offset-2 hover:underline", focusRing({ isFocusVisible })), children: name })) : undefined;
58
70
  const primary = link === undefined ? (name) : previewed === undefined ? (link) : (_jsx(Preview, { trigger: link, label: summaryLabel, children: previewed }));
59
- /* The `i`: the summary when there is no preview on the name; the preview's content when
60
- there is no link for it to ride on. */
61
- const info = previewed !== undefined
62
- ? goes
63
- ? undefined
64
- : () => previewed
65
- : summary === undefined
66
- ? undefined
67
- : (close) => card(onOpen === undefined
68
- ? undefined
69
- : () => {
70
- close();
71
- onOpen();
72
- });
73
- return frame(_jsxs("span", { className: "inline-flex max-w-full min-w-0 items-center gap-1", children: [_jsx(CellStack, { primary: primary, ...(secondary === undefined ? {} : { secondary }), ...(media === undefined ? {} : { media }) }), info === undefined ? null : (_jsx(PopoverTrigger, { label: summaryLabel, trigger: _jsx(IconButton, { icon: Info, label: summaryLabel, size: "sm" }), children: info }))] }));
71
+ /* The `i` only when the name is not a link: there is nothing for the card to ride on. */
72
+ const info = previewed === undefined || goes ? undefined : () => previewed;
73
+ return frame(_jsxs("span", { className: "inline-flex max-w-full min-w-0 items-center gap-1", children: [_jsx(CellStack, { primary: primary, ...(secondary === undefined ? {} : { secondary }), ...(media === undefined
74
+ ? {}
75
+ : media.person === true
76
+ ? { avatar: { name, ...(media.src === undefined ? {} : { src: media.src }) } }
77
+ : { media }) }), onOpenIn === undefined || openTargets.length < 2 ? null : (_jsx(Menu, { label: copy.picker.openMore, icon: Ellipsis, iconOnly: true, chevron: false, variant: "text", groups: [
78
+ {
79
+ options: openTargets.slice(1).map((to) => ({
80
+ id: to,
81
+ label: copy.picker.openIn[to],
82
+ })),
83
+ },
84
+ ], onSelect: (to) => onOpenIn(to) })), info === undefined ? null : (_jsx(PopoverTrigger, { label: summaryLabel, trigger: _jsx(IconButton, { icon: Info, label: summaryLabel, size: "sm" }), children: info }))] }));
74
85
  }
@@ -0,0 +1,30 @@
1
+ import { useUiCopy } from "../../foundations/copy.js";
2
+ import type { LucideIcon } from "lucide-react";
3
+ /** The ways to reach a person. Each one that is present becomes a row of the contact card. */
4
+ export type PersonContact = {
5
+ email?: string;
6
+ /** As people write it — «+591 7 123-4567». The `tel:` link keeps only the digits and `+`. */
7
+ phone?: string;
8
+ /** The WhatsApp number with its country code, written any way; `wa.me` gets the digits. */
9
+ whatsapp?: string;
10
+ };
11
+ /** The three contact links, built from what people type. `wa.me` takes digits only — a `+`,
12
+ * a space or a dash breaks it — and `tel:` takes digits and the leading `+`. */
13
+ export declare function contactRows(contact: PersonContact, copy: ReturnType<typeof useUiCopy>["person"]): {
14
+ key: string;
15
+ icon: LucideIcon;
16
+ label: string;
17
+ value: string;
18
+ href: string;
19
+ }[];
20
+ /**
21
+ * The ways to reach someone, one ROW each — write, call, WhatsApp — on the card of any record
22
+ * that is a person or a company. Each way is a row and not a bare icon: the address and the
23
+ * number are drawn, because often what the person wants is to read the number aloud, not to
24
+ * dial it from the browser. Every row is a real link (`mailto:`, `tel:`, `wa.me`), so
25
+ * middle-click, «copy address» and the phone's own dialer work. Internal: `RelationCard` draws
26
+ * it from its `contact`.
27
+ */
28
+ export declare function ContactRows({ contact }: {
29
+ contact: PersonContact;
30
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,51 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useUiCopy } from "../../foundations/copy.js";
4
+ import { Mail, MessageCircle, Phone } from "lucide-react";
5
+ /** The three contact links, built from what people type. `wa.me` takes digits only — a `+`,
6
+ * a space or a dash breaks it — and `tel:` takes digits and the leading `+`. */
7
+ export function contactRows(contact, copy) {
8
+ const rows = [];
9
+ if (contact.email !== undefined && contact.email !== "") {
10
+ rows.push({
11
+ key: "email",
12
+ icon: Mail,
13
+ label: copy.email,
14
+ value: contact.email,
15
+ href: `mailto:${contact.email}`,
16
+ });
17
+ }
18
+ if (contact.phone !== undefined && contact.phone !== "") {
19
+ rows.push({
20
+ key: "call",
21
+ icon: Phone,
22
+ label: copy.call,
23
+ value: contact.phone,
24
+ href: `tel:${contact.phone.replace(/[^\d+]/g, "")}`,
25
+ });
26
+ }
27
+ if (contact.whatsapp !== undefined && contact.whatsapp !== "") {
28
+ rows.push({
29
+ key: "whatsapp",
30
+ icon: MessageCircle,
31
+ label: copy.whatsapp,
32
+ value: contact.whatsapp,
33
+ href: `https://wa.me/${contact.whatsapp.replace(/\D/g, "")}`,
34
+ });
35
+ }
36
+ return rows;
37
+ }
38
+ /**
39
+ * The ways to reach someone, one ROW each — write, call, WhatsApp — on the card of any record
40
+ * that is a person or a company. Each way is a row and not a bare icon: the address and the
41
+ * number are drawn, because often what the person wants is to read the number aloud, not to
42
+ * dial it from the browser. Every row is a real link (`mailto:`, `tel:`, `wa.me`), so
43
+ * middle-click, «copy address» and the phone's own dialer work. Internal: `RelationCard` draws
44
+ * it from its `contact`.
45
+ */
46
+ export function ContactRows({ contact }) {
47
+ const rows = contactRows(contact, useUiCopy().person);
48
+ if (rows.length === 0)
49
+ return null;
50
+ return (_jsx("ul", { className: "-mx-1 flex flex-col border-t border-line pt-2", children: rows.map(({ key, icon: Icon, label, value, href }) => (_jsx("li", { children: _jsxs("a", { href: href, "data-contact": key, ...(key === "whatsapp" ? { target: "_blank", rel: "noreferrer" } : {}), className: "flex min-w-0 items-center gap-2.5 rounded-control px-1 py-1.5 no-underline outline-primary outline-offset-2 hover:bg-ink/6 focus-visible:outline-2 forced-colors:outline-[Highlight]", children: [_jsx(Icon, { "aria-hidden": true, className: "size-(--size-icon-md) shrink-0 text-primary", strokeWidth: 2 }), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "text-meta font-medium text-ink", children: label }), _jsx("span", { className: "truncate text-micro tabular-nums text-muted", children: value })] })] }) }, key))) }));
51
+ }
@@ -2,7 +2,7 @@ export { Copyable, type CopyableProps, CopyButton, } from "./Copyable.js";
2
2
  export { DateValue, formatDateTime, formatRelative, } from "./Fecha.js";
3
3
  export { Meter, type MeterLevel, meterLevel, type MeterProps, type MeterThresholds, } from "./Meter.js";
4
4
  export { Metric, type MetricProps, Metrics } from "./Metric.js";
5
- export { Avatar, type AvatarSize, ContactCard, type ContactCardProps, type PersonContact, UserChip, type UserChipProps, UserStack, } from "./Person.js";
5
+ export { Avatar, type AvatarSize, type PersonContact, UserChip, type UserChipProps, UserStack, } from "./Person.js";
6
6
  export { ProgressBar, type ProgressBarProps, } from "./ProgressBar.js";
7
7
  export { RelationCard, type RelationCardProps, type RelationSummary, RelationValue, type RelationValueProps, } from "./Relation.js";
8
8
  export { Totals, type TotalsCredit, type TotalsDeduction, type TotalsFact, type TotalsProps, type TotalsRow, } from "./Totals.js";
@@ -3,7 +3,7 @@ export { Copyable, CopyButton, } from "./Copyable.js";
3
3
  export { DateValue, formatDateTime, formatRelative, } from "./Fecha.js";
4
4
  export { Meter, meterLevel, } from "./Meter.js";
5
5
  export { Metric, Metrics } from "./Metric.js";
6
- export { Avatar, ContactCard, UserChip, UserStack, } from "./Person.js";
6
+ export { Avatar, UserChip, UserStack, } from "./Person.js";
7
7
  export { ProgressBar, } from "./ProgressBar.js";
8
8
  export { RelationCard, RelationValue, } from "./Relation.js";
9
9
  export { Totals, } from "./Totals.js";
package/dist/tokens.css CHANGED
@@ -11,6 +11,13 @@
11
11
  @import "@fontsource/ibm-plex-mono/latin-ext-400.css";
12
12
  @import "@fontsource/ibm-plex-mono/latin-500.css";
13
13
  @import "@fontsource/ibm-plex-mono/latin-ext-500.css";
14
+ /* The record calendar (`RecordCalendar`, FullCalendar v7): its structure and the `forma`
15
+ theme. The palette that theme reads is defined below, from web-ui's tokens. */
16
+ @import "@fullcalendar/react/skeleton.css";
17
+ @import "@fullcalendar/react/themes/forma/theme.css";
18
+ /* The record Gantt (`RecordGantt`, SVAR): its styles, Willow theme included. Its variables
19
+ are pointed at web-ui's tokens below. */
20
+ @import "@svar-ui/react-gantt/all.css";
14
21
 
15
22
  @import "tailwindcss";
16
23
 
@@ -491,6 +498,20 @@
491
498
  earlier. */
492
499
  --container-name: 12rem;
493
500
 
501
+ /* The widest a chip of the search bar gets before it truncates. A filter written by hand
502
+ («cliente contiene Importadora y Exportadora…») would otherwise take a whole line of the
503
+ bar and push the rest down; the full text stays in the chip's title. */
504
+ /* One stage of a board by stages: a card with its name, a tag and a figure, without
505
+ wrapping the name into three lines. Five stages fit a desk; more scroll sideways. */
506
+ --container-lane: 18rem;
507
+ /* The names column of a timeline (an employee with an avatar, a room, a machine): wide enough
508
+ for «Ammy Shalem Aranibar Zambrana» before it truncates. */
509
+ --container-timeline-label: 15rem;
510
+ --container-facet: 16rem;
511
+ /* The grouping chain is several levels in one chip, so it asks for more before it
512
+ truncates — at its end, as one sentence, never level by level. */
513
+ --container-chain: 28rem;
514
+
494
515
  /* The minimum width of a metric. It belongs to the system and not to the
495
516
  component: three metrics of different widths read as three different
496
517
  things, and what they have to let you compare is precisely the number. */
@@ -511,7 +532,7 @@
511
532
 
512
533
  `--container-beside` is how wide the record's PAGE has to be for the column to stand
513
534
  beside the sheet: the column and a sheet that keeps 54rem — two groups of fields side by
514
- side still fit their labels. Narrower (a laptop with a sidebar, a phone, a `Drill`), the
535
+ side still fit their labels. Narrower (a laptop with a sidebar, a phone, a modal), the
515
536
  panel goes BELOW the sheet as a card. Written as a number and not a `calc()` because a container query cannot
516
537
  read a variable.
517
538
 
@@ -766,6 +787,113 @@
766
787
  0 2px 6px -2px rgba(26, 23, 20, 0.18), 0 18px 40px -20px rgba(26, 23, 20, 0.42);
767
788
  }
768
789
 
790
+ /* ====================================================================
791
+ * THE WORKSPACE (@sincpro/web-composer) dressed in these tokens.
792
+ *
793
+ * Context: the composer depends on no design system and paints its frames, tabs, windows
794
+ * and modals with `--sc-*` variables. Pointing them at web-ui's tokens here means a composer
795
+ * on a web-ui page takes its theme — light, dark, and any brand — with nothing to wire: no
796
+ * import, no dependency, only names. They are aliases, so they follow the dark theme on
797
+ * their own and carry no contrast of their own to measure.
798
+ * ==================================================================== */
799
+ :root {
800
+ --sc-ground: var(--color-ground);
801
+ --sc-surface: var(--color-surface);
802
+ --sc-line: var(--color-line);
803
+ --sc-line-strong: var(--color-line-strong);
804
+ --sc-ink: var(--color-ink);
805
+ --sc-muted: var(--color-muted);
806
+ /* The workspace's accent marks where you are (the active tab, a focused frame): the navy
807
+ of a link, not the gold of the one primary action. */
808
+ --sc-accent: var(--color-primary);
809
+ --sc-danger: var(--color-danger);
810
+ --sc-on-danger: var(--color-surface);
811
+ --sc-scrim: var(--color-scrim);
812
+ --sc-modal-scrim: color-mix(in srgb, var(--color-scrim) 40%, transparent);
813
+ --sc-shadow: var(--shadow-window);
814
+ --sc-radius: var(--radius-panel);
815
+ --sc-radius-control: var(--radius-control);
816
+ --sc-text: var(--text-meta);
817
+ --sc-text-small: var(--text-micro);
818
+ --sc-icon: var(--size-icon-md);
819
+ }
820
+
821
+ /* ====================================================================
822
+ * THE RECORD CALENDAR (FullCalendar v7, `forma` theme) in these tokens.
823
+ *
824
+ * Context: the theme reads a palette of `--fc-forma-*` variables. Pointed at web-ui's tokens it
825
+ * takes the theme — light, dark, a brand — with no dark block of its own: the tokens already
826
+ * flip. Aliases, so no contrast of their own to measure; the translucent washes are mixed from
827
+ * the ink so they read on either theme.
828
+ * ==================================================================== */
829
+ :root {
830
+ --fc-forma-primary: var(--color-primary);
831
+ --fc-forma-primary-over: var(--color-primary-hover);
832
+ --fc-forma-primary-down: var(--color-primary-hover);
833
+ --fc-forma-primary-foreground: var(--color-surface);
834
+ --fc-forma-event: var(--color-primary);
835
+ --fc-forma-event-contrast: var(--color-surface);
836
+ --fc-forma-background-event: var(--color-primary);
837
+ --fc-forma-highlight: color-mix(in srgb, var(--color-primary) 18%, transparent);
838
+ --fc-forma-background: var(--color-surface);
839
+ --fc-forma-faint: color-mix(in srgb, var(--color-ink) 3%, transparent);
840
+ --fc-forma-muted: color-mix(in srgb, var(--color-ink) 7%, transparent);
841
+ --fc-forma-strong: color-mix(in srgb, var(--color-ink) 12%, transparent);
842
+ --fc-forma-stronger: color-mix(in srgb, var(--color-ink) 16%, transparent);
843
+ --fc-forma-strongest: color-mix(in srgb, var(--color-ink) 20%, transparent);
844
+ --fc-forma-foreground: var(--color-ink);
845
+ --fc-forma-faint-foreground: var(--color-muted);
846
+ --fc-forma-muted-foreground: var(--color-muted);
847
+ --fc-forma-border: var(--color-line);
848
+ --fc-forma-muted-border: var(--color-line);
849
+ --fc-forma-strong-border: var(--color-line-strong);
850
+ }
851
+
852
+ /* ====================================================================
853
+ * THE RECORD GANTT (SVAR, Willow theme) in these tokens.
854
+ *
855
+ * Context: Willow declares its `--wx-*` on `.wx-willow-theme`; redeclared on the same class
856
+ * here, later, they win. Bars are the navy of the system, a summary the ink, a milestone the
857
+ * info tone; the type is the system's. Aliases: they flip with the theme.
858
+ * ==================================================================== */
859
+ .wx-willow-theme {
860
+ --wx-color-primary: var(--color-primary);
861
+ --wx-color-primary-selected: color-mix(in srgb, var(--color-primary) 14%, transparent);
862
+ --wx-color-primary-font: var(--color-surface);
863
+ --wx-color-secondary-hover: color-mix(in srgb, var(--color-primary) 10%, transparent);
864
+ --wx-color-success: var(--color-good);
865
+ --wx-color-warning: var(--color-warn);
866
+ --wx-color-info: var(--color-info);
867
+ --wx-color-danger: var(--color-danger);
868
+ --wx-color-font: var(--color-ink);
869
+ --wx-color-font-alt: var(--color-muted);
870
+ --wx-background: var(--color-surface);
871
+ --wx-background-alt: var(--color-ground);
872
+ --wx-background-hover: color-mix(in srgb, var(--color-ink) 5%, transparent);
873
+ --wx-font-family: inherit;
874
+ --wx-font-size: var(--text-meta);
875
+ --wx-font-size-sm: var(--text-micro);
876
+ --wx-icon-color: var(--color-muted);
877
+ --wx-border-color: var(--color-line);
878
+ --wx-border-radius: var(--radius-control);
879
+ --wx-radius-major: var(--radius-box);
880
+ --wx-gantt-border-color: var(--color-line);
881
+ --wx-gantt-task-color: var(--color-primary);
882
+ --wx-gantt-task-fill-color: var(--color-primary-hover);
883
+ --wx-gantt-task-border-color: var(--color-primary-hover);
884
+ --wx-gantt-task-font-color: var(--color-surface);
885
+ --wx-gantt-summary-color: var(--color-line-strong);
886
+ --wx-gantt-summary-fill-color: color-mix(in srgb, var(--color-ink) 45%, transparent);
887
+ --wx-gantt-summary-border-color: var(--color-line-strong);
888
+ --wx-gantt-summary-font-color: var(--color-ink);
889
+ --wx-gantt-milestone-color: var(--color-info);
890
+ --wx-gantt-select-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
891
+ --wx-gantt-link-color: var(--color-muted);
892
+ --wx-gantt-link-color-hovered: var(--color-ink);
893
+ --wx-gantt-holiday-background: var(--color-ground);
894
+ --wx-gantt-holiday-color: var(--color-muted);
895
+ }
896
+
769
897
  @layer base {
770
898
  /* No forced height anywhere: the document is exactly as tall as its content.
771
899
  * The color still has to reach the bottom of the viewport on a short page —
@@ -1060,3 +1188,10 @@
1060
1188
  --size-control-lg: 48px;
1061
1189
  }
1062
1190
  }
1191
+
1192
+ /* A label written down a narrow strip: the name of a folded board column, read bottom-up
1193
+ like a book's spine. Tailwind has no writing-mode utility. */
1194
+ @utility text-vertical {
1195
+ writing-mode: vertical-rl;
1196
+ transform: rotate(180deg);
1197
+ }