eidotter 0.37.1 → 0.37.2

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.
Files changed (43) hide show
  1. package/LICENSE.md +3 -3
  2. package/dist/components/Brand/components/Wordmark.d.ts +1 -1
  3. package/dist/components/Brand/components/Wordmark.js +1 -1
  4. package/dist/components/CalendarHeatmap/components/CalendarHeatmap.d.ts +40 -0
  5. package/dist/components/CalendarHeatmap/components/CalendarHeatmap.js +84 -0
  6. package/dist/components/CalendarHeatmap/components/index.d.ts +2 -0
  7. package/dist/components/CalendarHeatmap/components/index.js +1 -0
  8. package/dist/components/CalendarHeatmap/index.d.ts +2 -0
  9. package/dist/components/CalendarHeatmap/index.js +1 -0
  10. package/dist/components/CommandPrompt/components/CommandPrompt.js +1 -1
  11. package/dist/components/HoldToComplete/components/HoldToComplete.d.ts +30 -0
  12. package/dist/components/HoldToComplete/components/HoldToComplete.js +145 -0
  13. package/dist/components/HoldToComplete/components/index.d.ts +2 -0
  14. package/dist/components/HoldToComplete/components/index.js +1 -0
  15. package/dist/components/HoldToComplete/index.d.ts +2 -0
  16. package/dist/components/HoldToComplete/index.js +1 -0
  17. package/dist/components/InlineExpand/components/InlineExpand.d.ts +1 -1
  18. package/dist/components/InlineExpand/components/InlineExpand.js +30 -2
  19. package/dist/components/ProgressRing/components/ProgressRing.d.ts +35 -0
  20. package/dist/components/ProgressRing/components/ProgressRing.js +34 -0
  21. package/dist/components/ProgressRing/components/index.d.ts +2 -0
  22. package/dist/components/ProgressRing/components/index.js +1 -0
  23. package/dist/components/ProgressRing/index.d.ts +2 -0
  24. package/dist/components/ProgressRing/index.js +1 -0
  25. package/dist/components/TimelineContainer/components/TimelineContainer.js +1 -1
  26. package/dist/components/registry.d.ts +1 -1
  27. package/dist/components/registry.js +422 -16
  28. package/dist/eidotter.css +1 -1
  29. package/dist/index.d.ts +6 -0
  30. package/dist/index.es.js +1790 -501
  31. package/dist/index.umd.js +3 -3
  32. package/package.json +12 -4
  33. package/src/styles/dos-utilities.css +15 -6
  34. package/src/styles/fonts/JetBrainsMonoNerdFont-Bold.woff2 +0 -0
  35. package/src/styles/fonts/JetBrainsMonoNerdFont-Medium.woff2 +0 -0
  36. package/src/styles/fonts/JetBrainsMonoNerdFont-Regular.woff2 +0 -0
  37. package/src/styles/fonts/JetBrainsMonoNerdFont-SemiBold.woff2 +0 -0
  38. package/src/styles/fonts/JetBrainsMonoNerdFont_OFL.txt +93 -0
  39. package/src/styles/fonts.css +50 -3
  40. package/src/styles/tokens.css +9 -4
  41. package/tailwind.preset.cjs +9 -1
  42. package/src/styles/fonts/Flexi_IBM_VGA_LICENSE.txt +0 -428
  43. package/src/styles/fonts/Flexi_IBM_VGA_True.ttf +0 -0
package/LICENSE.md CHANGED
@@ -106,8 +106,8 @@ Perfect DOS VGA 437 by [Zeh Fernando](https://zehfernando.com) (2002, updated 20
106
106
 
107
107
  Bundled as `src/styles/fonts/Perfect_DOS_VGA_437.ttf` and loaded via `src/styles/fonts.css` (eiDotter v0.22.0 onwards). Free to redistribute and use in any project per the author's license: `src/styles/fonts/Perfect_DOS_VGA_437_LICENSE.txt`. Selling the font as a standalone is not permitted (eiDotter does not — it is bundled as part of the free design system).
108
108
 
109
- ## Flexi IBM VGA True (legacy, bundled until v0.22.x)
109
+ ## JetBrains Mono Nerd Font
110
110
 
111
- Flexi IBM VGA True v2 — bitmap-style remake of the IBM VGA BIOS font by VileR (int10h.org). Bundled as `src/styles/fonts/Flexi_IBM_VGA_True.ttf` (no longer loaded by default after v0.22.0; superseded by Perfect DOS VGA 437).
111
+ [JetBrains Mono](https://www.jetbrains.com/legalnotices/monofont/) by The JetBrains Mono Project Authors, patched with Nerd Font glyph extensions by [Ryan L McIntyre](https://github.com/ryanoasis) (Nerd Fonts v3.4.0, 2025). Provides the `dos-body` typography tier: multi-weight monospace with Nerd Font glyph extensions (powerline symbols, devicons, language logos) for terminal-aesthetic UIs.
112
112
 
113
- Licensed under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). Full license text and attribution notice: `src/styles/fonts/Flexi_IBM_VGA_LICENSE.txt`.
113
+ Bundled as four per-weight woff2 files in `src/styles/fonts/JetBrainsMonoNerdFont-{Regular,Medium,SemiBold,Bold}.woff2` and loaded via `src/styles/fonts.css` (eiDotter v0.37.0). Licensed under SIL Open Font License Version 1.1: `src/styles/fonts/JetBrainsMonoNerdFont_OFL.txt`.
@@ -7,7 +7,7 @@ export interface WordmarkProps extends HTMLAttributes<HTMLSpanElement> {
7
7
  /**
8
8
  * eiDotter wordmark — "eiDotter" with the "ei" prefix dimmed against the full-bright "Dotter".
9
9
  *
10
- * Uses the primary DOS font (Flexi IBM VGA True) and the amber phosphor palette.
10
+ * Uses the primary DOS font (Perfect DOS VGA 437) and the amber phosphor palette.
11
11
  * Sized via `font-size` on the host; the component does not set its own size by
12
12
  * default so it inherits from the surrounding type scale. Wrap in a heading
13
13
  * element if you need semantic weight.
@@ -4,7 +4,7 @@ import { cn } from '../../../utils/cn.js';
4
4
  /**
5
5
  * eiDotter wordmark — "eiDotter" with the "ei" prefix dimmed against the full-bright "Dotter".
6
6
  *
7
- * Uses the primary DOS font (Flexi IBM VGA True) and the amber phosphor palette.
7
+ * Uses the primary DOS font (Perfect DOS VGA 437) and the amber phosphor palette.
8
8
  * Sized via `font-size` on the host; the component does not set its own size by
9
9
  * default so it inherits from the surrounding type scale. Wrap in a heading
10
10
  * element if you need semantic weight.
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ /** Per-day cell state. `empty` renders blank (days outside the month). */
3
+ export type HeatmapStatus = 'success' | 'fail' | 'skip' | 'pending' | 'empty';
4
+ export interface CalendarHeatmapProps {
5
+ /** Month to render, as `'YYYY-MM'` (or any `'YYYY-MM-DD'` inside it). */
6
+ month: string;
7
+ /** Map of `'YYYY-MM-DD'` → status. Days absent from the map use `defaultStatus`. */
8
+ statuses?: Record<string, HeatmapStatus>;
9
+ /** Status for in-month days not present in `statuses` (default `'skip'`). */
10
+ defaultStatus?: HeatmapStatus;
11
+ /** First column of the week (default `'monday'`). */
12
+ weekStart?: 'monday' | 'sunday';
13
+ /** Font scale (default `'md'`). */
14
+ size?: 'sm' | 'md' | 'lg';
15
+ /**
16
+ * Colour for `success` (█) cells. Any CSS colour. Defaults to the amber brand
17
+ * token, so it re-themes; override to colour-code per series (e.g. habit colour).
18
+ */
19
+ successColor?: string;
20
+ /** Render the legend row below the grid (default `true`). */
21
+ showLegend?: boolean;
22
+ /** Override legend labels. */
23
+ legendLabels?: Partial<Record<'success' | 'fail' | 'skip' | 'pending', string>>;
24
+ /** Override the per-status glyphs. */
25
+ glyphs?: Partial<Record<HeatmapStatus, string>>;
26
+ /** Additional CSS class name. */
27
+ className?: string;
28
+ /** Accessible label for the grid. Defaults to a month + counts summary. */
29
+ 'aria-label'?: string;
30
+ }
31
+ /**
32
+ * Month calendar rendered as DOS block characters — `█` done, `░` miss,
33
+ * `·` off, `▒` pending. Presentational and domain-agnostic: callers pass a
34
+ * `'YYYY-MM-DD'` → status map rather than any domain object. Ported from the
35
+ * Tracker habit app (DMNC-1040), where it visualises a habit's month.
36
+ *
37
+ * All calendar maths run in UTC so the rendered grid never drifts by a day
38
+ * across timezones — the `'YYYY-MM-DD'` keys are treated as plain calendar dates.
39
+ */
40
+ export declare const CalendarHeatmap: React.ForwardRefExoticComponent<CalendarHeatmapProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cn } from '../../../utils/cn.js';
4
+ const DEFAULT_GLYPHS = {
5
+ success: '█',
6
+ fail: '░',
7
+ skip: '·',
8
+ pending: '▒',
9
+ empty: ' ',
10
+ };
11
+ const LEGEND_ORDER = ['success', 'fail', 'skip', 'pending'];
12
+ const DEFAULT_LEGEND = {
13
+ success: 'DONE',
14
+ fail: 'MISS',
15
+ skip: 'OFF',
16
+ pending: 'TODAY',
17
+ };
18
+ const WEEKDAYS_MON = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
19
+ const WEEKDAYS_SUN = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
20
+ const MONTHS = [
21
+ 'January', 'February', 'March', 'April', 'May', 'June',
22
+ 'July', 'August', 'September', 'October', 'November', 'December',
23
+ ];
24
+ const sizeClasses = {
25
+ sm: 'eidotter-calendar-heatmap--sm',
26
+ md: 'eidotter-calendar-heatmap--md',
27
+ lg: 'eidotter-calendar-heatmap--lg',
28
+ };
29
+ const pad2 = (n) => String(n).padStart(2, '0');
30
+ /**
31
+ * Month calendar rendered as DOS block characters — `█` done, `░` miss,
32
+ * `·` off, `▒` pending. Presentational and domain-agnostic: callers pass a
33
+ * `'YYYY-MM-DD'` → status map rather than any domain object. Ported from the
34
+ * Tracker habit app (DMNC-1040), where it visualises a habit's month.
35
+ *
36
+ * All calendar maths run in UTC so the rendered grid never drifts by a day
37
+ * across timezones — the `'YYYY-MM-DD'` keys are treated as plain calendar dates.
38
+ */
39
+ export const CalendarHeatmap = forwardRef(({ month, statuses = {}, defaultStatus = 'skip', weekStart = 'monday', size = 'md', successColor, showLegend = true, legendLabels, glyphs, className, 'aria-label': ariaLabel, ...props }, ref) => {
40
+ const year = Number(month.slice(0, 4));
41
+ const monthIndex = Number(month.slice(5, 7)) - 1;
42
+ const valid = Number.isFinite(year) && monthIndex >= 0 && monthIndex <= 11;
43
+ const glyph = { ...DEFAULT_GLYPHS, ...glyphs };
44
+ const legend = { ...DEFAULT_LEGEND, ...legendLabels };
45
+ // Build the week rows (UTC maths — no local-timezone drift).
46
+ const weeks = [];
47
+ let successCount = 0;
48
+ let failCount = 0;
49
+ if (valid) {
50
+ const daysInMonth = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
51
+ const firstDow = new Date(Date.UTC(year, monthIndex, 1)).getUTCDay(); // 0 Sun..6 Sat
52
+ const leading = weekStart === 'monday' ? (firstDow + 6) % 7 : firstDow;
53
+ const cells = [];
54
+ for (let i = 0; i < leading; i++) {
55
+ cells.push({ key: `lead-${i}`, day: 0, status: 'empty' });
56
+ }
57
+ for (let day = 1; day <= daysInMonth; day++) {
58
+ const key = `${year}-${pad2(monthIndex + 1)}-${pad2(day)}`;
59
+ const status = statuses[key] ?? defaultStatus;
60
+ if (status === 'success')
61
+ successCount++;
62
+ if (status === 'fail')
63
+ failCount++;
64
+ cells.push({ key, day, status });
65
+ }
66
+ while (cells.length % 7 !== 0) {
67
+ cells.push({ key: `trail-${cells.length}`, day: 0, status: 'empty' });
68
+ }
69
+ for (let i = 0; i < cells.length; i += 7) {
70
+ weeks.push(cells.slice(i, i + 7));
71
+ }
72
+ }
73
+ const weekdays = weekStart === 'monday' ? WEEKDAYS_MON : WEEKDAYS_SUN;
74
+ const monthLabel = valid ? `${MONTHS[monthIndex]} ${year}` : month;
75
+ const gridLabel = ariaLabel ?? `${monthLabel}: ${successCount} done, ${failCount} missed`;
76
+ const rootStyle = successColor
77
+ ? { '--heatmap-success': successColor }
78
+ : undefined;
79
+ return (_jsxs("div", { ref: ref, className: cn('eidotter-calendar-heatmap font-dos', sizeClasses[size], className), style: rootStyle, ...props, children: [_jsxs("div", { className: "eidotter-calendar-heatmap__grid", role: "img", "aria-label": gridLabel, children: [_jsx("div", { className: "eidotter-calendar-heatmap__row", "aria-hidden": "true", children: weekdays.map((d) => (_jsx("span", { className: "eidotter-calendar-heatmap__cell eidotter-calendar-heatmap__head", children: d }, d))) }), weeks.map((week, w) => (_jsx("div", { className: "eidotter-calendar-heatmap__row", "aria-hidden": "true", children: week.map((cell) => (_jsx("span", { className: cn('eidotter-calendar-heatmap__cell', `eidotter-calendar-heatmap__cell--${cell.status}`), children: glyph[cell.status] }, cell.key))) }, w)))] }), showLegend && (
80
+ // The grid's role="img" aria-label already summarises counts; the
81
+ // glyph legend is a decorative key, hidden from assistive tech.
82
+ _jsx("div", { className: "eidotter-calendar-heatmap__legend", "aria-hidden": "true", children: LEGEND_ORDER.map((status) => (_jsxs("span", { className: `eidotter-calendar-heatmap__cell--${status}`, children: [glyph[status], " ", legend[status]] }, status))) }))] }));
83
+ });
84
+ CalendarHeatmap.displayName = 'CalendarHeatmap';
@@ -0,0 +1,2 @@
1
+ export { CalendarHeatmap } from './CalendarHeatmap.js';
2
+ export type { CalendarHeatmapProps, HeatmapStatus } from './CalendarHeatmap.js';
@@ -0,0 +1 @@
1
+ export { CalendarHeatmap } from './CalendarHeatmap.js';
@@ -0,0 +1,2 @@
1
+ export { CalendarHeatmap } from './components/index.js';
2
+ export type { CalendarHeatmapProps, HeatmapStatus } from './components/index.js';
@@ -0,0 +1 @@
1
+ export { CalendarHeatmap } from './components/index.js';
@@ -30,5 +30,5 @@ export const CommandPrompt = ({ prompt = 'C:\\>', onCommand, autoFocus = false,
30
30
  inputRef.current.focus();
31
31
  }
32
32
  };
33
- return (_jsxs("div", { className: `command-prompt ${disabled ? 'command-prompt--disabled' : ''} ${className}`.trim(), onClick: handleContainerClick, children: [_jsx("span", { className: "command-prompt__prompt", "aria-hidden": "true", children: prompt }), _jsxs("div", { className: "command-prompt__input-wrapper", children: [_jsx("input", { ref: inputRef, className: "command-prompt__input", value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleKeyDown, autoFocus: autoFocus, spellCheck: false, autoComplete: "off", autoCapitalize: "off", autoCorrect: "off", placeholder: placeholder, disabled: disabled, "aria-label": "Command input", size: value.length || 1 }), _jsx("span", { className: "command-prompt__cursor", "aria-hidden": "true", children: "\u2588" })] })] }));
33
+ return (_jsxs("div", { className: `command-prompt ${disabled ? 'command-prompt--disabled' : ''} ${className}`.trim(), onClick: handleContainerClick, children: [_jsx("span", { className: "command-prompt__prompt", "aria-hidden": "true", children: prompt }), _jsxs("div", { className: "command-prompt__input-wrapper", children: [_jsx("input", { ref: inputRef, className: "command-prompt__input", value: value, onChange: (e) => setValue(e.target.value), onKeyDown: handleKeyDown, autoFocus: autoFocus, spellCheck: false, autoComplete: "off", autoCapitalize: "off", autoCorrect: "off", placeholder: placeholder, disabled: disabled, "aria-label": "Command input", size: value.length || placeholder?.length || 1 }), _jsx("span", { className: "command-prompt__cursor", "aria-hidden": "true", children: "\u2588" })] })] }));
34
34
  };
@@ -0,0 +1,30 @@
1
+ import React from 'react';
2
+ export interface HoldToCompleteProps {
3
+ /** Fired when the hold reaches completion — the primary action. */
4
+ onHold: () => void;
5
+ /** Fired on a plain click/tap that did not complete a hold (e.g. open details). */
6
+ onTap?: () => void;
7
+ /** Fired with progress `0..1` as the hold builds — hook for haptics or sound. */
8
+ onHoldProgress?: (progress: number) => void;
9
+ /** Milliseconds the user must hold to complete (default 700). */
10
+ holdDuration?: number;
11
+ /** Ring colour. Defaults to the amber brand token. */
12
+ ringColor?: string;
13
+ /** Ring diameter as a fraction of the tile's smaller side (default 0.72). */
14
+ ringScale?: number;
15
+ /** Disable the interaction. */
16
+ disabled?: boolean;
17
+ /** Accessible label for the control. */
18
+ 'aria-label'?: string;
19
+ /** Additional CSS class name. */
20
+ className?: string;
21
+ /** The tile content. */
22
+ children: React.ReactNode;
23
+ }
24
+ /**
25
+ * Press-and-hold to complete, with a chunky {@link ProgressRing} filling during
26
+ * the hold and reversing on an early release. Works with mouse, touch, and pen
27
+ * via Pointer Events; keyboard users complete directly with Enter or Space.
28
+ * Ported from the Tracker habit app's hold-to-complete tile (DMNC-1040).
29
+ */
30
+ export declare const HoldToComplete: React.ForwardRefExoticComponent<HoldToCompleteProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,145 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
4
+ import { cn } from '../../../utils/cn.js';
5
+ import { prefersReducedMotion } from '../../../utils/prefersReducedMotion.js';
6
+ import { ProgressRing } from '../../ProgressRing/index.js';
7
+ /** Accelerating ease so tension builds toward completion. */
8
+ const easeIn = (t) => t * t;
9
+ const COMPLETE_THRESHOLD = 0.02;
10
+ const CANCEL_MS = 120;
11
+ /**
12
+ * Press-and-hold to complete, with a chunky {@link ProgressRing} filling during
13
+ * the hold and reversing on an early release. Works with mouse, touch, and pen
14
+ * via Pointer Events; keyboard users complete directly with Enter or Space.
15
+ * Ported from the Tracker habit app's hold-to-complete tile (DMNC-1040).
16
+ */
17
+ export const HoldToComplete = forwardRef(({ onHold, onTap, onHoldProgress, holdDuration = 700, ringColor, ringScale = 0.72, disabled = false, 'aria-label': ariaLabel, className, children, }, ref) => {
18
+ const [progress, setProgress] = useState(0);
19
+ const [tileSize, setTileSize] = useState(0);
20
+ const rootRef = useRef(null);
21
+ const rafRef = useRef(null);
22
+ const progressRef = useRef(0);
23
+ const fromRef = useRef(0);
24
+ const targetRef = useRef(0);
25
+ const startRef = useRef(0);
26
+ const durationRef = useRef(holdDuration);
27
+ const heldToCompletion = useRef(false);
28
+ // Keep the latest callbacks in refs so the self-scheduling rAF loop always
29
+ // invokes the current props — never a stale version captured when the hold
30
+ // began (a consumer re-render mid-hold must not fire an outdated handler).
31
+ const onHoldRef = useRef(onHold);
32
+ const onHoldProgressRef = useRef(onHoldProgress);
33
+ onHoldRef.current = onHold;
34
+ onHoldProgressRef.current = onHoldProgress;
35
+ const setProg = useCallback((p) => {
36
+ progressRef.current = p;
37
+ setProgress(p);
38
+ onHoldProgressRef.current?.(p);
39
+ }, []);
40
+ const stopRaf = useCallback(() => {
41
+ if (rafRef.current != null) {
42
+ cancelAnimationFrame(rafRef.current);
43
+ rafRef.current = null;
44
+ }
45
+ }, []);
46
+ const tick = useCallback((now) => {
47
+ const elapsed = now - startRef.current;
48
+ const d = durationRef.current;
49
+ const t = d <= 0 ? 1 : Math.min(1, elapsed / d);
50
+ const eased = targetRef.current === 1 ? easeIn(t) : t;
51
+ const p = fromRef.current + (targetRef.current - fromRef.current) * eased;
52
+ setProg(p);
53
+ if (t >= 1) {
54
+ rafRef.current = null;
55
+ if (targetRef.current === 1) {
56
+ heldToCompletion.current = true;
57
+ setProg(0);
58
+ onHoldRef.current();
59
+ }
60
+ return;
61
+ }
62
+ rafRef.current = requestAnimationFrame(tick);
63
+ }, [setProg]);
64
+ const animateTo = useCallback((target, duration) => {
65
+ stopRaf();
66
+ fromRef.current = progressRef.current;
67
+ targetRef.current = target;
68
+ durationRef.current = duration;
69
+ startRef.current =
70
+ typeof performance !== 'undefined' ? performance.now() : 0;
71
+ rafRef.current = requestAnimationFrame(tick);
72
+ }, [stopRaf, tick]);
73
+ const startHold = useCallback((e) => {
74
+ if (disabled)
75
+ return;
76
+ // Capture the pointer so the whole hold is delivered to this element —
77
+ // moving over child content won't fire pointerleave and abort it, and a
78
+ // release outside the bounds still reaches onPointerUp.
79
+ try {
80
+ e.currentTarget.setPointerCapture?.(e.pointerId);
81
+ }
82
+ catch {
83
+ /* jsdom / unsupported — capture is a progressive enhancement */
84
+ }
85
+ heldToCompletion.current = false;
86
+ setProg(0);
87
+ animateTo(1, holdDuration);
88
+ }, [disabled, holdDuration, animateTo, setProg]);
89
+ const cancelHold = useCallback(() => {
90
+ if (heldToCompletion.current)
91
+ return;
92
+ if (progressRef.current <= 0) {
93
+ stopRaf();
94
+ return;
95
+ }
96
+ if (prefersReducedMotion()) {
97
+ stopRaf();
98
+ setProg(0);
99
+ }
100
+ else {
101
+ animateTo(0, CANCEL_MS);
102
+ }
103
+ }, [animateTo, stopRaf, setProg]);
104
+ const handleClick = useCallback(() => {
105
+ if (disabled)
106
+ return;
107
+ // Suppress the click that trails a completed hold's release.
108
+ if (!heldToCompletion.current)
109
+ onTap?.();
110
+ heldToCompletion.current = false;
111
+ }, [disabled, onTap]);
112
+ const handleKeyDown = useCallback((e) => {
113
+ if (disabled || e.repeat)
114
+ return;
115
+ if (e.key === 'Enter' || e.key === ' ') {
116
+ e.preventDefault();
117
+ onHold();
118
+ }
119
+ }, [disabled, onHold]);
120
+ // Measure the tile so the ring overlay scales with it.
121
+ useEffect(() => {
122
+ const el = rootRef.current;
123
+ if (!el)
124
+ return;
125
+ const measure = () => setTileSize(Math.min(el.clientWidth, el.clientHeight));
126
+ measure();
127
+ if (typeof ResizeObserver !== 'undefined') {
128
+ const ro = new ResizeObserver(measure);
129
+ ro.observe(el);
130
+ return () => ro.disconnect();
131
+ }
132
+ }, []);
133
+ // Cancel any in-flight animation on unmount.
134
+ useEffect(() => stopRaf, [stopRaf]);
135
+ const setRefs = useCallback((node) => {
136
+ rootRef.current = node;
137
+ if (typeof ref === 'function')
138
+ ref(node);
139
+ else if (ref)
140
+ ref.current = node;
141
+ }, [ref]);
142
+ const showRing = progress > COMPLETE_THRESHOLD && tileSize > 0;
143
+ return (_jsxs("div", { ref: setRefs, className: cn('eidotter-hold', disabled && 'eidotter-hold--disabled', className), role: "button", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel, "aria-disabled": disabled || undefined, onPointerDown: startHold, onPointerUp: cancelHold, onPointerCancel: cancelHold, onClick: handleClick, onKeyDown: handleKeyDown, children: [children, showRing && (_jsx("div", { className: "eidotter-hold__overlay", children: _jsx(ProgressRing, { value: progress * 100, size: Math.floor(tileSize * ringScale), color: ringColor, "aria-label": "Hold progress" }) }))] }));
144
+ });
145
+ HoldToComplete.displayName = 'HoldToComplete';
@@ -0,0 +1,2 @@
1
+ export { HoldToComplete } from './HoldToComplete.js';
2
+ export type { HoldToCompleteProps } from './HoldToComplete.js';
@@ -0,0 +1 @@
1
+ export { HoldToComplete } from './HoldToComplete.js';
@@ -0,0 +1,2 @@
1
+ export { HoldToComplete } from './components/index.js';
2
+ export type { HoldToCompleteProps } from './components/index.js';
@@ -0,0 +1 @@
1
+ export { HoldToComplete } from './components/index.js';
@@ -4,7 +4,7 @@ export interface InlineExpandSource {
4
4
  title: string;
5
5
  /** URL — must be a valid absolute URL (http, https, or mailto) */
6
6
  url: string;
7
- /** Optional favicon URL; falls back to generic link icon */
7
+ /** Optional favicon URL. Falls back to Google Favicons API for the domain, then to a [→] text icon. */
8
8
  favicon?: string;
9
9
  }
10
10
  export interface InlineExpandProps {
@@ -4,6 +4,15 @@ import { useState, useId, useRef } from 'react';
4
4
  import { Button as AriaButton } from 'react-aria-components';
5
5
  import { cn } from '../../../utils/cn.js';
6
6
  import { isSafeUrl } from '../../../utils/isSafeUrl.js';
7
+ function getGoogleFaviconUrl(sourceUrl) {
8
+ try {
9
+ const { hostname } = new URL(sourceUrl);
10
+ return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=32`;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
7
16
  /**
8
17
  * Inline disclosure widget that reveals content when trigger text is clicked.
9
18
  *
@@ -23,7 +32,9 @@ import { isSafeUrl } from '../../../utils/isSafeUrl.js';
23
32
  */
24
33
  export const InlineExpand = ({ children, content, defaultExpanded = false, expanded, onToggle, sources = [], className, ...props }) => {
25
34
  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
26
- const [failedFavicons, setFailedFavicons] = useState(new Set());
35
+ // Tracks the current fallback stage per source URL:
36
+ // undefined = try primary; 'google' = primary failed, try Google Favicons; 'icon' = both failed
37
+ const [faviconFallbacks, setFaviconFallbacks] = useState({});
27
38
  const hasBeenExpanded = useRef(defaultExpanded);
28
39
  const contentId = useId();
29
40
  const triggerRef = useRef(null);
@@ -46,5 +57,22 @@ export const InlineExpand = ({ children, content, defaultExpanded = false, expan
46
57
  triggerRef.current?.focus();
47
58
  }
48
59
  };
49
- return (_jsxs("span", { className: cn('eidotter-inline-expand', isExpanded && 'eidotter-inline-expand--expanded', className), onKeyDown: handleKeyDown, ...props, children: [_jsxs(AriaButton, { ref: triggerRef, className: "eidotter-inline-expand__trigger", "aria-expanded": isExpanded, "aria-controls": contentId, onPress: handleToggle, children: [children, _jsx("span", { className: "eidotter-inline-expand__indicator", "aria-hidden": "true", children: isExpanded ? '[-]' : '[+]' })] }), _jsx("span", { id: contentId, className: "eidotter-inline-expand__content", role: "region", inert: !isExpanded, children: _jsxs("span", { className: "eidotter-inline-expand__content-clip", children: [_jsx("span", { className: "eidotter-inline-expand__inner", children: content }), sources.length > 0 && (_jsx("span", { className: "eidotter-inline-expand__sources", role: "list", children: sources.map((source) => (_jsx("span", { className: "eidotter-inline-expand__source-item", role: "listitem", children: _jsxs("a", { href: isSafeUrl(source.url) ? source.url : undefined, className: "eidotter-inline-expand__source-link", target: "_blank", rel: "noopener noreferrer", "aria-label": `${source.title} (opens external website)`, children: [hasBeenExpanded.current && source.favicon && isSafeUrl(source.favicon) && !failedFavicons.has(source.url) ? (_jsx("img", { className: "eidotter-inline-expand__source-favicon", src: source.favicon, alt: "", width: 16, height: 16, decoding: "async", onError: () => setFailedFavicons(prev => new Set(prev).add(source.url)) })) : (_jsx("span", { className: "eidotter-inline-expand__source-icon", "aria-hidden": "true", children: "[\u2192]" })), _jsx("span", { className: "eidotter-inline-expand__source-title", children: source.title })] }) }, source.url))) }))] }) })] }));
60
+ return (_jsxs("span", { className: cn('eidotter-inline-expand', isExpanded && 'eidotter-inline-expand--expanded', className), onKeyDown: handleKeyDown, ...props, children: [_jsxs(AriaButton, { ref: triggerRef, className: "eidotter-inline-expand__trigger", "aria-expanded": isExpanded, "aria-controls": contentId, onPress: handleToggle, children: [children, _jsx("span", { className: "eidotter-inline-expand__indicator", "aria-hidden": "true", children: isExpanded ? '[-]' : '[+]' })] }), _jsx("span", { id: contentId, className: "eidotter-inline-expand__content", role: "region", inert: !isExpanded, children: _jsxs("span", { className: "eidotter-inline-expand__content-clip", children: [_jsx("span", { className: "eidotter-inline-expand__inner", children: content }), sources.length > 0 && (_jsx("span", { className: "eidotter-inline-expand__sources", role: "list", children: sources.map((source) => (_jsx("span", { className: "eidotter-inline-expand__source-item", role: "listitem", children: _jsxs("a", { href: isSafeUrl(source.url) ? source.url : undefined, className: "eidotter-inline-expand__source-link", target: "_blank", rel: "noopener noreferrer", "aria-label": `${source.title} (opens external website)`, children: [(() => {
61
+ if (!hasBeenExpanded.current) {
62
+ return _jsx("span", { className: "eidotter-inline-expand__source-icon", "aria-hidden": "true", children: "[\u2192]" });
63
+ }
64
+ const stage = faviconFallbacks[source.url];
65
+ if (stage === 'icon') {
66
+ return _jsx("span", { className: "eidotter-inline-expand__source-icon", "aria-hidden": "true", children: "[\u2192]" });
67
+ }
68
+ const hasPrimary = !!source.favicon && isSafeUrl(source.favicon);
69
+ if (!stage && hasPrimary) {
70
+ return (_jsx("img", { className: "eidotter-inline-expand__source-favicon", src: source.favicon, alt: "", width: 16, height: 16, decoding: "async", onError: () => setFaviconFallbacks(prev => ({ ...prev, [source.url]: 'google' })) }));
71
+ }
72
+ const googleUrl = getGoogleFaviconUrl(source.url);
73
+ if (googleUrl) {
74
+ return (_jsx("img", { className: "eidotter-inline-expand__source-favicon", src: googleUrl, alt: "", width: 16, height: 16, decoding: "async", onError: () => setFaviconFallbacks(prev => ({ ...prev, [source.url]: 'icon' })) }));
75
+ }
76
+ return _jsx("span", { className: "eidotter-inline-expand__source-icon", "aria-hidden": "true", children: "[\u2192]" });
77
+ })(), _jsx("span", { className: "eidotter-inline-expand__source-title", children: source.title })] }) }, source.url))) }))] }) })] }));
50
78
  };
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ export interface ProgressRingProps {
3
+ /** Progress value from 0 to `max`. Quantized into `segments` chunks. */
4
+ value?: number;
5
+ /** Maximum value (default 100). */
6
+ max?: number;
7
+ /** Ring diameter in px (default 64). */
8
+ size?: number;
9
+ /** Ring stroke thickness in px (default 8). */
10
+ thickness?: number;
11
+ /** Number of quantized segments around the ring (default 16). */
12
+ segments?: number;
13
+ /**
14
+ * Stroke colour for filled segments. Any CSS colour. Defaults to the amber
15
+ * brand token via `currentColor`, so it re-themes and can be overridden by
16
+ * setting `color` on a wrapping element.
17
+ */
18
+ color?: string;
19
+ /** Render the quantized percentage in the centre of the ring. */
20
+ showLabel?: boolean;
21
+ /** Enable a phosphor glow on filled segments. */
22
+ glow?: boolean;
23
+ /** Additional CSS class name. */
24
+ className?: string;
25
+ /** Accessible label for screen readers. */
26
+ 'aria-label'?: string;
27
+ }
28
+ /**
29
+ * Blocky DOS-style progress ring: the arc is split into `segments` chunky
30
+ * quantized blocks. Filled blocks paint in the ring colour; the remainder
31
+ * stays as a faint track. Driven by a plain `value` prop so the renderer is
32
+ * presentational and swappable. Ported from the Tracker habit app (DMNC-1040),
33
+ * where it powers the hold-to-complete feedback ring.
34
+ */
35
+ export declare const ProgressRing: React.ForwardRefExoticComponent<ProgressRingProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cn } from '../../../utils/cn.js';
4
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
5
+ /**
6
+ * Blocky DOS-style progress ring: the arc is split into `segments` chunky
7
+ * quantized blocks. Filled blocks paint in the ring colour; the remainder
8
+ * stays as a faint track. Driven by a plain `value` prop so the renderer is
9
+ * presentational and swappable. Ported from the Tracker habit app (DMNC-1040),
10
+ * where it powers the hold-to-complete feedback ring.
11
+ */
12
+ export const ProgressRing = forwardRef(({ value = 0, max = 100, size = 64, thickness = 8, segments = 16, color, showLabel = false, glow = false, className, 'aria-label': ariaLabel, ...props }, ref) => {
13
+ const segmentCount = Math.max(1, Math.floor(segments));
14
+ const fraction = clamp(max === 0 ? 0 : value / max, 0, 1);
15
+ const filled = Math.floor(fraction * segmentCount);
16
+ // Show the quantized fraction, matching what the ring actually paints.
17
+ const quantizedPct = Math.round((filled / segmentCount) * 100);
18
+ const r = (size - thickness) / 2;
19
+ const cx = size / 2;
20
+ const cy = size / 2;
21
+ const circumference = 2 * Math.PI * r;
22
+ const per = circumference / segmentCount;
23
+ const gap = Math.max(2, per / 6);
24
+ const dash = Math.max(0.5, per - gap);
25
+ const stroke = color ?? 'currentColor';
26
+ const arcs = Array.from({ length: segmentCount }, (_, i) => {
27
+ const isFilled = i < filled;
28
+ return (_jsx("circle", { cx: cx, cy: cy, r: r, fill: "none", stroke: stroke, strokeWidth: thickness, strokeLinecap: "butt", strokeOpacity: isFilled ? 1 : 0.18,
29
+ // Draw exactly one segment arc: skip i*per, draw `dash`, then gap.
30
+ strokeDasharray: `0 ${i * per} ${dash} ${circumference}`, className: cn('eidotter-progress-ring__seg', isFilled && 'eidotter-progress-ring__seg--filled') }, i));
31
+ });
32
+ return (_jsxs("div", { ref: ref, className: cn('eidotter-progress-ring', glow && 'eidotter-progress-ring--glow', className), style: { width: size, height: size }, role: "progressbar", "aria-valuenow": quantizedPct, "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": ariaLabel ?? `Progress: ${quantizedPct}%`, ...props, children: [_jsx("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, "aria-hidden": "true", focusable: "false", children: _jsx("g", { transform: `rotate(-90 ${cx} ${cy})`, children: arcs }) }), showLabel && (_jsxs("span", { className: "eidotter-progress-ring__label font-dos", children: [quantizedPct, "%"] }))] }));
33
+ });
34
+ ProgressRing.displayName = 'ProgressRing';
@@ -0,0 +1,2 @@
1
+ export { ProgressRing } from './ProgressRing.js';
2
+ export type { ProgressRingProps } from './ProgressRing.js';
@@ -0,0 +1 @@
1
+ export { ProgressRing } from './ProgressRing.js';
@@ -0,0 +1,2 @@
1
+ export { ProgressRing } from './components/index.js';
2
+ export type { ProgressRingProps } from './components/index.js';
@@ -0,0 +1 @@
1
+ export { ProgressRing } from './components/index.js';
@@ -197,7 +197,7 @@ export const TimelineContainer = ({ entries, mode = 'interactive', zoomLevel: co
197
197
  return iso;
198
198
  }
199
199
  };
200
- return (_jsxs("div", { ref: containerRef, ...props, className: cn('font-dos text-dos-text-brand p-4 min-h-[200px]', 'eidotter-timeline-container', isStatic && 'eidotter-timeline-container--static', isFeed && 'eidotter-timeline-container--feed', isMasterDetail && 'eidotter-timeline-container--master-detail', props.className), role: "region", "aria-label": props['aria-label'] ?? 'Timeline', tabIndex: isVerticalFeed || isMasterDetail ? undefined : 0, children: [_jsx("div", { className: "sr-only", role: "status", "aria-live": "polite", "aria-atomic": "true", children: announcement }), !isVerticalFeed && !isMasterDetail && (_jsx(ZoomControls, { zoomLevel: zoomLevel, canZoomIn: canZoomIn, canZoomOut: canZoomOut, onZoomIn: handleZoomIn, onZoomOut: drillUp, onReset: reset, breadcrumbs: isDrillDownEnabled ? breadcrumbs : [], onBreadcrumbClick: (index) => {
200
+ return (_jsxs("div", { ref: containerRef, ...props, className: cn('font-dos text-dos-text-brand p-4', 'eidotter-timeline-container', isStatic && 'eidotter-timeline-container--static', isFeed && 'eidotter-timeline-container--feed', isMasterDetail && 'eidotter-timeline-container--master-detail', props.className), role: "region", "aria-label": props['aria-label'] ?? 'Timeline', tabIndex: isVerticalFeed || isMasterDetail ? undefined : 0, children: [_jsx("div", { className: "sr-only", role: "status", "aria-live": "polite", "aria-atomic": "true", children: announcement }), !isVerticalFeed && !isMasterDetail && (_jsx(ZoomControls, { zoomLevel: zoomLevel, canZoomIn: canZoomIn, canZoomOut: canZoomOut, onZoomIn: handleZoomIn, onZoomOut: drillUp, onReset: reset, breadcrumbs: isDrillDownEnabled ? breadcrumbs : [], onBreadcrumbClick: (index) => {
201
201
  // Navigate to a specific breadcrumb level
202
202
  const stepsBack = breadcrumbs.length - 1 - index;
203
203
  for (let i = 0; i < stepsBack; i++) {
@@ -1,4 +1,4 @@
1
- export type ProjectId = 'eidotter' | 'spacewar' | 'rizomorf' | 'pomodoke-calendar' | 'keepcoin' | 'steuerdash' | 'sella' | 'lifelines' | 'betamorf' | 'dmnctech';
1
+ export type ProjectId = 'eidotter' | 'spacewar' | 'rizomorf' | 'pomodoke-calendar' | 'keepcoin' | 'steuerdash' | 'sella' | 'lifelines' | 'betamorf' | 'dmnctech' | 'tracker';
2
2
  export interface ProjectInfo {
3
3
  displayName: string;
4
4
  url?: string;