hlmnhkl-ui 0.34.0 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,7 +65,8 @@ 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
- - **68 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
68
+ - **71 free components**, from Button, Dialog, Select and Table to Toast and Retime, one speed setting for every animation. See [COMPONENTS.md](COMPONENTS.md).
69
+ - **Controls that feel made by hand.** Dial, RulerScrubber and PickerWheel are drawn onto their marks like a magnet, glide after a flick without flying past, and settle without a bounce. Sheet rests at set heights, HoldToConfirm burns a line round its edge, SwipeDeck throws its cards and SplitFlap flips like a station board.
69
70
  - **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
71
  - **Light and dark.** Dark follows the system, or set `data-theme="dark"` on `<html>`.
71
72
  - **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 +75,7 @@ Setup for other tools: [MCP server](https://ui.hlmnhkl.my/mcp).
74
75
 
75
76
  ## Pro
76
77
 
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
+ 76 more components (such as DataGrid, Calendar, Chart, KanbanBoard, Combobox and WeekSchedule), 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
79
 
79
80
  ## Licence
80
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hlmnhkl-ui",
3
- "version": "0.34.0",
3
+ "version": "0.35.1",
4
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",
@@ -25,7 +25,8 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "typecheck": "tsc -p tsconfig.json",
28
- "add": "node scripts/add.js"
28
+ "add": "node scripts/add.js",
29
+ "prepublishOnly": "node scripts/check-publish.mjs"
29
30
  },
30
31
  "devDependencies": {
31
32
  "@types/react": "^19.3.0",
package/scripts/add.js CHANGED
@@ -41,6 +41,17 @@ const KINDS = [
41
41
  { key: 'theme', plural: 'Themes', folder: 'themes', pro: true, single: true },
42
42
  ].map((k) => ({ ...k, dir: join(SRC, k.folder) }))
43
43
 
44
+ // The import lines as they read from the app's entry file. A folder inside src/ is written
45
+ // relative to src/ (Vite, Next and most setups keep the entry there), so it can be pasted as is.
46
+ function fromEntry(folder, both = true) {
47
+ const clean = folder.replace(/^\.\//, '').replace(/\/$/, '')
48
+ const inSrc = clean.startsWith('src/')
49
+ const rel = inSrc ? clean.slice(4) : clean
50
+ const where = inSrc ? 'in your entry file inside src/ (for example src/main.tsx):' : 'in a file at your project root:'
51
+ const lines = [`import './${rel}/tokens.css'`, ...(both ? [`import './${rel}/base.css'`] : [])]
52
+ return `${where}\n${lines.map((l) => ` ${l}`).join('\n')}`
53
+ }
54
+
44
55
  function fail(message, usage = true) {
45
56
  console.error(`Error: ${message}`)
46
57
  if (usage) console.error(`\n${USAGE}`)
@@ -352,7 +363,7 @@ function runInit(opts) {
352
363
  if (!opts.dryRun) writeFileSync(config, `${JSON.stringify({ to: folder }, null, 2)}\n`)
353
364
  console.log(` ${CONFIG}: added, so later commands put files in ${folder}`)
354
365
  }
355
- console.log(`\nNext:\n 1. Import both once at your app root, tokens first:\n import './${folder.replace(/^\.\//, '')}/tokens.css'\n import './${folder.replace(/^\.\//, '')}/base.css'`)
366
+ console.log(`\nNext:\n 1. Import both once, tokens first, ${fromEntry(folder)}`)
356
367
  console.log(` 2. Add your first component: npx hlmnhkl-ui add button`)
357
368
  console.log(` See everything there is: npx hlmnhkl-ui list, or ${SITE}`)
358
369
  }
@@ -417,7 +428,7 @@ function runAdd(opts) {
417
428
  for (const s of skipped) console.log(` ${s}`)
418
429
  if (skipped.some((s) => /^(tokens|base)\.css /.test(s))) console.log(' tokens.css and base.css are never replaced, so your theme edits stay.')
419
430
  }
420
- if (tokensNew) console.log(`Next: import ${join(opts.to || readConfig()?.to || DEFAULT_TO, 'tokens.css')} once at your app root (or run init).`)
431
+ if (tokensNew) console.log(`Next: import tokens.css once ${fromEntry(opts.to || readConfig()?.to || DEFAULT_TO, false)} (or run init).`)
421
432
  if (items.some((i) => i.kind === 'theme')) console.log('Themes: import each one after tokens.css, then set data-theme-brand or data-density on <html>.')
422
433
  }
423
434
 
@@ -0,0 +1,191 @@
1
+ .root {
2
+ /* The row is the pill plus the card's inner padding, so the label keeps its pixels as the card grows round it. */
3
+ --pill-pad: calc(var(--hl-space-6) - (var(--hl-hit-min) - var(--hl-text-sm) * var(--hl-leading-normal)) / 2);
4
+ position: relative;
5
+ inline-size: 100%;
6
+ max-inline-size: var(--hl-panel-w-sm);
7
+ block-size: var(--pill-row, calc(var(--hl-hit-min) + var(--pill-pad)));
8
+ /* Only the pill takes taps; the clear row either side of it lets them through to the page. */
9
+ pointer-events: none;
10
+ }
11
+
12
+ .root[data-fixed] {
13
+ position: fixed;
14
+ z-index: var(--hl-z-sticky);
15
+ inset-inline: var(--hl-space-5);
16
+ inline-size: auto;
17
+ margin-inline: auto;
18
+ }
19
+
20
+ .root[data-fixed][data-placement='bottom'] {
21
+ bottom: calc(var(--hl-space-5) + env(safe-area-inset-bottom));
22
+ }
23
+
24
+ .root[data-fixed][data-placement='top'] {
25
+ top: calc(var(--hl-space-5) + env(safe-area-inset-top));
26
+ }
27
+
28
+ .shell {
29
+ --pill-bg: var(--hl-color-surface-sunken);
30
+ --pill-fg: var(--hl-color-fg);
31
+ --pill-muted: var(--hl-color-fg-muted);
32
+ --pill-line: var(--hl-color-border);
33
+ --pill-key: var(--hl-color-border-strong);
34
+ --pill-fill: var(--hl-color-inverse);
35
+ --pill-fill-fg: var(--hl-color-inverse-fg);
36
+ position: absolute;
37
+ inset-inline: 0;
38
+ display: flex;
39
+ flex-direction: column;
40
+ border-radius: var(--hl-radius-sheet);
41
+ background: var(--pill-bg);
42
+ color: var(--pill-fg);
43
+ box-shadow: inset 0 0 0 var(--hl-line) var(--pill-key);
44
+ pointer-events: auto;
45
+ clip-path: var(--pill-shut);
46
+ }
47
+
48
+ .shell[data-tone='ink'] {
49
+ --pill-bg: var(--hl-color-inverse);
50
+ --pill-fg: var(--hl-color-inverse-fg);
51
+ --pill-muted: color-mix(in srgb, var(--pill-fg) 62%, var(--pill-bg));
52
+ --pill-line: color-mix(in srgb, var(--pill-fg) 18%, var(--pill-bg));
53
+ --pill-key: transparent;
54
+ --pill-fill: var(--hl-color-surface);
55
+ --pill-fill-fg: var(--hl-color-fg);
56
+ }
57
+
58
+ /* Shut, it closes fast and falls away. Open, the surface pours out of the pill on the liquid curve. */
59
+ .shell[data-measured] {
60
+ transition: clip-path var(--hl-motion-fast) var(--hl-ease-in);
61
+ }
62
+
63
+ .shell[data-open] {
64
+ z-index: 1;
65
+ clip-path: inset(0 0 0 0 round var(--hl-radius-sheet));
66
+ transition: clip-path var(--hl-motion-liquid) var(--hl-ease-liquid);
67
+ }
68
+
69
+ /* Before it is measured (the first paint of a server page), only the pill's words show. */
70
+ .shell:not([data-measured]):not([data-open]) {
71
+ visibility: hidden;
72
+ }
73
+
74
+ .shell:not([data-measured]):not([data-open]) .button {
75
+ visibility: visible;
76
+ }
77
+
78
+ .root[data-placement='bottom'] .shell {
79
+ bottom: 0;
80
+ flex-direction: column-reverse;
81
+ }
82
+
83
+ .root[data-placement='top'] .shell {
84
+ top: 0;
85
+ }
86
+
87
+ .row {
88
+ display: flex;
89
+ justify-content: center;
90
+ }
91
+
92
+ .root[data-placement='bottom'] .row {
93
+ padding-block-end: var(--pill-pad);
94
+ }
95
+
96
+ .root[data-placement='top'] .row {
97
+ padding-block-start: var(--pill-pad);
98
+ }
99
+
100
+ .button {
101
+ position: relative;
102
+ display: inline-flex;
103
+ align-items: center;
104
+ min-block-size: var(--hl-hit-min);
105
+ max-inline-size: 100%;
106
+ padding: 0;
107
+ border: 0;
108
+ border-radius: var(--hl-radius-full);
109
+ background: transparent;
110
+ color: inherit;
111
+ box-shadow: inset 0 0 0 var(--hl-line) var(--pill-key);
112
+ font: inherit;
113
+ font-size: var(--hl-text-sm);
114
+ font-weight: var(--hl-weight-semibold);
115
+ font-variant-numeric: tabular-nums;
116
+ white-space: nowrap;
117
+ overflow: clip;
118
+ cursor: pointer;
119
+ -webkit-tap-highlight-color: transparent;
120
+ transition: box-shadow var(--hl-motion-fast) var(--hl-ease-out);
121
+ }
122
+
123
+ .button:focus-visible {
124
+ outline: var(--hl-line-focus) solid var(--hl-color-accent);
125
+ outline-offset: calc(var(--hl-line-focus) * -2);
126
+ }
127
+
128
+ /* Open, the pill stops being a chip: its outline goes, and its words read as the card's last row. */
129
+ .shell[data-open] .button {
130
+ box-shadow: inset 0 0 0 var(--hl-line) transparent;
131
+ }
132
+
133
+ .stack {
134
+ display: grid;
135
+ }
136
+
137
+ .face {
138
+ grid-area: 1 / 1;
139
+ display: inline-flex;
140
+ align-items: center;
141
+ gap: var(--hl-space-3);
142
+ block-size: var(--hl-hit-min);
143
+ padding-inline: var(--hl-space-5);
144
+ }
145
+
146
+ .face svg {
147
+ flex: none;
148
+ font-size: var(--hl-icon-md);
149
+ }
150
+
151
+ /* The pill as its own progress bar: words under the fill turn to the fill's own ink. */
152
+ .over {
153
+ background: var(--pill-fill);
154
+ color: var(--pill-fill-fg);
155
+ clip-path: inset(0 calc(100% - var(--p) * 100%) 0 0);
156
+ transition:
157
+ clip-path var(--hl-motion-slow) var(--hl-ease-out),
158
+ opacity var(--hl-motion-fast) var(--hl-ease-out);
159
+ }
160
+
161
+ .over:dir(rtl) {
162
+ clip-path: inset(0 0 0 calc(100% - var(--p) * 100%));
163
+ }
164
+
165
+ .shell[data-open] .over {
166
+ opacity: 0;
167
+ }
168
+
169
+ .body {
170
+ padding: var(--hl-space-2) var(--hl-space-6) var(--hl-space-6);
171
+ opacity: 0;
172
+ transition: opacity var(--hl-motion-instant) var(--hl-ease-in);
173
+ }
174
+
175
+ .root[data-placement='bottom'] .body {
176
+ padding: var(--hl-space-6) var(--hl-space-6) var(--hl-space-2);
177
+ }
178
+
179
+ .shell[data-open] .body {
180
+ opacity: 1;
181
+ transition: opacity var(--hl-motion-base) var(--hl-ease-out) var(--hl-motion-fast);
182
+ }
183
+
184
+ .sr {
185
+ position: absolute;
186
+ width: 1px;
187
+ height: 1px;
188
+ overflow: hidden;
189
+ clip-path: inset(50%);
190
+ white-space: nowrap;
191
+ }
@@ -0,0 +1,181 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type CSSProperties, type ReactNode } from 'react'
4
+ import { cx } from '../../internal/cx'
5
+ import { motionMs } from '../../internal/motion'
6
+ import { useControllable } from '../../internal/useControllable'
7
+ import styles from './LivePill.module.css'
8
+
9
+ export type LivePillProps = Omit<ComponentPropsWithRef<'div'>, 'children' | 'title'> & {
10
+ /** Screen reader name of the pill, the whole state in words, such as "Map download, 63%". */
11
+ label: string
12
+ /** What the small pill shows, such as an icon, a word and a number. It stays on its spot when the pill opens. */
13
+ compact: ReactNode
14
+ /** What the open card shows above (or below) the pill's own row. */
15
+ children: ReactNode
16
+ /** Whether it is open, when you control it. */
17
+ open?: boolean
18
+ /** Starting open state when you do not control it. */
19
+ defaultOpen?: boolean
20
+ /** Called with the new open state. */
21
+ onOpenChange?: (open: boolean) => void
22
+ /** Where it sits. At the bottom it is in thumb reach and grows up; at the top it grows down. */
23
+ placement?: 'bottom' | 'top'
24
+ /** Pins it to the screen edge, clear of the safe area. Leave off to place it yourself. */
25
+ fixed?: boolean
26
+ /** From 0 to 1: the pill fills from its start like a progress bar, and its words change ink under the fill. */
27
+ progress?: number
28
+ /** "paper" is a light pill with an outline; "ink" is a solid dark one. */
29
+ tone?: 'paper' | 'ink'
30
+ /** News for screen readers, said once each time it changes, such as "Download paused". Never a ticking number. */
31
+ news?: string
32
+ /** News that opens the pill by itself, such as "Rider is at your door". Said once, and the pill shrinks back after alertMs. */
33
+ alert?: string
34
+ /** How long an alert keeps it open, in ms. A hand or focus on the pill holds it open. */
35
+ alertMs?: number
36
+ }
37
+
38
+ // What the pill can hand focus to when it closes from a tap outside, so a tap on another control keeps it.
39
+ const TAKES_FOCUS = 'a[href], button, input, select, textarea, label, [tabindex]:not([tabindex="-1"])'
40
+
41
+ export function LivePill({
42
+ label,
43
+ compact,
44
+ children,
45
+ open: openProp,
46
+ defaultOpen = false,
47
+ onOpenChange,
48
+ placement = 'bottom',
49
+ fixed = false,
50
+ progress,
51
+ tone = 'paper',
52
+ news,
53
+ alert,
54
+ alertMs = 5000,
55
+ className,
56
+ style,
57
+ ...rest
58
+ }: LivePillProps) {
59
+ const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange)
60
+ const shell = useRef<HTMLDivElement>(null)
61
+ const button = useRef<HTMLButtonElement>(null)
62
+ const [shut, setShut] = useState<string>()
63
+ const [rowH, setRowH] = useState<number>()
64
+ const [byAlert, setByAlert] = useState(false)
65
+ const [held, setHeld] = useState(false)
66
+ const [said, setSaid] = useState('')
67
+ const lastNews = useRef(news)
68
+ const lastAlert = useRef(alert)
69
+
70
+ // Shut, the card is cut back to the button's own box, so the pill grows out of its own spot.
71
+ useLayoutEffect(() => {
72
+ const sh = shell.current
73
+ const b = button.current
74
+ if (!sh || !b) return
75
+ const measure = () => {
76
+ const right = sh.offsetWidth - b.offsetLeft - b.offsetWidth
77
+ const bottom = sh.offsetHeight - b.offsetTop - b.offsetHeight
78
+ setShut(`inset(${b.offsetTop}px ${right}px ${bottom}px ${b.offsetLeft}px round ${b.offsetHeight / 2}px)`)
79
+ const row = b.parentElement as HTMLElement
80
+ setRowH(row.offsetHeight)
81
+ }
82
+ measure()
83
+ const ro = new ResizeObserver(measure)
84
+ ro.observe(sh)
85
+ ro.observe(b)
86
+ return () => ro.disconnect()
87
+ }, [])
88
+
89
+ useEffect(() => {
90
+ if (news === lastNews.current) return
91
+ lastNews.current = news
92
+ if (news) setSaid(news)
93
+ }, [news])
94
+
95
+ useEffect(() => {
96
+ if (alert === lastAlert.current) return
97
+ lastAlert.current = alert
98
+ if (!alert) return
99
+ setSaid(alert)
100
+ // Only a card the alert opened shrinks back by itself; one the person opened stays.
101
+ if (open) return
102
+ setByAlert(true)
103
+ setOpen(true)
104
+ }, [alert, setOpen])
105
+
106
+ const close = (refocus: boolean) => {
107
+ setByAlert(false)
108
+ setOpen(false)
109
+ if (refocus) button.current?.focus({ preventScroll: true })
110
+ }
111
+ const focusInside = () => !!shell.current?.contains(document.activeElement) && document.activeElement !== button.current
112
+
113
+ useEffect(() => {
114
+ if (!open) {
115
+ setByAlert(false)
116
+ return
117
+ }
118
+ const outside = (e: PointerEvent) => {
119
+ const target = e.target instanceof Element ? e.target : null
120
+ if (!target || shell.current?.contains(target)) return
121
+ const inside = focusInside()
122
+ close(false)
123
+ if (inside && !target.closest(TAKES_FOCUS)) window.setTimeout(() => button.current?.focus({ preventScroll: true }))
124
+ }
125
+ document.addEventListener('pointerdown', outside)
126
+ return () => document.removeEventListener('pointerdown', outside)
127
+ }, [open])
128
+
129
+ useEffect(() => {
130
+ if (!open || !byAlert || held) return
131
+ const t = window.setTimeout(() => close(focusInside()), Math.max(alertMs, motionMs('liquid')))
132
+ return () => window.clearTimeout(t)
133
+ }, [open, byAlert, held, alertMs])
134
+
135
+ const p = progress === undefined ? undefined : Math.min(1, Math.max(0, progress))
136
+ const vars = { '--pill-shut': shut, '--pill-row': rowH === undefined ? undefined : `${rowH}px`, '--p': p, ...style } as CSSProperties
137
+
138
+ return (
139
+ <div className={cx(styles.root, className)} data-placement={placement} data-fixed={fixed || undefined} style={vars} {...rest}>
140
+ <div
141
+ ref={shell}
142
+ className={styles.shell}
143
+ data-open={open || undefined}
144
+ data-measured={shut ? '' : undefined}
145
+ data-tone={tone}
146
+ onKeyDown={(e) => {
147
+ if (e.key !== 'Escape' || !open) return
148
+ e.stopPropagation()
149
+ close(true)
150
+ }}
151
+ onPointerEnter={() => setHeld(true)}
152
+ onPointerLeave={() => setHeld(false)}
153
+ onFocus={() => setHeld(true)}
154
+ onBlur={(e) => {
155
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setHeld(false)
156
+ }}
157
+ >
158
+ <div className={styles.row}>
159
+ <button ref={button} type="button" className={styles.button} data-fill={p === undefined ? undefined : ''} aria-expanded={open} aria-label={label} onClick={() => (open ? close(false) : setOpen(true))}>
160
+ {p === undefined ? (
161
+ <span className={styles.face}>{compact}</span>
162
+ ) : (
163
+ <span className={styles.stack}>
164
+ <span className={styles.face}>{compact}</span>
165
+ <span className={cx(styles.face, styles.over)} aria-hidden="true">
166
+ {compact}
167
+ </span>
168
+ </span>
169
+ )}
170
+ </button>
171
+ </div>
172
+ <div className={styles.body} inert={!open} aria-hidden={!open || undefined}>
173
+ {children}
174
+ </div>
175
+ </div>
176
+ <span className={styles.sr} role="status" aria-live="polite" aria-atomic="true">
177
+ {said}
178
+ </span>
179
+ </div>
180
+ )
181
+ }
@@ -0,0 +1 @@
1
+ export * from './LivePill'
@@ -109,6 +109,10 @@
109
109
  /* Segments touch, so the box itself is the 44px target. The floor sits on the content, so the
110
110
  overflow check still sees the natural width, and tight mode shrinks no further than 44px. */
111
111
  @media (pointer: coarse) {
112
+ .item,
113
+ .sm .item {
114
+ min-height: var(--hl-hit-min);
115
+ }
112
116
  .text,
113
117
  .icon:only-child {
114
118
  min-width: calc(var(--hl-hit-min) - var(--pad) * 2);
@@ -0,0 +1,107 @@
1
+ .root {
2
+ display: inline-flex;
3
+ vertical-align: middle;
4
+ font-size: var(--hl-text-xl);
5
+ font-weight: var(--hl-weight-semibold);
6
+ font-variant-numeric: tabular-nums;
7
+ line-height: 1;
8
+ }
9
+
10
+ .root[data-size='sm'] {
11
+ font-size: var(--hl-text-md);
12
+ }
13
+
14
+ .root[data-size='lg'] {
15
+ font-size: var(--hl-text-4xl);
16
+ }
17
+
18
+ .sr {
19
+ position: absolute;
20
+ width: 1px;
21
+ height: 1px;
22
+ overflow: hidden;
23
+ clip-path: inset(50%);
24
+ white-space: nowrap;
25
+ }
26
+
27
+ .board {
28
+ display: inline-flex;
29
+ gap: var(--hl-space-1);
30
+ border-radius: var(--hl-radius-xs);
31
+ }
32
+
33
+ .root[data-size='lg'] .board {
34
+ gap: var(--hl-space-2);
35
+ }
36
+
37
+ .cell {
38
+ position: relative;
39
+ display: block;
40
+ inline-size: var(--hl-flap-w);
41
+ block-size: var(--hl-flap-h);
42
+ perspective: calc(var(--hl-flap-h) * 3);
43
+ color: var(--hl-color-fg);
44
+ }
45
+
46
+ /* Two halves with a hairline seam between them, where the page shows through. */
47
+ .top,
48
+ .bottom,
49
+ .front,
50
+ .back {
51
+ position: absolute;
52
+ inset-inline: 0;
53
+ block-size: calc(50% - var(--hl-line) / 2);
54
+ overflow: hidden;
55
+ }
56
+
57
+ .top,
58
+ .front {
59
+ top: 0;
60
+ border-radius: var(--hl-radius-xs) var(--hl-radius-xs) 0 0;
61
+ background: var(--hl-flap-top);
62
+ }
63
+
64
+ .bottom,
65
+ .back {
66
+ bottom: 0;
67
+ border-radius: 0 0 var(--hl-radius-xs) var(--hl-radius-xs);
68
+ background: var(--hl-flap-bottom);
69
+ }
70
+
71
+ .glyph {
72
+ position: absolute;
73
+ inset-inline: 0;
74
+ top: 0;
75
+ block-size: var(--hl-flap-h);
76
+ line-height: var(--hl-flap-h);
77
+ text-align: center;
78
+ }
79
+
80
+ .bottom .glyph,
81
+ .back .glyph {
82
+ top: auto;
83
+ bottom: 0;
84
+ }
85
+
86
+ /* The falling flap turns on the seam, so after half a turn its back lies over the bottom half. */
87
+ .flap {
88
+ position: absolute;
89
+ inset-inline: 0;
90
+ top: 0;
91
+ z-index: 1;
92
+ block-size: calc(50% - var(--hl-line) / 2);
93
+ transform-origin: 50% calc(100% + var(--hl-line) / 2);
94
+ transform-style: preserve-3d;
95
+ }
96
+
97
+ .front,
98
+ .back {
99
+ block-size: 100%;
100
+ backface-visibility: hidden;
101
+ }
102
+
103
+ .back {
104
+ top: 0;
105
+ bottom: auto;
106
+ transform: rotateX(180deg);
107
+ }