@wildmason/aegis 2.0.0 → 2.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.
package/CHANGELOG.md CHANGED
@@ -18,6 +18,56 @@ that 404s.
18
18
 
19
19
  ## [Unreleased]
20
20
 
21
+ ## [2.1.0] - 2026-09-20
22
+
23
+ Minor: one new component and its inputs. Nothing existing changes, so a consumer
24
+ on `^2.0.0` picks this up on its next install and nothing it already names moves.
25
+
26
+ ### Added
27
+
28
+ - `<wm-slider>` (`WmSlider`) — bounded continuous value selection, exported from
29
+ `@wildmason/aegis/components` with its `WmSliderTick` type. Built on a native
30
+ `<input type="range">`, so pointer drag, touch, arrow keys, `Page Up`/`Page
31
+ Down`, `Home`/`End`, RTL mirroring and the `role="slider"` semantics come from
32
+ the browser. Every value — two-way `[(value)]` binding, form write, or drag —
33
+ is clamped into `min`…`max` and snapped onto the step grid measured from
34
+ `min`, then written back, so the binding, the form control and the thumb never
35
+ disagree. Implements `ControlValueAccessor`. `ticks` draws labelled stops under
36
+ the rail; `showValue` renders a readout, and `formatValue` formats it and sets
37
+ `aria-valuetext`. Host knobs: `--wm-slider-thumb`, `--wm-slider-track`.
38
+ Documented at the `slider` guide page, served by the MCP, and specified in
39
+ `DESIGN_LANGUAGE.md` §7.6a.
40
+ - `WmSlider` `track` input — `'rail'` (default) or `'segmented'`. A segmented
41
+ track breaks the rail into blocks bounded by the resolved tick grid plus the
42
+ ends of the range, sized by their own spans, with the block holding the value
43
+ filling partially. Knob: `--wm-slider-gap` (3px). There is no `'auto'`
44
+ degradation, deliberately.
45
+ - `WmSlider` `ticks` accepts a **number** (a stop every N from `min`), a
46
+ `WmSliderTick[]`, or a `WmSliderTickFn` of `{ min, max, step }`, all exported.
47
+ `WmSliderTick` gains `level?: 'major' | 'minor'` for a second grid density.
48
+ The resolved grid is clamped, sorted and de-duplicated, and it both draws the
49
+ marks and bounds the segments — one source, two renderings, so a block and the
50
+ mark on its edge cannot disagree. A numeric interval that would draw over 1000
51
+ marks draws none rather than truncating or hanging. The grid stays independent
52
+ of `step`, so a 0–100 slider stepping by 1 can carry an 11-mark grid.
53
+ - `WmSlider` `showValue` reserves its column from the widest string the readout
54
+ can hold — the formatted endpoints plus every labelled stop — instead of from
55
+ the current value. The shell is `1fr auto`, so a column that grew with its text
56
+ took width from the rail and gave it back on the next value; with word labels
57
+ the bar moved while the user dragged it. Unlabelled stops are ignored, so a
58
+ 101-mark numeric grid reserves two strings rather than 101.
59
+
60
+ ### Fixed
61
+
62
+ - The MCP server served every guide page with its code samples missing. The
63
+ extractor decoded HTML entities in its `<pre>` pass, before the document-wide
64
+ tag strip, so an escaped sample — every Angular template in the docs — became
65
+ real tags and was deleted as markup. Entities now stay encoded until after the
66
+ strip and are decoded once at the end, with `&amp;` last so a double-escaped
67
+ sequence cannot be decoded twice. `&#123;` and `&#125;` are decoded for the
68
+ first time, so Angular interpolation braces survive. `mcp/` gains its first
69
+ tests: 8 cases under `node --test`, run by `npm test` in that package.
70
+
21
71
  ## [2.0.0] - 2026-09-15
22
72
 
23
73
  Major version because two exports go away: the license UI leaves
@@ -411,7 +461,9 @@ a `^1.x` range does not pick this release up by accident.
411
461
  Releases before 1.8.0 are in `git log`. See also `wiki/products/Aegis - Health.md`
412
462
  for the decisions behind these changes, including the ones decided against.
413
463
 
414
- [Unreleased]: https://github.com/wildmason/aegis/compare/v1.18.0...HEAD
464
+ [Unreleased]: https://github.com/wildmason/aegis/compare/v2.1.0...HEAD
465
+ [2.1.0]: https://github.com/wildmason/aegis/releases/tag/v2.1.0
466
+ [2.0.0]: https://github.com/wildmason/aegis/releases/tag/v2.0.0
415
467
  [1.18.0]: https://github.com/wildmason/aegis/releases/tag/v1.18.0
416
468
  [1.17.0]: https://github.com/wildmason/aegis/releases/tag/v1.17.0
417
469
  [1.16.0]: https://github.com/wildmason/aegis/releases/tag/v1.16.0
@@ -783,6 +783,332 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImpo
783
783
  args: [{ selector: 'wm-toggle', template: "<button\n type=\"button\"\n role=\"switch\"\n class=\"wm-toggle\"\n [class.is-on]=\"checked()\"\n [class.is-disabled]=\"disabled()\"\n [attr.aria-checked]=\"checked()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-disabled]=\"disabled() ? true : null\"\n [disabled]=\"disabled() || null\"\n (click)=\"toggle()\"\n>\n <span class=\"wm-toggle-track\" aria-hidden=\"true\">\n <span class=\"wm-toggle-thumb\"></span>\n </span>\n</button>\n", styles: [":host{display:inline-flex}.wm-toggle{display:inline-flex;align-items:center;background:none;border:none;padding:2px;margin:-2px;cursor:pointer;border-radius:var(--wm-radius-full);outline:none}.wm-toggle:focus-visible .wm-toggle-track{outline:2px solid var(--wm-color-accent);outline-offset:2px}.wm-toggle.is-disabled{opacity:.45;cursor:not-allowed;pointer-events:none}.wm-toggle-track{position:relative;width:40px;height:22px;background:var(--wm-border-input);border-radius:var(--wm-radius-full);transition:background var(--wm-transition-fast);flex-shrink:0}.wm-toggle.is-on .wm-toggle-track{background:var(--wm-color-accent)}.wm-toggle-thumb{position:absolute;top:3px;left:3px;width:16px;height:16px;background:#fff;border-radius:var(--wm-radius-full);box-shadow:0 1px 3px #00000040;transition:transform var(--wm-transition-fast)}.wm-toggle.is-on .wm-toggle-thumb{transform:translate(18px)}@media(prefers-reduced-motion:reduce){.wm-toggle-track,.wm-toggle-thumb{transition:none}}\n"] }]
784
784
  }], propDecorators: { checked: [{ type: i0.Input, args: [{ isSignal: true, alias: "checked", required: false }] }, { type: i0.Output, args: ["checkedChange"] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }] } });
785
785
 
786
+ /**
787
+ * Upper bound on generated marks. A numeric interval that would exceed it draws
788
+ * NOTHING rather than a truncated grid or a frozen tab: an empty rail is
789
+ * obviously wrong to whoever set it, where a silently shortened grid is not.
790
+ */
791
+ const MAX_TICKS = 1000;
792
+ /**
793
+ * Upper bound on reserved readout strings. Only labelled stops reach this, so
794
+ * it is a guard against a pathological grid rather than a limit anyone meets.
795
+ */
796
+ const MAX_VALUE_CANDIDATES = 64;
797
+ /**
798
+ * WmSlider — Continuous value selection along a bounded range.
799
+ *
800
+ * Built on a native `<input type="range">`. The native control is the whole
801
+ * interaction: pointer drag, touch, arrow keys, Home/End, Page Up/Page Down,
802
+ * RTL mirroring and the implicit `role="slider"` with `aria-valuenow` all come
803
+ * from the browser rather than from hand-written handlers. Aegis styles the
804
+ * track and thumb through the range pseudo-elements, so there is no invisible
805
+ * overlay input and no drag maths to get wrong.
806
+ *
807
+ * When to use:
808
+ * - A bounded value where the approximate position matters more than the exact
809
+ * number (volume, opacity, zoom, a brightness or threshold setting)
810
+ * - A value the user wants to sweep through and preview as it changes
811
+ *
812
+ * When NOT to use:
813
+ * - An exact number the user knows before they start typing it → <wm-number-input>
814
+ * - Fewer than about five discrete choices → <wm-select> or a pill tab group
815
+ * - An unbounded value — a slider has to have a min and a max
816
+ *
817
+ * Value pipeline: every value, whether it arrives from a two-way binding, a
818
+ * form, or a drag, is clamped into min..max and snapped onto the step grid
819
+ * measured FROM MIN. The snapped value is written back, so the binding, the
820
+ * form control and the thumb never disagree.
821
+ *
822
+ * Accessibility:
823
+ * - The native input is the focusable control and carries the slider role.
824
+ * - `ariaLabel` is required unless visible surrounding text names the control.
825
+ * - `formatValue` also drives `aria-valuetext`, so a screen reader announces
826
+ * "40 percent" rather than "40" when the raw number means nothing alone.
827
+ * - Ticks and the value readout are decoration; the native value is the truth.
828
+ *
829
+ * Form integration:
830
+ * <!-- Template-driven -->
831
+ * <wm-slider [(ngModel)]="volume" [min]="0" [max]="100" />
832
+ *
833
+ * <!-- Reactive -->
834
+ * <wm-slider formControlName="opacity" [min]="0" [max]="1" [step]="0.05" />
835
+ *
836
+ * <!-- Two-way signal binding, no forms -->
837
+ * <wm-slider [(value)]="volume" ariaLabel="Volume" />
838
+ */
839
+ class WmSlider {
840
+ /** Two-way bindable value. Always reads back clamped and snapped to the step grid. */
841
+ value = model(0, ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
842
+ /** Lower bound, inclusive. */
843
+ min = input(0, ...(ngDevMode ? [{ debugName: "min" }] : /* istanbul ignore next */ []));
844
+ /** Upper bound, inclusive. Values never exceed it, even when it is off the step grid. */
845
+ max = input(100, ...(ngDevMode ? [{ debugName: "max" }] : /* istanbul ignore next */ []));
846
+ /** Grid spacing, measured from `min`. Use 0 for a continuous slider. */
847
+ step = input(1, ...(ngDevMode ? [{ debugName: "step" }] : /* istanbul ignore next */ []));
848
+ /** Disables the control from the template. Reactive forms set this independently. */
849
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
850
+ /** Accessible name. Required unless visible text already names the control. */
851
+ ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
852
+ /** Shows a live readout beside the rail. */
853
+ showValue = input(false, ...(ngDevMode ? [{ debugName: "showValue" }] : /* istanbul ignore next */ []));
854
+ /**
855
+ * Renders the value for humans. Also sets `aria-valuetext`, so pass one
856
+ * whenever the bare number would not be understood on its own.
857
+ */
858
+ formatValue = input(null, ...(ngDevMode ? [{ debugName: "formatValue" }] : /* istanbul ignore next */ []));
859
+ /**
860
+ * How the track is drawn. `rail` is one continuous bar. `segmented` breaks it
861
+ * into blocks bounded by the grid stops, and the block holding the value fills
862
+ * partially — so a segmented track does not have to mean one block per step,
863
+ * and survives a dense range that one-block-per-step cannot.
864
+ */
865
+ track = input('rail', ...(ngDevMode ? [{ debugName: "track" }] : /* istanbul ignore next */ []));
866
+ /**
867
+ * The grid, in one of three shapes:
868
+ * - a **number** — a stop every N, measured from `min`
869
+ * - an **array** of {@link WmSliderTick} — explicit stops, with labels and levels
870
+ * - a **{@link WmSliderTickFn}** — computed from the live range
871
+ *
872
+ * Whatever the shape, the result is clamped to the range, sorted, and
873
+ * de-duplicated. The same resolved grid draws the marks AND bounds the blocks
874
+ * of a segmented track, so the two renderings cannot disagree.
875
+ *
876
+ * The grid is deliberately independent of `step`: a 0–100 slider stepping by 1
877
+ * carries 101 reachable values and should still draw about 11 marks.
878
+ */
879
+ ticks = input([], ...(ngDevMode ? [{ debugName: "ticks" }] : /* istanbul ignore next */ []));
880
+ /** Set by reactive forms through setDisabledState, independent of the input. */
881
+ formDisabled = signal(false, ...(ngDevMode ? [{ debugName: "formDisabled" }] : /* istanbul ignore next */ []));
882
+ isDisabled = computed(() => this.disabled() || this.formDisabled(), ...(ngDevMode ? [{ debugName: "isDisabled" }] : /* istanbul ignore next */ []));
883
+ /** Position of the thumb across the span, 0..1, as a CSS-ready string. */
884
+ fraction = computed(() => trim(this.fractionOf(this.value())), ...(ngDevMode ? [{ debugName: "fraction" }] : /* istanbul ignore next */ []));
885
+ displayValue = computed(() => {
886
+ const format = this.formatValue();
887
+ return format ? format(this.value()) : String(this.value());
888
+ }, ...(ngDevMode ? [{ debugName: "displayValue" }] : /* istanbul ignore next */ []));
889
+ /**
890
+ * Every string the readout can show at a grid stop, de-duplicated.
891
+ *
892
+ * The shell is `1fr auto`, so a readout column sized by the CURRENT value
893
+ * takes its width from the rail and gives it back on the next value — the bar
894
+ * moves while you drag it. Rendering these hidden in the readout's own grid
895
+ * cell makes the browser reserve the widest of them once, with no measuring.
896
+ *
897
+ * Only LABELLED stops contribute. An unlabelled stop is a position rather
898
+ * than a word, and a numeric readout is already bounded by tabular numerals
899
+ * and the two endpoints — so a 101-mark grid reserves two slots, not 101.
900
+ */
901
+ valueCandidates = computed(() => {
902
+ const format = this.formatValue();
903
+ const render = (value) => (format ? format(value) : String(value));
904
+ const values = [
905
+ this.min(),
906
+ this.max(),
907
+ ...this.resolvedTicks()
908
+ .filter(tick => tick.label !== undefined)
909
+ .map(tick => tick.value),
910
+ ];
911
+ const out = new Set();
912
+ for (const value of values) {
913
+ out.add(render(value));
914
+ if (out.size >= MAX_VALUE_CANDIDATES)
915
+ break;
916
+ }
917
+ return [...out];
918
+ }, ...(ngDevMode ? [{ debugName: "valueCandidates" }] : /* istanbul ignore next */ []));
919
+ /** Null unless a formatter is set — a valuetext echoing valuenow is noise. */
920
+ valueText = computed(() => {
921
+ const format = this.formatValue();
922
+ return format ? format(this.value()) : null;
923
+ }, ...(ngDevMode ? [{ debugName: "valueText" }] : /* istanbul ignore next */ []));
924
+ /** The declared grid, before it is measured against the range. */
925
+ declaredTicks = computed(() => {
926
+ const raw = this.ticks();
927
+ if (typeof raw === 'number') {
928
+ return gridEvery(this.min(), this.max(), raw);
929
+ }
930
+ if (typeof raw === 'function') {
931
+ return raw({ min: this.min(), max: this.max(), step: this.step() }) ?? [];
932
+ }
933
+ return raw ?? [];
934
+ }, ...(ngDevMode ? [{ debugName: "declaredTicks" }] : /* istanbul ignore next */ []));
935
+ resolvedTicks = computed(() => {
936
+ const min = this.min();
937
+ const max = this.max();
938
+ const current = this.value();
939
+ const seen = new Set();
940
+ return this.declaredTicks()
941
+ .filter(tick => tick.value >= min && tick.value <= max)
942
+ // A duplicate value is not cosmetic — two marks at one value collide on
943
+ // the @for track key and Angular throws. First declaration wins, so an
944
+ // explicit label is never dropped in favour of a bare repeat.
945
+ .filter(tick => {
946
+ if (seen.has(tick.value))
947
+ return false;
948
+ seen.add(tick.value);
949
+ return true;
950
+ })
951
+ .sort((a, b) => a.value - b.value)
952
+ .map(tick => {
953
+ const fraction = this.fractionOf(tick.value);
954
+ return {
955
+ ...tick,
956
+ level: tick.level ?? 'major',
957
+ fraction,
958
+ left: `calc(var(--wm-slider-thumb) / 2 + ${trim(fraction)} * (100% - var(--wm-slider-thumb)))`,
959
+ passed: tick.value <= current,
960
+ };
961
+ });
962
+ }, ...(ngDevMode ? [{ debugName: "resolvedTicks" }] : /* istanbul ignore next */ []));
963
+ /**
964
+ * Blocks of a segmented track. The boundaries are the grid stops plus the two
965
+ * ends of the range, so the blocks are the intervals BETWEEN stops — which is
966
+ * what a block can honestly represent. Each one is sized by its own span, so
967
+ * an uneven grid produces uneven blocks rather than lying about the scale.
968
+ */
969
+ segments = computed(() => {
970
+ if (this.track() !== 'segmented')
971
+ return [];
972
+ const min = this.min();
973
+ const max = this.max();
974
+ if (max <= min)
975
+ return [];
976
+ const current = this.value();
977
+ const bounds = [...new Set([min, ...this.resolvedTicks().map(t => t.value), max])].sort((a, b) => a - b);
978
+ const out = [];
979
+ for (let i = 0; i < bounds.length - 1; i++) {
980
+ const from = bounds[i];
981
+ const to = bounds[i + 1];
982
+ const span = to - from;
983
+ if (span <= 0)
984
+ continue;
985
+ const fill = Math.min(Math.max((current - from) / span, 0), 1);
986
+ out.push({
987
+ from,
988
+ to,
989
+ span,
990
+ fill,
991
+ passed: fill >= 1,
992
+ current: fill > 0 && fill < 1,
993
+ });
994
+ }
995
+ return out;
996
+ }, ...(ngDevMode ? [{ debugName: "segments" }] : /* istanbul ignore next */ []));
997
+ hasTickLabels = computed(() => this.resolvedTicks().some(tick => tick.label !== undefined), ...(ngDevMode ? [{ debugName: "hasTickLabels" }] : /* istanbul ignore next */ []));
998
+ onChangeFn = () => { };
999
+ onTouchedFn = () => { };
1000
+ constructor() {
1001
+ // Normalisation runs on the value itself rather than on a derived signal,
1002
+ // so a caller that binds [(value)] or a form control gets the corrected
1003
+ // number back instead of holding one the thumb does not sit on.
1004
+ effect(() => {
1005
+ const resolved = this.resolve(this.value());
1006
+ if (resolved !== this.value()) {
1007
+ this.value.set(resolved);
1008
+ this.onChangeFn(resolved);
1009
+ }
1010
+ });
1011
+ }
1012
+ writeValue(value) {
1013
+ this.value.set(this.resolve(value ?? this.min()));
1014
+ }
1015
+ registerOnChange(fn) {
1016
+ this.onChangeFn = fn;
1017
+ }
1018
+ registerOnTouched(fn) {
1019
+ this.onTouchedFn = fn;
1020
+ }
1021
+ setDisabledState(isDisabled) {
1022
+ this.formDisabled.set(isDisabled);
1023
+ }
1024
+ onRangeInput(event) {
1025
+ const resolved = this.resolve(Number(event.target.value));
1026
+ this.value.set(resolved);
1027
+ this.onChangeFn(resolved);
1028
+ }
1029
+ /** Clamps into min..max, then snaps onto the step grid measured from min. */
1030
+ resolve(raw) {
1031
+ const min = this.min();
1032
+ const max = this.max();
1033
+ if (!Number.isFinite(raw))
1034
+ return min;
1035
+ if (max <= min)
1036
+ return min;
1037
+ const clamped = Math.min(Math.max(raw, min), max);
1038
+ const step = this.step();
1039
+ if (!Number.isFinite(step) || step <= 0)
1040
+ return clamped;
1041
+ // Rounding through a fixed number of decimals keeps a 0.1 grid on 0.3
1042
+ // rather than 0.30000000000000004, which would then render and compare
1043
+ // as a different number than the one the caller asked for.
1044
+ const decimals = decimalsOf(step) + decimalsOf(min);
1045
+ const snapped = round(min + Math.round((clamped - min) / step) * step, decimals);
1046
+ // Snapping up can overshoot a max that is not a whole number of steps
1047
+ // above min; the last reachable stop is one step back.
1048
+ return snapped > max ? round(snapped - step, decimals) : snapped;
1049
+ }
1050
+ fractionOf(value) {
1051
+ const span = this.max() - this.min();
1052
+ if (span <= 0)
1053
+ return 0;
1054
+ return (value - this.min()) / span;
1055
+ }
1056
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: WmSlider, deps: [], target: i0.ɵɵFactoryTarget.Component });
1057
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.23", type: WmSlider, isStandalone: true, selector: "wm-slider", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, min: { classPropertyName: "min", publicName: "min", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, step: { classPropertyName: "step", publicName: "step", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, showValue: { classPropertyName: "showValue", publicName: "showValue", isSignal: true, isRequired: false, transformFunction: null }, formatValue: { classPropertyName: "formatValue", publicName: "formatValue", isSignal: true, isRequired: false, transformFunction: null }, track: { classPropertyName: "track", publicName: "track", isSignal: true, isRequired: false, transformFunction: null }, ticks: { classPropertyName: "ticks", publicName: "ticks", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange" }, providers: [
1058
+ {
1059
+ provide: NG_VALUE_ACCESSOR,
1060
+ useExisting: forwardRef(() => WmSlider),
1061
+ multi: true,
1062
+ },
1063
+ ], ngImport: i0, template: "<!--\n The native <input type=\"range\"> is the control, not a decoration over one:\n its track and thumb are the styled surfaces. --wm-slider-p carries the thumb\n position to the CSS so the filled part of the track, every tick and every\n block are placed from the same number the thumb is.\n-->\n<div\n class=\"wm-slider\"\n [attr.data-track]=\"track()\"\n [class.is-disabled]=\"isDisabled()\"\n [class.has-ticks]=\"resolvedTicks().length > 0\"\n [style.--wm-slider-p]=\"fraction()\"\n>\n <div class=\"wm-slider-rail\">\n @if (segments().length > 0) {\n <!--\n Blocks sit under the transparent native track. They are bounded by the\n same resolved grid the marks come from, so a block and the mark on its\n edge can never disagree about where a value falls.\n -->\n <div class=\"wm-slider-segments\" aria-hidden=\"true\">\n @for (segment of segments(); track segment.from) {\n <span\n class=\"wm-slider-seg\"\n [class.is-passed]=\"segment.passed\"\n [class.is-current]=\"segment.current\"\n [style.flexGrow]=\"segment.span\"\n [style.--wm-slider-seg-p]=\"segment.fill\"\n ></span>\n }\n </div>\n }\n\n @if (resolvedTicks().length > 0) {\n <div class=\"wm-slider-ticks\" aria-hidden=\"true\">\n @for (tick of resolvedTicks(); track tick.value) {\n <span\n class=\"wm-slider-tick\"\n [class.wm-slider-tick--minor]=\"tick.level === 'minor'\"\n [class.is-passed]=\"tick.passed\"\n [style.left]=\"tick.left\"\n ></span>\n }\n </div>\n }\n\n <input\n class=\"wm-slider-input\"\n type=\"range\"\n [value]=\"value()\"\n [min]=\"min()\"\n [max]=\"max()\"\n [step]=\"step() > 0 ? step() : 'any'\"\n [disabled]=\"isDisabled() || null\"\n [attr.aria-label]=\"ariaLabel() || null\"\n [attr.aria-valuetext]=\"valueText()\"\n (input)=\"onRangeInput($event)\"\n (blur)=\"onTouchedFn()\"\n />\n </div>\n\n @if (showValue()) {\n <!--\n The sizers stack on the output in one grid cell, so the column reserves\n the widest string the readout can hold and stops stealing width from the\n rail every time the value changes.\n -->\n <div class=\"wm-slider-value-cell\">\n <output class=\"wm-slider-value\">{{ displayValue() }}</output>\n @for (candidate of valueCandidates(); track candidate) {\n <span class=\"wm-slider-value-sizer\" aria-hidden=\"true\">{{ candidate }}</span>\n }\n </div>\n }\n\n @if (hasTickLabels()) {\n <div class=\"wm-slider-tick-labels\" aria-hidden=\"true\">\n @for (tick of resolvedTicks(); track tick.value) {\n @if (tick.label !== undefined) {\n <span class=\"wm-slider-tick-label\" [style.left]=\"tick.left\">{{ tick.label }}</span>\n }\n }\n </div>\n }\n</div>\n", styles: [":host{display:block}.wm-slider{--wm-slider-thumb: 16px;--wm-slider-track: 4px;--wm-slider-gap: 3px;--wm-slider-p: 0;--wm-slider-stop: calc( var(--wm-slider-thumb) / 2 + var(--wm-slider-p) * (100% - var(--wm-slider-thumb)) );--wm-slider-fill: linear-gradient( to right, var(--wm-color-accent) 0 var(--wm-slider-stop), var(--wm-border-input) var(--wm-slider-stop) 100% );display:grid;grid-template-columns:1fr auto;align-items:center;column-gap:.75rem;row-gap:.375rem}.wm-slider-rail{position:relative;min-width:0}.wm-slider.has-ticks .wm-slider-rail{margin-bottom:10px}.wm-slider-input{-webkit-appearance:none;appearance:none;display:block;width:100%;height:var(--wm-slider-thumb);margin:0;padding:0;background:transparent;cursor:pointer;position:relative;z-index:1}.wm-slider-input:focus{outline:none}.wm-slider-input::-webkit-slider-runnable-track{height:var(--wm-slider-track);border-radius:var(--wm-radius-full);background:var(--wm-slider-fill)}.wm-slider-input::-webkit-slider-thumb{-webkit-appearance:none;width:var(--wm-slider-thumb);height:var(--wm-slider-thumb);margin-top:calc((var(--wm-slider-track) - var(--wm-slider-thumb)) / 2);border-radius:var(--wm-radius-full);background:var(--wm-color-accent);border:2px solid var(--wm-bg-base);box-shadow:var(--wm-shadow-sm);transition:background var(--wm-transition-fast),transform var(--wm-transition-fast)}.wm-slider-input:hover::-webkit-slider-thumb{background:var(--wm-color-accent-hover);transform:scale(1.12)}.wm-slider-input:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 2px var(--wm-bg-base),0 0 0 4px var(--wm-color-accent)}.wm-slider-input::-moz-range-track{height:var(--wm-slider-track);border-radius:var(--wm-radius-full);background:var(--wm-slider-fill)}.wm-slider-input::-moz-range-thumb{width:var(--wm-slider-thumb);height:var(--wm-slider-thumb);box-sizing:border-box;border-radius:var(--wm-radius-full);background:var(--wm-color-accent);border:2px solid var(--wm-bg-base);box-shadow:var(--wm-shadow-sm);transition:background var(--wm-transition-fast),transform var(--wm-transition-fast)}.wm-slider-input:hover::-moz-range-thumb{background:var(--wm-color-accent-hover);transform:scale(1.12)}.wm-slider-input:focus-visible::-moz-range-thumb{box-shadow:0 0 0 2px var(--wm-bg-base),0 0 0 4px var(--wm-color-accent)}.wm-slider-ticks{position:absolute;inset-inline:0;top:100%;margin-top:2px;height:6px;pointer-events:none}.wm-slider-tick{position:absolute;top:0;width:2px;height:6px;margin-left:-1px;border-radius:1px;background:var(--wm-border-input);transition:background var(--wm-transition-fast)}.wm-slider-tick.is-passed{background:var(--wm-color-accent)}.wm-slider-tick--minor{height:3px;background:var(--wm-border-subtle)}.wm-slider-tick--minor.is-passed{background:color-mix(in srgb,var(--wm-color-accent) 55%,transparent)}.wm-slider[data-track=segmented]{--wm-slider-fill: none}.wm-slider-segments{position:absolute;inset:0 calc(var(--wm-slider-thumb) / 2);display:flex;align-items:center;gap:var(--wm-slider-gap);pointer-events:none}.wm-slider-seg{flex-basis:0;min-width:0;height:var(--wm-slider-track);border-radius:1px;background:var(--wm-border-input);transition:background var(--wm-transition-fast)}.wm-slider-seg.is-passed{background:var(--wm-color-accent)}.wm-slider-seg.is-current{background-image:linear-gradient(var(--wm-color-accent) 0 0);background-repeat:no-repeat;background-size:calc(var(--wm-slider-seg-p, 0) * 100%) 100%}.wm-slider-tick-labels{position:relative;grid-column:1;height:1rem}.wm-slider-tick-label{position:absolute;top:0;transform:translate(-50%);font-size:.75rem;color:var(--wm-text-muted);white-space:nowrap}.wm-slider-value-cell{grid-area:1 / 2;display:grid;justify-items:end}.wm-slider-value,.wm-slider-value-sizer{grid-area:1 / 1;font-family:var(--wm-font-mono);font-size:.8125rem;font-variant-numeric:tabular-nums;white-space:nowrap}.wm-slider-value{color:var(--wm-text-secondary);min-width:4ch;text-align:right}.wm-slider-value-sizer{visibility:hidden;pointer-events:none}.wm-slider.is-disabled{opacity:.5}.wm-slider-input:disabled{cursor:not-allowed}@media(prefers-reduced-motion:reduce){.wm-slider-input::-webkit-slider-thumb,.wm-slider-input::-moz-range-thumb,.wm-slider-tick{transition:none}.wm-slider-input:hover::-webkit-slider-thumb,.wm-slider-input:hover::-moz-range-thumb{transform:none}}\n"] });
1064
+ }
1065
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImport: i0, type: WmSlider, decorators: [{
1066
+ type: Component,
1067
+ args: [{ selector: 'wm-slider', providers: [
1068
+ {
1069
+ provide: NG_VALUE_ACCESSOR,
1070
+ useExisting: forwardRef(() => WmSlider),
1071
+ multi: true,
1072
+ },
1073
+ ], template: "<!--\n The native <input type=\"range\"> is the control, not a decoration over one:\n its track and thumb are the styled surfaces. --wm-slider-p carries the thumb\n position to the CSS so the filled part of the track, every tick and every\n block are placed from the same number the thumb is.\n-->\n<div\n class=\"wm-slider\"\n [attr.data-track]=\"track()\"\n [class.is-disabled]=\"isDisabled()\"\n [class.has-ticks]=\"resolvedTicks().length > 0\"\n [style.--wm-slider-p]=\"fraction()\"\n>\n <div class=\"wm-slider-rail\">\n @if (segments().length > 0) {\n <!--\n Blocks sit under the transparent native track. They are bounded by the\n same resolved grid the marks come from, so a block and the mark on its\n edge can never disagree about where a value falls.\n -->\n <div class=\"wm-slider-segments\" aria-hidden=\"true\">\n @for (segment of segments(); track segment.from) {\n <span\n class=\"wm-slider-seg\"\n [class.is-passed]=\"segment.passed\"\n [class.is-current]=\"segment.current\"\n [style.flexGrow]=\"segment.span\"\n [style.--wm-slider-seg-p]=\"segment.fill\"\n ></span>\n }\n </div>\n }\n\n @if (resolvedTicks().length > 0) {\n <div class=\"wm-slider-ticks\" aria-hidden=\"true\">\n @for (tick of resolvedTicks(); track tick.value) {\n <span\n class=\"wm-slider-tick\"\n [class.wm-slider-tick--minor]=\"tick.level === 'minor'\"\n [class.is-passed]=\"tick.passed\"\n [style.left]=\"tick.left\"\n ></span>\n }\n </div>\n }\n\n <input\n class=\"wm-slider-input\"\n type=\"range\"\n [value]=\"value()\"\n [min]=\"min()\"\n [max]=\"max()\"\n [step]=\"step() > 0 ? step() : 'any'\"\n [disabled]=\"isDisabled() || null\"\n [attr.aria-label]=\"ariaLabel() || null\"\n [attr.aria-valuetext]=\"valueText()\"\n (input)=\"onRangeInput($event)\"\n (blur)=\"onTouchedFn()\"\n />\n </div>\n\n @if (showValue()) {\n <!--\n The sizers stack on the output in one grid cell, so the column reserves\n the widest string the readout can hold and stops stealing width from the\n rail every time the value changes.\n -->\n <div class=\"wm-slider-value-cell\">\n <output class=\"wm-slider-value\">{{ displayValue() }}</output>\n @for (candidate of valueCandidates(); track candidate) {\n <span class=\"wm-slider-value-sizer\" aria-hidden=\"true\">{{ candidate }}</span>\n }\n </div>\n }\n\n @if (hasTickLabels()) {\n <div class=\"wm-slider-tick-labels\" aria-hidden=\"true\">\n @for (tick of resolvedTicks(); track tick.value) {\n @if (tick.label !== undefined) {\n <span class=\"wm-slider-tick-label\" [style.left]=\"tick.left\">{{ tick.label }}</span>\n }\n }\n </div>\n }\n</div>\n", styles: [":host{display:block}.wm-slider{--wm-slider-thumb: 16px;--wm-slider-track: 4px;--wm-slider-gap: 3px;--wm-slider-p: 0;--wm-slider-stop: calc( var(--wm-slider-thumb) / 2 + var(--wm-slider-p) * (100% - var(--wm-slider-thumb)) );--wm-slider-fill: linear-gradient( to right, var(--wm-color-accent) 0 var(--wm-slider-stop), var(--wm-border-input) var(--wm-slider-stop) 100% );display:grid;grid-template-columns:1fr auto;align-items:center;column-gap:.75rem;row-gap:.375rem}.wm-slider-rail{position:relative;min-width:0}.wm-slider.has-ticks .wm-slider-rail{margin-bottom:10px}.wm-slider-input{-webkit-appearance:none;appearance:none;display:block;width:100%;height:var(--wm-slider-thumb);margin:0;padding:0;background:transparent;cursor:pointer;position:relative;z-index:1}.wm-slider-input:focus{outline:none}.wm-slider-input::-webkit-slider-runnable-track{height:var(--wm-slider-track);border-radius:var(--wm-radius-full);background:var(--wm-slider-fill)}.wm-slider-input::-webkit-slider-thumb{-webkit-appearance:none;width:var(--wm-slider-thumb);height:var(--wm-slider-thumb);margin-top:calc((var(--wm-slider-track) - var(--wm-slider-thumb)) / 2);border-radius:var(--wm-radius-full);background:var(--wm-color-accent);border:2px solid var(--wm-bg-base);box-shadow:var(--wm-shadow-sm);transition:background var(--wm-transition-fast),transform var(--wm-transition-fast)}.wm-slider-input:hover::-webkit-slider-thumb{background:var(--wm-color-accent-hover);transform:scale(1.12)}.wm-slider-input:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 2px var(--wm-bg-base),0 0 0 4px var(--wm-color-accent)}.wm-slider-input::-moz-range-track{height:var(--wm-slider-track);border-radius:var(--wm-radius-full);background:var(--wm-slider-fill)}.wm-slider-input::-moz-range-thumb{width:var(--wm-slider-thumb);height:var(--wm-slider-thumb);box-sizing:border-box;border-radius:var(--wm-radius-full);background:var(--wm-color-accent);border:2px solid var(--wm-bg-base);box-shadow:var(--wm-shadow-sm);transition:background var(--wm-transition-fast),transform var(--wm-transition-fast)}.wm-slider-input:hover::-moz-range-thumb{background:var(--wm-color-accent-hover);transform:scale(1.12)}.wm-slider-input:focus-visible::-moz-range-thumb{box-shadow:0 0 0 2px var(--wm-bg-base),0 0 0 4px var(--wm-color-accent)}.wm-slider-ticks{position:absolute;inset-inline:0;top:100%;margin-top:2px;height:6px;pointer-events:none}.wm-slider-tick{position:absolute;top:0;width:2px;height:6px;margin-left:-1px;border-radius:1px;background:var(--wm-border-input);transition:background var(--wm-transition-fast)}.wm-slider-tick.is-passed{background:var(--wm-color-accent)}.wm-slider-tick--minor{height:3px;background:var(--wm-border-subtle)}.wm-slider-tick--minor.is-passed{background:color-mix(in srgb,var(--wm-color-accent) 55%,transparent)}.wm-slider[data-track=segmented]{--wm-slider-fill: none}.wm-slider-segments{position:absolute;inset:0 calc(var(--wm-slider-thumb) / 2);display:flex;align-items:center;gap:var(--wm-slider-gap);pointer-events:none}.wm-slider-seg{flex-basis:0;min-width:0;height:var(--wm-slider-track);border-radius:1px;background:var(--wm-border-input);transition:background var(--wm-transition-fast)}.wm-slider-seg.is-passed{background:var(--wm-color-accent)}.wm-slider-seg.is-current{background-image:linear-gradient(var(--wm-color-accent) 0 0);background-repeat:no-repeat;background-size:calc(var(--wm-slider-seg-p, 0) * 100%) 100%}.wm-slider-tick-labels{position:relative;grid-column:1;height:1rem}.wm-slider-tick-label{position:absolute;top:0;transform:translate(-50%);font-size:.75rem;color:var(--wm-text-muted);white-space:nowrap}.wm-slider-value-cell{grid-area:1 / 2;display:grid;justify-items:end}.wm-slider-value,.wm-slider-value-sizer{grid-area:1 / 1;font-family:var(--wm-font-mono);font-size:.8125rem;font-variant-numeric:tabular-nums;white-space:nowrap}.wm-slider-value{color:var(--wm-text-secondary);min-width:4ch;text-align:right}.wm-slider-value-sizer{visibility:hidden;pointer-events:none}.wm-slider.is-disabled{opacity:.5}.wm-slider-input:disabled{cursor:not-allowed}@media(prefers-reduced-motion:reduce){.wm-slider-input::-webkit-slider-thumb,.wm-slider-input::-moz-range-thumb,.wm-slider-tick{transition:none}.wm-slider-input:hover::-webkit-slider-thumb,.wm-slider-input:hover::-moz-range-thumb{transform:none}}\n"] }]
1074
+ }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], min: [{ type: i0.Input, args: [{ isSignal: true, alias: "min", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], step: [{ type: i0.Input, args: [{ isSignal: true, alias: "step", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], showValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "showValue", required: false }] }], formatValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "formatValue", required: false }] }], track: [{ type: i0.Input, args: [{ isSignal: true, alias: "track", required: false }] }], ticks: [{ type: i0.Input, args: [{ isSignal: true, alias: "ticks", required: false }] }] } });
1075
+ /**
1076
+ * A stop every `interval`, starting at `min` and never passing `max`. Returns
1077
+ * nothing for an interval that is not positive, or one that would draw more
1078
+ * than {@link MAX_TICKS} marks.
1079
+ */
1080
+ function gridEvery(min, max, interval) {
1081
+ if (!Number.isFinite(interval) || interval <= 0)
1082
+ return [];
1083
+ const span = max - min;
1084
+ if (!Number.isFinite(span) || span <= 0)
1085
+ return [];
1086
+ const count = Math.floor(span / interval + 1e-9);
1087
+ if (count + 1 > MAX_TICKS)
1088
+ return [];
1089
+ const decimals = decimalsOf(interval) + decimalsOf(min);
1090
+ const out = [];
1091
+ for (let i = 0; i <= count; i++) {
1092
+ out.push({ value: round(min + i * interval, decimals) });
1093
+ }
1094
+ return out;
1095
+ }
1096
+ /** Decimal places in a number's plain decimal form, capped to what f64 can hold. */
1097
+ function decimalsOf(value) {
1098
+ const text = String(value);
1099
+ if (text.includes('e') || text.includes('E'))
1100
+ return 10;
1101
+ const dot = text.indexOf('.');
1102
+ return dot === -1 ? 0 : Math.min(text.length - dot - 1, 10);
1103
+ }
1104
+ function round(value, decimals) {
1105
+ return Number(value.toFixed(Math.min(decimals, 10)));
1106
+ }
1107
+ /** A CSS-ready fraction: enough precision to be exact, no trailing noise. */
1108
+ function trim(fraction) {
1109
+ return String(round(fraction, 6));
1110
+ }
1111
+
786
1112
  /**
787
1113
  * GhostField — transparent-until-hover wrapper for inline-editable fields.
788
1114
  *
@@ -2610,5 +2936,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.23", ngImpo
2610
2936
  * Generated bundle index. Do not edit.
2611
2937
  */
2612
2938
 
2613
- export { Checkbox, GhostField, NumberInput, PageHeader, Toggle, WmAnchoredScrollStrategy, WmAutoFocus, WmButton, WmCombobox, WmDialog, WmModal, WmPopover, WmRichTooltip, WmRichTooltipContent, WmRichTooltipTrigger, WmSelect, WmTab, WmTabs, WmToastContainer, WmToastService, WmTooltip, onViewportChange };
2939
+ export { Checkbox, GhostField, NumberInput, PageHeader, Toggle, WmAnchoredScrollStrategy, WmAutoFocus, WmButton, WmCombobox, WmDialog, WmModal, WmPopover, WmRichTooltip, WmRichTooltipContent, WmRichTooltipTrigger, WmSelect, WmSlider, WmTab, WmTabs, WmToastContainer, WmToastService, WmTooltip, onViewportChange };
2614
2940
  //# sourceMappingURL=wildmason-aegis.mjs.map