@docentjs/dom 0.5.2 → 0.7.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,80 @@ 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
121
+ //#region src/looks.ts
122
+ const BUILT_IN_LOOKS = {
123
+ /** The default: a dimmed page, a soft cutout, a small caret. */
124
+ spotlight: {},
125
+ /**
126
+ * A light touch for tips beside a feature: the page stays usable and
127
+ * clickable, with a glowing ring and a drawn curve instead of a scrim.
128
+ */
129
+ hint: {
130
+ overlay: { style: "none" },
131
+ spotlight: {
132
+ ring: "glow",
133
+ padding: 6
134
+ },
135
+ arrow: "curve",
136
+ theme: { width: 300 }
137
+ },
138
+ /**
139
+ * For something to read rather than something to do: the page blurs away,
140
+ * nothing points anywhere, and the card is wider.
141
+ */
142
+ announcement: {
143
+ overlay: {
144
+ style: "blur",
145
+ blur: 6
146
+ },
147
+ spotlight: {
148
+ ring: "none",
149
+ padding: 10
150
+ },
151
+ arrow: "none",
152
+ theme: { width: 420 }
153
+ }
154
+ };
155
+ //#endregion
82
156
  //#region src/occlusion.ts
83
157
  function isPinned(el) {
84
158
  const view = el.ownerDocument.defaultView;
@@ -678,18 +752,82 @@ const THEME_VARS = {
678
752
  connector: "connector",
679
753
  ring: "ring"
680
754
  };
755
+ /** Tokens that take a unit when given as a number. */
756
+ const UNITS = {
757
+ radius: "px",
758
+ width: "px",
759
+ duration: "ms"
760
+ };
761
+ /** `12` becomes `12px`, `220` becomes `220ms`, strings are passed through. */
762
+ function cssValue(key, value) {
763
+ return typeof value === "number" ? `${value}${UNITS[key] ?? ""}` : value;
764
+ }
681
765
  /** Write theme tokens as inline custom properties on an element. Clears unset ones. */
682
766
  function applyTheme(el, theme) {
683
767
  for (const key of Object.keys(THEME_VARS)) {
684
768
  const value = theme?.[key];
685
769
  const prop = `--docent-${THEME_VARS[key]}`;
686
770
  if (value === void 0) el.style.removeProperty(prop);
687
- else el.style.setProperty(prop, value);
771
+ else el.style.setProperty(prop, cssValue(key, value));
772
+ }
773
+ if (theme?.accent !== void 0 && theme.accentForeground === void 0) {
774
+ const light = isLightColor(el, cssValue("accent", theme.accent));
775
+ if (light !== void 0) el.style.setProperty("--docent-accent-fg", light ? "var(--docent-fg)" : "var(--docent-bg)");
776
+ }
777
+ }
778
+ /**
779
+ * Is this colour light enough to need dark text on it? Resolves the colour
780
+ * through the browser, so any CSS colour works. Undefined when it cannot tell.
781
+ */
782
+ function isLightColor(el, color) {
783
+ const view = el.ownerDocument.defaultView;
784
+ if (!view) return void 0;
785
+ const previous = el.style.color;
786
+ el.style.color = color;
787
+ const computed = view.getComputedStyle(el).color;
788
+ el.style.color = previous;
789
+ const rgb = computed.match(/^(?:rgba?|color\(srgb)[( ]([^)]+)\)?/);
790
+ if (rgb?.[1]) {
791
+ 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));
792
+ const scale = computed.startsWith("color(") ? 255 : 1;
793
+ return (.2126 * r * scale + .7152 * g * scale + .0722 * b * scale) / 255 > .55;
688
794
  }
795
+ const ok = computed.match(/^oklch\(\s*([\d.]+)(%?)/);
796
+ if (ok?.[1]) return Number.parseFloat(ok[1]) / (ok[2] === "%" ? 100 : 1) > .62;
689
797
  }
690
798
  function mergeThemes(...themes) {
691
799
  return Object.assign({}, ...themes.filter(Boolean));
692
800
  }
801
+ /**
802
+ * Layer one theme over another when either may be a preset name. Used by the
803
+ * framework adapters to merge a provider's defaults with a local theme.
804
+ */
805
+ function mergeThemeSpecs(base, override) {
806
+ if (base === void 0) return override;
807
+ if (override === void 0) return base;
808
+ const first = typeof base === "string" ? { preset: base } : base;
809
+ const second = typeof override === "string" ? { preset: override } : override;
810
+ return {
811
+ ...first,
812
+ ...second
813
+ };
814
+ }
815
+ /** `{ theme }` when there is one, or nothing, for spreading into options. */
816
+ function themeOption(theme) {
817
+ return theme === void 0 ? {} : { theme };
818
+ }
819
+ /** True when this theme cannot be resolved without the built-in presets. */
820
+ function needsPresets(spec) {
821
+ return typeof spec === "string" || !!spec && typeof spec === "object" && "preset" in spec;
822
+ }
823
+ /** A preset name, tokens, or a preset with tokens on top, flattened to tokens. */
824
+ function resolveTheme(spec, presets) {
825
+ if (spec === void 0) return void 0;
826
+ if (typeof spec === "string") return presets?.[spec];
827
+ const { preset, ...tokens } = spec;
828
+ if (preset === void 0) return tokens;
829
+ return mergeThemes(presets?.[preset], tokens);
830
+ }
693
831
  //#endregion
694
832
  //#region src/renderer.ts
695
833
  const DEFAULT_LOOK = {
@@ -721,6 +859,11 @@ var DomRenderer = class {
721
859
  connectorLoading;
722
860
  /** Arrow, spotlight and overlay settings for the current step. */
723
861
  look = DEFAULT_LOOK;
862
+ /** Preset tokens, once loaded. */
863
+ presets;
864
+ presetLoad;
865
+ /** Set while `appearance: 'auto'` is following the system setting. */
866
+ schemeQuery;
724
867
  /** Until then the step's own transition runs; scroll updates may animate. */
725
868
  settleUntil = 0;
726
869
  /** Play the connector draw-in on its next render. */
@@ -744,6 +887,12 @@ var DomRenderer = class {
744
887
  return `${pathname}${search}`;
745
888
  }
746
889
  show(ctx) {
890
+ if (this.presets === void 0 && this.usesPresets(ctx)) return this.loadPresets().then(() => {
891
+ this.showNow(ctx);
892
+ });
893
+ this.showNow(ctx);
894
+ }
895
+ showNow(ctx) {
747
896
  const firstStep = !this.host;
748
897
  const host = this.mount();
749
898
  const from = this.popover?.style.transform || null;
@@ -751,7 +900,8 @@ var DomRenderer = class {
751
900
  this.ctx = ctx;
752
901
  this.target = ctx.step.target === void 0 ? null : resolveTarget(ctx.step.target, this.doc);
753
902
  const template = this.template(ctx);
754
- applyTheme(host, mergeThemes(this.options.theme, template?.theme, ctx.tour.options?.theme));
903
+ this.watchAppearance(ctx);
904
+ applyTheme(host, this.themeFor(ctx, template));
755
905
  this.setTemplateCss(template?.css);
756
906
  this.applyLook(host, this.resolveLook(ctx, template));
757
907
  this.settleUntil = performance.now() + this.duration(host) * 1.5;
@@ -781,6 +931,8 @@ var DomRenderer = class {
781
931
  }
782
932
  hide() {
783
933
  this.teardownStep();
934
+ this.schemeQuery?.removeEventListener("change", this.onSchemeChange);
935
+ this.schemeQuery = void 0;
784
936
  if (this.host) {
785
937
  this.host.remove();
786
938
  this.host = void 0;
@@ -965,6 +1117,55 @@ var DomRenderer = class {
965
1117
  shadow.insertBefore(style, before);
966
1118
  shadow.insertBefore(this.connector.el, before);
967
1119
  }
1120
+ /** Does anything here need the built-in presets? */
1121
+ usesPresets(ctx) {
1122
+ return this.appearance(ctx) !== "light" || needsPresets(this.options.theme) || needsPresets(ctx.tour.options?.theme) || needsPresets(this.template(ctx)?.theme);
1123
+ }
1124
+ loadPresets() {
1125
+ this.presetLoad ??= Promise.resolve().then(() => require("./themes.cjs")).then((mod) => {
1126
+ this.presets = {
1127
+ light: mod.light,
1128
+ dark: mod.dark,
1129
+ minimal: mod.minimal,
1130
+ contrast: mod.contrast
1131
+ };
1132
+ });
1133
+ return this.presetLoad;
1134
+ }
1135
+ appearance(ctx) {
1136
+ return ctx.tour.options?.appearance ?? this.options.appearance ?? "light";
1137
+ }
1138
+ /** The surface tokens for the current appearance: dark, or nothing for light. */
1139
+ appearanceTheme(ctx) {
1140
+ const appearance = this.appearance(ctx);
1141
+ if (appearance === "dark") return this.presets?.dark;
1142
+ if (appearance !== "auto") return void 0;
1143
+ return this.prefersDark() ? this.presets?.dark : this.presets?.light;
1144
+ }
1145
+ prefersDark() {
1146
+ return this.doc.defaultView?.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
1147
+ }
1148
+ /** Renderer, then the appearance surface, then template, then tour. */
1149
+ themeFor(ctx, template) {
1150
+ const presets = this.presets;
1151
+ return mergeThemes(resolveTheme(this.options.theme, presets), this.appearanceTheme(ctx), resolveTheme(template?.theme, presets), resolveTheme(ctx.tour.options?.theme, presets));
1152
+ }
1153
+ /** With `appearance: 'auto'`, follow the system setting while the tour runs. */
1154
+ watchAppearance(ctx) {
1155
+ const wanted = this.appearance(ctx) === "auto";
1156
+ if (wanted === (this.schemeQuery !== void 0)) return;
1157
+ if (!wanted) {
1158
+ this.schemeQuery?.removeEventListener("change", this.onSchemeChange);
1159
+ this.schemeQuery = void 0;
1160
+ return;
1161
+ }
1162
+ this.schemeQuery = this.doc.defaultView?.matchMedia?.("(prefers-color-scheme: dark)");
1163
+ this.schemeQuery?.addEventListener("change", this.onSchemeChange);
1164
+ }
1165
+ onSchemeChange = () => {
1166
+ const ctx = this.ctx;
1167
+ if (ctx && this.host) applyTheme(this.host, this.themeFor(ctx, this.template(ctx)));
1168
+ };
968
1169
  resolveLook(ctx, template) {
969
1170
  const tour = ctx.tour.options ?? {};
970
1171
  const step = ctx.step;
@@ -1088,9 +1289,11 @@ var DomRenderer = class {
1088
1289
  win.addEventListener("scrollend", finish, { once: true });
1089
1290
  this.cleanups.push(stop);
1090
1291
  }
1292
+ /** The tour's template: one the app registered, or a built-in look. */
1091
1293
  template(ctx) {
1092
1294
  const name = ctx.tour.options?.template ?? this.options.template;
1093
- return name === void 0 ? void 0 : this.options.templates?.[name];
1295
+ if (name === void 0) return void 0;
1296
+ return this.options.templates?.[name] ?? BUILT_IN_LOOKS[name];
1094
1297
  }
1095
1298
  buildDefault(ctx, host, template) {
1096
1299
  const slots = {
@@ -1365,6 +1568,7 @@ var DomTourController = class extends _docentjs_core.TourController {
1365
1568
  followRoutes;
1366
1569
  constructor(tour, options = {}) {
1367
1570
  const { renderer: rendererOptions, followRoutes, ...rest } = options;
1571
+ warnIfInvalid(tour);
1368
1572
  const renderer = new DomRenderer(rendererOptions);
1369
1573
  super({
1370
1574
  ...rest,
@@ -1470,7 +1674,7 @@ function createDocent(options = {}) {
1470
1674
  ...renderer,
1471
1675
  document: doc
1472
1676
  } : { ...renderer };
1473
- return new _docentjs_core.Docent({
1677
+ const docent = new _docentjs_core.Docent({
1474
1678
  ...rest,
1475
1679
  storage: rest.storage ?? createLocalStorage(),
1476
1680
  environment: createDomEnvironment(doc),
@@ -1479,8 +1683,11 @@ function createDocent(options = {}) {
1479
1683
  renderer: rendererOptions
1480
1684
  })
1481
1685
  });
1686
+ warnAboutTours(docent);
1687
+ return docent;
1482
1688
  }
1483
1689
  //#endregion
1690
+ exports.BUILT_IN_LOOKS = BUILT_IN_LOOKS;
1484
1691
  exports.CONNECTOR_STYLES = require_arrows.CONNECTOR_STYLES;
1485
1692
  exports.DEFAULT_LABELS = DEFAULT_LABELS;
1486
1693
  exports.DomRenderer = DomRenderer;
@@ -1512,6 +1719,7 @@ exports.holePath = holePath;
1512
1719
  exports.inflate = inflate;
1513
1720
  exports.isConnector = require_arrows.isConnector;
1514
1721
  exports.isSafeUrl = isSafeUrl;
1722
+ exports.mergeThemeSpecs = mergeThemeSpecs;
1515
1723
  exports.mergeThemes = mergeThemes;
1516
1724
  exports.parsePlacement = parsePlacement;
1517
1725
  exports.queryAllDeep = queryAllDeep;
@@ -1519,6 +1727,7 @@ exports.renderBody = renderBody;
1519
1727
  exports.renderMedia = renderMedia;
1520
1728
  exports.resolveSlots = resolveSlots;
1521
1729
  exports.resolveTarget = resolveTarget;
1730
+ exports.themeOption = themeOption;
1522
1731
  exports.toSpec = toSpec;
1523
1732
  exports.uncover = uncover;
1524
1733
  exports.waitForTarget = waitForTarget;