@fusion.dev/helix-core 0.57.0 → 0.58.1

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.
@@ -2095,19 +2095,9 @@
2095
2095
  "declarations": [
2096
2096
  {
2097
2097
  "kind": "class",
2098
- "description": "Reads a design token as the sRGB triple a shader or a 2D canvas can take, and\ntells the host when the palette underneath it moves.\n\n**Two hops, and both are load-bearing.** A custom property's own value is the\nunevaluated function text, so `getComputedStyle` on a probe is the only way\nto see what it resolves to. And what it resolves to is not `rgb()`: the\npalette derives from an `oklch()` seed, and Chrome answers `lab(100 0 0)` for\nwhite. Parsing that with a regular expression for numbers reads the three\nchannels as 100, 0 and 0, which divided by 255 is a dark red.\n\nA screenshot cannot catch that: a warm cast on a saturated gradient is also\nwhat chroma subsampling produces, so the picture agrees with the bug.\n\nThe conversion is therefore the browser's own: fill a pixel and read it back.\nThat holds for `lab()`, `oklch()`, `color-mix()` and whatever a token is\nwritten in next, which is the property worth having in a design system whose\npalette is derived rather than listed.\n\n**A controller and not a mixin**, by this repository's own line: it adds no\nproperty or method a consumer sets. It reacts to lifecycle, holding a probe\nthat has to be created after the host connects and removed when it leaves,\nand it watches for the color mode changing so the host can repaint.\n\n**Client only, and that is the contract rather than a limitation.** There is\nno computed style on a server and no canvas to convert with, so `read`\nanswers the fallback there. Every caller is drawing into a canvas, which does\nnot exist on a server either.",
2098
+ "description": "Reads a design token as the sRGB triple a shader or a 2D canvas can take, and\ntells the host when the palette underneath it moves.\n\n**Two hops, and both are load-bearing.** A custom property's own value is the\nunevaluated function text, so `getComputedStyle` on a probe is the only way\nto see what it resolves to. The second hop is `readColor`, which is shared\nwith every other caller that has a resolved color and needs channels: what it\nresolves to is not `rgb()`, the\npalette derives from an `oklch()` seed, and Chrome answers `lab(100 0 0)` for\nwhite. Parsing that with a regular expression for numbers reads the three\nchannels as 100, 0 and 0, which divided by 255 is a dark red.\n\nA screenshot cannot catch that: a warm cast on a saturated gradient is also\nwhat chroma subsampling produces, so the picture agrees with the bug.\n\nThe conversion is therefore the browser's own: fill a pixel and read it back.\nThat holds for `lab()`, `oklch()`, `color-mix()` and whatever a token is\nwritten in next, which is the property worth having in a design system whose\npalette is derived rather than listed.\n\n**A controller and not a mixin**, by this repository's own line: it adds no\nproperty or method a consumer sets. It reacts to lifecycle, holding a probe\nthat has to be created after the host connects and removed when it leaves,\nand it watches for the color mode changing so the host can repaint.\n\n**Client only, and that is the contract rather than a limitation.** There is\nno computed style on a server and no canvas to convert with, so `read`\nanswers the fallback there. Every caller is drawing into a canvas, which does\nnot exist on a server either.",
2099
2099
  "name": "TokenColorController",
2100
2100
  "members": [
2101
- {
2102
- "kind": "field",
2103
- "name": "context",
2104
- "type": {
2105
- "text": "CanvasRenderingContext2D | null"
2106
- },
2107
- "privacy": "private",
2108
- "default": "null",
2109
- "description": "Where a resolved color is converted, one pixel at a time."
2110
- },
2111
2101
  {
2112
2102
  "kind": "field",
2113
2103
  "name": "modes",
@@ -5977,6 +5967,54 @@
5977
5967
  "module": "./utils/create-sheet/create-sheet.js"
5978
5968
  }
5979
5969
  },
5970
+ {
5971
+ "kind": "js",
5972
+ "name": "Color",
5973
+ "declaration": {
5974
+ "name": "Color",
5975
+ "module": "./utils/css-color/css-color.js"
5976
+ }
5977
+ },
5978
+ {
5979
+ "kind": "js",
5980
+ "name": "ColorFormat",
5981
+ "declaration": {
5982
+ "name": "ColorFormat",
5983
+ "module": "./utils/css-color/css-color.js"
5984
+ }
5985
+ },
5986
+ {
5987
+ "kind": "js",
5988
+ "name": "formatColor",
5989
+ "declaration": {
5990
+ "name": "formatColor",
5991
+ "module": "./utils/css-color/css-color.js"
5992
+ }
5993
+ },
5994
+ {
5995
+ "kind": "js",
5996
+ "name": "parseColor",
5997
+ "declaration": {
5998
+ "name": "parseColor",
5999
+ "module": "./utils/css-color/css-color.js"
6000
+ }
6001
+ },
6002
+ {
6003
+ "kind": "js",
6004
+ "name": "splitColors",
6005
+ "declaration": {
6006
+ "name": "splitColors",
6007
+ "module": "./utils/css-color/css-color.js"
6008
+ }
6009
+ },
6010
+ {
6011
+ "kind": "js",
6012
+ "name": "toCss",
6013
+ "declaration": {
6014
+ "name": "toCss",
6015
+ "module": "./utils/css-color/css-color.js"
6016
+ }
6017
+ },
5980
6018
  {
5981
6019
  "kind": "js",
5982
6020
  "name": "customElement",
@@ -6193,6 +6231,22 @@
6193
6231
  "module": "./utils/on-unless-false/on-unless-false.js"
6194
6232
  }
6195
6233
  },
6234
+ {
6235
+ "kind": "js",
6236
+ "name": "ColorBytes",
6237
+ "declaration": {
6238
+ "name": "ColorBytes",
6239
+ "module": "./utils/read-color/read-color.js"
6240
+ }
6241
+ },
6242
+ {
6243
+ "kind": "js",
6244
+ "name": "readColor",
6245
+ "declaration": {
6246
+ "name": "readColor",
6247
+ "module": "./utils/read-color/read-color.js"
6248
+ }
6249
+ },
6196
6250
  {
6197
6251
  "kind": "js",
6198
6252
  "name": "Row",
@@ -6417,6 +6471,14 @@
6417
6471
  "module": "./utils/value-as/value-as.js"
6418
6472
  }
6419
6473
  },
6474
+ {
6475
+ "kind": "js",
6476
+ "name": "isDateValue",
6477
+ "declaration": {
6478
+ "name": "isDateValue",
6479
+ "module": "./utils/value-as/value-as.js"
6480
+ }
6481
+ },
6420
6482
  {
6421
6483
  "kind": "js",
6422
6484
  "name": "numberAsValue",
@@ -9803,6 +9865,130 @@
9803
9865
  }
9804
9866
  ]
9805
9867
  },
9868
+ {
9869
+ "kind": "javascript-module",
9870
+ "path": "src/utils/css-color/css-color.ts",
9871
+ "declarations": [
9872
+ {
9873
+ "kind": "function",
9874
+ "name": "splitColors",
9875
+ "return": {
9876
+ "type": {
9877
+ "text": "string[]"
9878
+ }
9879
+ },
9880
+ "parameters": [
9881
+ {
9882
+ "name": "value",
9883
+ "type": {
9884
+ "text": "string"
9885
+ }
9886
+ }
9887
+ ],
9888
+ "description": "Splits a comma-separated list of colors, ignoring the commas inside them.\n\nA plain `split(',')` cannot hold the legacy syntaxes:\n`#ef4444, rgb(249 115 22), rgba(34,197,94,.5)` comes apart into six fragments,\nfour of them not colors, and the row quietly renders three swatches instead of\nfour. The modern space-separated forms survive it, which is what makes such a\nlist look like it works.\n\nDepth rather than a regular expression, because the thing that matters is\nwhether a comma is inside a function call and that is exactly what a counter\nanswers. No color syntax nests, so a single counter is enough."
9889
+ },
9890
+ {
9891
+ "kind": "function",
9892
+ "name": "parseColor",
9893
+ "return": {
9894
+ "type": {
9895
+ "text": "Color | null"
9896
+ }
9897
+ },
9898
+ "parameters": [
9899
+ {
9900
+ "name": "value",
9901
+ "type": {
9902
+ "text": "string"
9903
+ }
9904
+ }
9905
+ ],
9906
+ "description": "Any of the syntaxes to the working model, or `null` where it is not a color.\n\nNamed colors are not here. Resolving `rebeccapurple` means either shipping\nthe table of a hundred and forty or asking the browser, and the browser is not\npresent on the server: a field that accepted a name client-side and rejected\nit during a server render would be worse than one that never took it."
9907
+ },
9908
+ {
9909
+ "kind": "function",
9910
+ "name": "formatColor",
9911
+ "return": {
9912
+ "type": {
9913
+ "text": "string"
9914
+ }
9915
+ },
9916
+ "parameters": [
9917
+ {
9918
+ "name": "color",
9919
+ "type": {
9920
+ "text": "Color"
9921
+ }
9922
+ },
9923
+ {
9924
+ "name": "format",
9925
+ "type": {
9926
+ "text": "ColorFormat"
9927
+ }
9928
+ },
9929
+ {
9930
+ "name": "withAlpha",
9931
+ "type": {
9932
+ "text": "boolean"
9933
+ }
9934
+ }
9935
+ ],
9936
+ "description": "The working model back out, in the syntax asked for.\n\nThe alpha is written only where the component carries one. A consumer who did\nnot ask for transparency should not have to strip `ff` off every hex or read\npast a fourth argument they never wanted."
9937
+ },
9938
+ {
9939
+ "kind": "function",
9940
+ "name": "toCss",
9941
+ "return": {
9942
+ "type": {
9943
+ "text": "string"
9944
+ }
9945
+ },
9946
+ "parameters": [
9947
+ {
9948
+ "name": "color",
9949
+ "type": {
9950
+ "text": "Color"
9951
+ }
9952
+ }
9953
+ ],
9954
+ "description": "The color as CSS, for the swatches and the tracks. Always with its alpha."
9955
+ }
9956
+ ],
9957
+ "exports": [
9958
+ {
9959
+ "kind": "js",
9960
+ "name": "splitColors",
9961
+ "declaration": {
9962
+ "name": "splitColors",
9963
+ "module": "src/utils/css-color/css-color.ts"
9964
+ }
9965
+ },
9966
+ {
9967
+ "kind": "js",
9968
+ "name": "parseColor",
9969
+ "declaration": {
9970
+ "name": "parseColor",
9971
+ "module": "src/utils/css-color/css-color.ts"
9972
+ }
9973
+ },
9974
+ {
9975
+ "kind": "js",
9976
+ "name": "formatColor",
9977
+ "declaration": {
9978
+ "name": "formatColor",
9979
+ "module": "src/utils/css-color/css-color.ts"
9980
+ }
9981
+ },
9982
+ {
9983
+ "kind": "js",
9984
+ "name": "toCss",
9985
+ "declaration": {
9986
+ "name": "toCss",
9987
+ "module": "src/utils/css-color/css-color.ts"
9988
+ }
9989
+ }
9990
+ ]
9991
+ },
9806
9992
  {
9807
9993
  "kind": "javascript-module",
9808
9994
  "path": "src/utils/custom-element/custom-element.ts",
@@ -10474,6 +10660,41 @@
10474
10660
  }
10475
10661
  ]
10476
10662
  },
10663
+ {
10664
+ "kind": "javascript-module",
10665
+ "path": "src/utils/read-color/read-color.ts",
10666
+ "declarations": [
10667
+ {
10668
+ "kind": "function",
10669
+ "name": "readColor",
10670
+ "return": {
10671
+ "type": {
10672
+ "text": "ColorBytes | undefined"
10673
+ }
10674
+ },
10675
+ "parameters": [
10676
+ {
10677
+ "name": "value",
10678
+ "type": {
10679
+ "text": "string"
10680
+ },
10681
+ "description": "A resolved color, as `getComputedStyle` reports one."
10682
+ }
10683
+ ],
10684
+ "description": "A computed color as three sRGB bytes, or nothing where it cannot be read.\n\n**Painting it is the only way to read it.** A color-typed property is\nresolved before it is reported, but resolved is not the same as converted:\nChrome hands back `lab(100 0 0)` for a token built through `oklab`, and\nscraping the first three numbers out of that and calling them red, green and\nblue made white arrive as `rgb(100, 0, 0)`. In light mode the near dots were\n`lab(0 0 0)`, which scrapes to black and looks right, so only dark mode\nshowed it: dark red mixed toward black, on a near-black ground.\n\n**Painted twice, over two different grounds, which is what makes the refusal\nhonest.** Assigning a value the engine will not parse leaves `fillStyle`\nexactly where it was, so a single paint cannot tell a color apart from the\nground it failed to replace. Over black and over white, a value that parsed\ngives the same answer twice and one that did not gives two. A caller then\nchooses its own fallback rather than being handed a color nobody picked,\nwhich is the half both earlier copies of this had to guess at.\n\nA readback is not free, so the answer is held against the string it came\nfrom, refusals included. The oldest goes when the store is full rather than\nall of them, or a page deriving colors on a slider pays for every frame\ntwice."
10685
+ }
10686
+ ],
10687
+ "exports": [
10688
+ {
10689
+ "kind": "js",
10690
+ "name": "readColor",
10691
+ "declaration": {
10692
+ "name": "readColor",
10693
+ "module": "src/utils/read-color/read-color.ts"
10694
+ }
10695
+ }
10696
+ ]
10697
+ },
10477
10698
  {
10478
10699
  "kind": "javascript-module",
10479
10700
  "path": "src/utils/read-series/read-series.ts",
@@ -11063,6 +11284,24 @@
11063
11284
  ],
11064
11285
  "description": "The date a `YYYY-MM-DD` value names, at midnight UTC, or `null`.\n\nThe range check is not decoration. `2026-02-31` matches the pattern and\n`Date.UTC` rolls it forward to 3 March, so without reading the parts back a\ncontrol would report a date the reader never typed and never saw."
11065
11286
  },
11287
+ {
11288
+ "kind": "function",
11289
+ "name": "isDateValue",
11290
+ "return": {
11291
+ "type": {
11292
+ "text": "boolean"
11293
+ }
11294
+ },
11295
+ "parameters": [
11296
+ {
11297
+ "name": "value",
11298
+ "type": {
11299
+ "text": "string"
11300
+ }
11301
+ }
11302
+ ],
11303
+ "description": "Whether a value names a real day, which is the shape *and* the range.\n\nThe predicate form of the conversion above, here rather than in a family\nbecause a family writing its own gets the shape right and the range wrong.\nTwo did: one tested `\\d{4}-\\d{2}-\\d{2}` and reported `2026-02-31` as whole\nwhile its own `valueAsDate` answered `null`, and the other guarded\n`Number.isNaN(date.getTime())`, which never fires, `Date.UTC(2026, 1, 31)`\nbeing a perfectly good third of March."
11304
+ },
11066
11305
  {
11067
11306
  "kind": "function",
11068
11307
  "name": "dateAsValue",
@@ -11163,6 +11402,14 @@
11163
11402
  "module": "src/utils/value-as/value-as.ts"
11164
11403
  }
11165
11404
  },
11405
+ {
11406
+ "kind": "js",
11407
+ "name": "isDateValue",
11408
+ "declaration": {
11409
+ "name": "isDateValue",
11410
+ "module": "src/utils/value-as/value-as.ts"
11411
+ }
11412
+ },
11166
11413
  {
11167
11414
  "kind": "js",
11168
11415
  "name": "dateAsValue",
@@ -7,7 +7,9 @@ export type TokenColor = [number, number, number];
7
7
  *
8
8
  * **Two hops, and both are load-bearing.** A custom property's own value is the
9
9
  * unevaluated function text, so `getComputedStyle` on a probe is the only way
10
- * to see what it resolves to. And what it resolves to is not `rgb()`: the
10
+ * to see what it resolves to. The second hop is `readColor`, which is shared
11
+ * with every other caller that has a resolved color and needs channels: what it
12
+ * resolves to is not `rgb()`, the
11
13
  * palette derives from an `oklch()` seed, and Chrome answers `lab(100 0 0)` for
12
14
  * white. Parsing that with a regular expression for numbers reads the three
13
15
  * channels as 100, 0 and 0, which divided by 255 is a dark red.
@@ -33,8 +35,6 @@ export type TokenColor = [number, number, number];
33
35
  export declare class TokenColorController implements ReactiveController {
34
36
  private readonly host;
35
37
  private readonly onChange?;
36
- /** Where a resolved color is converted, one pixel at a time. */
37
- private context;
38
38
  /** Watches the document for `data-color-mode`. */
39
39
  private modes;
40
40
  /** The hidden element a token is resolved against. */
@@ -1,8 +1,8 @@
1
+ import { readColor } from '../../utils/read-color/read-color.js';
1
2
  export class TokenColorController {
2
3
  constructor(host, onChange) {
3
4
  this.host = host;
4
5
  this.onChange = onChange;
5
- this.context = null;
6
6
  this.announce = () => {
7
7
  this.host.requestUpdate();
8
8
  this.onChange?.();
@@ -16,10 +16,6 @@ export class TokenColorController {
16
16
  this.probe = document.createElement('span');
17
17
  this.probe.style.display = 'none';
18
18
  document.body.appendChild(this.probe);
19
- const swatch = document.createElement('canvas');
20
- swatch.width = 1;
21
- swatch.height = 1;
22
- this.context = swatch.getContext('2d', { willReadFrequently: true });
23
19
  this.modes = new MutationObserver(() => this.announce());
24
20
  this.modes.observe(document.documentElement, {
25
21
  attributeFilter: ['data-color-mode'],
@@ -35,18 +31,18 @@ export class TokenColorController {
35
31
  this.scheme = undefined;
36
32
  this.probe?.remove();
37
33
  this.probe = undefined;
38
- this.context = null;
39
34
  }
40
35
  read(token, fallback) {
41
- const { context, probe } = this;
42
- if (!context || !probe) {
36
+ const { probe } = this;
37
+ if (!probe) {
43
38
  return [...fallback];
44
39
  }
45
40
  probe.style.color = `var(${token})`;
46
- context.fillStyle = '#000000';
47
- context.fillStyle = getComputedStyle(probe).color;
48
- context.fillRect(0, 0, 1, 1);
49
- const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
50
- return [(r ?? 0) / 255, (g ?? 0) / 255, (b ?? 0) / 255];
41
+ const read = readColor(getComputedStyle(probe).color);
42
+ if (!read) {
43
+ return [...fallback];
44
+ }
45
+ const [r, g, b] = read;
46
+ return [r / 255, g / 255, b / 255];
51
47
  }
52
48
  }
package/dist/index.d.ts CHANGED
@@ -37,6 +37,7 @@ export { anchorElement } from './utils/anchor-element/anchor-element.js';
37
37
  export { capturePointer } from './utils/capture-pointer/capture-pointer.js';
38
38
  export { closestInstance } from './utils/closest-instance/closest-instance.js';
39
39
  export { createSheet } from './utils/create-sheet/create-sheet.js';
40
+ export { type Color, type ColorFormat, formatColor, parseColor, splitColors, toCss, } from './utils/css-color/css-color.js';
40
41
  export { customElement, definedTags, duplicateRegistrations, } from './utils/custom-element/custom-element.js';
41
42
  export { type DrawIconOptions, drawIcon } from './utils/draw-icon/draw-icon.js';
42
43
  export { CHART_PLOT, drawTooltip } from './utils/draw-tooltip/draw-tooltip.js';
@@ -50,6 +51,7 @@ export { type Curve, linePath, type Point } from './utils/line-path/line-path.js
50
51
  export { markRegion, type RegionOptions } from './utils/mark-region/mark-region.js';
51
52
  export { copiesOf, type MediaCopy, sameCopies, } from './utils/media-copy/media-copy.js';
52
53
  export { onUnlessFalse } from './utils/on-unless-false/on-unless-false.js';
54
+ export { type ColorBytes, readColor } from './utils/read-color/read-color.js';
53
55
  export { type Row, type RowCell, readRows, readSeries, type Series, type SeriesPoint, } from './utils/read-series/read-series.js';
54
56
  export { type Relative, relativeTime, } from './utils/relative-time/relative-time.js';
55
57
  export { type ChangeRequestOptions, requestChange, } from './utils/request-change/request-change.js';
@@ -62,5 +64,5 @@ export { slotFilled } from './utils/slot-filled/slot-filled.js';
62
64
  export { tellDisclosure } from './utils/tell-disclosure/tell-disclosure.js';
63
65
  export { subscribe } from './utils/ticker/ticker.js';
64
66
  export { type HelixStatusTone, TONE_MARKS, type ToneMark, toneMark, } from './utils/tone-marks/tone-marks.js';
65
- export { dateAsValue, numberAsValue, timeAsValue, valueAsDate, valueAsNumber, valueAsTime, } from './utils/value-as/value-as.js';
67
+ export { dateAsValue, isDateValue, numberAsValue, timeAsValue, valueAsDate, valueAsNumber, valueAsTime, } from './utils/value-as/value-as.js';
66
68
  export { writtenList } from './utils/written-list/written-list.js';
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ export { anchorElement } from './utils/anchor-element/anchor-element.js';
37
37
  export { capturePointer } from './utils/capture-pointer/capture-pointer.js';
38
38
  export { closestInstance } from './utils/closest-instance/closest-instance.js';
39
39
  export { createSheet } from './utils/create-sheet/create-sheet.js';
40
+ export { formatColor, parseColor, splitColors, toCss, } from './utils/css-color/css-color.js';
40
41
  export { customElement, definedTags, duplicateRegistrations, } from './utils/custom-element/custom-element.js';
41
42
  export { drawIcon } from './utils/draw-icon/draw-icon.js';
42
43
  export { CHART_PLOT, drawTooltip } from './utils/draw-tooltip/draw-tooltip.js';
@@ -50,6 +51,7 @@ export { linePath } from './utils/line-path/line-path.js';
50
51
  export { markRegion } from './utils/mark-region/mark-region.js';
51
52
  export { copiesOf, sameCopies, } from './utils/media-copy/media-copy.js';
52
53
  export { onUnlessFalse } from './utils/on-unless-false/on-unless-false.js';
54
+ export { readColor } from './utils/read-color/read-color.js';
53
55
  export { readRows, readSeries, } from './utils/read-series/read-series.js';
54
56
  export { relativeTime, } from './utils/relative-time/relative-time.js';
55
57
  export { requestChange, } from './utils/request-change/request-change.js';
@@ -62,5 +64,5 @@ export { slotFilled } from './utils/slot-filled/slot-filled.js';
62
64
  export { tellDisclosure } from './utils/tell-disclosure/tell-disclosure.js';
63
65
  export { subscribe } from './utils/ticker/ticker.js';
64
66
  export { TONE_MARKS, toneMark, } from './utils/tone-marks/tone-marks.js';
65
- export { dateAsValue, numberAsValue, timeAsValue, valueAsDate, valueAsNumber, valueAsTime, } from './utils/value-as/value-as.js';
67
+ export { dateAsValue, isDateValue, numberAsValue, timeAsValue, valueAsDate, valueAsNumber, valueAsTime, } from './utils/value-as/value-as.js';
66
68
  export { writtenList } from './utils/written-list/written-list.js';
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Reading and writing the color syntaxes a field is allowed to hold.
3
+ *
4
+ * **The working model is HSB**, hue and saturation and brightness, because that
5
+ * is what the panel is: a square of saturation against brightness with a hue
6
+ * ramp beside it. HSL would put the square's corners somewhere a reader does not
7
+ * expect, since full saturation in HSL is a mid-lightness band rather than a
8
+ * corner.
9
+ *
10
+ * **Every syntax here is read; three are written.** A field that refuses
11
+ * `rgb(255 0 0)` because it prefers hex is a field arguing with the person using
12
+ * it, so the space-separated forms, the comma forms, `rgba` and `hsla` are all
13
+ * taken. What is written back is whichever of hex, rgb or hsl the component is
14
+ * set to, so a value stays in the shape the consumer asked for however it was
15
+ * entered.
16
+ *
17
+ * **What is written is the comma form.** Both are valid, and the commas are what
18
+ * a reader recognizes, what most code around them holds, and what pastes into
19
+ * anything without a second thought. A syntax a value is handed *out* in is not
20
+ * the place to be early. `rgba` and `hsla` come with them, since a comma form
21
+ * with a fourth argument is spelled that way.
22
+ *
23
+ * **Core's rather than a field's, because the CSS color grammar is the
24
+ * platform's.** Nothing here needs a color picker to make sense, and two
25
+ * families read it already. It is arithmetic with no DOM in it, so it runs on a
26
+ * server, which is also why named colors are refused below: resolving
27
+ * `rebeccapurple` means asking a browser that is not there.
28
+ *
29
+ * **One decision in here is a default rather than a capability.** What
30
+ * `formatColor` writes back, the comma form and an alpha only where one was
31
+ * asked for, is a choice about what a value looks like when it is handed out.
32
+ * It is core's choice now instead of the picker's, and a caller wanting another
33
+ * shape formats it itself.
34
+ */
35
+ /** A color as the panel holds it. Alpha and the two percentages are 0 to 100. */
36
+ export interface Color {
37
+ alpha: number;
38
+ brightness: number;
39
+ hue: number;
40
+ saturation: number;
41
+ }
42
+ /** The syntaxes a value can be written in. */
43
+ export type ColorFormat = 'hex' | 'hsl' | 'rgb';
44
+ /**
45
+ * Splits a comma-separated list of colors, ignoring the commas inside them.
46
+ *
47
+ * A plain `split(',')` cannot hold the legacy syntaxes:
48
+ * `#ef4444, rgb(249 115 22), rgba(34,197,94,.5)` comes apart into six fragments,
49
+ * four of them not colors, and the row quietly renders three swatches instead of
50
+ * four. The modern space-separated forms survive it, which is what makes such a
51
+ * list look like it works.
52
+ *
53
+ * Depth rather than a regular expression, because the thing that matters is
54
+ * whether a comma is inside a function call and that is exactly what a counter
55
+ * answers. No color syntax nests, so a single counter is enough.
56
+ */
57
+ export declare const splitColors: (value: string) => string[];
58
+ /**
59
+ * Any of the syntaxes to the working model, or `null` where it is not a color.
60
+ *
61
+ * Named colors are not here. Resolving `rebeccapurple` means either shipping
62
+ * the table of a hundred and forty or asking the browser, and the browser is not
63
+ * present on the server: a field that accepted a name client-side and rejected
64
+ * it during a server render would be worse than one that never took it.
65
+ */
66
+ export declare const parseColor: (value: string) => Color | null;
67
+ /**
68
+ * The working model back out, in the syntax asked for.
69
+ *
70
+ * The alpha is written only where the component carries one. A consumer who did
71
+ * not ask for transparency should not have to strip `ff` off every hex or read
72
+ * past a fourth argument they never wanted.
73
+ */
74
+ export declare const formatColor: (color: Color, format: ColorFormat, withAlpha: boolean) => string;
75
+ /** The color as CSS, for the swatches and the tracks. Always with its alpha. */
76
+ export declare const toCss: (color: Color) => string;
@@ -0,0 +1,168 @@
1
+ const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
2
+ const toAlpha = (token) => {
3
+ if (token === undefined) {
4
+ return 100;
5
+ }
6
+ const number = Number.parseFloat(token);
7
+ if (Number.isNaN(number)) {
8
+ return 100;
9
+ }
10
+ return clamp(token.includes('%') ? number : number * 100, 0, 100);
11
+ };
12
+ const toByte = (token) => {
13
+ const number = Number.parseFloat(token);
14
+ return clamp(token.includes('%') ? (number / 100) * 255 : number, 0, 255);
15
+ };
16
+ const rgbToHsb = (red, green, blue, alpha) => {
17
+ const r = red / 255;
18
+ const g = green / 255;
19
+ const b = blue / 255;
20
+ const high = Math.max(r, g, b);
21
+ const low = Math.min(r, g, b);
22
+ const span = high - low;
23
+ let hue = 0;
24
+ if (span !== 0) {
25
+ if (high === r) {
26
+ hue = (g - b) / span + (g < b ? 6 : 0);
27
+ }
28
+ else if (high === g) {
29
+ hue = (b - r) / span + 2;
30
+ }
31
+ else {
32
+ hue = (r - g) / span + 4;
33
+ }
34
+ hue *= 60;
35
+ }
36
+ return {
37
+ alpha,
38
+ brightness: Math.round(high * 100),
39
+ hue: Math.round(hue),
40
+ saturation: Math.round(high === 0 ? 0 : (span / high) * 100),
41
+ };
42
+ };
43
+ const toRgb = (color) => {
44
+ const hue = (((color.hue % 360) + 360) % 360) / 60;
45
+ const saturation = color.saturation / 100;
46
+ const brightness = color.brightness / 100;
47
+ const chroma = brightness * saturation;
48
+ const middle = chroma * (1 - Math.abs((hue % 2) - 1));
49
+ const base = brightness - chroma;
50
+ const sextants = [
51
+ [chroma, middle, 0],
52
+ [middle, chroma, 0],
53
+ [0, chroma, middle],
54
+ [0, middle, chroma],
55
+ [middle, 0, chroma],
56
+ [chroma, 0, middle],
57
+ ];
58
+ const [red, green, blue] = sextants[Math.floor(hue) % 6] ?? [0, 0, 0];
59
+ return {
60
+ blue: Math.round((blue + base) * 255),
61
+ green: Math.round((green + base) * 255),
62
+ red: Math.round((red + base) * 255),
63
+ };
64
+ };
65
+ const toHsl = (color) => {
66
+ const saturation = color.saturation / 100;
67
+ const brightness = color.brightness / 100;
68
+ const lightness = (brightness * (2 - saturation)) / 2;
69
+ return {
70
+ hue: ((color.hue % 360) + 360) % 360,
71
+ lightness: Math.round(lightness * 100),
72
+ saturation: Math.round((lightness === 0 || lightness === 1
73
+ ? 0
74
+ : (brightness - lightness) / Math.min(lightness, 1 - lightness)) * 100),
75
+ };
76
+ };
77
+ export const splitColors = (value) => {
78
+ const found = [];
79
+ let depth = 0;
80
+ let current = '';
81
+ for (const character of value) {
82
+ if (character === '(') {
83
+ depth += 1;
84
+ }
85
+ else if (character === ')') {
86
+ depth = Math.max(0, depth - 1);
87
+ }
88
+ if (character === ',' && depth === 0) {
89
+ found.push(current.trim());
90
+ current = '';
91
+ }
92
+ else {
93
+ current += character;
94
+ }
95
+ }
96
+ found.push(current.trim());
97
+ return found.filter(Boolean);
98
+ };
99
+ export const parseColor = (value) => {
100
+ const text = value.trim().toLowerCase();
101
+ if (text === '') {
102
+ return null;
103
+ }
104
+ if (text.startsWith('#')) {
105
+ const digits = text.slice(1);
106
+ const short = digits.length === 3 || digits.length === 4;
107
+ const long = digits.length === 6 || digits.length === 8;
108
+ if ((!short && !long) || !/^[\da-f]+$/.test(digits)) {
109
+ return null;
110
+ }
111
+ const width = short ? 1 : 2;
112
+ const channel = (index) => {
113
+ const piece = digits.slice(index * width, index * width + width);
114
+ return Number.parseInt(short ? piece + piece : piece, 16);
115
+ };
116
+ const carriesAlpha = digits.length === 4 || digits.length === 8;
117
+ return rgbToHsb(channel(0), channel(1), channel(2), carriesAlpha ? Math.round((channel(3) / 255) * 100) : 100);
118
+ }
119
+ const call = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(text);
120
+ if (!call?.[1] || call[2] === undefined) {
121
+ return null;
122
+ }
123
+ const [first = '', second = '', third = '', fourth] = call[2]
124
+ .replace(/[,/]/g, ' ')
125
+ .split(/\s+/)
126
+ .filter(Boolean);
127
+ if (first === '' || second === '' || third === '') {
128
+ return null;
129
+ }
130
+ const alpha = toAlpha(fourth);
131
+ if (call[1].startsWith('rgb')) {
132
+ return rgbToHsb(toByte(first), toByte(second), toByte(third), alpha);
133
+ }
134
+ const hue = Number.parseFloat(first);
135
+ const saturation = clamp(Number.parseFloat(second), 0, 100) / 100;
136
+ const lightness = clamp(Number.parseFloat(third), 0, 100) / 100;
137
+ if (Number.isNaN(hue) || Number.isNaN(saturation) || Number.isNaN(lightness)) {
138
+ return null;
139
+ }
140
+ const brightness = lightness + saturation * Math.min(lightness, 1 - lightness);
141
+ return {
142
+ alpha,
143
+ brightness: Math.round(brightness * 100),
144
+ hue: Math.round(((hue % 360) + 360) % 360),
145
+ saturation: Math.round((brightness === 0 ? 0 : 2 * (1 - lightness / brightness)) * 100),
146
+ };
147
+ };
148
+ export const formatColor = (color, format, withAlpha) => {
149
+ const alpha = clamp(color.alpha, 0, 100) / 100;
150
+ const opacity = Number(alpha.toFixed(2));
151
+ if (format === 'hsl') {
152
+ const { hue, lightness, saturation } = toHsl(color);
153
+ const body = `${Math.round(hue)}, ${saturation}%, ${lightness}%`;
154
+ return withAlpha ? `hsla(${body}, ${opacity})` : `hsl(${body})`;
155
+ }
156
+ const { blue, green, red } = toRgb(color);
157
+ if (format === 'rgb') {
158
+ const body = `${red}, ${green}, ${blue}`;
159
+ return withAlpha ? `rgba(${body}, ${opacity})` : `rgb(${body})`;
160
+ }
161
+ const byte = (one) => one.toString(16).padStart(2, '0');
162
+ const hex = `#${byte(red)}${byte(green)}${byte(blue)}`;
163
+ return withAlpha ? hex + byte(Math.round(alpha * 255)) : hex;
164
+ };
165
+ export const toCss = (color) => {
166
+ const { blue, green, red } = toRgb(color);
167
+ return `rgb(${red} ${green} ${blue} / ${clamp(color.alpha, 0, 100)}%)`;
168
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Reading a computed color the engine has resolved but not converted.
3
+ *
4
+ * Distinct from `css-color`, which reads the syntax a *consumer* writes and
5
+ * runs on a server. This reads what the engine hands back, which can be in any
6
+ * space the platform supports, and needs a canvas to do it.
7
+ */
8
+ /** A color as three sRGB bytes, which is what a canvas reports and takes. */
9
+ export type ColorBytes = [number, number, number];
10
+ /**
11
+ * A computed color as three sRGB bytes, or nothing where it cannot be read.
12
+ *
13
+ * **Painting it is the only way to read it.** A color-typed property is
14
+ * resolved before it is reported, but resolved is not the same as converted:
15
+ * Chrome hands back `lab(100 0 0)` for a token built through `oklab`, and
16
+ * scraping the first three numbers out of that and calling them red, green and
17
+ * blue made white arrive as `rgb(100, 0, 0)`. In light mode the near dots were
18
+ * `lab(0 0 0)`, which scrapes to black and looks right, so only dark mode
19
+ * showed it: dark red mixed toward black, on a near-black ground.
20
+ *
21
+ * **Painted twice, over two different grounds, which is what makes the refusal
22
+ * honest.** Assigning a value the engine will not parse leaves `fillStyle`
23
+ * exactly where it was, so a single paint cannot tell a color apart from the
24
+ * ground it failed to replace. Over black and over white, a value that parsed
25
+ * gives the same answer twice and one that did not gives two. A caller then
26
+ * chooses its own fallback rather than being handed a color nobody picked,
27
+ * which is the half both earlier copies of this had to guess at.
28
+ *
29
+ * A readback is not free, so the answer is held against the string it came
30
+ * from, refusals included. The oldest goes when the store is full rather than
31
+ * all of them, or a page deriving colors on a slider pays for every frame
32
+ * twice.
33
+ *
34
+ * @param value A resolved color, as `getComputedStyle` reports one.
35
+ */
36
+ export declare const readColor: (value: string) => ColorBytes | undefined;
@@ -0,0 +1,36 @@
1
+ let swatch;
2
+ const known = new Map();
3
+ const KEPT = 64;
4
+ const painted = (context, value, under) => {
5
+ context.fillStyle = under;
6
+ context.fillStyle = value;
7
+ context.fillRect(0, 0, 1, 1);
8
+ const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
9
+ return [red ?? 0, green ?? 0, blue ?? 0];
10
+ };
11
+ export const readColor = (value) => {
12
+ if (known.has(value)) {
13
+ const seen = known.get(value);
14
+ return seen ? [...seen] : undefined;
15
+ }
16
+ if (typeof document === 'undefined') {
17
+ return undefined;
18
+ }
19
+ if (swatch === undefined) {
20
+ swatch = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
21
+ }
22
+ if (!swatch) {
23
+ return undefined;
24
+ }
25
+ const over = painted(swatch, value, '#000000');
26
+ const under = painted(swatch, value, '#ffffff');
27
+ const read = over.every((one, at) => one === under[at]) ? over : undefined;
28
+ if (known.size >= KEPT) {
29
+ const oldest = known.keys().next().value;
30
+ if (oldest !== undefined) {
31
+ known.delete(oldest);
32
+ }
33
+ }
34
+ known.set(value, read);
35
+ return read ? [...read] : undefined;
36
+ };
@@ -6,6 +6,17 @@
6
6
  * control would report a date the reader never typed and never saw.
7
7
  */
8
8
  export declare const valueAsDate: (value: string) => Date | null;
9
+ /**
10
+ * Whether a value names a real day, which is the shape *and* the range.
11
+ *
12
+ * The predicate form of the conversion above, here rather than in a family
13
+ * because a family writing its own gets the shape right and the range wrong.
14
+ * Two did: one tested `\d{4}-\d{2}-\d{2}` and reported `2026-02-31` as whole
15
+ * while its own `valueAsDate` answered `null`, and the other guarded
16
+ * `Number.isNaN(date.getTime())`, which never fires, `Date.UTC(2026, 1, 31)`
17
+ * being a perfectly good third of March.
18
+ */
19
+ export declare const isDateValue: (value: string) => boolean;
9
20
  /** The `YYYY-MM-DD` value for a date, or the empty string, which is how a control says nothing. */
10
21
  export declare const dateAsValue: (date: Date | null) => string;
11
22
  /**
@@ -11,6 +11,7 @@ export const valueAsDate = (value) => {
11
11
  const rolled = date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day;
12
12
  return rolled ? null : date;
13
13
  };
14
+ export const isDateValue = (value) => valueAsDate(value) !== null;
14
15
  export const dateAsValue = (date) => {
15
16
  if (!date || Number.isNaN(date.getTime())) {
16
17
  return '';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-core",
3
3
  "description": "Helix element primitives: base classes, mixins, decorators, utilities.",
4
- "version": "0.57.0",
4
+ "version": "0.58.1",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -34,12 +34,12 @@
34
34
  "bugs": "https://github.com/project-fusion/fusion/issues",
35
35
  "dependencies": {
36
36
  "@floating-ui/dom": "^1.8.0",
37
- "@fusion.dev/helix-icons": "0.57.0"
37
+ "@fusion.dev/helix-icons": "0.58.1"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@custom-elements-manifest/analyzer": "^0.11.0",
41
- "@fusion.dev/helix-cem": "0.57.0",
42
- "@fusion.dev/helix-testing": "0.57.0",
41
+ "@fusion.dev/helix-cem": "0.58.1",
42
+ "@fusion.dev/helix-testing": "0.58.1",
43
43
  "lit": "^3.2.1"
44
44
  },
45
45
  "homepage": "https://fusion.dev",