@overpunch/speechtype 1.0.11
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 +258 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +153 -0
- package/dist/index.js +136 -0
- package/dist/speechtype.webflow.min.js +1 -0
- package/package.json +91 -0
package/README.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# speechType
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@overpunch/speechtype) [](https://opensource.org/licenses/MIT) [](https://github.com/over-punch/type-tools)
|
|
4
|
+
|
|
5
|
+
Typography that follows your voice — per-word typographic emphasis synced to Web Speech API boundary events. Each spoken word gets wider tracking, heavier weight, and larger optical size; the rest of the text recedes. A read-along effect grounded in typographic logic, not arbitrary highlight colours.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
**[speechtype.vercel.app](https://speechtype.vercel.app)** · [npm](https://www.npmjs.com/package/@overpunch/speechtype) · [GitHub](https://github.com/over-punch/speechType)
|
|
10
|
+
|
|
11
|
+
TypeScript · Zero dependencies · React + Vanilla JS
|
|
12
|
+
|
|
13
|
+
**Good for** read-along reading aids, language-learning apps, teleprompters, and any interface where a spoken voice and on-screen text need to stay visibly in sync.
|
|
14
|
+
|
|
15
|
+
> **Requires a variable font** with `wght` and `opsz` axes (e.g. Merriweather, Inter, Source Serif). The weight and optical-size emphasis are written via `font-variation-settings`; with a static font only the tracking and opacity changes apply.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @overpunch/speechtype
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
> **Next.js App Router:** this library uses browser APIs. Add `"use client"` to any component file that imports from it.
|
|
30
|
+
|
|
31
|
+
### React component (controlled)
|
|
32
|
+
|
|
33
|
+
`SpeechTypeText` is a controlled component — you manage a `SpeechSynthesisUtterance` yourself, track which word is active in state, and pass the index as a prop. This pattern gives you full control over voice, timing, and UI.
|
|
34
|
+
|
|
35
|
+
> **Note:** the controlled component and the `useSpeechType` hook only apply the *visual* options (`activeTracking`, `activeWeight`, `activeOpsz`, `inactiveOpacity`, `transitionMs`). The *speech* options (`rate`, `pitch`, `volume`, `onUnsupported`, `onError`) are only read by `startSpeechType`, since in the controlled pattern you own the `SpeechSynthesisUtterance`. `SpeechTypeText` also takes an `as` prop (default `"p"`) and forwards any `aria-*`, `data-*`, `role`, and `lang` attributes to the rendered element.
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
"use client"
|
|
39
|
+
import { SpeechTypeText } from '@overpunch/speechtype'
|
|
40
|
+
import { useState, useCallback } from 'react'
|
|
41
|
+
|
|
42
|
+
const TEXT = 'The quick brown fox jumps over the lazy dog.'
|
|
43
|
+
|
|
44
|
+
export default function Demo() {
|
|
45
|
+
const [activeWordIndex, setActiveWordIndex] = useState(-1)
|
|
46
|
+
|
|
47
|
+
const handleSpeak = useCallback(() => {
|
|
48
|
+
const utterance = new SpeechSynthesisUtterance(TEXT)
|
|
49
|
+
utterance.onboundary = (e) => {
|
|
50
|
+
if (e.name === 'word') {
|
|
51
|
+
const wordIndex = TEXT.slice(0, e.charIndex).trim().split(/\s+/).filter(Boolean).length
|
|
52
|
+
setActiveWordIndex(wordIndex)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
utterance.onend = () => setActiveWordIndex(-1)
|
|
56
|
+
speechSynthesis.speak(utterance)
|
|
57
|
+
}, [])
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<SpeechTypeText activeWordIndex={activeWordIndex} activeWeight={700} inactiveOpacity={0.45}>
|
|
62
|
+
{TEXT}
|
|
63
|
+
</SpeechTypeText>
|
|
64
|
+
<button onClick={handleSpeak}>Speak</button>
|
|
65
|
+
</>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### React — imperative (startSpeechType)
|
|
71
|
+
|
|
72
|
+
For a simpler setup, skip `SpeechTypeText` and let `startSpeechType` manage everything directly on a plain element ref:
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
"use client"
|
|
76
|
+
import { useRef } from 'react'
|
|
77
|
+
import { startSpeechType, removeSpeechType } from '@overpunch/speechtype'
|
|
78
|
+
|
|
79
|
+
export default function Demo() {
|
|
80
|
+
const ref = useRef<HTMLParagraphElement>(null)
|
|
81
|
+
// stop() cancels speech and resets emphasis but keeps spans in place.
|
|
82
|
+
// removeSpeechType() does a full teardown — cancels speech AND restores original HTML.
|
|
83
|
+
// Call stop() for pause/stop controls; call removeSpeechType() only on unmount or full reset.
|
|
84
|
+
const stopRef = useRef<(() => void) | null>(null)
|
|
85
|
+
|
|
86
|
+
function handleSpeak() {
|
|
87
|
+
if (!ref.current) return
|
|
88
|
+
stopRef.current?.() // cancel any in-progress speech first
|
|
89
|
+
stopRef.current = startSpeechType(ref.current, { activeWeight: 700, rate: 0.9 })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handleStop() {
|
|
93
|
+
stopRef.current?.()
|
|
94
|
+
stopRef.current = null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<p ref={ref}>The quick brown fox jumps over the lazy dog.</p>
|
|
100
|
+
<button onClick={handleSpeak}>Speak</button>
|
|
101
|
+
<button onClick={handleStop}>Stop</button>
|
|
102
|
+
</>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### React hook
|
|
108
|
+
|
|
109
|
+
`useSpeechType` is the low-level hook behind `SpeechTypeText`. Use it when you need the controlled pattern but want to render your own element:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
"use client"
|
|
113
|
+
import { useSpeechType } from '@overpunch/speechtype'
|
|
114
|
+
import { useRef, useState, useCallback } from 'react'
|
|
115
|
+
|
|
116
|
+
export default function Demo() {
|
|
117
|
+
const ref = useRef<HTMLParagraphElement>(null)
|
|
118
|
+
const [activeWordIndex, setActiveWordIndex] = useState(-1)
|
|
119
|
+
|
|
120
|
+
useSpeechType(ref, activeWordIndex, { activeWeight: 700 })
|
|
121
|
+
|
|
122
|
+
return <p ref={ref}>The quick brown fox jumps over the lazy dog.</p>
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Vanilla JS
|
|
127
|
+
|
|
128
|
+
`startSpeechType` is the all-in-one entry point for vanilla use. It wraps the words in spans, starts the Web Speech API, updates the emphasis on each boundary event, and returns a `stop` function.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { startSpeechType, removeSpeechType } from '@overpunch/speechtype'
|
|
132
|
+
|
|
133
|
+
const el = document.querySelector('p')
|
|
134
|
+
const stop = startSpeechType(el, {
|
|
135
|
+
activeWeight: 700,
|
|
136
|
+
activeTracking: 0.06,
|
|
137
|
+
rate: 0.9,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// Later — stop speech and restore original HTML:
|
|
141
|
+
stop()
|
|
142
|
+
removeSpeechType(el)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
For more control, use the lower-level functions:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { prepareSpeechType, applySpeechType, removeSpeechType } from '@overpunch/speechtype'
|
|
149
|
+
|
|
150
|
+
const el = document.querySelector('p')
|
|
151
|
+
prepareSpeechType(el) // wraps each word in a span
|
|
152
|
+
|
|
153
|
+
applySpeechType(el, 3) // emphasise word at index 3
|
|
154
|
+
applySpeechType(el, -1) // clear emphasis
|
|
155
|
+
|
|
156
|
+
removeSpeechType(el) // restore original HTML
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### TypeScript
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import type { SpeechTypeOptions } from '@overpunch/speechtype'
|
|
163
|
+
|
|
164
|
+
const opts: SpeechTypeOptions = {
|
|
165
|
+
activeTracking: 0.08,
|
|
166
|
+
activeWeight: 800,
|
|
167
|
+
inactiveOpacity: 0.3,
|
|
168
|
+
rate: 0.85,
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Options
|
|
175
|
+
|
|
176
|
+
Visual options apply everywhere; speech options are only read by `startSpeechType` (see the note under [React component](#react-component-controlled)).
|
|
177
|
+
|
|
178
|
+
| Option | Type | Default | Scope | Description |
|
|
179
|
+
|--------|------|---------|-------|-------------|
|
|
180
|
+
| `activeTracking` | `number` | `0.06` | visual | Letter-spacing on the active (currently spoken) word, in em |
|
|
181
|
+
| `activeWeight` | `number` | `700` | visual | `wght` axis value on the active word. Must sit within the font's `wght` axis range |
|
|
182
|
+
| `activeOpsz` | `number` | `24` | visual | `opsz` axis value on the active word. Must sit within the font's `opsz` axis range |
|
|
183
|
+
| `inactiveOpacity` | `number` | `0.45` | visual | Opacity of inactive (not currently spoken) words. Keep ≥ 0.3 for legibility — values below ~0.5 may drop contrast under WCAG AA depending on your colours |
|
|
184
|
+
| `transitionMs` | `number` | `80` | visual | CSS transition duration in ms for style changes |
|
|
185
|
+
| `rate` | `number` | `0.9` | speech | Speech rate (0.1–10). Passed to `SpeechSynthesisUtterance` |
|
|
186
|
+
| `pitch` | `number` | `1` | speech | Speech pitch (0–2). Passed to `SpeechSynthesisUtterance` |
|
|
187
|
+
| `volume` | `number` | `1` | speech | Speech volume (0–1). Passed to `SpeechSynthesisUtterance` |
|
|
188
|
+
| `onUnsupported` | `() => void` | — | speech | Called when the browser has no `speechSynthesis`. Use it to surface a fallback (e.g. show the text statically or a manual stepper) |
|
|
189
|
+
| `onError` | `(e: SpeechSynthesisErrorEvent) => void` | — | speech | Called on a real speech error. The normal `"interrupted"` cancellation is filtered out for you |
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## How it works
|
|
194
|
+
|
|
195
|
+
`prepareSpeechType` reads the element's text content and wraps each word in a `<span class="st-word">` — without changing visual layout. Note: inline child elements (`<em>`, `<strong>`, `<a>`, etc.) are flattened to plain text during wrapping. `applySpeechType` then writes `font-variation-settings`, `letter-spacing`, and `opacity` as inline styles directly on each span (no CSS class toggles). The active span gets wider tracking, heavier weight, and larger optical size; inactive spans get reduced opacity. CSS transitions on those properties are set once by `prepareSpeechType`.
|
|
196
|
+
|
|
197
|
+
`startSpeechType` wires a `SpeechSynthesisUtterance` to the browser's Web Speech API, listens for `boundary` events, maps the character offset to a word index, and calls `applySpeechType` on each event. It returns a `stop` function that cancels synthesis and removes all emphasis.
|
|
198
|
+
|
|
199
|
+
**Browser support:** Web Speech API is supported in Chrome, Edge, and Safari. Firefox requires a flag. Note that Safari fires `boundary` events sparsely, so word-level sync is most reliable in Chromium-based browsers; where boundaries don't fire, the text simply stays un-emphasised. `startSpeechType` falls back silently in environments without `speechSynthesis` — pass `onUnsupported` to detect that case and render your own fallback:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
startSpeechType(el, {
|
|
203
|
+
onUnsupported: () => showManualStepper(), // no Web Speech API here
|
|
204
|
+
onError: (e) => console.warn('Speech failed', e.error),
|
|
205
|
+
})
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Accessibility
|
|
211
|
+
|
|
212
|
+
speechType is built for read-along contexts, so it ships screen-reader support rather than leaving it to you:
|
|
213
|
+
|
|
214
|
+
- Each word span is marked `aria-hidden="true"` and the active word also gets `aria-current="true"`, so assistive tech reads continuous text instead of 27 separate spans.
|
|
215
|
+
- An off-screen `aria-live="polite"` region announces the active word as emphasis moves, keeping non-visual users in sync with the highlight.
|
|
216
|
+
- All emphasis is plain CSS (`font-variation-settings`, `letter-spacing`, `opacity`) — no content is duplicated or reordered.
|
|
217
|
+
|
|
218
|
+
Two trade-offs to design around:
|
|
219
|
+
|
|
220
|
+
- **Contrast.** Inactive words fade to `inactiveOpacity` (default `0.45`), which *reduces* contrast. Keep it at `0.3` or higher and verify the result still meets WCAG AA (4.5:1) against your background — or raise it toward `1` if your audience needs maximum legibility.
|
|
221
|
+
- **Inline markup is flattened.** `prepareSpeechType` reads `textContent`, so inline children (`<em>`, `<strong>`, `<a>`, …) inside the target element are replaced by plain text when words are wrapped. Apply speechType to elements whose formatting you don't need to preserve, and use `getCleanHTML(el)` to recover the unwrapped markup if needed.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## API reference
|
|
226
|
+
|
|
227
|
+
| Export | Description |
|
|
228
|
+
|--------|-------------|
|
|
229
|
+
| `prepareSpeechType(el, options?)` | Wraps each word in a span. Call once before `applySpeechType`. |
|
|
230
|
+
| `applySpeechType(el, activeIndex, options?)` | Emphasises word at `activeIndex`. Pass `-1` to clear. |
|
|
231
|
+
| `startSpeechType(el, options?)` | All-in-one: prepares spans, starts Web Speech API, returns `stop()`. |
|
|
232
|
+
| `removeSpeechType(el)` | Cancels synthesis and restores original HTML. |
|
|
233
|
+
| `getCleanHTML(el)` | Returns element HTML with all injected spans removed. |
|
|
234
|
+
| `useSpeechType` | React hook: `(ref, activeWordIndex, options?)` |
|
|
235
|
+
| `SpeechTypeText` | React component. Controlled via `activeWordIndex` prop. Forwards ref. |
|
|
236
|
+
| `SpeechTypeOptions` | TypeScript interface for all options. |
|
|
237
|
+
| `SPEECH_CLASSES` | CSS class names injected by the algorithm (`st-word`). |
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Next.js
|
|
242
|
+
|
|
243
|
+
`SpeechTypeText`, `useSpeechType`, and `startSpeechType` all require a browser environment. Add `"use client"` to any component that imports them:
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
"use client"
|
|
247
|
+
import { SpeechTypeText } from '@overpunch/speechtype'
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Dev notes
|
|
253
|
+
|
|
254
|
+
### `next` in root devDependencies
|
|
255
|
+
|
|
256
|
+
`package.json` at the repo root lists `next` as a devDependency. This is a **Vercel detection workaround** — not a real dependency of the npm package. Vercel's build system inspects the root `package.json` to detect the framework; without `next` present it falls back to a static build and skips the Next.js pipeline, breaking the `/site` subdirectory deploy.
|
|
257
|
+
|
|
258
|
+
The package itself has zero runtime dependencies. Do not remove this entry.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const g=require("react"),b=require("react/jsx-runtime"),v={word:"st-word"},w=new WeakMap;function C(t,e={}){if(typeof window>"u")return[];const r=window.scrollY,n=w.get(t),l=(n==null?void 0:n.originalHTML)??t.innerHTML;t.innerHTML=l;const d=(t.textContent??"").split(/(\s+)/);t.innerHTML=d.map(i=>!i||/^\s+$/.test(i)?i:`<span class="${v.word}" aria-hidden="true">${i}</span>`).join("");const u=Array.from(t.querySelectorAll(`.${v.word}`)),o=e.transitionMs??80;u.forEach(i=>{i.style.display="inline",i.style.transition=[`font-variation-settings ${o}ms ease`,`letter-spacing ${o}ms ease`,`opacity ${o}ms ease`].join(", ")});let c=t.querySelector("[data-st-live]");return c||(c=document.createElement("span"),c.setAttribute("data-st-live",""),c.setAttribute("aria-live","polite"),c.setAttribute("aria-atomic","true"),c.style.cssText="position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0",t.appendChild(c)),w.set(t,{originalHTML:l,wordSpans:u,utterance:null,activeIndex:-1}),requestAnimationFrame(()=>{Math.abs(window.scrollY-r)>2&&window.scrollTo({top:r,behavior:"instant"})}),u}function y(t,e,r={}){var c;if(typeof window>"u")return;const n=w.get(t);if(!n)return;const l=r.activeTracking??.06,s=r.activeWeight??700,d=r.activeOpsz??24,u=r.inactiveOpacity??.45;n.activeIndex=e;const o=t.querySelector("[data-st-live]");o&&(o.textContent=e>=0?((c=n.wordSpans[e])==null?void 0:c.textContent)??"":""),n.wordSpans.forEach((i,S)=>{S===e?(i.setAttribute("aria-current","true"),i.style.fontVariationSettings=`"wght" ${s}, "opsz" ${d}`,i.style.letterSpacing=`${l}em`,i.style.opacity="1"):(i.removeAttribute("aria-current"),i.style.fontVariationSettings="",i.style.letterSpacing="",i.style.opacity=e===-1?"1":String(u))})}function L(t,e={}){var m;if(typeof window>"u"||!("speechSynthesis"in window))return(m=e.onUnsupported)==null||m.call(e),()=>{};const r=C(t,e),n=w.get(t);if(!n)return()=>{};window.speechSynthesis.cancel();const l=r.map(a=>a.textContent??"").join(" "),s=new SpeechSynthesisUtterance(l);s.rate=e.rate??.9,s.pitch=e.pitch??1,s.volume=e.volume??1;const d=e.activeTracking??.06,u=e.activeWeight??700,o=e.activeOpsz??24,c={...e,activeTracking:d,activeWeight:u,activeOpsz:o};let i=0;const S=r.map(a=>{var h;const p=i;return i+=(((h=a.textContent)==null?void 0:h.length)??0)+1,p});let f=!1;return s.onboundary=a=>{if(f||a.name!=="word")return;const p=S.findIndex((h,x)=>{const T=S[x+1]??1/0;return a.charIndex>=h&&a.charIndex<T});p!==-1&&y(t,p,c)},s.onend=()=>{f||(y(t,-1,c),n.utterance=null)},s.onerror=a=>{var p;a.error!=="interrupted"&&((p=e.onError)==null||p.call(e,a)),f||(y(t,-1,c),n.utterance=null)},n.utterance=s,window.speechSynthesis.speak(s),()=>{f||(f=!0,window.speechSynthesis.cancel(),y(t,-1,c),n.utterance=null)}}function M(t){const e=w.get(t);e&&(typeof window<"u"&&"speechSynthesis"in window&&window.speechSynthesis.cancel(),t.innerHTML=e.originalHTML,w.delete(t))}function A(t){const e=t.cloneNode(!0);return e.querySelectorAll(`.${v.word}`).forEach(r=>{const n=r.parentNode;if(n){for(;r.firstChild;)n.insertBefore(r.firstChild,r);n.removeChild(r)}}),e.innerHTML}function E(t,e,r){g.useEffect(()=>{const n=t.current;if(n)return C(n,r),()=>M(n)},[r==null?void 0:r.transitionMs]),g.useEffect(()=>{const n=t.current;n&&y(n,e,r)},[e])}const $=g.forwardRef(function({activeWordIndex:e,as:r="p",children:n,style:l,className:s,activeTracking:d,activeWeight:u,activeOpsz:o,inactiveOpacity:c,transitionMs:i,rate:S,pitch:f,volume:m,onUnsupported:a,onError:p,...h},x){const T=g.useRef(null);g.useImperativeHandle(x,()=>T.current),E(T,e,{activeTracking:d,activeWeight:u,activeOpsz:o,inactiveOpacity:c,transitionMs:i});const H=r;return b.jsx(H,{ref:T,style:l,className:s,...h,children:n})});exports.SPEECH_CLASSES=v;exports.SpeechTypeText=$;exports.applySpeechType=y;exports.getCleanHTML=A;exports.prepareSpeechType=C;exports.removeSpeechType=M;exports.startSpeechType=L;exports.useSpeechType=E;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { ElementType } from 'react';
|
|
2
|
+
import { ForwardRefExoticComponent } from 'react';
|
|
3
|
+
import { RefAttributes } from 'react';
|
|
4
|
+
import { RefObject } from 'react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Apply typographic emphasis to the word at activeIndex.
|
|
8
|
+
* All other words receive the inactive style. Pass -1 to reset all words to neutral.
|
|
9
|
+
*
|
|
10
|
+
* @param el - Element previously prepared by prepareSpeechType
|
|
11
|
+
* @param activeIndex - Index of the word to emphasise, -1 for none
|
|
12
|
+
* @param options - SpeechTypeOptions (merged with defaults)
|
|
13
|
+
*/
|
|
14
|
+
export declare function applySpeechType(el: HTMLElement, activeIndex: number, options?: SpeechTypeOptions): void;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Return a clean copy of el's innerHTML with all speechType spans unwrapped.
|
|
18
|
+
* Does not modify el itself.
|
|
19
|
+
*
|
|
20
|
+
* @param el - Element to read clean HTML from
|
|
21
|
+
*/
|
|
22
|
+
export declare function getCleanHTML(el: HTMLElement): string;
|
|
23
|
+
|
|
24
|
+
declare type HTMLForwardProps = Omit<React.HTMLAttributes<HTMLElement>, keyof SpeechTypeOptions | 'children' | 'style' | 'className'>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Wrap the text content of el in per-word <span> elements with the SPEECH_CLASSES.word
|
|
28
|
+
* class. Saves the original innerHTML for cleanup. Idempotent — calling again re-wraps
|
|
29
|
+
* from the saved original. Returns the array of word span elements.
|
|
30
|
+
*
|
|
31
|
+
* @param el - Element whose text will be wrapped
|
|
32
|
+
* @param options - SpeechTypeOptions (merged with defaults)
|
|
33
|
+
*/
|
|
34
|
+
export declare function prepareSpeechType(el: HTMLElement, options?: SpeechTypeOptions): HTMLElement[];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Remove speechType from el — cancel any active speech, restore original innerHTML,
|
|
38
|
+
* and delete all saved state. No-op if prepareSpeechType was never called.
|
|
39
|
+
*
|
|
40
|
+
* @param el - The element previously prepared by prepareSpeechType
|
|
41
|
+
*/
|
|
42
|
+
export declare function removeSpeechType(el: HTMLElement): void;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* CSS class names injected by speechType.
|
|
46
|
+
* Only `word` ('st-word') is used — there are no st-active or st-inactive classes.
|
|
47
|
+
* Emphasis is applied via inline styles (fontVariationSettings, letterSpacing, opacity),
|
|
48
|
+
* not via class toggles.
|
|
49
|
+
*/
|
|
50
|
+
export declare const SPEECH_CLASSES: {
|
|
51
|
+
readonly word: "st-word";
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Options controlling how speechType emphasises spoken words.
|
|
56
|
+
*
|
|
57
|
+
* @remarks
|
|
58
|
+
* Visual options (activeTracking, activeWeight, activeOpsz, inactiveOpacity, transitionMs)
|
|
59
|
+
* are used by prepareSpeechType and applySpeechType.
|
|
60
|
+
* Speech options (rate, pitch, volume) are only used by startSpeechType and are ignored
|
|
61
|
+
* by applySpeechType, useSpeechType, and SpeechTypeText.
|
|
62
|
+
*
|
|
63
|
+
* activeWeight and activeOpsz are written directly into font-variation-settings. Ensure
|
|
64
|
+
* the values are within the wght/opsz axis ranges supported by your font — out-of-range
|
|
65
|
+
* values may cause some engines to silently ignore the entire declaration.
|
|
66
|
+
*
|
|
67
|
+
* inactiveOpacity: ensure the resulting contrast ratio remains at least 4.5:1 against
|
|
68
|
+
* your background colour to meet WCAG AA. The default of 0.45 may fail this threshold
|
|
69
|
+
* depending on your foreground/background pairing.
|
|
70
|
+
*/
|
|
71
|
+
export declare interface SpeechTypeOptions {
|
|
72
|
+
/** Letter-spacing on the active (currently spoken) word in em. Default: 0.06 */
|
|
73
|
+
activeTracking?: number;
|
|
74
|
+
/**
|
|
75
|
+
* wght axis value on the active word. Default: 700.
|
|
76
|
+
* Must be within the font's supported wght axis range (e.g. 100–900 for most variable fonts).
|
|
77
|
+
*/
|
|
78
|
+
activeWeight?: number;
|
|
79
|
+
/**
|
|
80
|
+
* opsz axis value on the active word. Default: 24.
|
|
81
|
+
* Must be within the font's supported opsz axis range (e.g. 6–72 for many variable fonts).
|
|
82
|
+
*/
|
|
83
|
+
activeOpsz?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Opacity of inactive (not currently spoken) words. Default: 0.45.
|
|
86
|
+
* Values below ~0.5 may reduce contrast below WCAG AA (4.5:1) depending on your colours.
|
|
87
|
+
* Minimum recommended value: 0.3.
|
|
88
|
+
*/
|
|
89
|
+
inactiveOpacity?: number;
|
|
90
|
+
/** CSS transition duration in ms for style changes. Default: 80 */
|
|
91
|
+
transitionMs?: number;
|
|
92
|
+
/** Speech rate (0.1–10). Used only by startSpeechType. Default: 0.9 */
|
|
93
|
+
rate?: number;
|
|
94
|
+
/** Speech pitch (0–2). Used only by startSpeechType. Default: 1 */
|
|
95
|
+
pitch?: number;
|
|
96
|
+
/** Speech volume (0–1). Used only by startSpeechType. Default: 1 */
|
|
97
|
+
volume?: number;
|
|
98
|
+
/**
|
|
99
|
+
* Called when speech synthesis is unavailable in the current browser.
|
|
100
|
+
* Used only by startSpeechType.
|
|
101
|
+
*/
|
|
102
|
+
onUnsupported?: () => void;
|
|
103
|
+
/**
|
|
104
|
+
* Called when a real speech error occurs (any error code other than "interrupted",
|
|
105
|
+
* which is a normal cancellation). Receives the SpeechSynthesisErrorEvent.
|
|
106
|
+
* Used only by startSpeechType.
|
|
107
|
+
*/
|
|
108
|
+
onError?: (event: SpeechSynthesisErrorEvent) => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Renders children inside the given tag, wraps words in spans, and emphasises
|
|
113
|
+
* the word at activeWordIndex with wider tracking, heavier weight, and larger opsz.
|
|
114
|
+
* Forwards a ref to the underlying DOM element for imperative startSpeechType access.
|
|
115
|
+
* All aria-*, data-*, role, lang, and other HTML attributes are forwarded to the DOM element.
|
|
116
|
+
*/
|
|
117
|
+
export declare const SpeechTypeText: ForwardRefExoticComponent<SpeechTypeTextProps & RefAttributes<HTMLElement>>;
|
|
118
|
+
|
|
119
|
+
/** Props accepted by SpeechTypeText */
|
|
120
|
+
declare interface SpeechTypeTextProps extends SpeechTypeOptions, HTMLForwardProps {
|
|
121
|
+
/** Index of the word currently being spoken (-1 = no active word). Default: -1 */
|
|
122
|
+
activeWordIndex: number;
|
|
123
|
+
/** HTML element tag to render. Default: 'p' */
|
|
124
|
+
as?: ElementType;
|
|
125
|
+
/** React children (text content to highlight) */
|
|
126
|
+
children: React.ReactNode;
|
|
127
|
+
/** Inline styles forwarded to the rendered element */
|
|
128
|
+
style?: React.CSSProperties;
|
|
129
|
+
/** Class name forwarded to the rendered element */
|
|
130
|
+
className?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Start speech synthesis on el's text content, syncing word emphasis to Web Speech API
|
|
135
|
+
* boundary events. Calls prepareSpeechType first. Cancels any existing speech.
|
|
136
|
+
* Returns a stop() function that cancels speech and resets all styles.
|
|
137
|
+
*
|
|
138
|
+
* @param el - Element to speak and highlight
|
|
139
|
+
* @param options - SpeechTypeOptions (merged with defaults)
|
|
140
|
+
*/
|
|
141
|
+
export declare function startSpeechType(el: HTMLElement, options?: SpeechTypeOptions): () => void;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Prepare word spans on mount and apply emphasis when activeWordIndex changes.
|
|
145
|
+
* Cleans up by restoring original innerHTML on unmount.
|
|
146
|
+
*
|
|
147
|
+
* @param ref - Ref to the element containing text to highlight
|
|
148
|
+
* @param activeWordIndex - Index of the currently active word (-1 = none)
|
|
149
|
+
* @param options - SpeechTypeOptions (merged with defaults)
|
|
150
|
+
*/
|
|
151
|
+
export declare function useSpeechType(ref: RefObject<HTMLElement | null>, activeWordIndex: number, options?: SpeechTypeOptions): void;
|
|
152
|
+
|
|
153
|
+
export { }
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useEffect as x, forwardRef as C, useRef as E, useImperativeHandle as H } from "react";
|
|
2
|
+
import { jsx as $ } from "react/jsx-runtime";
|
|
3
|
+
const T = {
|
|
4
|
+
word: "st-word"
|
|
5
|
+
}, w = /* @__PURE__ */ new WeakMap();
|
|
6
|
+
function M(t, e = {}) {
|
|
7
|
+
if (typeof window > "u") return [];
|
|
8
|
+
const r = window.scrollY, n = w.get(t), l = (n == null ? void 0 : n.originalHTML) ?? t.innerHTML;
|
|
9
|
+
t.innerHTML = l;
|
|
10
|
+
const f = (t.textContent ?? "").split(/(\s+)/);
|
|
11
|
+
t.innerHTML = f.map((i) => !i || /^\s+$/.test(i) ? i : `<span class="${T.word}" aria-hidden="true">${i}</span>`).join("");
|
|
12
|
+
const u = Array.from(t.querySelectorAll(`.${T.word}`)), o = e.transitionMs ?? 80;
|
|
13
|
+
u.forEach((i) => {
|
|
14
|
+
i.style.display = "inline", i.style.transition = [
|
|
15
|
+
`font-variation-settings ${o}ms ease`,
|
|
16
|
+
`letter-spacing ${o}ms ease`,
|
|
17
|
+
`opacity ${o}ms ease`
|
|
18
|
+
].join(", ");
|
|
19
|
+
});
|
|
20
|
+
let c = t.querySelector("[data-st-live]");
|
|
21
|
+
return c || (c = document.createElement("span"), c.setAttribute("data-st-live", ""), c.setAttribute("aria-live", "polite"), c.setAttribute("aria-atomic", "true"), c.style.cssText = "position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0", t.appendChild(c)), w.set(t, { originalHTML: l, wordSpans: u, utterance: null, activeIndex: -1 }), requestAnimationFrame(() => {
|
|
22
|
+
Math.abs(window.scrollY - r) > 2 && window.scrollTo({ top: r, behavior: "instant" });
|
|
23
|
+
}), u;
|
|
24
|
+
}
|
|
25
|
+
function v(t, e, r = {}) {
|
|
26
|
+
var c;
|
|
27
|
+
if (typeof window > "u") return;
|
|
28
|
+
const n = w.get(t);
|
|
29
|
+
if (!n) return;
|
|
30
|
+
const l = r.activeTracking ?? 0.06, s = r.activeWeight ?? 700, f = r.activeOpsz ?? 24, u = r.inactiveOpacity ?? 0.45;
|
|
31
|
+
n.activeIndex = e;
|
|
32
|
+
const o = t.querySelector("[data-st-live]");
|
|
33
|
+
o && (o.textContent = e >= 0 ? ((c = n.wordSpans[e]) == null ? void 0 : c.textContent) ?? "" : ""), n.wordSpans.forEach((i, y) => {
|
|
34
|
+
y === e ? (i.setAttribute("aria-current", "true"), i.style.fontVariationSettings = `"wght" ${s}, "opsz" ${f}`, i.style.letterSpacing = `${l}em`, i.style.opacity = "1") : (i.removeAttribute("aria-current"), i.style.fontVariationSettings = "", i.style.letterSpacing = "", i.style.opacity = e === -1 ? "1" : String(u));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function R(t, e = {}) {
|
|
38
|
+
var g;
|
|
39
|
+
if (typeof window > "u" || !("speechSynthesis" in window))
|
|
40
|
+
return (g = e.onUnsupported) == null || g.call(e), () => {
|
|
41
|
+
};
|
|
42
|
+
const r = M(t, e), n = w.get(t);
|
|
43
|
+
if (!n) return () => {
|
|
44
|
+
};
|
|
45
|
+
window.speechSynthesis.cancel();
|
|
46
|
+
const l = r.map((a) => a.textContent ?? "").join(" "), s = new SpeechSynthesisUtterance(l);
|
|
47
|
+
s.rate = e.rate ?? 0.9, s.pitch = e.pitch ?? 1, s.volume = e.volume ?? 1;
|
|
48
|
+
const f = e.activeTracking ?? 0.06, u = e.activeWeight ?? 700, o = e.activeOpsz ?? 24, c = { ...e, activeTracking: f, activeWeight: u, activeOpsz: o };
|
|
49
|
+
let i = 0;
|
|
50
|
+
const y = r.map((a) => {
|
|
51
|
+
var h;
|
|
52
|
+
const d = i;
|
|
53
|
+
return i += (((h = a.textContent) == null ? void 0 : h.length) ?? 0) + 1, d;
|
|
54
|
+
});
|
|
55
|
+
let p = !1;
|
|
56
|
+
return s.onboundary = (a) => {
|
|
57
|
+
if (p || a.name !== "word") return;
|
|
58
|
+
const d = y.findIndex((h, m) => {
|
|
59
|
+
const S = y[m + 1] ?? 1 / 0;
|
|
60
|
+
return a.charIndex >= h && a.charIndex < S;
|
|
61
|
+
});
|
|
62
|
+
d !== -1 && v(t, d, c);
|
|
63
|
+
}, s.onend = () => {
|
|
64
|
+
p || (v(t, -1, c), n.utterance = null);
|
|
65
|
+
}, s.onerror = (a) => {
|
|
66
|
+
var d;
|
|
67
|
+
a.error !== "interrupted" && ((d = e.onError) == null || d.call(e, a)), p || (v(t, -1, c), n.utterance = null);
|
|
68
|
+
}, n.utterance = s, window.speechSynthesis.speak(s), () => {
|
|
69
|
+
p || (p = !0, window.speechSynthesis.cancel(), v(t, -1, c), n.utterance = null);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function b(t) {
|
|
73
|
+
const e = w.get(t);
|
|
74
|
+
e && (typeof window < "u" && "speechSynthesis" in window && window.speechSynthesis.cancel(), t.innerHTML = e.originalHTML, w.delete(t));
|
|
75
|
+
}
|
|
76
|
+
function j(t) {
|
|
77
|
+
const e = t.cloneNode(!0);
|
|
78
|
+
return e.querySelectorAll(`.${T.word}`).forEach((r) => {
|
|
79
|
+
const n = r.parentNode;
|
|
80
|
+
if (n) {
|
|
81
|
+
for (; r.firstChild; ) n.insertBefore(r.firstChild, r);
|
|
82
|
+
n.removeChild(r);
|
|
83
|
+
}
|
|
84
|
+
}), e.innerHTML;
|
|
85
|
+
}
|
|
86
|
+
function A(t, e, r) {
|
|
87
|
+
x(() => {
|
|
88
|
+
const n = t.current;
|
|
89
|
+
if (n)
|
|
90
|
+
return M(n, r), () => b(n);
|
|
91
|
+
}, [r == null ? void 0 : r.transitionMs]), x(() => {
|
|
92
|
+
const n = t.current;
|
|
93
|
+
n && v(n, e, r);
|
|
94
|
+
}, [e]);
|
|
95
|
+
}
|
|
96
|
+
const k = C(
|
|
97
|
+
function({
|
|
98
|
+
activeWordIndex: e,
|
|
99
|
+
as: r = "p",
|
|
100
|
+
children: n,
|
|
101
|
+
style: l,
|
|
102
|
+
className: s,
|
|
103
|
+
// Extract SpeechTypeOptions fields explicitly so they are NOT forwarded to the DOM
|
|
104
|
+
activeTracking: f,
|
|
105
|
+
activeWeight: u,
|
|
106
|
+
activeOpsz: o,
|
|
107
|
+
inactiveOpacity: c,
|
|
108
|
+
transitionMs: i,
|
|
109
|
+
rate: y,
|
|
110
|
+
pitch: p,
|
|
111
|
+
volume: g,
|
|
112
|
+
onUnsupported: a,
|
|
113
|
+
onError: d,
|
|
114
|
+
// Remaining props (aria-*, data-*, role, lang, etc.) are forwarded to the DOM element
|
|
115
|
+
...h
|
|
116
|
+
}, m) {
|
|
117
|
+
const S = E(null);
|
|
118
|
+
return H(m, () => S.current), A(S, e, {
|
|
119
|
+
activeTracking: f,
|
|
120
|
+
activeWeight: u,
|
|
121
|
+
activeOpsz: o,
|
|
122
|
+
inactiveOpacity: c,
|
|
123
|
+
transitionMs: i
|
|
124
|
+
}), /* @__PURE__ */ $(r, { ref: S, style: l, className: s, ...h, children: n });
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
export {
|
|
128
|
+
T as SPEECH_CLASSES,
|
|
129
|
+
k as SpeechTypeText,
|
|
130
|
+
v as applySpeechType,
|
|
131
|
+
j as getCleanHTML,
|
|
132
|
+
M as prepareSpeechType,
|
|
133
|
+
b as removeSpeechType,
|
|
134
|
+
R as startSpeechType,
|
|
135
|
+
A as useSpeechType
|
|
136
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var SpeechType=(function(d){"use strict";const k={word:"st-word"},f=new WeakMap;function O(e,t={}){if(typeof window>"u")return[];const i=window.scrollY,n=f.get(e),p=(n==null?void 0:n.originalHTML)??e.innerHTML;e.innerHTML=p;const y=(e.textContent??"").split(/(\s+)/);e.innerHTML=y.map(s=>!s||/^\s+$/.test(s)?s:`<span class="${k.word}" aria-hidden="true">${s}</span>`).join("");const u=Array.from(e.querySelectorAll(`.${k.word}`)),o=t.transitionMs??80;u.forEach(s=>{s.style.display="inline",s.style.transition=[`font-variation-settings ${o}ms ease`,`letter-spacing ${o}ms ease`,`opacity ${o}ms ease`].join(", ")});let r=e.querySelector("[data-st-live]");return r||(r=document.createElement("span"),r.setAttribute("data-st-live",""),r.setAttribute("aria-live","polite"),r.setAttribute("aria-atomic","true"),r.style.cssText="position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0",e.appendChild(r)),f.set(e,{originalHTML:p,wordSpans:u,utterance:null,activeIndex:-1}),requestAnimationFrame(()=>{Math.abs(window.scrollY-i)>2&&window.scrollTo({top:i,behavior:"instant"})}),u}function w(e,t,i={}){var r;if(typeof window>"u")return;const n=f.get(e);if(!n)return;const p=i.activeTracking??.06,a=i.activeWeight??700,y=i.activeOpsz??24,u=i.inactiveOpacity??.45;n.activeIndex=t;const o=e.querySelector("[data-st-live]");o&&(o.textContent=t>=0?((r=n.wordSpans[t])==null?void 0:r.textContent)??"":""),n.wordSpans.forEach((s,S)=>{S===t?(s.setAttribute("aria-current","true"),s.style.fontVariationSettings=`"wght" ${a}, "opsz" ${y}`,s.style.letterSpacing=`${p}em`,s.style.opacity="1"):(s.removeAttribute("aria-current"),s.style.fontVariationSettings="",s.style.letterSpacing="",s.style.opacity=t===-1?"1":String(u))})}function C(e,t={}){var A;if(typeof window>"u"||!("speechSynthesis"in window))return(A=t.onUnsupported)==null||A.call(t),()=>{};const i=O(e,t),n=f.get(e);if(!n)return()=>{};window.speechSynthesis.cancel();const p=i.map(c=>c.textContent??"").join(" "),a=new SpeechSynthesisUtterance(p);a.rate=t.rate??.9,a.pitch=t.pitch??1,a.volume=t.volume??1;const y=t.activeTracking??.06,u=t.activeWeight??700,o=t.activeOpsz??24,r={...t,activeTracking:y,activeWeight:u,activeOpsz:o};let s=0;const S=i.map(c=>{var m;const l=s;return s+=(((m=c.textContent)==null?void 0:m.length)??0)+1,l});let v=!1;return a.onboundary=c=>{if(v||c.name!=="word")return;const l=S.findIndex((m,F)=>{const P=S[F+1]??1/0;return c.charIndex>=m&&c.charIndex<P});l!==-1&&w(e,l,r)},a.onend=()=>{v||(w(e,-1,r),n.utterance=null)},a.onerror=c=>{var l;c.error!=="interrupted"&&((l=t.onError)==null||l.call(t,c)),v||(w(e,-1,r),n.utterance=null)},n.utterance=a,window.speechSynthesis.speak(a),()=>{v||(v=!0,window.speechSynthesis.cancel(),w(e,-1,r),n.utterance=null)}}function E(e){const t=f.get(e);t&&(typeof window<"u"&&"speechSynthesis"in window&&window.speechSynthesis.cancel(),e.innerHTML=t.originalHTML,f.delete(e))}const L="data-speechtype",H="false",h=new WeakMap;function b(e){const t=e.dataset,i={};if(t.stTracking!==void 0){const n=parseFloat(t.stTracking);isNaN(n)||(i.activeTracking=n)}if(t.stWeight!==void 0){const n=parseFloat(t.stWeight);isNaN(n)||(i.activeWeight=n)}if(t.stOpsz!==void 0){const n=parseFloat(t.stOpsz);isNaN(n)||(i.activeOpsz=n)}if(t.stInactiveOpacity!==void 0){const n=parseFloat(t.stInactiveOpacity);isNaN(n)||(i.inactiveOpacity=n)}if(t.stTransition!==void 0){const n=parseFloat(t.stTransition);isNaN(n)||(i.transitionMs=n)}if(t.stRate!==void 0){const n=parseFloat(t.stRate);isNaN(n)||(i.rate=n)}if(t.stPitch!==void 0){const n=parseFloat(t.stPitch);isNaN(n)||(i.pitch=n)}if(t.stVolume!==void 0){const n=parseFloat(t.stVolume);isNaN(n)||(i.volume=n)}return i.onUnsupported=()=>{console.warn("SpeechType: this browser does not support the Web Speech API — nothing will be spoken.")},i}function T(e){const t=h.get(e);if(t){if(t.stop){N(e);return}t.stop=C(e,b(e))}}function N(e){const t=h.get(e);!t||!t.stop||(t.stop(),t.stop=null)}function $(e){N(e),T(e)}function I(e){M(e),O(e,b(e));let t=null;e.dataset.stClick!==H&&(t=()=>T(e),e.addEventListener("click",t),e.style.cursor="pointer"),h.set(e,{stop:null,clickHandler:t})}function M(e){const t=h.get(e);t&&(t.stop&&t.stop(),t.clickHandler&&(e.removeEventListener("click",t.clickHandler),e.style.cursor=""),E(e),h.delete(e))}function g(e=document){e.querySelectorAll(`[${L}]`).forEach(I)}function W(){const e=()=>{var t;(t=document.fonts)!=null&&t.ready?document.fonts.ready.then(()=>g()).catch(()=>g()):g()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}return W(),d.destroy=M,d.init=g,d.restart=$,d.speak=T,d.stop=N,Object.defineProperty(d,Symbol.toStringTag,{value:"Module"}),d})({});
|
package/package.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@overpunch/speechtype",
|
|
3
|
+
"version": "1.0.11",
|
|
4
|
+
"description": "Typography that follows your voice — per-word typographic emphasis synced to Web Speech API boundary events",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.cjs",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "vite build",
|
|
21
|
+
"build:webflow": "vite build --config vite.webflow.config.ts",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"prepublishOnly": "npm run test && npm run build && npm run build:webflow"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": ">=17",
|
|
28
|
+
"react-dom": ">=17"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"react": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"react-dom": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@testing-library/react": "^16.3.2",
|
|
40
|
+
"@testing-library/user-event": "^14.6.1",
|
|
41
|
+
"@types/react": "^19.0.0",
|
|
42
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
43
|
+
"happy-dom": "^20.10.6",
|
|
44
|
+
"next": "16.2.2",
|
|
45
|
+
"react": "^19.0.0",
|
|
46
|
+
"typescript": "^5.0.0",
|
|
47
|
+
"vite": "^6.0.0",
|
|
48
|
+
"vite-plugin-dts": "^4.0.0",
|
|
49
|
+
"vitest": "^3.0.0"
|
|
50
|
+
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"typography",
|
|
53
|
+
"web-typography",
|
|
54
|
+
"frontend",
|
|
55
|
+
"liiift-studio",
|
|
56
|
+
"speech-synthesis",
|
|
57
|
+
"web-speech-api",
|
|
58
|
+
"tts",
|
|
59
|
+
"text-to-speech",
|
|
60
|
+
"voice",
|
|
61
|
+
"karaoke",
|
|
62
|
+
"word-highlight",
|
|
63
|
+
"accessibility",
|
|
64
|
+
"a11y",
|
|
65
|
+
"language-learning",
|
|
66
|
+
"teleprompter",
|
|
67
|
+
"variable-font",
|
|
68
|
+
"font-variation-settings",
|
|
69
|
+
"wght",
|
|
70
|
+
"opsz",
|
|
71
|
+
"letter-spacing",
|
|
72
|
+
"zero-dependencies",
|
|
73
|
+
"react",
|
|
74
|
+
"typescript",
|
|
75
|
+
"css"
|
|
76
|
+
],
|
|
77
|
+
"author": "Quinn Keaveney <quinn@liiift.studio>",
|
|
78
|
+
"license": "MIT",
|
|
79
|
+
"homepage": "https://speechtype.vercel.app",
|
|
80
|
+
"repository": {
|
|
81
|
+
"type": "git",
|
|
82
|
+
"url": "git+https://github.com/over-punch/speechType.git"
|
|
83
|
+
},
|
|
84
|
+
"bugs": {
|
|
85
|
+
"url": "https://github.com/over-punch/speechType/issues"
|
|
86
|
+
},
|
|
87
|
+
"sideEffects": false,
|
|
88
|
+
"publishConfig": {
|
|
89
|
+
"access": "public"
|
|
90
|
+
}
|
|
91
|
+
}
|