@zumer/orbit-kit 0.1.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/src/color.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Color helpers for the kit: parsing, HSV/HSL/RGB conversions, contrast and
3
+ * plain-English names. Pure functions, safe in Node; named CSS colors are
4
+ * resolved through a canvas when there is a document.
5
+ */
6
+
7
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
8
+ const round = (n, digits = 0) => Number(Number(n).toFixed(digits));
9
+ const hex2 = (n) => Math.round(clamp(n, 0, 255)).toString(16).padStart(2, '0');
10
+
11
+ /** { r, g, b } (0-255) → "#rrggbb". */
12
+ export const rgbToHex = ({ r, g, b }) => `#${hex2(r)}${hex2(g)}${hex2(b)}`;
13
+
14
+ /** h 0-360, s and v 0-1 → { r, g, b } 0-255. */
15
+ export function hsvToRgb({ h, s, v }) {
16
+ const f = (n) => {
17
+ const k = (n + h / 60) % 6;
18
+ return (v - v * s * Math.max(0, Math.min(k, 4 - k, 1))) * 255;
19
+ };
20
+ return { r: f(5), g: f(3), b: f(1) };
21
+ }
22
+
23
+ /** { r, g, b } 0-255 → { h, s, v }, h 0-360 (0 for grays), s and v 0-1. */
24
+ export function rgbToHsv({ r, g, b }) {
25
+ r /= 255; g /= 255; b /= 255;
26
+ const max = Math.max(r, g, b), d = max - Math.min(r, g, b);
27
+ let h = 0;
28
+ if (d) h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
29
+ return { h: (h * 60 + 360) % 360, s: max ? d / max : 0, v: max };
30
+ }
31
+
32
+ /** { h, s, v } → { h, s, l }, all but h 0-1. */
33
+ export function hsvToHsl({ h, s, v }) {
34
+ const l = v * (1 - s / 2);
35
+ return { h, s: l === 0 || l === 1 ? 0 : (v - l) / Math.min(l, 1 - l), l };
36
+ }
37
+
38
+ /** { h, s, l } → { h, s, v }. */
39
+ export function hslToHsv({ h, s, l }) {
40
+ const v = l + s * Math.min(l, 1 - l);
41
+ return { h, s: v ? 2 * (1 - l / v) : 0, v };
42
+ }
43
+
44
+ let probe;
45
+ /** A named or any other CSS color, through the browser; null when there is none. */
46
+ function browserColor(text) {
47
+ if (typeof document === 'undefined' || !document.createElement) return null;
48
+ try {
49
+ probe ??= document.createElement('canvas').getContext('2d');
50
+ if (!probe) return null;
51
+ probe.fillStyle = '#010203';
52
+ probe.fillStyle = text;
53
+ const out = probe.fillStyle;
54
+ if (out === '#010203' && !/^#?010203$/i.test(text.trim())) return null;
55
+ return out.startsWith('#') ? out : null;
56
+ } catch { return null; }
57
+ }
58
+
59
+ const num = (part, scale) => {
60
+ const text = String(part).trim();
61
+ if (text.endsWith('%')) return Number.parseFloat(text) / 100 * scale;
62
+ return Number.parseFloat(text);
63
+ };
64
+
65
+ /**
66
+ * A CSS color → { r, g, b } (0-255), or null. Reads #rgb, #rrggbb (alpha is
67
+ * ignored), rgb()/rgba(), hsl()/hsla() and, in a browser, any named color.
68
+ */
69
+ export function parseColor(value) {
70
+ const text = String(value ?? '').trim().toLowerCase();
71
+ if (!text) return null;
72
+ let m = text.match(/^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
73
+ if (m) {
74
+ let hex = m[1];
75
+ if (hex.length <= 4) hex = hex.slice(0, 3).split('').map((c) => c + c).join('');
76
+ return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16) };
77
+ }
78
+ m = text.match(/^(rgba?|hsla?)\(([^)]*)\)$/);
79
+ if (m) {
80
+ const parts = m[2].split(/[\s,/]+/).filter(Boolean);
81
+ if (parts.length < 3) return null;
82
+ if (m[1].startsWith('rgb')) {
83
+ const [r, g, b] = parts.slice(0, 3).map((p) => num(p, 255));
84
+ return [r, g, b].every(Number.isFinite) ? { r: clamp(r, 0, 255), g: clamp(g, 0, 255), b: clamp(b, 0, 255) } : null;
85
+ }
86
+ const h = Number.parseFloat(parts[0]), s = num(parts[1], 1), l = num(parts[2], 1);
87
+ const S = parts[1].endsWith('%') ? s : s / 100, L = parts[2].endsWith('%') ? l : l / 100;
88
+ if (![h, S, L].every(Number.isFinite)) return null;
89
+ return hsvToRgb(hslToHsv({ h: ((h % 360) + 360) % 360, s: clamp(S, 0, 1), l: clamp(L, 0, 1) }));
90
+ }
91
+ const resolved = browserColor(text);
92
+ return resolved ? parseColor(resolved) : null;
93
+ }
94
+
95
+ /** WCAG relative luminance, 0 (black) to 1 (white). */
96
+ export function luminance({ r, g, b }) {
97
+ const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
98
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
99
+ }
100
+
101
+ /** Dark or light text, whichever reads better on `color`. */
102
+ export function contrastText(color, dark = '#0c0f13', light = '#ffffff') {
103
+ const rgb = typeof color === 'string' ? parseColor(color) : color;
104
+ if (!rgb) return light;
105
+ const L = luminance(rgb);
106
+ // Contrast against near-black (#0c0f13, L≈.005) versus white.
107
+ return (L + 0.05) / 0.055 >= 1.05 / (L + 0.05) ? dark : light;
108
+ }
109
+
110
+ const HUES = [
111
+ [15, 'red'], [40, 'orange'], [64, 'yellow'], [80, 'lime'], [150, 'green'], [178, 'teal'],
112
+ [200, 'cyan'], [242, 'blue'], [270, 'indigo'], [292, 'purple'], [325, 'magenta'], [345, 'pink'], [360, 'red']
113
+ ];
114
+
115
+ /** A short plain-English name: "dark blue", "light gray", "orange", "brown". */
116
+ export function colorName(color) {
117
+ const rgb = typeof color === 'string' ? parseColor(color) : color;
118
+ if (!rgb) return '';
119
+ const { h, s, l } = hsvToHsl(rgbToHsv(rgb));
120
+ if (l < 0.07) return 'black';
121
+ if (l > 0.96) return 'white';
122
+ if (s < 0.1 || (s < 0.2 && (l < 0.2 || l > 0.85))) return l < 0.3 ? 'dark gray' : l > 0.72 ? 'light gray' : 'gray';
123
+ let hue = HUES.find(([end]) => h < end)?.[1] || 'red';
124
+ if ((hue === 'orange' || hue === 'red' || hue === 'yellow') && l < 0.4 && h >= 8 && h < 50) hue = 'brown';
125
+ else if (hue === 'red' && l > 0.7) return 'pink';
126
+ if (hue === 'brown') return l < 0.18 ? 'dark brown' : 'brown';
127
+ const tone = l < 0.36 ? 'dark ' : l > 0.78 ? 'light ' : s < 0.35 ? 'muted ' : (s > 0.85 && l > 0.4 && l < 0.6 ? 'vivid ' : '');
128
+ return tone + hue;
129
+ }
130
+
131
+ /** "#1e90ff", "rgb(30, 144, 255)" or "hsl(210, 100%, 56%)". */
132
+ export function formatColor(rgb, format = 'hex') {
133
+ if (format === 'rgb') return `rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`;
134
+ if (format === 'hsl') {
135
+ const { h, s, l } = hsvToHsl(rgbToHsv(rgb));
136
+ return `hsl(${round(h) % 360}, ${round(s * 100)}%, ${round(l * 100)}%)`;
137
+ }
138
+ return rgbToHex(rgb);
139
+ }
package/src/o-clock.js CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * <o-clock> — analog clock / watch face. The kit's flagship.
2
+ * <o-clock>: an analog clock or watch face.
3
3
  *
4
- * The idiomatic Orbit clock: every part is a plain element on a nested orbit,
5
- * and the rotation math is the framework's job. Hands are `.vector`s at the
6
- * center orbit, pointed by `--o-angle-composite`; ticks are vectors
7
- * auto-distributed around a ring; numerals are satellites on an outer ring;
8
- * sub-dials are nested gravity-spots with their own hands.
4
+ * Every part is a plain Orbit element and the rotation math is Orbit's job.
5
+ * The dial is the universe (orbit-12 is its edge). Hands are `.vector`s on the
6
+ * center orbit, pointed by `--o-angle-composite`; ticks are vectors spread
7
+ * around orbit-11; numerals are satellites on orbit-9; sub-dials are
8
+ * satellites that hold a universe of their own, with their own hands.
9
9
  *
10
10
  * Variants:
11
11
  * - case: round | square | rounded | rectangle (the body/bezel)
@@ -17,53 +17,57 @@
17
17
  * - presets: classic | roman | minimal | chrono | smartwatch
18
18
  *
19
19
  * <o-clock live seconds></o-clock>
20
- * <o-clock preset="chrono" live brand="ORBIT"></o-clock>
20
+ * <o-clock preset="chrono" live brand="Orbit"></o-clock>
21
21
  * <o-clock case="rectangle" dial="oval" live smooth></o-clock> <!-- tonneau -->
22
22
  *
23
23
  * Hands/readouts are patched in place every frame/second; the structure is
24
24
  * rebuilt only when the face itself changes.
25
25
  */
26
- import { OrbitKitElement, define, injectStyleOnce } from './base.js';
26
+ import { OrbitKitElement, define, injectStyleOnce, universe, hub, escapeHTML as e } from './base.js';
27
27
 
28
28
  const CLOCK_CSS = `
29
+ o-clock { --ok-font-size: 7.5cqmin; }
29
30
  o-clock .ok-clock-case {
30
31
  box-sizing: border-box; display: flex; align-items: center; justify-content: center;
31
32
  }
33
+ o-clock .ok-clock-case > .bigbang { width: 100%; }
32
34
  o-clock .ok-clock-case--round { border-radius: 50%; }
33
- o-clock .ok-clock-case--square { border-radius: 9%; }
34
- o-clock .ok-clock-case--rounded { border-radius: 26%; }
35
- o-clock .ok-clock-case--rectangle { border-radius: 24%; }
35
+ o-clock .ok-clock-case--square { border-radius: 9%; padding: 8%; }
36
+ o-clock .ok-clock-case--rounded { border-radius: 26%; padding: 8%; }
37
+ o-clock .ok-clock-case--rectangle { border-radius: 24%; padding: 8%; }
38
+ o-clock .ok-clock-case--rectangle > .bigbang { height: auto; align-self: stretch; aspect-ratio: auto; }
36
39
  o-clock .ok-clock-case--square,
37
40
  o-clock .ok-clock-case--rounded,
38
41
  o-clock .ok-clock-case--rectangle {
39
- background: var(--ok-clock-case-bg, linear-gradient(160deg, #232a34, #11151b));
42
+ background: var(--ok-clock-case-bg, var(--ok-case, linear-gradient(160deg, #232a34, #11151b)));
40
43
  box-shadow: 0 10px 30px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.06);
41
44
  }
42
- o-clock .ok-clock-face { color: var(--ok-clock-numeral, #e6edf3); }
45
+ o-clock .ok-clock-face { color: var(--ok-clock-numeral, var(--ok-text, #e6edf3)); }
43
46
  o-clock .ok-clock-dial {
44
- background: var(--ok-clock-face, radial-gradient(circle at 50% 35%, #1b212b, #0c0f14));
45
- box-shadow: inset 0 0 0 4px var(--ok-clock-bezel, #2a3340), inset 0 4px 18px rgba(0,0,0,.6);
47
+ background: var(--ok-clock-face, var(--ok-face, radial-gradient(circle at 50% 35%, #1b212b, #0c0f14)));
48
+ box-shadow: inset 0 0 0 4px var(--ok-clock-bezel, var(--ok-bezel, #2a3340)), inset 0 4px 18px rgba(0,0,0,.6);
46
49
  }
47
- o-clock .ok-clock-tick { background: var(--ok-clock-tick, rgba(230,237,243,.35)); height: 2px; border-radius: 2px; }
48
- o-clock .ok-clock-tick-h { background: var(--ok-clock-tick-h, #e6edf3); height: 3px; border-radius: 2px; }
50
+ o-clock .ok-clock-tick { background: var(--ok-clock-tick, var(--ok-tick, rgba(230,237,243,.35))); height: max(1px, .6cqmin); border-radius: 2px; }
51
+ o-clock .ok-clock-tick-h { background: var(--ok-clock-tick-h, var(--ok-hand, #e6edf3)); height: max(2px, 1cqmin); border-radius: 2px; }
49
52
  o-clock .ok-clock-num { font-weight: 700; font-variant-numeric: tabular-nums; }
50
- o-clock .ok-clock-brand { font-size: .62em; letter-spacing: .18em; font-weight: 800; text-transform: uppercase; color: var(--ok-clock-accent, #ef4565); }
51
- o-clock .ok-clock-digital { font-size: .7em; font-weight: 700; letter-spacing: .04em; font-variant-numeric: tabular-nums; opacity: .85; }
52
- o-clock .ok-clock-date { font-size: .62em; font-weight: 800; font-variant-numeric: tabular-nums; padding: .12em .4em; border-radius: 4px; background: rgba(255,255,255,.1); }
53
- o-clock .ok-clock-hour { background: var(--ok-clock-hand, #e6edf3); height: 7px; border-radius: 4px; }
54
- o-clock .ok-clock-min { background: var(--ok-clock-hand, #e6edf3); height: 5px; border-radius: 3px; }
55
- o-clock .ok-clock-sec { background: var(--ok-clock-accent, #ef4565); height: 2px; border-radius: 2px; }
56
- o-clock .ok-clock-cap { background: var(--ok-clock-accent, #ef4565); box-shadow: 0 0 0 3px var(--ok-clock-face-cap, #0c0f14); }
57
- o-clock .ok-clock-ringtime { font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: .02em; color: var(--ok-clock-numeral, #e6edf3); text-align: center; }
53
+ o-clock .ok-clock-brand { font-size: .6em; font-weight: 800; white-space: nowrap; color: var(--ok-clock-accent, var(--ok-accent, #ef4565)); }
54
+ o-clock .ok-clock-digital { font-size: .7em; font-weight: 700; font-variant-numeric: tabular-nums; opacity: .85; }
55
+ o-clock .ok-clock-date { font-size: .62em; font-weight: 800; font-variant-numeric: tabular-nums; padding: .12em .4em; border-radius: 4px; background: color-mix(in srgb, currentColor 12%, transparent); }
56
+ o-clock .ok-clock-hour { background: var(--ok-clock-hand, var(--ok-hand, #e6edf3)); height: max(3px, 2.4cqmin); border-radius: 9px; }
57
+ o-clock .ok-clock-min { background: var(--ok-clock-hand, var(--ok-hand, #e6edf3)); height: max(2px, 1.7cqmin); border-radius: 9px; }
58
+ o-clock .ok-clock-sec { background: var(--ok-clock-accent, var(--ok-accent, #ef4565)); height: max(1px, .7cqmin); border-radius: 2px; }
59
+ o-clock .ok-clock-cap { background: var(--ok-clock-accent, var(--ok-accent, #ef4565)); box-shadow: 0 0 0 max(2px, .8cqmin) var(--ok-clock-face-cap, var(--ok-surface, #0c0f14)); }
60
+ o-clock .ok-clock-ringtime { font-weight: 800; font-variant-numeric: tabular-nums; color: var(--ok-clock-numeral, var(--ok-text, #e6edf3)); text-align: center; --ok-hub-size: 1.1em; }
58
61
  /* sub-dials */
59
62
  o-clock .ok-subdial-ring { border: 1px solid var(--ok-clock-tick, rgba(230,237,243,.3)); box-shadow: inset 0 0 8px rgba(0,0,0,.4); }
60
63
  o-clock .ok-sub-tick { background: var(--ok-clock-tick, rgba(230,237,243,.3)); height: 1px; }
61
- o-clock .ok-subdial-hand { background: var(--ok-clock-accent, #ef4565); height: 2px; border-radius: 2px; }
62
- o-clock .ok-subdial-cap { background: var(--ok-clock-tick-h, #e6edf3); }
63
- o-clock .ok-subdial-label{ font-size: .4em; opacity: .55; letter-spacing: .1em; text-transform: uppercase; }
64
+ o-clock .ok-subdial-hand { background: var(--ok-clock-accent, var(--ok-accent, #ef4565)); height: max(1px, .6cqmin); border-radius: 2px; }
65
+ o-clock .ok-subdial-cap { background: var(--ok-clock-tick-h, var(--ok-hand, #e6edf3)); }
66
+ o-clock .ok-subdial-label{ font-size: .4em; opacity: .6; }
64
67
  o-clock.ok-glow .ok-clock-hour,
65
- o-clock.ok-glow .ok-clock-min { filter: drop-shadow(0 0 4px var(--ok-clock-hand, #e6edf3)); }
66
- o-clock.ok-glow .ok-clock-sec { filter: drop-shadow(0 0 5px var(--ok-clock-accent, #ef4565)); }
68
+ o-clock.ok-glow .ok-clock-min { filter: drop-shadow(0 0 var(--ok-clock-glow, 4px) var(--ok-clock-hand, var(--ok-hand, #e6edf3))); }
69
+ o-clock.ok-glow .ok-clock-sec { filter: drop-shadow(0 0 var(--ok-clock-glow, 5px) var(--ok-clock-accent, var(--ok-accent, #ef4565))); }
70
+ o-clock .ok-ring-track, o-clock .ok-ring-value { --o-stroke: var(--o-fill); }
67
71
  `;
68
72
 
69
73
  const ROMAN = ['XII', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI'];
@@ -74,7 +78,7 @@ const PRESETS = {
74
78
  classic: { case: 'round', numerals: 'arabic' },
75
79
  roman: { case: 'round', numerals: 'roman' },
76
80
  minimal: { case: 'round', numerals: 'none' },
77
- chrono: { case: 'round', numerals: 'arabic', subdials: true, brand: 'ORBIT' },
81
+ chrono: { case: 'round', numerals: 'arabic', subdials: true, brand: 'Orbit' },
78
82
  smartwatch: { case: 'rounded', numerals: 'arabic', seconds: true, smooth: true,
79
83
  digital: true, date: true, accent: '#3da9fc' }
80
84
  };
@@ -84,7 +88,8 @@ export class OrbitClock extends OrbitKitElement {
84
88
  static get observedAttributes() {
85
89
  return ['time', 'live', 'smooth', 'seconds', 'numerals', 'size', 'preset',
86
90
  'case', 'dial', 'digital', 'date', 'brand', 'subdials',
87
- 'face', 'bezel', 'case-bg', 'hand-color', 'accent', 'tick-color', 'glow'];
91
+ 'face', 'bezel', 'case-bg', 'hand-color', 'accent', 'tick-color', 'glow',
92
+ 'hour-color', 'minute-color', 'second-color'];
88
93
  }
89
94
 
90
95
  /** Resolve a string attribute against the active preset, then a hard default. */
@@ -107,7 +112,7 @@ export class OrbitClock extends OrbitKitElement {
107
112
  const smooth = this._flag('smooth');
108
113
  return {
109
114
  time: this._str('time', null),
110
- live: this.hasAttribute('live'),
115
+ live: this._flag('live'),
111
116
  smooth,
112
117
  seconds: this._flag('seconds') || smooth,
113
118
  numerals: this._opt('numerals', 'arabic'),
@@ -118,76 +123,58 @@ export class OrbitClock extends OrbitKitElement {
118
123
  date: this._flag('date'),
119
124
  subdials: this._flag('subdials'),
120
125
  brand: this._opt('brand', null),
121
- size: this._str('size', '300px'),
122
- glow: this.hasAttribute('glow'),
123
- theme: ['face', 'bezel', 'case-bg', 'hand-color', 'accent', 'tick-color']
126
+ size: this._size('size', '300px'),
127
+ glow: this.getAttribute('glow'),
128
+ theme: ['face', 'bezel', 'case-bg', 'hand-color', 'accent', 'tick-color', 'hour-color', 'minute-color', 'second-color']
124
129
  .map((a) => this.getAttribute(a)).join('|')
125
130
  };
126
131
  }
127
132
 
128
133
  structuralSignature(cfg) {
129
134
  // Hands + digital/date/sub-dial readouts are patched live; only the face rebuilds.
130
- const { time, ...rest } = cfg;
135
+ const { time, live, smooth, ...rest } = cfg;
131
136
  return JSON.stringify(rest);
132
137
  }
133
138
 
134
139
  build(cfg) {
135
140
  injectStyleOnce('orbit-kit-clock', CLOCK_CSS);
136
- this.classList.toggle('ok-glow', cfg.glow);
137
- this._ringTime = null; // reset face mode each build
141
+ this.classList.toggle('ok-glow', cfg.glow != null);
142
+ this._ringTime = this._ringH = this._ringM = this._ringS = null; // reset face mode each build
138
143
 
139
144
  // Convenience attributes mirror the CSS custom properties.
140
145
  const map = {
141
146
  face: '--ok-clock-face', bezel: '--ok-clock-bezel', 'case-bg': '--ok-clock-case-bg',
142
147
  'hand-color': '--ok-clock-hand', accent: '--ok-clock-accent', 'tick-color': '--ok-clock-tick'
143
148
  };
144
- for (const [attr, prop] of Object.entries(map)) {
145
- const v = this.getAttribute(attr);
146
- if (v != null) this.style.setProperty(prop, v);
147
- }
148
- if (this.getAttribute('accent') == null) {
149
- const p = PRESETS[this.getAttribute('preset')];
150
- if (p && p.accent) this.style.setProperty('--ok-clock-accent', p.accent);
151
- }
149
+ const overrides = Object.fromEntries(Object.entries(map).map(([attr, prop]) => [prop, this.getAttribute(attr)]));
150
+ overrides['--ok-clock-accent'] = this.getAttribute('accent') ?? PRESETS[this.getAttribute('preset')]?.accent ?? null;
151
+ overrides['--ok-clock-glow'] = cfg.glow == null ? null : `${Math.max(0, this._num('glow', 5))}px`;
152
+ this._applyTheme(overrides);
152
153
 
153
154
  if (cfg.ringsFace) { this._buildRingsFace(cfg); return; }
154
155
 
155
- // Orbit sizes track --o-force (orbit-N diameter = N/12 * force), NOT the
156
- // box. The dial is orbit-11, so scale force to make it equal the requested
157
- // size. The case wraps the dial with extra frame for non-round shapes.
158
- const dialPx = parseFloat(cfg.size) || 300;
159
- const force = (dialPx * 12 / 11).toFixed(1);
160
- const frame = cfg.caseShape === 'round' ? 0 : Math.round(dialPx * 0.08);
161
- const caseW = dialPx + frame * 2;
162
- const caseH = caseW + (cfg.caseShape === 'rectangle' ? Math.round(dialPx * 0.26) : 0);
163
- const baseFont = (dialPx * 0.075).toFixed(1);
164
-
165
- // Oval dial: stretch the orbits to match the CASE orientation so a tall
166
- // (rectangle) case gives a tall dial (true tonneau), not a sideways oval.
167
- let ellipse = '';
168
- if (cfg.oval) {
169
- if (caseH > caseW) ellipse = `--o-ellipse-x:1;--o-ellipse-y:${(caseW / caseH).toFixed(3)};`;
170
- else if (caseW > caseH) ellipse = `--o-ellipse-x:${(caseH / caseW).toFixed(3)};--o-ellipse-y:1;`;
171
- else ellipse = '--o-ellipse-x:1;--o-ellipse-y:.82;'; // square box → gentle vertical oval
172
- }
156
+ // The case is a box around the dial; the dial is the universe. A
157
+ // rectangle case gives a tall universe whose shorter side is its width.
158
+ const caseH = cfg.caseShape === 'rectangle' ? 126 : 100;
159
+ const ellipse = cfg.oval ? `--o-ellipse-x:1;--o-ellipse-y:${cfg.caseShape === 'rectangle' ? (100 / caseH).toFixed(3) : '.82'};` : '';
173
160
 
174
161
  // --- ticks: 60 minute, 12 hour (overdraw every 5th) ---
175
162
  const minuteTicks = Array.from({ length: 60 }, () =>
176
- '<div class="vector inner-orbit shrink-55 ok-clock-tick"></div>').join('');
163
+ '<div class="vector inner-orbit ok-clock-tick"></div>').join('');
177
164
  const hourTicks = Array.from({ length: 12 }, () =>
178
- '<div class="vector inner-orbit shrink-15 ok-clock-tick-h"></div>').join('');
165
+ '<div class="vector inner-orbit ok-clock-tick-h"></div>').join('');
179
166
 
180
167
  // --- numerals ---
181
168
  const labels = cfg.numerals === 'roman' ? ROMAN
182
169
  : cfg.numerals === 'none' ? null : ARABIC;
183
170
  const numerals = labels
184
- ? `<div class="orbit-8">${labels.map((n) =>
171
+ ? `<div class="orbit-${cfg.numerals === 'roman' ? 8 : 9}">${labels.map((n) =>
185
172
  `<div class="satellite"><div class="capsule ok-clock-num">${n}</div></div>`).join('')}</div>`
186
173
  : '';
187
174
 
188
175
  // --- complications (suppressed when sub-dials own the lower face) ---
189
176
  const brand = cfg.brand
190
- ? `<div class="orbit-4"><div class="satellite angle-0"><div class="capsule ok-clock-brand">${cfg.brand}</div></div></div>`
177
+ ? `<div class="orbit-4"><div class="satellite angle-0"><div class="capsule ok-clock-brand">${e(cfg.brand)}</div></div></div>`
191
178
  : '';
192
179
  const digital = (cfg.digital && !cfg.subdials)
193
180
  ? `<div class="orbit-4"><div class="satellite angle-180"><div class="capsule ok-clock-digital"></div></div></div>`
@@ -197,32 +184,31 @@ export class OrbitClock extends OrbitKitElement {
197
184
  : '';
198
185
 
199
186
  // --- sub-dials: chronograph tri-compax at 3/6/9 ---
200
- const subForce = (dialPx * 0.96).toFixed(1);
201
187
  const subdials = cfg.subdials
202
- ? this._subdial('sec', 'angle-270', subForce, 'S') // 9 o'clock — small seconds
203
- + this._subdial('h24', 'angle-90', subForce, '24') // 3 o'clock — 24-hour
204
- + this._subdial('date', 'angle-180', subForce, '31') // 6 o'clock — date
188
+ ? this._subdial('sec', 'angle-270', 'S') // 9 o'clock: small seconds
189
+ + this._subdial('h24', 'angle-90', '24') // 3 o'clock: 24-hour
190
+ + this._subdial('date', 'angle-180', '31') // 6 o'clock: date
205
191
  : '';
206
192
 
207
- // --- hands ---
193
+ // --- hands (length in lanes; outer-orbit starts them at the center) ---
208
194
  const secondHand = cfg.seconds
209
- ? '<div class="vector outer-orbit grow-4x ok-clock-hand ok-clock-sec"></div>'
195
+ ? '<div class="vector outer-orbit ok-clock-hand ok-clock-sec" style="--o-size-ratio:10"></div>'
210
196
  : '';
211
197
 
212
198
  this.innerHTML = `
213
- <div class="ok-clock-case ok-clock-case--${cfg.caseShape}" style="width:${caseW}px;height:${caseH}px;font-size:${baseFont}px;">
214
- <div class="bigbang" style="width:100%;height:100%;">
215
- <div class="gravity-spot ok-clock-face" style="--o-force:${force}px;${ellipse}">
216
- <div class="orbit-11 ok-clock-dial"></div>
217
- <div class="orbit-10">${minuteTicks}</div>
218
- <div class="orbit-10">${hourTicks}</div>
199
+ <div class="ok-clock-case ok-clock-case--${cfg.caseShape}${cfg.caseShape === 'round' ? '' : ' ok-surface'}" style="width:${e(cfg.size)};aspect-ratio:100 / ${caseH}">
200
+ <div class="bigbang">
201
+ <div class="gravity-spot ok-clock-face"${ellipse ? ` style="${ellipse}"` : ''}>
202
+ <div class="orbit-12 ok-clock-dial ok-surface"></div>
203
+ <div class="orbit-11" style="--o-size-ratio:.45">${minuteTicks}</div>
204
+ <div class="orbit-11" style="--o-size-ratio:.9">${hourTicks}</div>
219
205
  ${numerals}
220
206
  ${brand}${digital}${date}${subdials}
221
207
  <div class="orbit-0">
222
- <div class="vector outer-orbit grow-2x ok-clock-hand ok-clock-hour"></div>
223
- <div class="vector outer-orbit grow-3x ok-clock-hand ok-clock-min"></div>
208
+ <div class="vector outer-orbit ok-clock-hand ok-clock-hour" style="--o-size-ratio:5.4"></div>
209
+ <div class="vector outer-orbit ok-clock-hand ok-clock-min" style="--o-size-ratio:8.2"></div>
224
210
  ${secondHand}
225
- <div class="satellite at-center shrink-80 ok-clock-cap"></div>
211
+ <div class="satellite at-center ok-clock-cap" style="--o-size-ratio:.9"></div>
226
212
  </div>
227
213
  </div>
228
214
  </div>
@@ -239,22 +225,22 @@ export class OrbitClock extends OrbitKitElement {
239
225
  }
240
226
 
241
227
  /**
242
- * One chronograph sub-dial: a nested gravity-spot (its own --o-force keeps it
243
- * small) with a tick ring, a labeled counter and a single live hand.
228
+ * One chronograph sub-dial: a satellite that is a universe of its own, with
229
+ * a tick ring, a labeled counter and a single live hand.
244
230
  */
245
- _subdial(kind, angleClass, subForce, label) {
231
+ _subdial(kind, angleClass, label) {
246
232
  const ticks = Array.from({ length: 12 }, () =>
247
- '<div class="vector inner-orbit shrink-60 ok-sub-tick"></div>').join('');
233
+ '<div class="vector inner-orbit ok-sub-tick"></div>').join('');
248
234
  return `
249
- <div class="orbit-5">
235
+ <div class="orbit-5" style="--o-size-ratio:5.8">
250
236
  <div class="satellite ${angleClass}">
251
- <div class="gravity-spot" style="--o-force:${subForce}px;">
252
- <div class="orbit-3 ok-subdial-ring"></div>
253
- <div class="orbit-3">${ticks}</div>
254
- <div class="orbit-1"><div class="satellite angle-180"><div class="capsule ok-subdial-label">${label}</div></div></div>
237
+ <div class="gravity-spot">
238
+ <div class="orbit-12 ok-subdial-ring"></div>
239
+ <div class="orbit-12" style="--o-size-ratio:1.6">${ticks}</div>
240
+ <div class="orbit-5"><div class="satellite angle-180"><div class="capsule ok-subdial-label">${label}</div></div></div>
255
241
  <div class="orbit-0">
256
- <div class="vector outer-orbit grow-1x ok-subdial-hand ok-sub-${kind}"></div>
257
- <div class="satellite at-center shrink-90 ok-subdial-cap"></div>
242
+ <div class="vector outer-orbit ok-subdial-hand ok-sub-${kind}" style="--o-size-ratio:8.5"></div>
243
+ <div class="satellite at-center ok-subdial-cap" style="--o-size-ratio:1.4"></div>
258
244
  </div>
259
245
  </div>
260
246
  </div>
@@ -268,35 +254,22 @@ export class OrbitClock extends OrbitKitElement {
268
254
  this._subSec = this._sub24 = this._subDate = this._digital = this._dateEl = null;
269
255
 
270
256
  const defs = [
271
- { key: 'H', color: this.getAttribute('hour-color') || '#ff453a' },
272
- { key: 'M', color: this.getAttribute('minute-color') || '#32d74b' }
257
+ { key: 'H', color: this._color('hour-color', '#ff453a') },
258
+ { key: 'M', color: this._color('minute-color', '#32d74b') }
273
259
  ];
274
- if (cfg.seconds) defs.push({ key: 'S', color: this.getAttribute('second-color') || this.getAttribute('accent') || '#0a84ff' });
275
-
276
- const outer = 1 + defs.length;
277
- const sizeNum = parseFloat(cfg.size) || 300;
278
- const force = (sizeNum * 12 / (outer + 1)).toFixed(1);
279
- const sw = Math.max(6, Math.round(sizeNum * 0.05));
260
+ if (cfg.seconds) defs.push({ key: 'S', color: this._color('second-color', this._color('accent', '#0a84ff')) });
280
261
 
262
+ const thickness = 2.6;
281
263
  const rings = defs.map((r, i) => {
282
- const orbit = outer - i;
283
- const track = `color-mix(in srgb, ${r.color} 14%, #0c0f14)`;
284
- const glow = cfg.glow ? `filter:drop-shadow(0 0 5px ${r.color});` : '';
285
- const fill = `--o-fill:${r.color};--o-stroke:${r.color};--o-stroke-width:${sw};${glow}`;
286
- // Solid track o-arc behind + o-progress value (o-progress back is hollow).
287
- return `<div class="orbit-${orbit}"><o-arc value="100" shape="rounded" style="--o-fill:${track};--o-stroke:${track};--o-stroke-width:${sw}"></o-arc></div>`
288
- + `<div class="orbit-${orbit}"><o-progress class="ok-ring-${r.key}" value="0" max="100" shape="rounded" style="${fill}"></o-progress></div>`;
264
+ const ring = `class="orbit-${12 - i * 3}" style="--o-size-ratio:${thickness};--o-gap:0"`;
265
+ const track = `color-mix(in srgb, ${r.color} 18%, transparent)`;
266
+ const glow = cfg.glow != null ? `filter:drop-shadow(0 0 ${Math.max(0, this._num('glow', 5))}px ${r.color});` : '';
267
+ // Solid track arc behind the progress value.
268
+ return `<div ${ring}><o-arc class="inner-orbit ok-ring-track" value="100" shape="rounded" style="--o-fill:${track}"></o-arc></div>`
269
+ + `<div ${ring}><o-progress class="inner-orbit ok-ring-value ok-ring-${r.key}" value="0" max="100" shape="rounded" style="${e(`--o-fill:${r.color};${glow}`)}"></o-progress></div>`;
289
270
  }).join('');
290
-
291
- this.innerHTML = `
292
- <div class="bigbang" style="width:${cfg.size};aspect-ratio:1;">
293
- <div class="gravity-spot" style="--o-force:${force}px;">
294
- ${rings}
295
- <div class="orbit-0"><div class="satellite at-center">
296
- <div class="capsule ok-clock-ringtime" style="font-size:${(sizeNum * 0.13).toFixed(1)}px;"></div>
297
- </div></div>
298
- </div>
299
- </div>`;
271
+ const hole = 12 - (defs.length - 1) * 3 - thickness;
272
+ this.innerHTML = universe(cfg.size, `${rings}${hub(2 * hole - .5, '', 'ok-hub ok-clock-ringtime')}`);
300
273
 
301
274
  this._ringH = this.querySelector('.ok-ring-H');
302
275
  this._ringM = this.querySelector('.ok-ring-M');
@@ -317,15 +290,17 @@ export class OrbitClock extends OrbitKitElement {
317
290
  /** Point all hands and update digital/date readouts for a given Date. */
318
291
  _paint(date, smooth) {
319
292
  const h = date.getHours(), m = date.getMinutes();
293
+ this._describe(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`);
294
+ const text = (el, value) => { if (el && el.textContent !== value) el.textContent = value; };
320
295
  const s = smooth ? date.getSeconds() + date.getMilliseconds() / 1000 : date.getSeconds();
321
296
 
322
297
  // Rings face (iOS-style): fill concentric progress rings by time.
323
298
  if (this._ringTime) {
324
- const setV = (el, frac) => el && el.setAttribute('value', (frac * 100).toFixed(2));
299
+ const setV = (el, frac) => { const v = (frac * 100).toFixed(2); if (el && el.getAttribute('value') !== v) el.setAttribute('value', v); };
325
300
  setV(this._ringH, ((h % 12) + m / 60) / 12);
326
301
  setV(this._ringM, (m + s / 60) / 60);
327
302
  setV(this._ringS, (s % 60) / 60);
328
- this._ringTime.textContent = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
303
+ text(this._ringTime, `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
329
304
  return;
330
305
  }
331
306
 
@@ -340,18 +315,20 @@ export class OrbitClock extends OrbitKitElement {
340
315
  set(this._subDate, ((date.getDate() - 1) / 31) * 360 - 90);
341
316
  // Digital / date readouts.
342
317
  if (this._digital) {
343
- this._digital.textContent = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
318
+ text(this._digital, `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
344
319
  }
345
- if (this._dateEl) this._dateEl.textContent = String(date.getDate());
320
+ text(this._dateEl, String(date.getDate()));
346
321
  }
347
322
 
348
323
  /** Resolve the configured time: explicit `time="HH:MM[:SS]"` or now. */
349
324
  _resolveDate(cfg) {
350
- if (cfg.time) {
351
- const [hh, mm, ss] = cfg.time.split(':').map((n) => parseInt(n, 10));
352
- const d = new Date();
353
- d.setHours(hh || 0, mm || 0, ss || 0, 0);
354
- return d;
325
+ if (!cfg.live && /^\d{1,2}:\d{2}(?::\d{2})?$/.test(cfg.time || '')) {
326
+ const [hh, mm, ss = 0] = cfg.time.split(':').map(Number);
327
+ if (hh < 24 && mm < 60 && ss < 60) {
328
+ const d = new Date();
329
+ d.setHours(hh, mm, ss, 0);
330
+ return d;
331
+ }
355
332
  }
356
333
  return new Date();
357
334
  }