@marianmeres/stuic 3.187.0 → 3.189.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.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The Gantt's geometry: a date window in, columns and 0..1 track fractions out.
3
+ *
4
+ * Kept out of the component (and out of the browser) on purpose — everything
5
+ * that could be wrong by a day is decided here, in plain functions a node test
6
+ * can pin. The component only multiplies fractions by a width.
7
+ *
8
+ * Whole-day semantics throughout (`YYYY-MM-DD`, both ends inclusive), so a bar
9
+ * never shifts by a day because the visitor is in Auckland. The day arithmetic
10
+ * reuses `Calendar/iso-date.js`, which is DST-safe via `@marianmeres/calendar-utils`.
11
+ */
12
+ import { addDaysIso, addMonths, compareIso, daysBetweenIso, daysInMonth, firstOfMonth, formatYearMonth, getMonthNames, normalizeIsoDate, toIsoDate, weekdayColumn, yearMonthKey, yearMonthOf, } from "../Calendar/iso-date.js";
13
+ /** Cap on generated columns — a mistyped year would otherwise build 700k of them. */
14
+ export const GANTT_MAX_COLUMNS = 2000;
15
+ /** The 7 localized weekday short names, Monday first. 2024-01-01 was a Monday. */
16
+ function getWeekdayNames(locale, format = "short") {
17
+ const f = new Intl.DateTimeFormat(locale, { weekday: format });
18
+ return Array.from({ length: 7 }, (_, i) =>
19
+ // noon, so no zone can pull the formatter back into the previous day
20
+ f.format(new Date(2024, 0, 1 + i, 12)));
21
+ }
22
+ /** ISO-8601 week number of `iso` (weeks start Monday; week 1 holds the first Thursday). */
23
+ export function isoWeekNumber(iso) {
24
+ // Thursday of this ISO week decides which year (and therefore which week 1) the
25
+ // week belongs to — the standard trick, done on plain day arithmetic.
26
+ const thursday = addDaysIso(iso, 3 - weekdayColumn(iso, 1));
27
+ const year = Number(thursday.slice(0, 4));
28
+ const jan4 = toIsoDate(year, 1, 4);
29
+ const week1Monday = addDaysIso(jan4, -weekdayColumn(jan4, 1));
30
+ return Math.floor(daysBetweenIso(week1Monday, thursday) / 7) + 1;
31
+ }
32
+ /** First day of the unit `iso` falls in. */
33
+ function unitStart(iso, unit, weekStartsOn) {
34
+ if (unit === "week")
35
+ return addDaysIso(iso, -weekdayColumn(iso, weekStartsOn));
36
+ if (unit === "month")
37
+ return firstOfMonth(yearMonthOf(iso));
38
+ return iso;
39
+ }
40
+ /** Last day of the unit `iso` falls in. */
41
+ function unitEnd(iso, unit, weekStartsOn) {
42
+ if (unit === "week")
43
+ return addDaysIso(unitStart(iso, unit, weekStartsOn), 6);
44
+ if (unit === "month") {
45
+ const ym = yearMonthOf(iso);
46
+ return toIsoDate(ym.year, ym.month, daysInMonth(ym.year, ym.month));
47
+ }
48
+ return iso;
49
+ }
50
+ /**
51
+ * The columns and the two header rows for a `[from, to]` window (both inclusive).
52
+ *
53
+ * Throws on an unparseable window, and on one so wide it would exceed
54
+ * {@link GANTT_MAX_COLUMNS} columns — at that zoom nothing is readable anyway,
55
+ * and silently drawing 100k nodes is worse than saying so.
56
+ */
57
+ export function buildGanttAxis(from, to, options = {}) {
58
+ const { unit = "day", weekStartsOn = 1, locale, today = null } = options;
59
+ const isoFrom = normalizeIsoDate(from);
60
+ const isoTo = normalizeIsoDate(to);
61
+ if (!isoFrom)
62
+ throw new Error(`Gantt: invalid "from" date: ${String(from)}`);
63
+ if (!isoTo)
64
+ throw new Error(`Gantt: invalid "to" date: ${String(to)}`);
65
+ // A backwards window is a caller bug, not a reason to render nothing silently.
66
+ if (compareIso(isoFrom, isoTo) > 0) {
67
+ throw new Error(`Gantt: "from" (${isoFrom}) is after "to" (${isoTo})`);
68
+ }
69
+ const start = unitStart(isoFrom, unit, weekStartsOn);
70
+ const end = unitEnd(isoTo, unit, weekStartsOn);
71
+ const totalDays = daysBetweenIso(start, end) + 1;
72
+ const months = getMonthNames(locale, "short");
73
+ const weekdays = unit === "day" ? getWeekdayNames(locale) : [];
74
+ const columns = [];
75
+ const groups = [];
76
+ const pushColumn = (date, days, groupKey, label, subLabel) => {
77
+ const last = groups[groups.length - 1];
78
+ if (last?.key === groupKey)
79
+ last.span += 1;
80
+ else
81
+ groups.push({ key: groupKey, label: "", span: 1, from: columns.length });
82
+ columns.push({
83
+ date,
84
+ days,
85
+ offset: daysBetweenIso(start, date),
86
+ index: columns.length,
87
+ label,
88
+ subLabel,
89
+ groupIndex: groups.length - 1,
90
+ isWeekend: unit === "day" && weekdayColumn(date, 1) >= 5,
91
+ isToday: !!today && compareIso(today, date) >= 0 && daysBetweenIso(date, today) < days,
92
+ });
93
+ };
94
+ const estimated = unit === "day" ? totalDays : unit === "week" ? totalDays / 7 : totalDays / 28;
95
+ if (estimated > GANTT_MAX_COLUMNS) {
96
+ throw new Error(`Gantt: ${Math.round(estimated)} "${unit}" columns for ${isoFrom}…${isoTo} ` +
97
+ `exceeds the ${GANTT_MAX_COLUMNS} limit — narrow the window or use a coarser unit.`);
98
+ }
99
+ if (unit === "month") {
100
+ let ym = yearMonthOf(start);
101
+ const endYm = yearMonthOf(end);
102
+ while (ym.year * 12 + ym.month <= endYm.year * 12 + endYm.month) {
103
+ pushColumn(firstOfMonth(ym), daysInMonth(ym.year, ym.month), `${ym.year}`, months[ym.month - 1]);
104
+ ym = addMonths(ym, 1);
105
+ }
106
+ for (const g of groups)
107
+ g.label = g.key;
108
+ }
109
+ else {
110
+ const step = unit === "week" ? 7 : 1;
111
+ for (let date = start; compareIso(date, end) <= 0; date = addDaysIso(date, step)) {
112
+ const ym = yearMonthOf(date);
113
+ pushColumn(date, step, yearMonthKey(ym), `${Number(date.slice(8, 10))}`, unit === "day" ? weekdays[weekdayColumn(date, 1)] : `W${isoWeekNumber(date)}`);
114
+ }
115
+ for (const g of groups) {
116
+ const [y, m] = g.key.split("-").map(Number);
117
+ g.label = formatYearMonth({ year: y, month: m }, locale, {
118
+ month: "short",
119
+ year: "numeric",
120
+ });
121
+ }
122
+ }
123
+ return { unit, from: isoFrom, to: isoTo, start, end, columns, groups, totalDays };
124
+ }
125
+ /**
126
+ * Where a day offset sits on the track, 0..1.
127
+ *
128
+ * Columns all render at the same width but do not all cover the same number of
129
+ * days (February against March), so this maps through the column the offset
130
+ * falls in rather than dividing by `totalDays`. Fractional offsets are allowed
131
+ * — `offset + 0.5` is the middle of that day.
132
+ */
133
+ export function dayToFraction(offset, axis) {
134
+ const n = axis.columns.length;
135
+ if (!n)
136
+ return 0;
137
+ if (offset <= 0)
138
+ return 0;
139
+ if (offset >= axis.totalDays)
140
+ return 1;
141
+ // binary search for the column containing `offset`
142
+ let lo = 0;
143
+ let hi = n - 1;
144
+ while (lo < hi) {
145
+ const mid = (lo + hi + 1) >> 1;
146
+ if (axis.columns[mid].offset <= offset)
147
+ lo = mid;
148
+ else
149
+ hi = mid - 1;
150
+ }
151
+ const col = axis.columns[lo];
152
+ return (lo + (offset - col.offset) / col.days) / n;
153
+ }
154
+ /**
155
+ * A `[from, to]` inclusive range as a track placement, or `null` when it misses
156
+ * the axis entirely.
157
+ *
158
+ * A miss is `null` rather than a zero-width bar on purpose: a hairline pinned to
159
+ * the window's edge reads as "starts today", which is exactly the wrong thing to
160
+ * tell a planner.
161
+ */
162
+ export function placeRange(from, to, axis) {
163
+ const a = normalizeIsoDate(from);
164
+ const b = normalizeIsoDate(to);
165
+ if (!a || !b)
166
+ return null;
167
+ const [lo, hi] = compareIso(a, b) <= 0 ? [a, b] : [b, a];
168
+ if (compareIso(hi, axis.start) < 0 || compareIso(lo, axis.end) > 0)
169
+ return null;
170
+ const clippedStart = compareIso(lo, axis.start) < 0;
171
+ const clippedEnd = compareIso(hi, axis.end) > 0;
172
+ const visFrom = clippedStart ? axis.start : lo;
173
+ const visTo = clippedEnd ? axis.end : hi;
174
+ const startDay = daysBetweenIso(axis.start, visFrom);
175
+ const endDay = daysBetweenIso(axis.start, visTo) + 1; // exclusive
176
+ return {
177
+ start: dayToFraction(startDay, axis),
178
+ end: dayToFraction(endDay, axis),
179
+ clippedStart,
180
+ clippedEnd,
181
+ days: endDay - startDay,
182
+ };
183
+ }
184
+ /**
185
+ * The middle of a single day as a 0..1 fraction (milestones, the today line), or
186
+ * `null` when the day is outside the axis.
187
+ */
188
+ export function placePoint(date, axis) {
189
+ const iso = normalizeIsoDate(date);
190
+ if (!iso)
191
+ return null;
192
+ if (compareIso(iso, axis.start) < 0 || compareIso(iso, axis.end) > 0)
193
+ return null;
194
+ return dayToFraction(daysBetweenIso(axis.start, iso) + 0.5, axis);
195
+ }
196
+ /**
197
+ * The `[min, max]` of every date in `ranges` — what the axis defaults to when
198
+ * the consumer gives no explicit window. `null` when nothing is datable.
199
+ */
200
+ export function boundsOf(ranges) {
201
+ let min = null;
202
+ let max = null;
203
+ for (const r of ranges) {
204
+ for (const v of [normalizeIsoDate(r.from), normalizeIsoDate(r.to ?? r.from)]) {
205
+ if (!v)
206
+ continue;
207
+ if (!min || compareIso(v, min) < 0)
208
+ min = v;
209
+ if (!max || compareIso(v, max) > 0)
210
+ max = v;
211
+ }
212
+ }
213
+ return min && max ? { from: min, to: max } : null;
214
+ }
@@ -0,0 +1,496 @@
1
+ /* ============================================================================
2
+ GANTT COMPONENT TOKENS
3
+ Override globally: :root { --stuic-gantt-unit-width: 3rem; }
4
+ Override locally: <Gantt style="--stuic-gantt-row-height: 3rem;" />
5
+
6
+ Structural tokens (radius, border-width, transition) are NOT declared here —
7
+ they are resolved as var() fallbacks at the usage sites below, so a scoped
8
+ --stuic-radius override still reaches them.
9
+ ============================================================================ */
10
+
11
+ /* prettier-ignore */
12
+ :root {
13
+ /* Layout */
14
+ --stuic-gantt-unit-width: 2.5rem; /* one column — the horizontal zoom */
15
+ --stuic-gantt-label-width: 12rem; /* the sticky left column */
16
+ --stuic-gantt-row-height: 2.5rem;
17
+ --stuic-gantt-max-height: none; /* set it to make the header sticky */
18
+
19
+ /* Frame */
20
+ --stuic-gantt-bg: var(--stuic-color-background);
21
+ --stuic-gantt-border-color: var(--stuic-color-border);
22
+ --stuic-gantt-font-size: var(--text-base);
23
+
24
+ /* Header (both tiers) */
25
+ --stuic-gantt-header-bg: var(--stuic-color-surface);
26
+ --stuic-gantt-header-text: var(--stuic-color-foreground);
27
+ --stuic-gantt-header-font-size: var(--text-xs);
28
+ --stuic-gantt-header-padding: 0.25rem 0.375rem;
29
+ --stuic-gantt-header-sub-text: var(--stuic-color-muted-foreground);
30
+
31
+ /* Grid */
32
+ --stuic-gantt-grid-line-color: var(--stuic-color-border);
33
+ --stuic-gantt-weekend-bg: var(--stuic-color-muted);
34
+ --stuic-gantt-today-color: var(--stuic-color-destructive);
35
+ --stuic-gantt-today-width: 2px;
36
+
37
+ /* Row + left label column */
38
+ --stuic-gantt-row-bg-hover: color-mix(in srgb, var(--stuic-color-muted) 45%, transparent);
39
+ --stuic-gantt-label-bg: var(--stuic-color-background);
40
+ --stuic-gantt-label-padding: 0.375rem 0.75rem;
41
+ --stuic-gantt-label-font-size: var(--text-base);
42
+ --stuic-gantt-label-text: var(--stuic-color-foreground);
43
+ --stuic-gantt-description-font-size: var(--text-xs);
44
+ --stuic-gantt-description-text: var(--stuic-color-muted-foreground);
45
+
46
+ /* Bars */
47
+ --stuic-gantt-bar-height: 1.25rem;
48
+ --stuic-gantt-bar-height-inset: 0.625rem; /* data-inset — the emphasis range */
49
+ --stuic-gantt-bar-bg: var(--stuic-color-muted-foreground);
50
+ --stuic-gantt-bar-text: var(--stuic-color-background);
51
+ --stuic-gantt-bar-font-size: var(--text-xs);
52
+ --stuic-gantt-bar-padding-inline: 0.375rem;
53
+ --stuic-gantt-bar-opacity-disabled: 0.45;
54
+ --stuic-gantt-milestone-size: 0.875rem;
55
+
56
+ /* Empty state area */
57
+ --stuic-gantt-empty-padding: 2rem 1rem;
58
+ }
59
+
60
+ @layer components {
61
+ /* ============================================================================
62
+ FRAME
63
+ One scroll container; everything inside is laid out in two flex columns —
64
+ the sticky label column (width 0 when there are no labels) and the track.
65
+ ============================================================================ */
66
+
67
+ .stuic-gantt {
68
+ --_label-w: 0px;
69
+ font-size: var(--stuic-gantt-font-size);
70
+ color: var(--stuic-color-foreground);
71
+ }
72
+
73
+ .stuic-gantt[data-labels] {
74
+ --_label-w: var(--stuic-gantt-label-width);
75
+ }
76
+
77
+ /* Deliberately NOT `width: max-content`: besides leaving the frame narrower
78
+ than its container, a max-content scroll box that gains a vertical scrollbar
79
+ loses that width from its content area and starts scrolling horizontally for
80
+ no reason. The columns absorb the spare width instead (see below). */
81
+ .stuic-gantt-viewport {
82
+ overflow: auto;
83
+ max-height: var(--stuic-gantt-max-height);
84
+ background: var(--stuic-gantt-bg);
85
+ border: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
86
+ var(--stuic-gantt-border-color);
87
+ border-radius: var(--stuic-gantt-radius, var(--stuic-radius-container));
88
+ }
89
+
90
+ /* `max-content` so the px-sized track can overflow into the scroll; `min-width`
91
+ so a short axis still fills the frame (and the sticky cells have a box). */
92
+ .stuic-gantt-inner {
93
+ width: max-content;
94
+ min-width: 100%;
95
+ }
96
+
97
+ .stuic-gantt[data-fit] .stuic-gantt-inner {
98
+ width: 100%;
99
+ }
100
+
101
+ /* ============================================================================
102
+ HEADER — two tiers: groups (month / year) over units (day / week / month)
103
+ ============================================================================ */
104
+
105
+ .stuic-gantt-head {
106
+ display: flex;
107
+ position: sticky;
108
+ top: 0;
109
+ z-index: 3;
110
+ background: var(--stuic-gantt-header-bg);
111
+ color: var(--stuic-gantt-header-text);
112
+ font-size: var(--stuic-gantt-header-font-size);
113
+ border-bottom: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
114
+ var(--stuic-gantt-border-color);
115
+ }
116
+
117
+ .stuic-gantt-corner {
118
+ flex: 0 0 var(--_label-w);
119
+ box-sizing: border-box;
120
+ position: sticky;
121
+ left: 0;
122
+ z-index: 1;
123
+ display: flex;
124
+ align-items: flex-end;
125
+ padding: var(--stuic-gantt-header-padding);
126
+ background: var(--stuic-gantt-header-bg);
127
+ border-right: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
128
+ var(--stuic-gantt-border-color);
129
+ }
130
+
131
+ /* `1 0 <basis>` everywhere below: the column width is a MINIMUM. When the axis
132
+ is narrower than the frame the columns share out what is left (no dead gutter,
133
+ and the grid layer still matches the track because both grow by the same
134
+ rule); when it is wider they keep their basis and the frame scrolls. */
135
+ .stuic-gantt-axis,
136
+ .stuic-gantt-track {
137
+ flex: 1 0 calc(var(--stuic-gantt-unit-width) * var(--_gantt-columns));
138
+ min-width: 0;
139
+ }
140
+
141
+ .stuic-gantt[data-fit] .stuic-gantt-axis,
142
+ .stuic-gantt[data-fit] .stuic-gantt-track {
143
+ flex: 1 1 0;
144
+ }
145
+
146
+ .stuic-gantt-groups,
147
+ .stuic-gantt-units {
148
+ display: flex;
149
+ }
150
+
151
+ .stuic-gantt-group {
152
+ flex-grow: var(--_gantt-span);
153
+ flex-shrink: 0;
154
+ flex-basis: calc(var(--stuic-gantt-unit-width) * var(--_gantt-span));
155
+ min-width: 0;
156
+ box-sizing: border-box;
157
+ padding: var(--stuic-gantt-header-padding);
158
+ border-left: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
159
+ var(--stuic-gantt-grid-line-color);
160
+ font-weight: var(--font-weight-medium);
161
+ white-space: nowrap;
162
+ overflow: hidden;
163
+ text-overflow: ellipsis;
164
+ }
165
+
166
+ .stuic-gantt[data-fit] .stuic-gantt-group {
167
+ flex-shrink: 1;
168
+ flex-basis: 0;
169
+ }
170
+
171
+ .stuic-gantt-unit {
172
+ flex: 1 0 var(--stuic-gantt-unit-width);
173
+ min-width: 0;
174
+ box-sizing: border-box;
175
+ display: flex;
176
+ flex-direction: column;
177
+ align-items: center;
178
+ justify-content: flex-end;
179
+ padding: var(--stuic-gantt-header-padding);
180
+ border-left: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
181
+ var(--stuic-gantt-grid-line-color);
182
+ line-height: 1.2;
183
+ overflow: hidden;
184
+ }
185
+
186
+ .stuic-gantt[data-fit] .stuic-gantt-unit {
187
+ flex: 1 1 0;
188
+ /* border-box padding does not shrink, so it is a hard floor on how narrow a
189
+ column can get — which would break `fit`'s "never scrolls" promise at high
190
+ column counts. The label is centered, so dropping it costs nothing. */
191
+ padding-inline: 0;
192
+ }
193
+
194
+ .stuic-gantt-group:first-child,
195
+ .stuic-gantt-unit:first-child {
196
+ border-left-color: transparent;
197
+ }
198
+
199
+ .stuic-gantt-unit[data-weekend] {
200
+ background: var(--stuic-gantt-weekend-bg);
201
+ }
202
+
203
+ .stuic-gantt-unit[data-today] {
204
+ color: var(--stuic-gantt-today-color);
205
+ font-weight: var(--font-weight-bold);
206
+ }
207
+
208
+ .stuic-gantt-unit-sub {
209
+ color: var(--stuic-gantt-header-sub-text);
210
+ font-size: 0.85em;
211
+ }
212
+
213
+ .stuic-gantt-unit[data-today] .stuic-gantt-unit-sub {
214
+ color: inherit;
215
+ }
216
+
217
+ /* ============================================================================
218
+ BODY + the single grid layer
219
+ Weekend shading and the gridlines are drawn once for the whole body rather
220
+ than per row — otherwise it is rows × columns nodes for the same pixels.
221
+ The rows come later in the DOM and are positioned, so they paint on top.
222
+ ============================================================================ */
223
+
224
+ .stuic-gantt-body {
225
+ position: relative;
226
+ }
227
+
228
+ .stuic-gantt-grid {
229
+ position: absolute;
230
+ inset: 0 0 0 var(--_label-w);
231
+ display: flex;
232
+ pointer-events: none;
233
+ }
234
+
235
+ .stuic-gantt-gridline {
236
+ flex: 1 0 var(--stuic-gantt-unit-width);
237
+ border-left: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
238
+ var(--stuic-gantt-grid-line-color);
239
+ }
240
+
241
+ .stuic-gantt[data-fit] .stuic-gantt-gridline {
242
+ flex: 1 1 0;
243
+ }
244
+
245
+ .stuic-gantt-gridline:first-child {
246
+ border-left-color: transparent;
247
+ }
248
+
249
+ .stuic-gantt-gridline[data-weekend] {
250
+ background: var(--stuic-gantt-weekend-bg);
251
+ }
252
+
253
+ .stuic-gantt-today {
254
+ position: absolute;
255
+ top: 0;
256
+ bottom: 0;
257
+ width: var(--stuic-gantt-today-width);
258
+ translate: -50% 0;
259
+ background: var(--stuic-gantt-today-color);
260
+ }
261
+
262
+ /* ============================================================================
263
+ ROWS
264
+ ============================================================================ */
265
+
266
+ .stuic-gantt-row {
267
+ position: relative;
268
+ display: flex;
269
+ min-height: var(--stuic-gantt-row-height);
270
+ border-bottom: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
271
+ var(--stuic-gantt-border-color);
272
+ }
273
+
274
+ .stuic-gantt-row:last-child {
275
+ border-bottom-color: transparent;
276
+ }
277
+
278
+ /* translucent on purpose — the weekend shading must stay visible under it */
279
+ .stuic-gantt-row:hover {
280
+ background: var(--stuic-gantt-row-bg-hover);
281
+ }
282
+
283
+ .stuic-gantt-row-label {
284
+ flex: 0 0 var(--_label-w);
285
+ box-sizing: border-box;
286
+ position: sticky;
287
+ left: 0;
288
+ z-index: 2;
289
+ display: flex;
290
+ flex-direction: column;
291
+ justify-content: center;
292
+ gap: 0.0625rem;
293
+ padding: var(--stuic-gantt-label-padding);
294
+ background: var(--stuic-gantt-label-bg);
295
+ border-right: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
296
+ var(--stuic-gantt-border-color);
297
+ overflow: hidden;
298
+ }
299
+
300
+ .stuic-gantt-row-title {
301
+ font-size: var(--stuic-gantt-label-font-size);
302
+ color: var(--stuic-gantt-label-text);
303
+ white-space: nowrap;
304
+ overflow: hidden;
305
+ text-overflow: ellipsis;
306
+ }
307
+
308
+ .stuic-gantt-row-link {
309
+ color: inherit;
310
+ text-decoration: none;
311
+ }
312
+
313
+ .stuic-gantt-row-link:hover {
314
+ text-decoration: underline;
315
+ }
316
+
317
+ .stuic-gantt-row-description {
318
+ font-size: var(--stuic-gantt-description-font-size);
319
+ color: var(--stuic-gantt-description-text);
320
+ white-space: nowrap;
321
+ overflow: hidden;
322
+ text-overflow: ellipsis;
323
+ }
324
+
325
+ /* `clip` rather than `hidden`: a bar label positioned past the bar (barLabels
326
+ "after", and every milestone label) would otherwise be scrollable overflow and
327
+ put a horizontal scrollbar on a chart that fits. No `overflow-clip-margin` —
328
+ content in that margin still counts toward an ancestor's scrollable overflow,
329
+ which is the very scrollbar this is here to prevent. */
330
+ .stuic-gantt-track {
331
+ position: relative;
332
+ overflow: clip;
333
+ }
334
+
335
+ /* ============================================================================
336
+ BARS
337
+ ============================================================================ */
338
+
339
+ .stuic-gantt-bar {
340
+ position: absolute;
341
+ top: 50%;
342
+ translate: 0 -50%;
343
+ height: var(--stuic-gantt-bar-height);
344
+ box-sizing: border-box;
345
+ display: flex;
346
+ align-items: center;
347
+ margin: 0;
348
+ padding: 0;
349
+ border: 0;
350
+ font: inherit;
351
+ font-size: var(--stuic-gantt-bar-font-size);
352
+ text-align: start;
353
+ text-decoration: none;
354
+ background: var(--stuic-gantt-bar-bg);
355
+ color: var(--stuic-gantt-bar-text);
356
+ border-radius: var(--stuic-gantt-bar-radius, var(--stuic-radius));
357
+ overflow: hidden;
358
+ transition: box-shadow var(--stuic-gantt-transition, var(--stuic-transition)) ease;
359
+ }
360
+
361
+ /* the emphasis range drawn inside a wider bar of the same row */
362
+ .stuic-gantt-bar[data-inset] {
363
+ height: var(--stuic-gantt-bar-height-inset);
364
+ z-index: 1;
365
+ }
366
+
367
+ .stuic-gantt-bar[data-clipped-start] {
368
+ border-start-start-radius: 0;
369
+ border-end-start-radius: 0;
370
+ }
371
+
372
+ .stuic-gantt-bar[data-clipped-end] {
373
+ border-start-end-radius: 0;
374
+ border-end-end-radius: 0;
375
+ }
376
+
377
+ .stuic-gantt-bar:is(a, button) {
378
+ cursor: pointer;
379
+ }
380
+
381
+ .stuic-gantt-bar:is(a, button):hover {
382
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--stuic-gantt-bar-bg) 40%, transparent);
383
+ }
384
+
385
+ .stuic-gantt-bar:focus-visible {
386
+ outline: 2px solid var(--stuic-color-ring);
387
+ outline-offset: 1px;
388
+ }
389
+
390
+ .stuic-gantt-bar[data-disabled] {
391
+ opacity: var(--stuic-gantt-bar-opacity-disabled);
392
+ cursor: default;
393
+ }
394
+
395
+ .stuic-gantt-bar-progress {
396
+ position: absolute;
397
+ inset: 0 auto 0 0;
398
+ border-radius: inherit;
399
+ /* darken, rather than mix toward the foreground: mixing follows the color
400
+ scheme, so in dark mode the fill turned LIGHTER than the bar and the
401
+ label (sized for the bar's own foreground) lost its contrast. */
402
+ background: var(
403
+ --stuic-gantt-bar-progress-bg,
404
+ color-mix(in oklab, var(--stuic-gantt-bar-bg) 80%, black)
405
+ );
406
+ }
407
+
408
+ .stuic-gantt-bar-label {
409
+ position: relative; /* above the progress fill */
410
+ min-width: 0;
411
+ padding-inline: var(--stuic-gantt-bar-padding-inline);
412
+ white-space: nowrap;
413
+ overflow: hidden;
414
+ text-overflow: ellipsis;
415
+ }
416
+
417
+ /* `after`: the label escapes the bar to its right, in body (not bar) color */
418
+ .stuic-gantt[data-bar-labels="after"] .stuic-gantt-bar {
419
+ overflow: visible;
420
+ }
421
+
422
+ .stuic-gantt[data-bar-labels="after"] .stuic-gantt-bar-label,
423
+ .stuic-gantt-bar[data-kind="milestone"] .stuic-gantt-bar-label {
424
+ position: absolute;
425
+ left: 100%;
426
+ top: 50%;
427
+ translate: 0 -50%;
428
+ margin-left: 0.375rem;
429
+ padding-inline: 0;
430
+ overflow: visible;
431
+ color: var(--stuic-gantt-label-text);
432
+ }
433
+
434
+ /* ============================================================================
435
+ MILESTONE — a fixed box centered on its day; the diamond is the pseudo
436
+ element, so the label (and any renderBar content) is never rotated with it.
437
+ ============================================================================ */
438
+
439
+ .stuic-gantt-bar[data-kind="milestone"] {
440
+ width: var(--stuic-gantt-milestone-size);
441
+ height: var(--stuic-gantt-milestone-size);
442
+ translate: -50% -50%;
443
+ background: transparent;
444
+ border-radius: 0;
445
+ overflow: visible;
446
+ }
447
+
448
+ .stuic-gantt-bar[data-kind="milestone"]::before {
449
+ content: "";
450
+ position: absolute;
451
+ inset: 0;
452
+ rotate: 45deg;
453
+ background: var(--stuic-gantt-bar-bg);
454
+ border-radius: var(--stuic-gantt-milestone-radius, 2px);
455
+ }
456
+
457
+ /* ============================================================================
458
+ INTENT — the bar's own two tokens, so progress and the milestone follow
459
+ ============================================================================ */
460
+
461
+ .stuic-gantt-bar[data-intent="primary"] {
462
+ --stuic-gantt-bar-bg: var(--stuic-color-primary);
463
+ --stuic-gantt-bar-text: var(--stuic-color-primary-foreground);
464
+ }
465
+
466
+ .stuic-gantt-bar[data-intent="accent"] {
467
+ --stuic-gantt-bar-bg: var(--stuic-color-accent);
468
+ --stuic-gantt-bar-text: var(--stuic-color-accent-foreground);
469
+ }
470
+
471
+ .stuic-gantt-bar[data-intent="success"] {
472
+ --stuic-gantt-bar-bg: var(--stuic-color-success);
473
+ --stuic-gantt-bar-text: var(--stuic-color-success-foreground);
474
+ }
475
+
476
+ .stuic-gantt-bar[data-intent="warning"] {
477
+ --stuic-gantt-bar-bg: var(--stuic-color-warning);
478
+ --stuic-gantt-bar-text: var(--stuic-color-warning-foreground);
479
+ }
480
+
481
+ .stuic-gantt-bar[data-intent="destructive"] {
482
+ --stuic-gantt-bar-bg: var(--stuic-color-destructive);
483
+ --stuic-gantt-bar-text: var(--stuic-color-destructive-foreground);
484
+ }
485
+
486
+ /* ============================================================================
487
+ EMPTY
488
+ ============================================================================ */
489
+
490
+ /* positioned for the same reason the rows are: the grid layer is an absolute
491
+ element earlier in the DOM, so in-flow content would paint underneath it */
492
+ .stuic-gantt-empty {
493
+ position: relative;
494
+ padding: var(--stuic-gantt-empty-padding);
495
+ }
496
+ }