@tzslot/theme 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Plinthworks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # tzslot
2
+
3
+ Date and time pickers that know what daylight saving does.
4
+
5
+ Most pickers — flatpickr, Air Datepicker, nearly every Angular wrapper — are
6
+ built on `Date`, which has no notion of an IANA time zone. They will let
7
+ someone book 02:30 on a morning when 02:30 does not happen, or on one when it
8
+ happens twice, and store whichever reading the browser guessed. They call a
9
+ night shift from 23:00 to 05:00 six hours on the night it lasts seven.
10
+
11
+ tzslot is built on [Temporal](https://tc39.es/proposal-temporal/docs/). It
12
+ shows the hour that does not exist, offers both readings of the one that
13
+ happens twice, and says in words when an interval lasts something other than
14
+ what the clock faces suggest.
15
+
16
+ ## Packages
17
+
18
+ | | |
19
+ |---|---|
20
+ | `@tzslot/core` | The arithmetic. No DOM, no framework — usable on a server too. |
21
+ | `@tzslot/dom` | Every widget, in plain DOM. Works in any framework, or none. |
22
+ | `@tzslot/angular` | Angular components over `@tzslot/dom`: signals, forms, zoneless or not. Angular 18 to 21. |
23
+ | `@tzslot/theme` | Optional. CSS (or Sass) setting the widgets' colours; light, dark, high contrast, a Tailwind v4 bridge. |
24
+
25
+ The widgets live in `@tzslot/dom`; a framework package only translates its own
26
+ idioms into calls on them. A behaviour fixed there is fixed everywhere.
27
+
28
+ ## The widgets
29
+
30
+ | Angular | Plain DOM | What it chooses |
31
+ |---|---|---|
32
+ | `<tz-calendar>` | `createCalendar` | a day — month and year views, keyboard, Today / Clear |
33
+ | `<tz-multi-date>` | `createMultiDate` | several days, not necessarily adjacent |
34
+ | `<tz-date-field>` | `createDateField` | a day, from a field that opens a panel, anchored or centred |
35
+ | `<tz-date-range>` | `createDateRange` | two days, refusing a range across a closed one |
36
+ | `<tz-time-slots>` | `createTimeSlots` | a moment on one day: the skipped hour struck through, the repeated one offered twice |
37
+ | `<tz-datetime-range>` | `createDateTimeRange` | an interval with a time at both ends, and the hour it hides |
38
+ | `<tz-daily-range>` | `createDailyRange` | a range of days with the same hours on each, overnight allowed |
39
+
40
+ `renderCell` puts a price, places left or your own class on any day.
41
+
42
+ ## Angular
43
+
44
+ ```bash
45
+ npm install @tzslot/angular @tzslot/theme
46
+ ```
47
+
48
+ ```jsonc
49
+ // angular.json → build.options
50
+ "styles": ["@tzslot/theme/tzslot.css", "src/styles.css"]
51
+ ```
52
+
53
+ ```ts
54
+ import { Component, signal } from '@angular/core';
55
+ import { Calendar, TimeSlotPicker, provideTzslotMessages, FR } from '@tzslot/angular';
56
+ import type { Instant, PlainDate } from '@tzslot/core';
57
+
58
+ @Component({
59
+ imports: [Calendar, TimeSlotPicker],
60
+ template: `
61
+ <tz-calendar [(value)]="day" [buttons]="['today', 'clear']" />
62
+ @if (day(); as d) {
63
+ <tz-time-slots [date]="d" timeZone="Europe/Paris" [(value)]="moment" />
64
+ }
65
+ `,
66
+ })
67
+ export class Booking {
68
+ readonly day = signal<PlainDate | null>(null);
69
+ readonly moment = signal<Instant | null>(null);
70
+ }
71
+
72
+ // French everywhere: bootstrapApplication(App, { providers: [provideTzslotMessages(FR)] })
73
+ ```
74
+
75
+ Every component is a `ControlValueAccessor` — `formControlName`, `ngModel` and
76
+ `[(value)]` all work — and takes `valueAs="date"` to keep an existing
77
+ `FormControl<Date>` untouched. See `docs/migrating-from-flatpickr.md`.
78
+
79
+ ## Without a framework
80
+
81
+ ```bash
82
+ npm install @tzslot/dom @tzslot/theme
83
+ ```
84
+
85
+ ```js
86
+ import { createCalendar } from '@tzslot/dom';
87
+ import { Temporal } from '@tzslot/core';
88
+ import '@tzslot/theme';
89
+
90
+ const calendar = createCalendar(document.querySelector('#day'), {
91
+ onChange: (day) => console.log(day?.toString()),
92
+ });
93
+ calendar.update({ min: Temporal.Now.plainDateISO() });
94
+ ```
95
+
96
+ Each `create…` returns an instance with `update`, `clear` and `destroy`.
97
+ Layout CSS is injected once; pass `injectStyles: false` under a strict
98
+ Content-Security-Policy and include the exported `*_CSS` strings yourself.
99
+
100
+ ## The arithmetic
101
+
102
+ ```ts
103
+ import { getDaySlots, getDailyWindows } from '@tzslot/core';
104
+
105
+ getDaySlots('2026-10-25', 'Europe/Paris').filter((s) => s.time.hour === 2);
106
+ // 02:00 is ambiguous: offsets ['+02:00', '+01:00'], two instants an hour apart
107
+
108
+ getDailyWindows('2026-10-23', '2026-10-26', '22:00', '06:00', 'Europe/Paris').minutes / 60;
109
+ // 33 — four night shifts, one of them nine hours long
110
+ ```
111
+
112
+ Store an instant, never a wall time: it is unambiguous everywhere and
113
+ survives a change of zone, of the rules, and being read back next year.
114
+
115
+ ## Theming
116
+
117
+ Nine palette colours, each `light-dark(light, dark)`. `data-theme="dark"` on
118
+ any element themes everything inside it; `--tz-accent` on any element
119
+ recolours the selection, the range tint and the focus ring beneath it.
120
+ `@tzslot/theme/contrast.css` follows `prefers-contrast: more`, and
121
+ `@tzslot/theme/tailwind.css` maps the palette to a Tailwind v4 theme. From
122
+ Sass, `@use '@tzslot/theme/tzslot' with ($accent: …)`. All of it in
123
+ `docs/customising.md`.
124
+
125
+ ## Browsers
126
+
127
+ Temporal is used natively where it exists and polyfilled where it does not.
128
+ The polyfill is a static import, so a bundle carries it for every visitor —
129
+ about 19 kB gzipped. There is deliberately no fallback to `Date`: it would
130
+ bring back the exact bug this library exists to fix.
131
+
132
+ The theme uses `light-dark()` and `color-mix()`: Chrome 123, Safari 17.5,
133
+ Firefox 120 and later.
134
+
135
+ ## Development
136
+
137
+ ```bash
138
+ npm install
139
+ npm start # playground: http://localhost:4500/playground/index.html
140
+ npm test # 263 tests, against real time zone rules
141
+ npm run typecheck
142
+ npm run build # every package into its own dist/, ready to publish
143
+ ```
144
+
145
+ The playground has a page per way of using it: Angular, Tailwind, and plain
146
+ DOM.
147
+
148
+ ## Licence
149
+
150
+ [MIT](LICENSE). Free to use, in personal and commercial projects alike, free
151
+ to copy, modify and redistribute; keep the copyright notice. It comes with no
152
+ warranty.
package/contrast.css ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @tzslot/theme/contrast.css — more contrast, for people who need it.
3
+ *
4
+ * import '@tzslot/theme';
5
+ * import '@tzslot/theme/contrast.css';
6
+ *
7
+ * Applies on its own when the system asks for more contrast
8
+ * (prefers-contrast: more), and on request with data-contrast="more" on any
9
+ * element. data-contrast="normal" on the root opts the page out of the
10
+ * automatic version.
11
+ *
12
+ * A separate axis from light and dark, not a third theme: someone who needs
13
+ * more contrast still has a preference between the two.
14
+ *
15
+ * Contrast: text and the selection meet WCAG AAA (7:1); faded states that
16
+ * carry meaning (outside the month, unavailable) stay above 4.5:1.
17
+ */
18
+
19
+ @media (prefers-contrast: more) {
20
+ :root:not([data-contrast='normal']) {
21
+ --tz-bg: light-dark(#ffffff, #000000);
22
+ --tz-bg-raised: light-dark(#e4e4e7, #27272a);
23
+ --tz-fg: light-dark(#000000, #ffffff);
24
+ --tz-fg-muted: light-dark(#27272a, #e4e4e7);
25
+ --tz-border: light-dark(#000000, #ffffff);
26
+ --tz-accent: light-dark(#1d3fb8, #ffd400);
27
+ --tz-accent-fg: light-dark(#ffffff, #000000);
28
+ --tz-danger: light-dark(#8b0000, #ff8a8a);
29
+ --tz-warning: light-dark(#6b3a00, #ffd27a);
30
+ --tz-cal-outside-opacity: 0.7;
31
+ --tz-slot-missing-opacity: 0.75;
32
+ --tz-slot-unavailable-opacity: 0.75;
33
+ --tz-focus-ring: 3px solid var(--tz-fg);
34
+ }
35
+ }
36
+
37
+ [data-contrast='more'] {
38
+ --tz-bg: light-dark(#ffffff, #000000);
39
+ --tz-bg-raised: light-dark(#e4e4e7, #27272a);
40
+ --tz-fg: light-dark(#000000, #ffffff);
41
+ --tz-fg-muted: light-dark(#27272a, #e4e4e7);
42
+ --tz-border: light-dark(#000000, #ffffff);
43
+ --tz-accent: light-dark(#1d3fb8, #ffd400);
44
+ --tz-accent-fg: light-dark(#ffffff, #000000);
45
+ --tz-danger: light-dark(#8b0000, #ff8a8a);
46
+ --tz-warning: light-dark(#6b3a00, #ffd27a);
47
+ --tz-cal-outside-opacity: 0.7;
48
+ --tz-slot-missing-opacity: 0.75;
49
+ --tz-slot-unavailable-opacity: 0.75;
50
+ --tz-focus-ring: 3px solid var(--tz-fg);
51
+ }
52
+
53
+ /* Borders on every cell, so the grid reads without relying on colour. */
54
+ :is([data-contrast='more']) :is(.tz-cal__day, .tz-range__day):not(:disabled) {
55
+ box-shadow: inset 0 0 0 1px var(--tz-border);
56
+ }
57
+ @media (prefers-contrast: more) {
58
+ :root:not([data-contrast='normal']) :is(.tz-cal__day, .tz-range__day):not(:disabled) {
59
+ box-shadow: inset 0 0 0 1px var(--tz-border);
60
+ }
61
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@tzslot/theme",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "An optional stylesheet for the tzslot components. Plain CSS, no framework.",
5
+ "license": "MIT",
6
+ "main": "./tzslot.css",
7
+ "exports": {
8
+ ".": "./tzslot.css",
9
+ "./tzslot.css": "./tzslot.css",
10
+ "./contrast.css": "./contrast.css",
11
+ "./tzslot.scss": "./tzslot.scss",
12
+ "./tzslot": {
13
+ "sass": "./tzslot.scss",
14
+ "default": "./tzslot.css"
15
+ },
16
+ "./tailwind.css": "./tailwind.css"
17
+ },
18
+ "sideEffects": [
19
+ "*.css"
20
+ ],
21
+ "sass": "./tzslot.scss",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/plinthworks/tzslot.git",
25
+ "directory": "packages/theme"
26
+ },
27
+ "homepage": "https://github.com/plinthworks/tzslot#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/plinthworks/tzslot/issues"
30
+ },
31
+ "keywords": [
32
+ "date",
33
+ "time",
34
+ "datepicker",
35
+ "timezone",
36
+ "dst",
37
+ "temporal",
38
+ "calendar",
39
+ "time-slots"
40
+ ]
41
+ }
package/tailwind.css ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @tzslot/theme/tailwind.css — the widgets in your Tailwind v4 theme.
3
+ *
4
+ * In the CSS file that imports Tailwind, after it:
5
+ *
6
+ * @import "tailwindcss";
7
+ * @import "@tzslot/theme";
8
+ * @import "@tzslot/theme/tailwind.css";
9
+ *
10
+ * It has to go through Tailwind, not beside it: Tailwind v4 only emits the
11
+ * theme variables something references, and this file is what references
12
+ * them. Imported on its own, it would point at variables that do not exist.
13
+ *
14
+ * ── What it maps ────────────────────────────────────────────────────────────
15
+ *
16
+ * surfaces and text → the zinc scale
17
+ * accent → your --color-primary-600 / -400 if your @theme has
18
+ * them, Tailwind's blue otherwise
19
+ * danger / warning → red / amber
20
+ * font, radius → --font-sans, --radius-md
21
+ *
22
+ * Another grey (slate, neutral, stone…) is a matter of overriding the five
23
+ * surface lines below with your own scale.
24
+ *
25
+ * ── Dark mode ───────────────────────────────────────────────────────────────
26
+ *
27
+ * Tailwind's default dark mode follows the system, and so do the widgets:
28
+ * nothing to do. With the class strategy (a .dark class toggled by your
29
+ * app), the .dark rule below reaches them; add
30
+ *
31
+ * :root { --tz-color-scheme: light; }
32
+ *
33
+ * so that no .dark means light even when the system is dark.
34
+ */
35
+
36
+ :root {
37
+ --tz-bg: light-dark(var(--color-white), var(--color-zinc-900));
38
+ --tz-bg-raised: light-dark(var(--color-zinc-100), var(--color-zinc-800));
39
+ --tz-fg: light-dark(var(--color-zinc-900), var(--color-zinc-100));
40
+ --tz-fg-muted: light-dark(var(--color-zinc-600), var(--color-zinc-400));
41
+ --tz-border: light-dark(var(--color-zinc-300), var(--color-zinc-700));
42
+ --tz-accent: light-dark(
43
+ var(--color-primary-600, var(--color-blue-600)),
44
+ var(--color-primary-400, var(--color-blue-400))
45
+ );
46
+ --tz-accent-fg: light-dark(var(--color-white), var(--color-zinc-950));
47
+ --tz-danger: light-dark(var(--color-red-700), var(--color-red-400));
48
+ --tz-warning: light-dark(var(--color-amber-700), var(--color-amber-400));
49
+ --tz-font: var(--font-sans);
50
+ --tz-radius: var(--radius-md);
51
+ }
52
+
53
+ .dark {
54
+ --tz-color-scheme: dark;
55
+ }
package/tzslot.css ADDED
@@ -0,0 +1,182 @@
1
+ @charset "UTF-8";
2
+ /**
3
+ * @tzslot/theme — one import and the widgets are presentable.
4
+ *
5
+ * import '@tzslot/theme'; // plain CSS
6
+ * @use '@tzslot/theme/tzslot' with ($accent: #e11d48, $accent-fg: #fff);
7
+ *
8
+ * Generated from tzslot.scss — edit that file, then `npm run build:theme`.
9
+ *
10
+ * Optional by design. The widgets carry structural class names and read their
11
+ * colours from custom properties; this file only sets those properties and a
12
+ * handful of interaction rules. Replacing it means writing your own values for
13
+ * the same names, not fighting selectors.
14
+ *
15
+ * ── How it is built ──────────────────────────────────────────────────────────
16
+ *
17
+ * One palette, nine colours, each written light-dark(light, dark). Which half
18
+ * applies is decided by `color-scheme` at the element that uses the colour, so:
19
+ *
20
+ * - by default the widgets follow the system;
21
+ * - data-theme="dark" or "light" on ANY element forces it for everything
22
+ * inside — a dark card on a light page is one attribute;
23
+ * - there is no second copy of the dark values to keep in step.
24
+ *
25
+ * Nothing derived is declared here. A variable like "the selected day's
26
+ * background" defaults to the accent at the place it is drawn, not on :root —
27
+ * a custom property that refers to another is frozen where it is declared, and
28
+ * declaring it on :root would stop a card's own --tz-accent from reaching it.
29
+ * Every such default lives in the widgets' base CSS as a var() fallback.
30
+ *
31
+ * Contrast: every text/background pair meets WCAG AA at the sizes used.
32
+ * light-dark() needs Chrome 123, Safari 17.5 or Firefox 120 (all of 2024).
33
+ */
34
+ :root {
35
+ --tz-bg: light-dark(#ffffff, #18181b);
36
+ --tz-bg-raised: light-dark(#f4f4f5, #27272a);
37
+ --tz-fg: light-dark(#18181b, #f4f4f5);
38
+ --tz-fg-muted: light-dark(#52525b, #a1a1aa);
39
+ --tz-border: light-dark(#d4d4d8, #3f3f46);
40
+ --tz-accent: light-dark(#2563eb, #60a5fa);
41
+ --tz-accent-fg: light-dark(#ffffff, #0b1220);
42
+ --tz-danger: light-dark(#b91c1c, #f87171);
43
+ --tz-warning: light-dark(#b45309, #fbbf24);
44
+ /**
45
+ * The widgets inherit the page's font by default, which is almost always
46
+ * right. Set this only to make them differ from their surroundings.
47
+ */
48
+ --tz-font: inherit;
49
+ --tz-radius: 0.375rem;
50
+ --tz-gap: 0.25rem;
51
+ --tz-cal-cell-size: 2.25rem;
52
+ --tz-cal-weekday-size: 0.6875rem;
53
+ --tz-cal-outside-opacity: 0.32;
54
+ --tz-slot-gap: 0.375rem;
55
+ --tz-slot-padding: 0.4375rem 0.25rem;
56
+ --tz-slot-note-size: 0.6875rem;
57
+ --tz-field-width: 13rem;
58
+ --tz-dtr-gap: 1.25rem;
59
+ --tz-dtr-leg-min: 15rem;
60
+ --tz-dtr-legend-size: 0.6875rem;
61
+ --tz-dtr-summary-size: 1.375rem;
62
+ }
63
+
64
+ /**
65
+ * Which half of every light-dark() applies. Declared on the widgets rather than
66
+ * the page, so importing the theme never changes the page's own form controls
67
+ * and scrollbars. --tz-color-scheme is inherited, which is how data-theme on
68
+ * any ancestor reaches them.
69
+ */
70
+ .tz-cal,
71
+ .tz-range,
72
+ .tz-slots,
73
+ .tz-field,
74
+ .tz-field__panel,
75
+ .tz-field__backdrop,
76
+ .tz-dtr,
77
+ .tz-daily {
78
+ color-scheme: var(--tz-color-scheme, light dark);
79
+ color: var(--tz-fg);
80
+ font: var(--tz-font);
81
+ }
82
+
83
+ [data-theme=light] {
84
+ --tz-color-scheme: light;
85
+ }
86
+
87
+ [data-theme=dark] {
88
+ --tz-color-scheme: dark;
89
+ }
90
+
91
+ /**
92
+ * The rules the variables cannot express: hover, focus, the panel surface.
93
+ * Kept short — every rule here is one a consumer may have to override.
94
+ */
95
+ .tz-cal__day:hover:not(:disabled),
96
+ .tz-cal__coarse-cell:hover:not(:disabled),
97
+ .tz-range__day:hover:not(:disabled),
98
+ .tz-cal__nav:hover:not(:disabled),
99
+ .tz-cal__title:hover:not(:disabled),
100
+ .tz-cal__action:hover:not(:disabled),
101
+ .tz-range__nav:hover:not(:disabled) {
102
+ background: var(--tz-bg-raised);
103
+ }
104
+
105
+ /* Hovering the selection must not hide it. */
106
+ .tz-cal__day--selected:hover:not(:disabled),
107
+ .tz-cal__coarse-cell--selected:hover:not(:disabled),
108
+ .tz-range__day--start:hover:not(:disabled),
109
+ .tz-range__day--end:hover:not(:disabled) {
110
+ background: color-mix(in srgb, var(--tz-accent) 85%, var(--tz-fg));
111
+ }
112
+
113
+ .tz-slots__slot:hover:not(:disabled),
114
+ .tz-field__trigger:hover:not(:disabled) {
115
+ border-color: var(--tz-accent);
116
+ }
117
+
118
+ /* Focus must be visible on every control, including those inside the panel —
119
+ a keyboard user who cannot see where they are is stuck. */
120
+ .tz-cal__day:focus-visible,
121
+ .tz-cal__coarse-cell:focus-visible,
122
+ .tz-cal__title:focus-visible,
123
+ .tz-range__day:focus-visible,
124
+ .tz-cal__nav:focus-visible,
125
+ .tz-cal__action:focus-visible,
126
+ .tz-range__nav:focus-visible,
127
+ .tz-slots__slot:focus-visible,
128
+ .tz-field__trigger:focus-visible {
129
+ outline: var(--tz-focus-ring, 2px solid var(--tz-accent));
130
+ outline-offset: 2px;
131
+ }
132
+
133
+ .tz-field__panel {
134
+ border: 1px solid var(--tz-border);
135
+ border-radius: calc(var(--tz-radius) * 1.5);
136
+ background: var(--tz-bg);
137
+ box-shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.35);
138
+ }
139
+
140
+ .tz-field__panel--dialog {
141
+ box-shadow: 0 25px 60px -15px rgba(0, 0, 0, 0.5);
142
+ }
143
+
144
+ .tz-field__backdrop {
145
+ background: rgba(0, 0, 0, 0.45);
146
+ }
147
+
148
+ .tz-cal__weekday,
149
+ .tz-range__weekday,
150
+ .tz-dtr__legend,
151
+ .tz-daily__legend {
152
+ color: var(--tz-fg-muted);
153
+ }
154
+
155
+ /**
156
+ * Windows high contrast and other forced-colour modes replace every colour
157
+ * with the system's, backgrounds included — so a selection shown only by its
158
+ * background would vanish. System colours keep it visible.
159
+ */
160
+ @media (forced-colors: active) {
161
+ .tz-cal__day--selected,
162
+ .tz-cal__coarse-cell--selected,
163
+ .tz-range__day--start,
164
+ .tz-range__day--end,
165
+ .tz-slots__slot--selected {
166
+ forced-color-adjust: none;
167
+ background: Highlight;
168
+ color: HighlightText;
169
+ }
170
+ .tz-cal__day--today {
171
+ outline-color: CanvasText;
172
+ }
173
+ }
174
+ @media (prefers-reduced-motion: no-preference) {
175
+ .tz-cal__day,
176
+ .tz-cal__coarse-cell,
177
+ .tz-range__day,
178
+ .tz-slots__slot,
179
+ .tz-field__trigger {
180
+ transition: background-color 120ms ease, border-color 120ms ease;
181
+ }
182
+ }
package/tzslot.scss ADDED
@@ -0,0 +1,247 @@
1
+ @use 'sass:list';
2
+ @use 'sass:meta';
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // The source of @tzslot/theme. tzslot.css is compiled from this file and a
6
+ // test fails when the two drift apart, so there is one theme, not two.
7
+ //
8
+ // Sass is optional for consumers. What it adds over the CSS:
9
+ //
10
+ // // configure the palette at compile time
11
+ // @use '@tzslot/theme/tzslot' with ($accent: (#be123c, #fb7185));
12
+ //
13
+ // // or scope one to a selector, from your own palette variables
14
+ // @use '@tzslot/theme/tzslot' as tz;
15
+ // .brand-card { @include tz.palette((accent: $brand, accent-fg: #fff)); }
16
+ //
17
+ // A colour is either one value, used in both schemes, or a pair
18
+ // (light, dark) that becomes light-dark(light, dark).
19
+ //
20
+ // Everything still ends up as --tz-* custom properties: that is what lets a
21
+ // theme change at runtime, per element, which Sass variables alone cannot do.
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ // ── palette ──────────────────────────────────────────────────────────────────
25
+ $bg: (#ffffff, #18181b) !default;
26
+ $bg-raised: (#f4f4f5, #27272a) !default;
27
+ $fg: (#18181b, #f4f4f5) !default;
28
+ $fg-muted: (#52525b, #a1a1aa) !default;
29
+ $border: (#d4d4d8, #3f3f46) !default;
30
+ $accent: (#2563eb, #60a5fa) !default;
31
+ $accent-fg: (#ffffff, #0b1220) !default;
32
+ $danger: (#b91c1c, #f87171) !default;
33
+ $warning: (#b45309, #fbbf24) !default;
34
+
35
+ $font: inherit !default;
36
+
37
+ // ── shape and size ───────────────────────────────────────────────────────────
38
+ $sizes: (
39
+ radius: 0.375rem,
40
+ gap: 0.25rem,
41
+ cal-cell-size: 2.25rem,
42
+ cal-weekday-size: 0.6875rem,
43
+ cal-outside-opacity: 0.32,
44
+ slot-gap: 0.375rem,
45
+ slot-padding: 0.4375rem 0.25rem,
46
+ slot-note-size: 0.6875rem,
47
+ field-width: 13rem,
48
+ dtr-gap: 1.25rem,
49
+ dtr-leg-min: 15rem,
50
+ dtr-legend-size: 0.6875rem,
51
+ dtr-summary-size: 1.375rem,
52
+ ) !default;
53
+
54
+ /// False to load only the functions and mixins, with no CSS emitted.
55
+ $emit: true !default;
56
+
57
+ /// A single colour, or a (light, dark) pair turned into light-dark().
58
+ @function value($colour) {
59
+ @if meta.type-of($colour) == 'list' and list.length($colour) == 2 {
60
+ @return light-dark(list.nth($colour, 1), list.nth($colour, 2));
61
+ }
62
+ @return $colour;
63
+ }
64
+
65
+ /// Sets palette variables on the current selector. Keys are the names without
66
+ /// the prefix: (accent: #e11d48) sets --tz-accent.
67
+ @mixin palette($colours) {
68
+ @each $name, $colour in $colours {
69
+ --tz-#{$name}: #{value($colour)};
70
+ }
71
+ }
72
+
73
+ @if $emit {
74
+ /**
75
+ * @tzslot/theme — one import and the widgets are presentable.
76
+ *
77
+ * import '@tzslot/theme'; // plain CSS
78
+ * @use '@tzslot/theme/tzslot' with ($accent: #e11d48, $accent-fg: #fff);
79
+ *
80
+ * Generated from tzslot.scss — edit that file, then `npm run build:theme`.
81
+ *
82
+ * Optional by design. The widgets carry structural class names and read their
83
+ * colours from custom properties; this file only sets those properties and a
84
+ * handful of interaction rules. Replacing it means writing your own values for
85
+ * the same names, not fighting selectors.
86
+ *
87
+ * ── How it is built ──────────────────────────────────────────────────────────
88
+ *
89
+ * One palette, nine colours, each written light-dark(light, dark). Which half
90
+ * applies is decided by `color-scheme` at the element that uses the colour, so:
91
+ *
92
+ * - by default the widgets follow the system;
93
+ * - data-theme="dark" or "light" on ANY element forces it for everything
94
+ * inside — a dark card on a light page is one attribute;
95
+ * - there is no second copy of the dark values to keep in step.
96
+ *
97
+ * Nothing derived is declared here. A variable like "the selected day's
98
+ * background" defaults to the accent at the place it is drawn, not on :root —
99
+ * a custom property that refers to another is frozen where it is declared, and
100
+ * declaring it on :root would stop a card's own --tz-accent from reaching it.
101
+ * Every such default lives in the widgets' base CSS as a var() fallback.
102
+ *
103
+ * Contrast: every text/background pair meets WCAG AA at the sizes used.
104
+ * light-dark() needs Chrome 123, Safari 17.5 or Firefox 120 (all of 2024).
105
+ */
106
+ :root {
107
+ @include palette(
108
+ (
109
+ bg: $bg,
110
+ bg-raised: $bg-raised,
111
+ fg: $fg,
112
+ fg-muted: $fg-muted,
113
+ border: $border,
114
+ accent: $accent,
115
+ accent-fg: $accent-fg,
116
+ danger: $danger,
117
+ warning: $warning,
118
+ )
119
+ );
120
+
121
+ /**
122
+ * The widgets inherit the page's font by default, which is almost always
123
+ * right. Set this only to make them differ from their surroundings.
124
+ */
125
+ --tz-font: #{$font};
126
+
127
+ @each $name, $size in $sizes {
128
+ --tz-#{$name}: #{$size};
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Which half of every light-dark() applies. Declared on the widgets rather than
134
+ * the page, so importing the theme never changes the page's own form controls
135
+ * and scrollbars. --tz-color-scheme is inherited, which is how data-theme on
136
+ * any ancestor reaches them.
137
+ */
138
+ .tz-cal,
139
+ .tz-range,
140
+ .tz-slots,
141
+ .tz-field,
142
+ .tz-field__panel,
143
+ .tz-field__backdrop,
144
+ .tz-dtr,
145
+ .tz-daily {
146
+ color-scheme: var(--tz-color-scheme, light dark);
147
+ color: var(--tz-fg);
148
+ font: var(--tz-font);
149
+ }
150
+
151
+ [data-theme='light'] { --tz-color-scheme: light; }
152
+ [data-theme='dark'] { --tz-color-scheme: dark; }
153
+
154
+ /**
155
+ * The rules the variables cannot express: hover, focus, the panel surface.
156
+ * Kept short — every rule here is one a consumer may have to override.
157
+ */
158
+ .tz-cal__day:hover:not(:disabled),
159
+ .tz-cal__coarse-cell:hover:not(:disabled),
160
+ .tz-range__day:hover:not(:disabled),
161
+ .tz-cal__nav:hover:not(:disabled),
162
+ .tz-cal__title:hover:not(:disabled),
163
+ .tz-cal__action:hover:not(:disabled),
164
+ .tz-range__nav:hover:not(:disabled) {
165
+ background: var(--tz-bg-raised);
166
+ }
167
+
168
+ /* Hovering the selection must not hide it. */
169
+ .tz-cal__day--selected:hover:not(:disabled),
170
+ .tz-cal__coarse-cell--selected:hover:not(:disabled),
171
+ .tz-range__day--start:hover:not(:disabled),
172
+ .tz-range__day--end:hover:not(:disabled) {
173
+ background: color-mix(in srgb, var(--tz-accent) 85%, var(--tz-fg));
174
+ }
175
+
176
+ .tz-slots__slot:hover:not(:disabled),
177
+ .tz-field__trigger:hover:not(:disabled) {
178
+ border-color: var(--tz-accent);
179
+ }
180
+
181
+ /* Focus must be visible on every control, including those inside the panel —
182
+ a keyboard user who cannot see where they are is stuck. */
183
+ .tz-cal__day:focus-visible,
184
+ .tz-cal__coarse-cell:focus-visible,
185
+ .tz-cal__title:focus-visible,
186
+ .tz-range__day:focus-visible,
187
+ .tz-cal__nav:focus-visible,
188
+ .tz-cal__action:focus-visible,
189
+ .tz-range__nav:focus-visible,
190
+ .tz-slots__slot:focus-visible,
191
+ .tz-field__trigger:focus-visible {
192
+ outline: var(--tz-focus-ring, 2px solid var(--tz-accent));
193
+ outline-offset: 2px;
194
+ }
195
+
196
+ .tz-field__panel {
197
+ border: 1px solid var(--tz-border);
198
+ border-radius: calc(var(--tz-radius) * 1.5);
199
+ background: var(--tz-bg);
200
+ box-shadow: 0 10px 30px -12px rgb(0 0 0 / 0.35);
201
+ }
202
+
203
+ .tz-field__panel--dialog {
204
+ box-shadow: 0 25px 60px -15px rgb(0 0 0 / 0.5);
205
+ }
206
+
207
+ .tz-field__backdrop {
208
+ background: rgb(0 0 0 / 0.45);
209
+ }
210
+
211
+ .tz-cal__weekday,
212
+ .tz-range__weekday,
213
+ .tz-dtr__legend,
214
+ .tz-daily__legend {
215
+ color: var(--tz-fg-muted);
216
+ }
217
+
218
+ /**
219
+ * Windows high contrast and other forced-colour modes replace every colour
220
+ * with the system's, backgrounds included — so a selection shown only by its
221
+ * background would vanish. System colours keep it visible.
222
+ */
223
+ @media (forced-colors: active) {
224
+ .tz-cal__day--selected,
225
+ .tz-cal__coarse-cell--selected,
226
+ .tz-range__day--start,
227
+ .tz-range__day--end,
228
+ .tz-slots__slot--selected {
229
+ forced-color-adjust: none;
230
+ background: Highlight;
231
+ color: HighlightText;
232
+ }
233
+ .tz-cal__day--today { outline-color: CanvasText; }
234
+ }
235
+
236
+ @media (prefers-reduced-motion: no-preference) {
237
+ .tz-cal__day,
238
+ .tz-cal__coarse-cell,
239
+ .tz-range__day,
240
+ .tz-slots__slot,
241
+ .tz-field__trigger {
242
+ transition:
243
+ background-color 120ms ease,
244
+ border-color 120ms ease;
245
+ }
246
+ }
247
+ }