@docentjs/dom 0.5.2 → 0.6.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/dist/index.cjs CHANGED
@@ -79,6 +79,45 @@ function renderMedia(doc, media) {
79
79
  return video;
80
80
  }
81
81
  //#endregion
82
+ //#region src/dev.ts
83
+ const checked = /* @__PURE__ */ new WeakSet();
84
+ /**
85
+ * Written exactly like this on purpose: bundlers replace
86
+ * `process.env.NODE_ENV` literally, so a production build turns this into
87
+ * `false`, and the check plus its import are dropped. Without a bundler
88
+ * `process` is simply not defined, and the catch treats that as development.
89
+ */
90
+ function isProduction() {
91
+ try {
92
+ return process.env.NODE_ENV === "production";
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+ /**
98
+ * Check every tour the manager knows, as they load. Tours that never start
99
+ * still get checked, which is where a broken condition usually hides.
100
+ */
101
+ function warnAboutTours(docent) {
102
+ if (isProduction()) return;
103
+ const check = () => {
104
+ for (const tour of docent.getTours()) warnIfInvalid(tour);
105
+ };
106
+ docent.subscribe(check);
107
+ check();
108
+ }
109
+ /** Warn about anything wrong with this tour, once per tour, in development. */
110
+ function warnIfInvalid(tour) {
111
+ if (!tour || isProduction() || checked.has(tour)) return;
112
+ checked.add(tour);
113
+ import("@docentjs/core/validate").then(({ formatIssues, validateTour }) => {
114
+ const issues = validateTour(tour);
115
+ if (issues.length === 0) return;
116
+ const label = issues.filter((issue) => issue.level === "error").length > 0 ? "error" : "warning";
117
+ console.warn(`[docent] Tour "${tour.id}" has ${issues.length} ${label}${issues.length === 1 ? "" : "s"}:\n${formatIssues(issues)}\nThis check runs in development only. See https://docentjs.dev/reference/schema/`);
118
+ }).catch(() => {});
119
+ }
120
+ //#endregion
82
121
  //#region src/occlusion.ts
83
122
  function isPinned(el) {
84
123
  const view = el.ownerDocument.defaultView;
@@ -678,18 +717,82 @@ const THEME_VARS = {
678
717
  connector: "connector",
679
718
  ring: "ring"
680
719
  };
720
+ /** Tokens that take a unit when given as a number. */
721
+ const UNITS = {
722
+ radius: "px",
723
+ width: "px",
724
+ duration: "ms"
725
+ };
726
+ /** `12` becomes `12px`, `220` becomes `220ms`, strings are passed through. */
727
+ function cssValue(key, value) {
728
+ return typeof value === "number" ? `${value}${UNITS[key] ?? ""}` : value;
729
+ }
681
730
  /** Write theme tokens as inline custom properties on an element. Clears unset ones. */
682
731
  function applyTheme(el, theme) {
683
732
  for (const key of Object.keys(THEME_VARS)) {
684
733
  const value = theme?.[key];
685
734
  const prop = `--docent-${THEME_VARS[key]}`;
686
735
  if (value === void 0) el.style.removeProperty(prop);
687
- else el.style.setProperty(prop, value);
736
+ else el.style.setProperty(prop, cssValue(key, value));
737
+ }
738
+ if (theme?.accent !== void 0 && theme.accentForeground === void 0) {
739
+ const light = isLightColor(el, cssValue("accent", theme.accent));
740
+ if (light !== void 0) el.style.setProperty("--docent-accent-fg", light ? "var(--docent-fg)" : "var(--docent-bg)");
688
741
  }
689
742
  }
743
+ /**
744
+ * Is this colour light enough to need dark text on it? Resolves the colour
745
+ * through the browser, so any CSS colour works. Undefined when it cannot tell.
746
+ */
747
+ function isLightColor(el, color) {
748
+ const view = el.ownerDocument.defaultView;
749
+ if (!view) return void 0;
750
+ const previous = el.style.color;
751
+ el.style.color = color;
752
+ const computed = view.getComputedStyle(el).color;
753
+ el.style.color = previous;
754
+ const rgb = computed.match(/^(?:rgba?|color\(srgb)[( ]([^)]+)\)?/);
755
+ if (rgb?.[1]) {
756
+ const [r = 0, g = 0, b = 0] = rgb[1].split(/[\s,/]+/).slice(0, 3).map((n) => n.endsWith("%") ? Number.parseFloat(n) / 100 * 255 : Number.parseFloat(n));
757
+ const scale = computed.startsWith("color(") ? 255 : 1;
758
+ return (.2126 * r * scale + .7152 * g * scale + .0722 * b * scale) / 255 > .55;
759
+ }
760
+ const ok = computed.match(/^oklch\(\s*([\d.]+)(%?)/);
761
+ if (ok?.[1]) return Number.parseFloat(ok[1]) / (ok[2] === "%" ? 100 : 1) > .62;
762
+ }
690
763
  function mergeThemes(...themes) {
691
764
  return Object.assign({}, ...themes.filter(Boolean));
692
765
  }
766
+ /**
767
+ * Layer one theme over another when either may be a preset name. Used by the
768
+ * framework adapters to merge a provider's defaults with a local theme.
769
+ */
770
+ function mergeThemeSpecs(base, override) {
771
+ if (base === void 0) return override;
772
+ if (override === void 0) return base;
773
+ const first = typeof base === "string" ? { preset: base } : base;
774
+ const second = typeof override === "string" ? { preset: override } : override;
775
+ return {
776
+ ...first,
777
+ ...second
778
+ };
779
+ }
780
+ /** `{ theme }` when there is one, or nothing, for spreading into options. */
781
+ function themeOption(theme) {
782
+ return theme === void 0 ? {} : { theme };
783
+ }
784
+ /** True when this theme cannot be resolved without the built-in presets. */
785
+ function needsPresets(spec) {
786
+ return typeof spec === "string" || !!spec && typeof spec === "object" && "preset" in spec;
787
+ }
788
+ /** A preset name, tokens, or a preset with tokens on top, flattened to tokens. */
789
+ function resolveTheme(spec, presets) {
790
+ if (spec === void 0) return void 0;
791
+ if (typeof spec === "string") return presets?.[spec];
792
+ const { preset, ...tokens } = spec;
793
+ if (preset === void 0) return tokens;
794
+ return mergeThemes(presets?.[preset], tokens);
795
+ }
693
796
  //#endregion
694
797
  //#region src/renderer.ts
695
798
  const DEFAULT_LOOK = {
@@ -721,6 +824,11 @@ var DomRenderer = class {
721
824
  connectorLoading;
722
825
  /** Arrow, spotlight and overlay settings for the current step. */
723
826
  look = DEFAULT_LOOK;
827
+ /** Preset tokens, once loaded. */
828
+ presets;
829
+ presetLoad;
830
+ /** Set while `appearance: 'auto'` is following the system setting. */
831
+ schemeQuery;
724
832
  /** Until then the step's own transition runs; scroll updates may animate. */
725
833
  settleUntil = 0;
726
834
  /** Play the connector draw-in on its next render. */
@@ -744,6 +852,12 @@ var DomRenderer = class {
744
852
  return `${pathname}${search}`;
745
853
  }
746
854
  show(ctx) {
855
+ if (this.presets === void 0 && this.usesPresets(ctx)) return this.loadPresets().then(() => {
856
+ this.showNow(ctx);
857
+ });
858
+ this.showNow(ctx);
859
+ }
860
+ showNow(ctx) {
747
861
  const firstStep = !this.host;
748
862
  const host = this.mount();
749
863
  const from = this.popover?.style.transform || null;
@@ -751,7 +865,8 @@ var DomRenderer = class {
751
865
  this.ctx = ctx;
752
866
  this.target = ctx.step.target === void 0 ? null : resolveTarget(ctx.step.target, this.doc);
753
867
  const template = this.template(ctx);
754
- applyTheme(host, mergeThemes(this.options.theme, template?.theme, ctx.tour.options?.theme));
868
+ this.watchAppearance(ctx);
869
+ applyTheme(host, this.themeFor(ctx, template));
755
870
  this.setTemplateCss(template?.css);
756
871
  this.applyLook(host, this.resolveLook(ctx, template));
757
872
  this.settleUntil = performance.now() + this.duration(host) * 1.5;
@@ -781,6 +896,8 @@ var DomRenderer = class {
781
896
  }
782
897
  hide() {
783
898
  this.teardownStep();
899
+ this.schemeQuery?.removeEventListener("change", this.onSchemeChange);
900
+ this.schemeQuery = void 0;
784
901
  if (this.host) {
785
902
  this.host.remove();
786
903
  this.host = void 0;
@@ -965,6 +1082,55 @@ var DomRenderer = class {
965
1082
  shadow.insertBefore(style, before);
966
1083
  shadow.insertBefore(this.connector.el, before);
967
1084
  }
1085
+ /** Does anything here need the built-in presets? */
1086
+ usesPresets(ctx) {
1087
+ return this.appearance(ctx) !== "light" || needsPresets(this.options.theme) || needsPresets(ctx.tour.options?.theme) || needsPresets(this.template(ctx)?.theme);
1088
+ }
1089
+ loadPresets() {
1090
+ this.presetLoad ??= Promise.resolve().then(() => require("./themes.cjs")).then((mod) => {
1091
+ this.presets = {
1092
+ light: mod.light,
1093
+ dark: mod.dark,
1094
+ minimal: mod.minimal,
1095
+ contrast: mod.contrast
1096
+ };
1097
+ });
1098
+ return this.presetLoad;
1099
+ }
1100
+ appearance(ctx) {
1101
+ return ctx.tour.options?.appearance ?? this.options.appearance ?? "light";
1102
+ }
1103
+ /** The surface tokens for the current appearance: dark, or nothing for light. */
1104
+ appearanceTheme(ctx) {
1105
+ const appearance = this.appearance(ctx);
1106
+ if (appearance === "dark") return this.presets?.dark;
1107
+ if (appearance !== "auto") return void 0;
1108
+ return this.prefersDark() ? this.presets?.dark : this.presets?.light;
1109
+ }
1110
+ prefersDark() {
1111
+ return this.doc.defaultView?.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
1112
+ }
1113
+ /** Renderer, then the appearance surface, then template, then tour. */
1114
+ themeFor(ctx, template) {
1115
+ const presets = this.presets;
1116
+ return mergeThemes(resolveTheme(this.options.theme, presets), this.appearanceTheme(ctx), resolveTheme(template?.theme, presets), resolveTheme(ctx.tour.options?.theme, presets));
1117
+ }
1118
+ /** With `appearance: 'auto'`, follow the system setting while the tour runs. */
1119
+ watchAppearance(ctx) {
1120
+ const wanted = this.appearance(ctx) === "auto";
1121
+ if (wanted === (this.schemeQuery !== void 0)) return;
1122
+ if (!wanted) {
1123
+ this.schemeQuery?.removeEventListener("change", this.onSchemeChange);
1124
+ this.schemeQuery = void 0;
1125
+ return;
1126
+ }
1127
+ this.schemeQuery = this.doc.defaultView?.matchMedia?.("(prefers-color-scheme: dark)");
1128
+ this.schemeQuery?.addEventListener("change", this.onSchemeChange);
1129
+ }
1130
+ onSchemeChange = () => {
1131
+ const ctx = this.ctx;
1132
+ if (ctx && this.host) applyTheme(this.host, this.themeFor(ctx, this.template(ctx)));
1133
+ };
968
1134
  resolveLook(ctx, template) {
969
1135
  const tour = ctx.tour.options ?? {};
970
1136
  const step = ctx.step;
@@ -1365,6 +1531,7 @@ var DomTourController = class extends _docentjs_core.TourController {
1365
1531
  followRoutes;
1366
1532
  constructor(tour, options = {}) {
1367
1533
  const { renderer: rendererOptions, followRoutes, ...rest } = options;
1534
+ warnIfInvalid(tour);
1368
1535
  const renderer = new DomRenderer(rendererOptions);
1369
1536
  super({
1370
1537
  ...rest,
@@ -1470,7 +1637,7 @@ function createDocent(options = {}) {
1470
1637
  ...renderer,
1471
1638
  document: doc
1472
1639
  } : { ...renderer };
1473
- return new _docentjs_core.Docent({
1640
+ const docent = new _docentjs_core.Docent({
1474
1641
  ...rest,
1475
1642
  storage: rest.storage ?? createLocalStorage(),
1476
1643
  environment: createDomEnvironment(doc),
@@ -1479,6 +1646,8 @@ function createDocent(options = {}) {
1479
1646
  renderer: rendererOptions
1480
1647
  })
1481
1648
  });
1649
+ warnAboutTours(docent);
1650
+ return docent;
1482
1651
  }
1483
1652
  //#endregion
1484
1653
  exports.CONNECTOR_STYLES = require_arrows.CONNECTOR_STYLES;
@@ -1512,6 +1681,7 @@ exports.holePath = holePath;
1512
1681
  exports.inflate = inflate;
1513
1682
  exports.isConnector = require_arrows.isConnector;
1514
1683
  exports.isSafeUrl = isSafeUrl;
1684
+ exports.mergeThemeSpecs = mergeThemeSpecs;
1515
1685
  exports.mergeThemes = mergeThemes;
1516
1686
  exports.parsePlacement = parsePlacement;
1517
1687
  exports.queryAllDeep = queryAllDeep;
@@ -1519,6 +1689,7 @@ exports.renderBody = renderBody;
1519
1689
  exports.renderMedia = renderMedia;
1520
1690
  exports.resolveSlots = resolveSlots;
1521
1691
  exports.resolveTarget = resolveTarget;
1692
+ exports.themeOption = themeOption;
1522
1693
  exports.toSpec = toSpec;
1523
1694
  exports.uncover = uncover;
1524
1695
  exports.waitForTarget = waitForTarget;