hlmnhkl-ui 0.25.1 → 0.29.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # hlmnhkl/ui
2
2
 
3
- React components, measured down to the part you never see: the 44px tap area, the 2px focus ring, the timing of every move. Black, white and grey, and copied into your app so the code is yours.
3
+ React components, measured down to the part you never see: the 44px tap area, the 2px focus ring, the timing of every move. Black, white and grey with one blue accent, and copied into your app so the code is yours.
4
4
 
5
5
  <picture>
6
6
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/hilmanhaikal/hlmnhkl-ui/main/.github/assets/home-dark.png">
@@ -65,7 +65,7 @@ Setup for other tools: [MCP server](https://ui.hlmnhkl.my/mcp).
65
65
  ## What you get
66
66
 
67
67
  - **The source, not a package.** The command copies plain files into your app. Change anything; there is no update to wait for.
68
- - **59 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
68
+ - **63 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
69
69
  - **One look, set by tokens.** Every colour, size and timing comes from one file, `tokens.css`. Colour appears only to mean something: good, caution or bad.
70
70
  - **Light and dark.** Dark follows the system, or set `data-theme="dark"` on `<html>`.
71
71
  - **Accessible.** Keyboard support per the WAI-ARIA patterns, visible focus, screen reader labels, and reduced motion. On touch screens every control gives at least a 44px target, even the small sizes; only the six PIN boxes and the seven calendar days get narrower when the row has no room, about 36px wide on a 320px phone. CalendarHeatmap's days are small by design, since a year must fit on screen; its month table gives the same numbers at full size.
@@ -74,7 +74,7 @@ Setup for other tools: [MCP server](https://ui.hlmnhkl.my/mcp).
74
74
 
75
75
  ## Pro
76
76
 
77
- 71 more components (such as DataGrid, Calendar, Chart, KanbanBoard and Combobox), page blocks, full templates and brand themes are coming as hlmnhkl/ui Pro. You can see them on the [docs site](https://ui.hlmnhkl.my).
77
+ 72 more components (such as DataGrid, Calendar, Chart, KanbanBoard and Combobox), page blocks, full templates and brand themes are coming as hlmnhkl/ui Pro. You can see them on the [docs site](https://ui.hlmnhkl.my).
78
78
 
79
79
  ## Licence
80
80
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hlmnhkl-ui",
3
- "version": "0.25.1",
4
- "description": "React components in a monochrome style. Copy them into your app with npx hlmnhkl-ui add <name>.",
3
+ "version": "0.29.0",
4
+ "description": "React components in black, white and grey with one blue accent. Copy them into your app with npx hlmnhkl-ui add <name>.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ui.hlmnhkl.my",
7
7
  "repository": {
@@ -22,6 +22,7 @@
22
22
  "Drawer",
23
23
  "EmojiPicker",
24
24
  "FileUpload",
25
+ "GlassLens",
25
26
  "HoverCard",
26
27
  "IcInput",
27
28
  "Image",
@@ -74,7 +74,7 @@
74
74
  box-shadow: var(--hl-shadow-1);
75
75
  }
76
76
  @media (hover: hover) {
77
- .primary:hover:not([aria-disabled="true"]) { background: var(--hl-color-inverse-hover); }
77
+ .primary:hover:not([aria-disabled="true"]) { background: var(--hl-color-accent-hover); }
78
78
  }
79
79
 
80
80
  .secondary {
@@ -27,9 +27,11 @@
27
27
  max-height: calc(100dvh - var(--hl-space-5) * 2);
28
28
  display: flex;
29
29
  flex-direction: column;
30
- border: var(--hl-line) solid var(--hl-color-border);
30
+ border: var(--hl-line) solid var(--hl-glass-edge, var(--hl-color-border));
31
31
  border-radius: var(--hl-radius-panel);
32
- background: var(--hl-color-surface);
32
+ background: var(--hl-glass-bg, var(--hl-color-surface));
33
+ -webkit-backdrop-filter: var(--hl-glass-filter, none);
34
+ backdrop-filter: var(--hl-glass-filter, none);
33
35
  box-shadow: var(--hl-shadow-4);
34
36
  outline: none;
35
37
  pointer-events: auto;
@@ -6,9 +6,11 @@
6
6
  max-height: calc(100dvh - 2 * var(--hl-space-5));
7
7
  overflow-y: auto;
8
8
  padding: var(--hl-space-2);
9
- border: var(--hl-line) solid var(--hl-color-border);
9
+ border: var(--hl-line) solid var(--hl-glass-edge, var(--hl-color-border));
10
10
  border-radius: var(--hl-radius-md);
11
- background: var(--hl-color-surface);
11
+ background: var(--hl-glass-bg, var(--hl-color-surface));
12
+ -webkit-backdrop-filter: var(--hl-glass-filter, none);
13
+ backdrop-filter: var(--hl-glass-filter, none);
12
14
  box-shadow: var(--hl-shadow-3);
13
15
  outline: none;
14
16
  transform-origin: top left;
@@ -0,0 +1,176 @@
1
+ /* In the thumb zone: the bottom corner of the screen, clear of the home indicator and a notch. */
2
+ .root {
3
+ position: fixed;
4
+ bottom: calc(env(safe-area-inset-bottom) + var(--hl-fab-gap));
5
+ z-index: var(--hl-z-sticky);
6
+ display: flex;
7
+ flex-direction: column;
8
+ align-items: flex-end;
9
+ gap: var(--hl-space-4);
10
+ pointer-events: none;
11
+ }
12
+
13
+ .end { right: calc(env(safe-area-inset-right) + var(--hl-fab-gap)); }
14
+
15
+ .center {
16
+ left: 50%;
17
+ align-items: center;
18
+ translate: -50% 0;
19
+ }
20
+
21
+ .contained { position: absolute; }
22
+ .contained.end { right: var(--hl-fab-gap); }
23
+ .contained { bottom: var(--hl-fab-gap); }
24
+
25
+ .fab {
26
+ position: relative;
27
+ display: inline-flex;
28
+ align-items: center;
29
+ min-width: var(--hl-fab-size);
30
+ height: var(--hl-fab-size);
31
+ padding: 0 calc((var(--hl-fab-size) - var(--hl-icon-md)) / 2);
32
+ border: 0;
33
+ border-radius: var(--hl-radius-full);
34
+ background: var(--hl-color-accent);
35
+ color: var(--hl-color-accent-fg);
36
+ box-shadow: var(--hl-shadow-3);
37
+ font: inherit;
38
+ font-weight: var(--hl-weight-medium);
39
+ cursor: pointer;
40
+ pointer-events: auto;
41
+ -webkit-tap-highlight-color: transparent;
42
+ transition:
43
+ background-color var(--hl-motion-fast) var(--hl-ease-out),
44
+ scale var(--hl-motion-fast) var(--hl-ease-out);
45
+ }
46
+
47
+ @media (hover: hover) {
48
+ .fab:hover { background: var(--hl-color-accent-hover); }
49
+ }
50
+
51
+ .fab:active { scale: var(--hl-press-scale); }
52
+ .fab:focus-visible { outline: none; box-shadow: var(--hl-shadow-3), var(--hl-focus-ring); }
53
+
54
+ .icon {
55
+ display: grid;
56
+ place-items: center;
57
+ flex: none;
58
+ width: var(--hl-icon-md);
59
+ height: var(--hl-icon-md);
60
+ transition: rotate var(--hl-motion-slow) var(--hl-ease-spring);
61
+ }
62
+
63
+ .icon > svg { width: 100%; height: 100%; }
64
+
65
+ /* Open: the icon turns a quarter, the way a plus becomes a cross. */
66
+ .root[data-open] .icon { rotate: 45deg; }
67
+
68
+ /* The label pours away into the icon: its column closes, so the pill shrinks to a circle around the icon. */
69
+ .labelWrap {
70
+ display: grid;
71
+ grid-template-columns: 0fr;
72
+ transition: grid-template-columns var(--hl-motion-slower) var(--hl-ease-in-out);
73
+ }
74
+
75
+ .fab[data-extended] .labelWrap { grid-template-columns: 1fr; }
76
+
77
+ .label {
78
+ min-width: 0;
79
+ overflow: hidden;
80
+ padding-inline-start: 0;
81
+ white-space: nowrap;
82
+ opacity: 0;
83
+ transition:
84
+ opacity var(--hl-motion-base) var(--hl-ease-out),
85
+ padding var(--hl-motion-slower) var(--hl-ease-in-out);
86
+ }
87
+
88
+ .fab[data-extended] .label {
89
+ padding-inline: var(--hl-space-3) var(--hl-space-2);
90
+ opacity: 1;
91
+ transition-delay: 0s, 0s;
92
+ }
93
+
94
+ /* The actions pour up out of the button, the nearest first, each a short step after the last. */
95
+ .actions {
96
+ display: flex;
97
+ flex-direction: column;
98
+ align-items: flex-end;
99
+ gap: var(--hl-space-3);
100
+ margin: 0;
101
+ padding: 0;
102
+ list-style: none;
103
+ }
104
+
105
+ .center .actions { align-items: center; }
106
+
107
+ .actions li {
108
+ opacity: 0;
109
+ translate: 0 var(--hl-shift-md);
110
+ scale: 0.6;
111
+ transform-origin: 100% 100%;
112
+ transition:
113
+ opacity var(--hl-motion-base) var(--hl-ease-out),
114
+ translate var(--hl-motion-slow) var(--hl-ease-spring),
115
+ scale var(--hl-motion-slow) var(--hl-ease-spring);
116
+ }
117
+
118
+ .center .actions li { transform-origin: 50% 100%; }
119
+
120
+ .root[data-open] .actions li {
121
+ opacity: 1;
122
+ translate: none;
123
+ scale: 1;
124
+ transition-delay: calc(var(--i) * var(--hl-motion-stagger) * 2);
125
+ }
126
+
127
+ .action {
128
+ display: inline-flex;
129
+ align-items: center;
130
+ gap: var(--hl-space-4);
131
+ min-height: var(--hl-hit-min);
132
+ padding: 0;
133
+ border: 0;
134
+ background: none;
135
+ color: var(--hl-color-fg);
136
+ font: inherit;
137
+ cursor: pointer;
138
+ pointer-events: auto;
139
+ -webkit-tap-highlight-color: transparent;
140
+ }
141
+
142
+ .actionLabel {
143
+ padding: var(--hl-space-2) var(--hl-space-4);
144
+ border-radius: var(--hl-radius-full);
145
+ background: var(--hl-glass-bg, var(--hl-color-surface));
146
+ -webkit-backdrop-filter: var(--hl-glass-filter, none);
147
+ backdrop-filter: var(--hl-glass-filter, none);
148
+ box-shadow: var(--hl-shadow-2);
149
+ font-size: var(--hl-text-sm);
150
+ font-weight: var(--hl-weight-medium);
151
+ white-space: nowrap;
152
+ }
153
+
154
+ /* Lined up under the button's centre: a smaller circle in the same column. */
155
+ .actionIcon {
156
+ display: grid;
157
+ place-items: center;
158
+ width: var(--hl-hit-min);
159
+ height: var(--hl-hit-min);
160
+ margin-inline-end: calc((var(--hl-fab-size) - var(--hl-hit-min)) / 2);
161
+ border-radius: var(--hl-radius-full);
162
+ background: var(--hl-glass-bg, var(--hl-color-surface));
163
+ -webkit-backdrop-filter: var(--hl-glass-filter, none);
164
+ backdrop-filter: var(--hl-glass-filter, none);
165
+ box-shadow: var(--hl-shadow-2);
166
+ }
167
+
168
+ .actionIcon > svg { width: var(--hl-icon-sm); height: var(--hl-icon-sm); }
169
+
170
+ @media (hover: hover) {
171
+ .action:hover .actionIcon,
172
+ .action:hover .actionLabel { background: var(--hl-color-surface-hover); }
173
+ }
174
+
175
+ .action:focus-visible { outline: none; }
176
+ .action:focus-visible .actionIcon { box-shadow: var(--hl-shadow-2), var(--hl-focus-ring); }
@@ -0,0 +1,117 @@
1
+ 'use client'
2
+
3
+ import { useId, useRef, useState, type ReactNode } from 'react'
4
+ import { cx } from '../../internal/cx'
5
+ import { useDismiss } from '../../internal/layers'
6
+ import { useScrollFrame } from '../../internal/scrollFrame'
7
+ import styles from './Fab.module.css'
8
+
9
+ export type FabAction = {
10
+ /** Shown beside the action's button and read as its name. */
11
+ label: string
12
+ icon: ReactNode
13
+ onSelect: () => void
14
+ }
15
+
16
+ export type FabProps = {
17
+ /** The button's name. Shown as text when extended, and always read by screen readers. */
18
+ label: string
19
+ /** The icon in the button, such as a plus. With actions it turns a quarter while they are open. */
20
+ icon: ReactNode
21
+ /** Called on tap. With actions, a tap opens them instead. */
22
+ onClick?: () => void
23
+ /** Shows the label beside the icon. It pours away into the icon as the page scrolls down, and back on the way up. */
24
+ extended?: boolean
25
+ /** A short stack of actions that pours up out of the button when it is tapped. */
26
+ actions?: FabAction[]
27
+ /** Where it sits along the bottom of the screen. */
28
+ position?: 'end' | 'center'
29
+ /** Sits at the bottom of its nearest positioned parent instead of the screen, for a demo or a panel. */
30
+ contained?: boolean
31
+ /** Extra class for the root element. */
32
+ className?: string
33
+ }
34
+
35
+ // One main action, floating in the thumb zone at the bottom of the screen.
36
+ export function Fab({ label, icon, onClick, extended = false, actions, position = 'end', contained = false, className }: FabProps) {
37
+ const id = useId()
38
+ const rootRef = useRef<HTMLDivElement>(null)
39
+ const buttonRef = useRef<HTMLButtonElement>(null)
40
+ const firstRef = useRef<HTMLButtonElement>(null)
41
+ const [open, setOpen] = useState(false)
42
+ const [compact, setCompact] = useState(false)
43
+ const last = useRef<number | null>(null)
44
+
45
+ // Extended: down the page it folds to the icon, back up it opens again. It reads the scroll, never a timer.
46
+ useScrollFrame(
47
+ () => {
48
+ const y = window.scrollY
49
+ const prev = last.current
50
+ last.current = y
51
+ if (prev === null || Math.abs(y - prev) < 4) return
52
+ const next = y > prev && y > 80
53
+ return () => setCompact(next)
54
+ },
55
+ extended,
56
+ )
57
+
58
+ useDismiss(open, [rootRef], (reason) => {
59
+ setOpen(false)
60
+ if (reason === 'escape') buttonRef.current?.focus()
61
+ })
62
+
63
+ const hasActions = Boolean(actions?.length)
64
+ const toggle = () => {
65
+ if (!hasActions) return onClick?.()
66
+ const next = !open
67
+ setOpen(next)
68
+ if (next) requestAnimationFrame(() => firstRef.current?.focus())
69
+ }
70
+
71
+ return (
72
+ <div ref={rootRef} className={cx(styles.root, styles[position], contained && styles.contained, className)} data-open={open || undefined}>
73
+ {hasActions && (
74
+ <ul id={`${id}-actions`} className={styles.actions} inert={!open}>
75
+ {actions!.map((a, i) => (
76
+ <li key={a.label} style={{ ['--i' as string]: actions!.length - 1 - i }}>
77
+ <button
78
+ ref={i === 0 ? firstRef : undefined}
79
+ type="button"
80
+ className={styles.action}
81
+ onClick={() => {
82
+ setOpen(false)
83
+ buttonRef.current?.focus()
84
+ a.onSelect()
85
+ }}
86
+ >
87
+ <span className={styles.actionLabel}>{a.label}</span>
88
+ <span className={styles.actionIcon} aria-hidden="true">
89
+ {a.icon}
90
+ </span>
91
+ </button>
92
+ </li>
93
+ ))}
94
+ </ul>
95
+ )}
96
+ <button
97
+ ref={buttonRef}
98
+ type="button"
99
+ className={styles.fab}
100
+ aria-label={extended ? undefined : label}
101
+ aria-expanded={hasActions ? open : undefined}
102
+ aria-controls={hasActions ? `${id}-actions` : undefined}
103
+ data-extended={(extended && !compact) || undefined}
104
+ onClick={toggle}
105
+ >
106
+ <span className={styles.icon} aria-hidden="true">
107
+ {icon}
108
+ </span>
109
+ {extended && (
110
+ <span className={styles.labelWrap}>
111
+ <span className={styles.label}>{label}</span>
112
+ </span>
113
+ )}
114
+ </button>
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1 @@
1
+ export * from './Fab'
@@ -74,5 +74,5 @@
74
74
  }
75
75
  .primary { background: var(--hl-color-accent); color: var(--hl-color-accent-fg); }
76
76
  @media (hover: hover) {
77
- .primary:hover:not(:disabled) { background: var(--hl-color-inverse-hover); }
77
+ .primary:hover:not(:disabled) { background: var(--hl-color-accent-hover); }
78
78
  }
@@ -4,9 +4,11 @@
4
4
  width: max-content;
5
5
  max-width: min(var(--hl-panel-w-sm), calc(100vw - var(--hl-space-5) * 2));
6
6
  padding: var(--hl-space-5);
7
- border: var(--hl-line) solid var(--hl-color-border);
7
+ border: var(--hl-line) solid var(--hl-glass-edge, var(--hl-color-border));
8
8
  border-radius: var(--hl-radius-panel);
9
- background: var(--hl-color-surface);
9
+ background: var(--hl-glass-bg, var(--hl-color-surface));
10
+ -webkit-backdrop-filter: var(--hl-glass-filter, none);
11
+ backdrop-filter: var(--hl-glass-filter, none);
10
12
  box-shadow: var(--hl-shadow-3);
11
13
  outline: none;
12
14
  transform-origin: top left;
@@ -0,0 +1,115 @@
1
+ /* A line of about 68 characters, the length an eye can follow back to the next line without losing it.
2
+ :where keeps every rule at zero weight, so a class you add inside always wins. */
3
+ .root {
4
+ max-width: var(--hl-prose-w);
5
+ color: var(--hl-color-fg);
6
+ font-size: var(--hl-text-md);
7
+ line-height: var(--hl-leading-normal);
8
+ overflow-wrap: break-word;
9
+ }
10
+
11
+ .root > :where(:first-child) { margin-top: 0; }
12
+ .root > :where(:last-child) { margin-bottom: 0; }
13
+
14
+ .root :where(p, ul, ol, blockquote, pre, figure, table, hr) { margin: 0 0 var(--hl-space-6); }
15
+
16
+ /* A heading sits close to the text it opens and well clear of the text before it. */
17
+ .root :where(h2, h3, h4) {
18
+ margin: var(--hl-space-10) 0 var(--hl-space-4);
19
+ font-weight: var(--hl-weight-semibold);
20
+ letter-spacing: var(--hl-tracking-tight);
21
+ line-height: var(--hl-leading-tight);
22
+ text-wrap: balance;
23
+ }
24
+
25
+ .root :where(h2) { font-size: var(--hl-text-2xl); }
26
+ .root :where(h3) { font-size: var(--hl-text-xl); }
27
+ .root :where(h4) { font-size: var(--hl-text-lg); }
28
+ .root :where(h2, h3, h4) + :where(h3, h4) { margin-top: var(--hl-space-5); }
29
+
30
+ .root :where(strong) { font-weight: var(--hl-weight-semibold); }
31
+
32
+ .root :where(a) {
33
+ color: var(--hl-color-fg);
34
+ font-weight: var(--hl-weight-medium);
35
+ text-decoration: underline;
36
+ text-decoration-color: var(--hl-color-border-strong);
37
+ text-decoration-thickness: var(--hl-line);
38
+ text-underline-offset: 0.2em;
39
+ transition: text-decoration-color var(--hl-motion-fast) var(--hl-ease-out);
40
+ }
41
+
42
+ @media (hover: hover) {
43
+ .root :where(a):hover { text-decoration-color: currentColor; }
44
+ }
45
+
46
+ .root :where(a):focus-visible { outline: none; border-radius: var(--hl-radius-xs); box-shadow: var(--hl-focus-ring); }
47
+
48
+ .root :where(ul, ol) { padding-left: var(--hl-space-6); }
49
+ .root :where(li) { margin-bottom: var(--hl-space-2); padding-left: var(--hl-space-1); }
50
+ .root :where(li) > :where(ul, ol) { margin: var(--hl-space-2) 0 0; }
51
+ .root :where(li)::marker { color: var(--hl-color-fg-subtle); }
52
+
53
+ /* A quote sits on a soft panel of its own, not behind a line down its side. */
54
+ .root :where(blockquote) {
55
+ padding: var(--hl-space-5) var(--hl-space-6);
56
+ border-radius: var(--hl-radius-md);
57
+ background: var(--hl-color-surface-sunken);
58
+ color: var(--hl-color-fg-muted);
59
+ }
60
+
61
+ .root :where(blockquote) > :where(:last-child) { margin-bottom: 0; }
62
+
63
+ .root :where(code) {
64
+ padding: 0.1em 0.35em;
65
+ border-radius: var(--hl-radius-xs);
66
+ background: var(--hl-color-surface-sunken);
67
+ font-family: var(--hl-font-mono);
68
+ font-size: 0.9em;
69
+ }
70
+
71
+ .root :where(pre) {
72
+ overflow-x: auto;
73
+ padding: var(--hl-space-5);
74
+ border-radius: var(--hl-radius-md);
75
+ background: var(--hl-code-bg);
76
+ color: var(--hl-code-fg);
77
+ font-size: var(--hl-text-sm);
78
+ line-height: var(--hl-leading-normal);
79
+ }
80
+
81
+ .root :where(pre) :where(code) { padding: 0; background: none; font-size: inherit; }
82
+
83
+ .root :where(img, video) {
84
+ display: block;
85
+ max-width: 100%;
86
+ height: auto;
87
+ border-radius: var(--hl-radius-md);
88
+ }
89
+
90
+ .root :where(figcaption) {
91
+ margin-top: var(--hl-space-3);
92
+ color: var(--hl-color-fg-muted);
93
+ font-size: var(--hl-text-sm);
94
+ }
95
+
96
+ .root :where(hr) {
97
+ height: 0;
98
+ border: 0;
99
+ border-top: var(--hl-line) solid var(--hl-color-border);
100
+ }
101
+
102
+ .root :where(table) {
103
+ display: block;
104
+ overflow-x: auto;
105
+ border-collapse: collapse;
106
+ font-size: var(--hl-text-sm);
107
+ }
108
+
109
+ .root :where(th, td) {
110
+ padding: var(--hl-space-3) var(--hl-space-4);
111
+ border-bottom: var(--hl-line) solid var(--hl-color-border);
112
+ text-align: left;
113
+ }
114
+
115
+ .root :where(th) { font-weight: var(--hl-weight-semibold); }
@@ -0,0 +1,14 @@
1
+ import type { ComponentPropsWithRef, ElementType } from 'react'
2
+ import { cx } from '../../internal/cx'
3
+ import styles from './Prose.module.css'
4
+
5
+ export type ProseProps = {
6
+ /** The element to render, such as article. */
7
+ as?: ElementType
8
+ } & ComponentPropsWithRef<'div'>
9
+
10
+ // Long text written elsewhere, such as Markdown or a CMS: headings, paragraphs, lists, quotes, code and
11
+ // pictures, set for reading. It styles plain HTML inside it, so nothing needs a class.
12
+ export function Prose({ as: Tag = 'div', className, ...rest }: ProseProps) {
13
+ return <Tag className={cx(styles.root, className)} {...rest} />
14
+ }
@@ -0,0 +1 @@
1
+ export * from './Prose'
@@ -19,10 +19,12 @@
19
19
  display: flex;
20
20
  flex-direction: column;
21
21
  margin-left: calc(min(100%, var(--hl-panel-w-lg)) / -2);
22
- border: var(--hl-line) solid var(--hl-color-border);
22
+ border: var(--hl-line) solid var(--hl-glass-edge, var(--hl-color-border));
23
23
  border-bottom: 0;
24
24
  border-radius: var(--hl-radius-sheet) var(--hl-radius-sheet) 0 0;
25
- background: var(--hl-color-surface);
25
+ background: var(--hl-glass-bg, var(--hl-color-surface));
26
+ -webkit-backdrop-filter: var(--hl-glass-filter, none);
27
+ backdrop-filter: var(--hl-glass-filter, none);
26
28
  box-shadow: var(--hl-shadow-4);
27
29
  outline: none;
28
30
  padding-bottom: env(safe-area-inset-bottom);
@@ -0,0 +1,107 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--hl-space-3);
5
+ }
6
+
7
+ .title {
8
+ margin: 0 0 var(--hl-space-2);
9
+ font-size: var(--hl-text-sm);
10
+ font-weight: var(--hl-weight-semibold);
11
+ color: var(--hl-color-fg);
12
+ }
13
+
14
+ /* A strip of frames, one per section, threaded on a faint line through their middles. */
15
+ .list {
16
+ position: relative;
17
+ margin: 0;
18
+ padding: 0;
19
+ list-style: none;
20
+ }
21
+
22
+ .list::before {
23
+ content: "";
24
+ position: absolute;
25
+ top: var(--hl-space-6);
26
+ bottom: var(--hl-space-6);
27
+ left: calc(var(--hl-space-3) + var(--hl-space-2) - var(--hl-line) / 2);
28
+ border-left: var(--hl-line) solid var(--hl-color-border);
29
+ }
30
+
31
+ /* Looks compact but keeps a 44px hit area with an invisible layer. */
32
+ .link {
33
+ position: relative;
34
+ display: flex;
35
+ align-items: baseline;
36
+ gap: var(--hl-space-4);
37
+ width: 100%;
38
+ min-height: var(--hl-control-h-sm);
39
+ padding: var(--hl-space-2) var(--hl-space-3);
40
+ border-radius: var(--hl-radius-sm);
41
+ color: var(--hl-color-fg-muted);
42
+ font-size: var(--hl-text-sm);
43
+ line-height: var(--hl-leading-snug);
44
+ text-decoration: none;
45
+ transition: color var(--hl-motion-fast) var(--hl-ease-out), background-color var(--hl-motion-fast) var(--hl-ease-out);
46
+ }
47
+
48
+ .link::after {
49
+ content: "";
50
+ position: absolute;
51
+ inset-inline: 0;
52
+ inset-block: min(0px, calc((100% - var(--hl-hit-min)) / 2));
53
+ }
54
+
55
+ @media (hover: hover) {
56
+ .link:hover { color: var(--hl-color-fg); background: var(--hl-color-surface-hover); }
57
+ }
58
+
59
+ .link:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
60
+
61
+ /* Each section's frame: open ahead, filled once read, ringed where the reader is. */
62
+ .node {
63
+ position: relative;
64
+ z-index: 1;
65
+ flex: none;
66
+ width: var(--hl-space-4);
67
+ height: var(--hl-space-4);
68
+ translate: 0 calc(var(--hl-space-1) / 2);
69
+ border: var(--hl-line-thick) solid var(--hl-color-border-strong);
70
+ border-radius: var(--hl-radius-full);
71
+ background: var(--hl-color-bg);
72
+ transition:
73
+ background-color var(--hl-motion-base) var(--hl-ease-out),
74
+ border-color var(--hl-motion-base) var(--hl-ease-out),
75
+ box-shadow var(--hl-motion-base) var(--hl-ease-out);
76
+ }
77
+
78
+ [data-state="read"] .node { border-color: var(--hl-color-fg-subtle); background: var(--hl-color-fg-subtle); }
79
+
80
+ [data-state="here"] .link { color: var(--hl-color-fg); font-weight: var(--hl-weight-medium); }
81
+
82
+ [data-state="here"] .node {
83
+ border-color: var(--hl-color-fg);
84
+ background: var(--hl-color-fg);
85
+ box-shadow: 0 0 0 var(--hl-space-1) var(--hl-color-bg), 0 0 0 calc(var(--hl-space-1) + var(--hl-line-thick)) var(--hl-color-fg);
86
+ }
87
+
88
+ .toTop {
89
+ display: inline-flex;
90
+ align-items: center;
91
+ gap: var(--hl-space-2);
92
+ align-self: flex-start;
93
+ min-height: var(--hl-hit-min);
94
+ padding-inline: var(--hl-space-3);
95
+ border-radius: var(--hl-radius-sm);
96
+ color: var(--hl-color-fg-muted);
97
+ font-size: var(--hl-text-sm);
98
+ text-decoration: none;
99
+ }
100
+
101
+ .toTop > svg { width: var(--hl-icon-sm); height: var(--hl-icon-sm); }
102
+
103
+ @media (hover: hover) {
104
+ .toTop:hover { color: var(--hl-color-fg); background: var(--hl-color-surface-hover); }
105
+ }
106
+
107
+ .toTop:focus-visible { outline: none; box-shadow: var(--hl-focus-ring); }
@@ -0,0 +1,93 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, type MouseEvent } from 'react'
4
+ import { cx } from '../../internal/cx'
5
+ import { ArrowUpIcon } from '../../internal/icons'
6
+ import { prefersReducedMotion } from '../../internal/motion'
7
+ import styles from './TableOfContents.module.css'
8
+
9
+ export type TocItem = {
10
+ /** The id of the heading on the page. */
11
+ id: string
12
+ label: string
13
+ }
14
+
15
+ export type TableOfContentsProps = {
16
+ /** The sections, in page order: each heading's id and the text to show for it. */
17
+ items: TocItem[]
18
+ /** Heading above the list, also the nav's name. */
19
+ title?: string
20
+ /** Adds a way back to the top once the reader has passed the first section. */
21
+ backToTop?: boolean
22
+ /** Extra class for the root element. */
23
+ className?: string
24
+ }
25
+
26
+ // The sections of a page as links to their headings. Each is a frame on a strip: filled once read, ringed
27
+ // where the reader is, open ahead, so it shows how far through the page they are, not only where.
28
+ export function TableOfContents({ items, title = 'On this page', backToTop = false, className }: TableOfContentsProps) {
29
+ const [active, setActive] = useState(items[0]?.id)
30
+
31
+ // The section being read is the last heading past the top third of the screen.
32
+ useEffect(() => {
33
+ const headings = items.map((t) => document.getElementById(t.id)).filter((h): h is HTMLElement => !!h)
34
+ const update = () => {
35
+ const line = window.innerHeight / 3
36
+ let current = headings[0]?.id
37
+ for (const h of headings) if (h.getBoundingClientRect().top <= line) current = h.id
38
+ // A short last section never reaches the line, so the bottom of the page selects it.
39
+ const atBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2
40
+ setActive(atBottom ? headings[headings.length - 1]?.id : current)
41
+ }
42
+ update()
43
+ window.addEventListener('scroll', update, { passive: true })
44
+ window.addEventListener('resize', update)
45
+ return () => {
46
+ window.removeEventListener('scroll', update)
47
+ window.removeEventListener('resize', update)
48
+ }
49
+ }, [items])
50
+
51
+ const plain = (e: MouseEvent) => e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey
52
+
53
+ // A plain click scrolls there and keeps one history entry; Cmd-click still opens a new tab.
54
+ const go = (e: MouseEvent<HTMLAnchorElement>, id: string) => {
55
+ const heading = document.getElementById(id)
56
+ if (!plain(e) || !heading) return
57
+ e.preventDefault()
58
+ heading.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'start' })
59
+ if (!heading.hasAttribute('tabindex')) heading.setAttribute('tabindex', '-1')
60
+ heading.focus({ preventScroll: true })
61
+ window.history.replaceState(window.history.state, '', `#${id}`)
62
+ }
63
+
64
+ const toTop = (e: MouseEvent<HTMLAnchorElement>) => {
65
+ if (!plain(e)) return
66
+ e.preventDefault()
67
+ window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' })
68
+ window.history.replaceState(window.history.state, '', window.location.pathname + window.location.search)
69
+ }
70
+
71
+ const at = Math.max(0, items.findIndex((t) => t.id === active))
72
+ return (
73
+ <nav className={cx(styles.root, className)} aria-label={title}>
74
+ <p className={styles.title}>{title}</p>
75
+ <ol className={styles.list}>
76
+ {items.map((t, n) => (
77
+ <li key={t.id} data-state={n < at ? 'read' : n === at ? 'here' : 'ahead'}>
78
+ <a href={`#${t.id}`} className={styles.link} aria-current={active === t.id ? 'location' : undefined} onClick={(e) => go(e, t.id)}>
79
+ <span className={styles.node} aria-hidden="true" />
80
+ {t.label}
81
+ </a>
82
+ </li>
83
+ ))}
84
+ </ol>
85
+ {backToTop && at > 0 && (
86
+ <a href="#" className={styles.toTop} onClick={toTop}>
87
+ <ArrowUpIcon aria-hidden="true" />
88
+ Back to top
89
+ </a>
90
+ )}
91
+ </nav>
92
+ )
93
+ }
@@ -0,0 +1 @@
1
+ export * from './TableOfContents'
@@ -0,0 +1,21 @@
1
+ /* The sun and the moon share one spot: the one for the other theme turns away and shrinks as the
2
+ current one turns in. */
3
+ .icon {
4
+ display: grid;
5
+ width: 1em;
6
+ height: 1em;
7
+ }
8
+
9
+ .sun,
10
+ .moon {
11
+ grid-area: 1 / 1;
12
+ width: 100%;
13
+ height: 100%;
14
+ transition:
15
+ transform var(--hl-motion-slower) var(--hl-ease-spring),
16
+ opacity var(--hl-motion-base) var(--hl-ease-out);
17
+ }
18
+
19
+ /* Shows the theme the button switches to: the moon in light, the sun in dark. */
20
+ .icon:not([data-theme-shown="dark"]) .sun { opacity: 0; transform: rotate(90deg) scale(0.4); }
21
+ .icon[data-theme-shown="dark"] .moon { opacity: 0; transform: rotate(-90deg) scale(0.4); }
@@ -0,0 +1,69 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { MoonIcon, SunIcon } from '../../internal/icons'
5
+ import { IconButton } from '../IconButton'
6
+ import styles from './ThemeToggle.module.css'
7
+
8
+ export type Theme = 'light' | 'dark'
9
+
10
+ export type ThemeToggleProps = {
11
+ /** The theme, when your app keeps it. Leave it out and the toggle sets data-theme on <html> itself. */
12
+ theme?: Theme
13
+ /** Called with the new theme on every flip. */
14
+ onThemeChange?: (theme: Theme) => void
15
+ /** Where the choice is remembered when the toggle runs the page. Match it in themeScript. */
16
+ storageKey?: string
17
+ /** The button's size, the same as IconButton. */
18
+ size?: 'sm' | 'md' | 'lg'
19
+ /** Extra class for the button. */
20
+ className?: string
21
+ }
22
+
23
+ /** Put in <head>, before the page paints, so a saved dark theme never flashes light first. */
24
+ export const themeScript = (storageKey = 'hl-theme') =>
25
+ `try{var t=localStorage.getItem(${JSON.stringify(storageKey)});if(t!=='light'&&t!=='dark')t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.dataset.theme=t}catch(e){}`
26
+
27
+ const pageTheme = (): Theme => (document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')
28
+
29
+ // One button that flips between the light and dark theme. The sun turns into the moon and back.
30
+ export function ThemeToggle({ theme: themeProp, onThemeChange, storageKey = 'hl-theme', size = 'sm', className }: ThemeToggleProps) {
31
+ const controlled = themeProp !== undefined
32
+ const [own, setOwn] = useState<Theme>('light')
33
+ const theme = controlled ? themeProp : own
34
+
35
+ // Read after mount: the server cannot know the page's theme, and themeScript has already set it.
36
+ useEffect(() => {
37
+ if (!controlled) setOwn(pageTheme())
38
+ }, [controlled])
39
+
40
+ const next: Theme = theme === 'dark' ? 'light' : 'dark'
41
+ const flip = () => {
42
+ if (!controlled) {
43
+ document.documentElement.dataset.theme = next
44
+ try {
45
+ localStorage.setItem(storageKey, next)
46
+ } catch {
47
+ // Not saved, so it lasts only this visit; the theme still changes.
48
+ }
49
+ setOwn(next)
50
+ }
51
+ onThemeChange?.(next)
52
+ }
53
+
54
+ return (
55
+ <IconButton
56
+ label={`Switch to ${next} theme`}
57
+ variant="secondary"
58
+ size={size}
59
+ className={className}
60
+ onClick={flip}
61
+ icon={
62
+ <span className={styles.icon} data-theme-shown={theme}>
63
+ <SunIcon className={styles.sun} />
64
+ <MoonIcon className={styles.moon} />
65
+ </span>
66
+ }
67
+ />
68
+ )
69
+ }
@@ -0,0 +1 @@
1
+ export * from './ThemeToggle'
@@ -46,9 +46,11 @@
46
46
  align-items: flex-start;
47
47
  gap: var(--hl-space-4);
48
48
  padding: var(--hl-space-5) var(--hl-space-2) var(--hl-space-5) var(--hl-space-5);
49
- border: var(--hl-line) solid var(--hl-color-border);
49
+ border: var(--hl-line) solid var(--hl-glass-edge, var(--hl-color-border));
50
50
  border-radius: var(--hl-radius-md);
51
- background: var(--hl-color-surface);
51
+ background: var(--hl-glass-bg, var(--hl-color-surface));
52
+ -webkit-backdrop-filter: var(--hl-glass-filter, none);
53
+ backdrop-filter: var(--hl-glass-filter, none);
52
54
  box-shadow: var(--hl-shadow-3);
53
55
  pointer-events: auto;
54
56
  touch-action: pan-y;
@@ -59,7 +59,7 @@
59
59
 
60
60
  @media (hover: hover) {
61
61
  .root:hover:not(:disabled):not([data-on]) { background: var(--hl-color-surface-hover); }
62
- .root[data-on]:hover:not(:disabled) { background: var(--hl-color-inverse-hover); }
62
+ .root[data-on]:hover:not(:disabled) { background: var(--hl-color-accent-hover); }
63
63
  }
64
64
 
65
65
  .root:focus-visible {
package/src/tokens.css CHANGED
@@ -36,9 +36,13 @@
36
36
  --hl-color-inverse-fg: var(--hl-gray-0);
37
37
  --hl-color-inverse-hover: var(--hl-gray-700);
38
38
  --hl-color-shimmer: rgb(255 255 255 / 0.7);
39
- /* Accent: the foreground by default, so the library stays monochrome */
40
- --hl-color-accent: var(--hl-color-inverse);
41
- --hl-color-accent-fg: var(--hl-color-inverse-fg);
39
+ /* Accent: marking blue, the layout dye a machinist wipes on a part to check its fit, for what is chosen
40
+ or on: primary buttons, checked and selected states, the current tab, progress. Blue because most
41
+ apps use it for the main action, so it reads at once. Everything else stays black, white and grey.
42
+ White on it is 6.8:1. */
43
+ --hl-color-accent: #1f5aa6;
44
+ --hl-color-accent-fg: #ffffff;
45
+ --hl-color-accent-hover: #194a8a;
42
46
 
43
47
  /* Status: always paired with an icon or word */
44
48
  --hl-color-success: #12733a;
@@ -72,6 +76,8 @@
72
76
  --hl-leading-tight: 1.2;
73
77
  --hl-leading-snug: 1.4;
74
78
  --hl-leading-normal: 1.6;
79
+ /* Prose: the longest line of reading text, about the most an eye follows back to the next line */
80
+ --hl-prose-w: 68ch;
75
81
  --hl-weight-regular: 400;
76
82
  --hl-weight-medium: 500;
77
83
  --hl-weight-semibold: 600;
@@ -166,6 +172,8 @@
166
172
  --hl-motion-base: calc(220ms * var(--hl-motion-scale));
167
173
  --hl-motion-slow: calc(360ms * var(--hl-motion-scale));
168
174
  --hl-motion-slower: calc(600ms * var(--hl-motion-scale));
175
+ /* Navbar dock: the trip to and from the bottom of the screen, long enough to read as liquid */
176
+ --hl-motion-pour: calc(760ms * var(--hl-motion-scale));
169
177
  --hl-motion-stagger: calc(30ms * var(--hl-motion-scale));
170
178
  --hl-motion-delay-tooltip: 400ms;
171
179
  --hl-motion-shimmer: 1400ms;
@@ -231,6 +239,9 @@
231
239
  --hl-color-inverse: #ececec;
232
240
  --hl-color-inverse-fg: #0a0a0a;
233
241
  --hl-color-inverse-hover: var(--hl-gray-300);
242
+ --hl-color-accent: #7fa8e6;
243
+ --hl-color-accent-fg: #0a0a0a;
244
+ --hl-color-accent-hover: #9bbcec;
234
245
  --hl-color-shimmer: rgb(255 255 255 / 0.06);
235
246
  --hl-color-success: #4cc97a;
236
247
  --hl-color-warning: #f2b33d;
@@ -269,6 +280,9 @@
269
280
  --hl-color-inverse: #ececec;
270
281
  --hl-color-inverse-fg: #0a0a0a;
271
282
  --hl-color-inverse-hover: var(--hl-gray-300);
283
+ --hl-color-accent: #7fa8e6;
284
+ --hl-color-accent-fg: #0a0a0a;
285
+ --hl-color-accent-hover: #9bbcec;
272
286
  --hl-color-shimmer: rgb(255 255 255 / 0.06);
273
287
  --hl-color-success: #4cc97a;
274
288
  --hl-color-warning: #f2b33d;
@@ -296,6 +310,7 @@
296
310
  --hl-motion-base: 0ms;
297
311
  --hl-motion-slow: 0ms;
298
312
  --hl-motion-slower: 0ms;
313
+ --hl-motion-pour: 0ms;
299
314
  --hl-motion-stagger: 0ms;
300
315
  --hl-motion-shimmer: 0ms;
301
316
  --hl-motion-spin: 1600ms;
@@ -378,6 +393,9 @@
378
393
  --hl-navbar-h: 64px;
379
394
  /* Navbar float: how far the page scrolls while the bar lifts into a floating card */
380
395
  --hl-navbar-float-run: 160px;
396
+ /* Fab: the round button's size, and its gap from the bottom and side of the screen */
397
+ --hl-fab-size: 56px;
398
+ --hl-fab-gap: 16px;
381
399
  --hl-sidebar-w: 248px;
382
400
  --hl-sidebar-w-collapsed: 64px;
383
401
  --hl-tabbar-h: 64px;
@@ -403,15 +421,18 @@
403
421
  /* A cell's widest, so long text wraps and a sideways-scrolling table stays readable. */
404
422
  --hl-table-cell-max: 320px;
405
423
 
406
- /* Charts: series differ by shade, dash and marker, never colour alone */
424
+ /* Charts: series differ by colour, dash and marker, never colour alone. The colours are a survey map's
425
+ own code, in a fixed order: navy (the accent's family), contour brown, survey green, revision purple,
426
+ chart ochre. Checked for colour-blind separation (8.0 or more for adjacent pairs) and 3:1 against the surface, in both
427
+ themes. A sixth series folds into "Other" or a second chart; it never gets a made-up colour. */
407
428
  --hl-chart-h: 260px;
408
429
  --hl-chart-donut: 220px;
409
430
  --hl-chart-area-opacity: 0.14;
410
- --hl-chart-1: var(--hl-gray-950);
411
- --hl-chart-2: var(--hl-gray-600);
412
- --hl-chart-3: #8a8a8a;
413
- --hl-chart-4: var(--hl-gray-800);
414
- --hl-chart-5: var(--hl-gray-500);
431
+ --hl-chart-1: #2f64b4;
432
+ --hl-chart-2: #b36324;
433
+ --hl-chart-3: #2f9a6e;
434
+ --hl-chart-4: #8a52c2;
435
+ --hl-chart-5: #a8861a;
415
436
  --hl-chart-dash-1: none;
416
437
  --hl-chart-dash-2: 6 4;
417
438
  --hl-chart-dash-3: 1.5 3.5;
@@ -426,22 +447,23 @@
426
447
  --hl-editor-min-h: 160px;
427
448
  }
428
449
 
450
+ /* The same five colours, a step lighter for the dark surface, checked there on their own. */
429
451
  @media (prefers-color-scheme: dark) {
430
452
  :root:not([data-theme="light"]) {
431
- --hl-chart-1: var(--hl-gray-50);
432
- --hl-chart-2: var(--hl-gray-400);
433
- --hl-chart-3: #707070;
434
- --hl-chart-4: var(--hl-gray-200);
435
- --hl-chart-5: var(--hl-gray-500);
453
+ --hl-chart-1: #638fdb;
454
+ --hl-chart-2: #c97638;
455
+ --hl-chart-3: #3ea97a;
456
+ --hl-chart-4: #a873d6;
457
+ --hl-chart-5: #b0902a;
436
458
  }
437
459
  }
438
460
 
439
461
  [data-theme="dark"] {
440
- --hl-chart-1: var(--hl-gray-50);
441
- --hl-chart-2: var(--hl-gray-400);
442
- --hl-chart-3: #707070;
443
- --hl-chart-4: var(--hl-gray-200);
444
- --hl-chart-5: var(--hl-gray-500);
462
+ --hl-chart-1: #638fdb;
463
+ --hl-chart-2: #c97638;
464
+ --hl-chart-3: #3ea97a;
465
+ --hl-chart-4: #a873d6;
466
+ --hl-chart-5: #b0902a;
445
467
  }
446
468
 
447
469
  @media (prefers-reduced-motion: reduce) {