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.
- package/LICENSE.md +3 -3
- package/dist/components/Brand/components/Wordmark.d.ts +1 -1
- package/dist/components/Brand/components/Wordmark.js +1 -1
- package/dist/components/CalendarHeatmap/components/CalendarHeatmap.d.ts +40 -0
- package/dist/components/CalendarHeatmap/components/CalendarHeatmap.js +84 -0
- package/dist/components/CalendarHeatmap/components/index.d.ts +2 -0
- package/dist/components/CalendarHeatmap/components/index.js +1 -0
- package/dist/components/CalendarHeatmap/index.d.ts +2 -0
- package/dist/components/CalendarHeatmap/index.js +1 -0
- package/dist/components/CommandPrompt/components/CommandPrompt.js +1 -1
- package/dist/components/HoldToComplete/components/HoldToComplete.d.ts +30 -0
- package/dist/components/HoldToComplete/components/HoldToComplete.js +145 -0
- package/dist/components/HoldToComplete/components/index.d.ts +2 -0
- package/dist/components/HoldToComplete/components/index.js +1 -0
- package/dist/components/HoldToComplete/index.d.ts +2 -0
- package/dist/components/HoldToComplete/index.js +1 -0
- package/dist/components/InlineExpand/components/InlineExpand.d.ts +1 -1
- package/dist/components/InlineExpand/components/InlineExpand.js +30 -2
- package/dist/components/ProgressRing/components/ProgressRing.d.ts +35 -0
- package/dist/components/ProgressRing/components/ProgressRing.js +34 -0
- package/dist/components/ProgressRing/components/index.d.ts +2 -0
- package/dist/components/ProgressRing/components/index.js +1 -0
- package/dist/components/ProgressRing/index.d.ts +2 -0
- package/dist/components/ProgressRing/index.js +1 -0
- package/dist/components/TimelineContainer/components/TimelineContainer.js +1 -1
- package/dist/components/registry.d.ts +1 -1
- package/dist/components/registry.js +422 -16
- package/dist/eidotter.css +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.es.js +1790 -501
- package/dist/index.umd.js +3 -3
- package/package.json +12 -4
- package/src/styles/dos-utilities.css +15 -6
- package/src/styles/fonts/JetBrainsMonoNerdFont-Bold.woff2 +0 -0
- package/src/styles/fonts/JetBrainsMonoNerdFont-Medium.woff2 +0 -0
- package/src/styles/fonts/JetBrainsMonoNerdFont-Regular.woff2 +0 -0
- package/src/styles/fonts/JetBrainsMonoNerdFont-SemiBold.woff2 +0 -0
- package/src/styles/fonts/JetBrainsMonoNerdFont_OFL.txt +93 -0
- package/src/styles/fonts.css +50 -3
- package/src/styles/tokens.css +9 -4
- package/tailwind.preset.cjs +9 -1
- package/src/styles/fonts/Flexi_IBM_VGA_LICENSE.txt +0 -428
- 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
|
-
##
|
|
109
|
+
## JetBrains Mono Nerd Font
|
|
110
110
|
|
|
111
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 @@
|
|
|
1
|
+
export { CalendarHeatmap } from './CalendarHeatmap.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 @@
|
|
|
1
|
+
export { HoldToComplete } from './HoldToComplete.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
|
|
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
|
-
|
|
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: [
|
|
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 @@
|
|
|
1
|
+
export { ProgressRing } from './ProgressRing.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
|
|
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;
|