eprec 1.3.0 → 1.5.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.
@@ -0,0 +1,197 @@
1
+ import type { Handle } from 'remix/component'
2
+ import {
3
+ colors,
4
+ mq,
5
+ radius,
6
+ responsive,
7
+ shadows,
8
+ spacing,
9
+ transitions,
10
+ typography,
11
+ } from '../styles/tokens.ts'
12
+
13
+ const sectionStyle = {
14
+ display: 'flex',
15
+ flexDirection: 'column',
16
+ gap: spacing.lg,
17
+ marginTop: responsive.spacingSection,
18
+ }
19
+
20
+ const headerStyle = {
21
+ display: 'flex',
22
+ alignItems: 'center',
23
+ justifyContent: 'space-between',
24
+ gap: spacing.md,
25
+ flexWrap: 'wrap',
26
+ }
27
+
28
+ const gridStyle = {
29
+ display: 'grid',
30
+ gap: spacing.lg,
31
+ gridTemplateColumns: `repeat(auto-fit, minmax(${responsive.cardMinWidth}, 1fr))`,
32
+ [mq.mobile]: {
33
+ gridTemplateColumns: '1fr',
34
+ },
35
+ }
36
+
37
+ const cardStyle = {
38
+ padding: spacing.xl,
39
+ backgroundColor: colors.surface,
40
+ borderRadius: radius.lg,
41
+ border: `1px solid ${colors.border}`,
42
+ boxShadow: shadows.sm,
43
+ display: 'flex',
44
+ flexDirection: 'column',
45
+ gap: spacing.md,
46
+ transition: `box-shadow ${transitions.fast}, transform ${transitions.fast}`,
47
+ '&:hover': {
48
+ boxShadow: shadows.md,
49
+ transform: 'translateY(-1px)',
50
+ },
51
+ [mq.mobile]: {
52
+ padding: spacing.lg,
53
+ },
54
+ }
55
+
56
+ const primaryButtonStyle = {
57
+ display: 'inline-flex',
58
+ alignItems: 'center',
59
+ justifyContent: 'center',
60
+ gap: spacing.sm,
61
+ padding: `${spacing.sm} ${spacing.lg}`,
62
+ borderRadius: radius.md,
63
+ border: `1px solid ${colors.primaryActive}`,
64
+ backgroundColor: colors.primary,
65
+ color: colors.onPrimary,
66
+ fontSize: typography.fontSize.base,
67
+ fontWeight: typography.fontWeight.semibold,
68
+ cursor: 'pointer',
69
+ transition: `background-color ${transitions.fast}, box-shadow ${transitions.fast}, transform ${transitions.fast}`,
70
+ '&:hover': {
71
+ backgroundColor: colors.primaryHover,
72
+ boxShadow: shadows.sm,
73
+ },
74
+ '&:active': {
75
+ backgroundColor: colors.primaryActive,
76
+ transform: 'translateY(1px)',
77
+ },
78
+ }
79
+
80
+ const pillStyle = {
81
+ padding: `${spacing.xs} ${spacing.sm}`,
82
+ borderRadius: radius.pill,
83
+ backgroundColor: colors.infoSurface,
84
+ color: colors.infoText,
85
+ fontSize: typography.fontSize.xs,
86
+ fontWeight: typography.fontWeight.semibold,
87
+ }
88
+
89
+ const swatchStyle = (color: string) => ({
90
+ width: spacing.sm,
91
+ height: spacing.sm,
92
+ borderRadius: radius.xl,
93
+ backgroundColor: color,
94
+ boxShadow: `0 0 0 1px ${colors.border}`,
95
+ })
96
+
97
+ export function StyleSystemSample(handle: Handle) {
98
+ return () => (
99
+ <section css={sectionStyle}>
100
+ <header css={headerStyle}>
101
+ <div>
102
+ <h2
103
+ css={{
104
+ margin: 0,
105
+ fontSize: typography.fontSize.xl,
106
+ fontWeight: typography.fontWeight.semibold,
107
+ color: colors.text,
108
+ }}
109
+ >
110
+ Design tokens
111
+ </h2>
112
+ <p
113
+ css={{
114
+ margin: 0,
115
+ color: colors.textMuted,
116
+ fontSize: typography.fontSize.base,
117
+ lineHeight: 1.6,
118
+ }}
119
+ >
120
+ Shared CSS variables and TypeScript helpers for consistent theming.
121
+ </p>
122
+ </div>
123
+ <span css={pillStyle}>Auto dark mode</span>
124
+ </header>
125
+
126
+ <div css={gridStyle}>
127
+ <div css={cardStyle}>
128
+ <h3
129
+ css={{
130
+ margin: 0,
131
+ fontSize: typography.fontSize.lg,
132
+ fontWeight: typography.fontWeight.semibold,
133
+ color: colors.text,
134
+ }}
135
+ >
136
+ Surface card
137
+ </h3>
138
+ <p
139
+ css={{
140
+ margin: 0,
141
+ color: colors.textMuted,
142
+ fontSize: typography.fontSize.base,
143
+ lineHeight: 1.5,
144
+ }}
145
+ >
146
+ Spacing, radius, and shadows come from tokens with responsive
147
+ overrides.
148
+ </p>
149
+ <button type="button" css={primaryButtonStyle}>
150
+ Primary action
151
+ </button>
152
+ </div>
153
+
154
+ <div css={cardStyle}>
155
+ <h3
156
+ css={{
157
+ margin: 0,
158
+ fontSize: typography.fontSize.lg,
159
+ fontWeight: typography.fontWeight.semibold,
160
+ color: colors.text,
161
+ }}
162
+ >
163
+ Semantic palette
164
+ </h3>
165
+ <p
166
+ css={{
167
+ margin: 0,
168
+ color: colors.textMuted,
169
+ fontSize: typography.fontSize.base,
170
+ lineHeight: 1.5,
171
+ }}
172
+ >
173
+ Use semantic names like primary, surface, and text instead of hex
174
+ values.
175
+ </p>
176
+ <div
177
+ css={{
178
+ display: 'flex',
179
+ gap: spacing.sm,
180
+ alignItems: 'center',
181
+ flexWrap: 'wrap',
182
+ color: colors.textSecondary,
183
+ fontSize: typography.fontSize.sm,
184
+ }}
185
+ >
186
+ <span css={swatchStyle(colors.primary)} />
187
+ <span css={swatchStyle(colors.infoSurface)} />
188
+ <span css={swatchStyle(colors.successSurface)} />
189
+ <span css={swatchStyle(colors.warningSurface)} />
190
+ <span css={swatchStyle(colors.dangerSurface)} />
191
+ <span>Primary, info, success, warning, danger</span>
192
+ </div>
193
+ </div>
194
+ </div>
195
+ </section>
196
+ )
197
+ }
@@ -0,0 +1,99 @@
1
+ export const colors = {
2
+ primary: 'var(--color-primary)',
3
+ primaryHover: 'var(--color-primary-hover)',
4
+ primaryActive: 'var(--color-primary-active)',
5
+ onPrimary: 'var(--color-on-primary)',
6
+ background: 'var(--color-background)',
7
+ surface: 'var(--color-surface)',
8
+ surfaceMuted: 'var(--color-surface-muted)',
9
+ surfaceInverse: 'var(--color-surface-inverse)',
10
+ text: 'var(--color-text)',
11
+ textMuted: 'var(--color-text-muted)',
12
+ textSubtle: 'var(--color-text-subtle)',
13
+ textSecondary: 'var(--color-text-secondary)',
14
+ textFaint: 'var(--color-text-faint)',
15
+ textInverse: 'var(--color-text-inverse)',
16
+ border: 'var(--color-border)',
17
+ borderStrong: 'var(--color-border-strong)',
18
+ borderAccent: 'var(--color-border-accent)',
19
+ infoSurface: 'var(--color-info-surface)',
20
+ infoText: 'var(--color-info-text)',
21
+ successSurface: 'var(--color-success-surface)',
22
+ successText: 'var(--color-success-text)',
23
+ warningSurface: 'var(--color-warning-surface)',
24
+ warningText: 'var(--color-warning-text)',
25
+ warningBorder: 'var(--color-warning-border)',
26
+ dangerSurface: 'var(--color-danger-surface)',
27
+ dangerText: 'var(--color-danger-text)',
28
+ dangerBorder: 'var(--color-danger-border)',
29
+ dangerBorderStrong: 'var(--color-danger-border-strong)',
30
+ primarySoft: 'color-mix(in srgb, var(--color-primary) 12%, transparent)',
31
+ primaryMuted: 'color-mix(in srgb, var(--color-primary) 24%, transparent)',
32
+ borderSubtle: 'color-mix(in srgb, var(--color-border) 60%, transparent)',
33
+ } as const
34
+
35
+ export const typography = {
36
+ fontFamily: 'var(--font-family)',
37
+ fontSize: {
38
+ xs: 'var(--font-size-xs)',
39
+ sm: 'var(--font-size-sm)',
40
+ base: 'var(--font-size-base)',
41
+ lg: 'var(--font-size-lg)',
42
+ xl: 'var(--font-size-xl)',
43
+ '2xl': 'var(--font-size-2xl)',
44
+ },
45
+ fontWeight: {
46
+ normal: 'var(--font-weight-normal)',
47
+ medium: 'var(--font-weight-medium)',
48
+ semibold: 'var(--font-weight-semibold)',
49
+ bold: 'var(--font-weight-bold)',
50
+ },
51
+ } as const
52
+
53
+ export const spacing = {
54
+ xs: 'var(--spacing-xs)',
55
+ sm: 'var(--spacing-sm)',
56
+ md: 'var(--spacing-md)',
57
+ lg: 'var(--spacing-lg)',
58
+ xl: 'var(--spacing-xl)',
59
+ '2xl': 'var(--spacing-2xl)',
60
+ '3xl': 'var(--spacing-3xl)',
61
+ '4xl': 'var(--spacing-4xl)',
62
+ '5xl': 'var(--spacing-5xl)',
63
+ } as const
64
+
65
+ export const radius = {
66
+ sm: 'var(--radius-sm)',
67
+ md: 'var(--radius-md)',
68
+ lg: 'var(--radius-lg)',
69
+ xl: 'var(--radius-xl)',
70
+ pill: 'var(--radius-pill)',
71
+ } as const
72
+
73
+ export const shadows = {
74
+ sm: 'var(--shadow-sm)',
75
+ md: 'var(--shadow-md)',
76
+ lg: 'var(--shadow-lg)',
77
+ } as const
78
+
79
+ export const transitions = {
80
+ fast: 'var(--transition-fast)',
81
+ normal: 'var(--transition-normal)',
82
+ } as const
83
+
84
+ export const responsive = {
85
+ spacingPage: 'var(--spacing-page)',
86
+ spacingSection: 'var(--spacing-section)',
87
+ cardMinWidth: 'var(--card-min-width)',
88
+ } as const
89
+
90
+ export const breakpoints = {
91
+ mobile: '640px',
92
+ tablet: '1024px',
93
+ } as const
94
+
95
+ export const mq = {
96
+ mobile: `@media (max-width: ${breakpoints.mobile})`,
97
+ tablet: `@media (max-width: ${breakpoints.tablet})`,
98
+ desktop: `@media (min-width: ${parseInt(breakpoints.tablet) + 1}px)`,
99
+ } as const
package/app-server.ts CHANGED
@@ -10,6 +10,140 @@ type AppServerOptions = {
10
10
  port?: number
11
11
  }
12
12
 
13
+ const LOCALHOST_ALIASES = new Set(['127.0.0.1', '::1', 'localhost'])
14
+ const COLOR_ENABLED =
15
+ process.env.FORCE_COLOR === '1' ||
16
+ (Boolean(process.stdout.isTTY) && !process.env.NO_COLOR)
17
+ const SHORTCUT_COLORS: Record<string, string> = {
18
+ o: '\u001b[36m',
19
+ r: '\u001b[33m',
20
+ q: '\u001b[31m',
21
+ h: '\u001b[35m',
22
+ }
23
+ const ANSI_RESET = '\u001b[0m'
24
+
25
+ function colorizeShortcut(key: string) {
26
+ if (!COLOR_ENABLED) {
27
+ return key
28
+ }
29
+ const color = SHORTCUT_COLORS[key.toLowerCase()]
30
+ return color ? `${color}${key}${ANSI_RESET}` : key
31
+ }
32
+
33
+ function formatHostnameForDisplay(hostname: string) {
34
+ if (LOCALHOST_ALIASES.has(hostname)) {
35
+ return 'localhost'
36
+ }
37
+ if (hostname.includes(':')) {
38
+ return `[${hostname}]`
39
+ }
40
+ return hostname
41
+ }
42
+
43
+ function formatServerUrl(hostname: string, port: number) {
44
+ return `http://${formatHostnameForDisplay(hostname)}:${port}`
45
+ }
46
+
47
+ function getShortcutLines(url: string) {
48
+ return [
49
+ '[app] shortcuts:',
50
+ ` ${colorizeShortcut('o')}: open ${url} in browser`,
51
+ ` ${colorizeShortcut('r')}: restart server`,
52
+ ` ${colorizeShortcut('q')}: quit server`,
53
+ ` ${colorizeShortcut('h')}: show shortcuts`,
54
+ ]
55
+ }
56
+
57
+ function logShortcuts(url: string) {
58
+ for (const line of getShortcutLines(url)) {
59
+ console.log(line)
60
+ }
61
+ }
62
+
63
+ function openBrowser(url: string) {
64
+ const platform = process.platform
65
+ const command =
66
+ platform === 'darwin'
67
+ ? ['open', url]
68
+ : platform === 'win32'
69
+ ? ['cmd', '/c', 'start', '', url]
70
+ : ['xdg-open', url]
71
+ try {
72
+ const subprocess = Bun.spawn({
73
+ cmd: command,
74
+ stdin: 'ignore',
75
+ stdout: 'ignore',
76
+ stderr: 'ignore',
77
+ })
78
+ void subprocess.exited.catch((error) => {
79
+ console.warn(
80
+ `[app] failed to open browser: ${
81
+ error instanceof Error ? error.message : String(error)
82
+ }`,
83
+ )
84
+ })
85
+ } catch (error) {
86
+ console.warn(
87
+ `[app] failed to open browser: ${
88
+ error instanceof Error ? error.message : String(error)
89
+ }`,
90
+ )
91
+ }
92
+ }
93
+
94
+ function setupShortcutHandling(options: {
95
+ getUrl: () => string
96
+ restart: () => void
97
+ stop: () => void
98
+ }) {
99
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
100
+ return () => {}
101
+ }
102
+
103
+ const stdin = process.stdin
104
+ const handleKey = (key: string) => {
105
+ if (key === '\u0003') {
106
+ options.stop()
107
+ return
108
+ }
109
+ const lower = key.toLowerCase()
110
+ if (lower === 'o') {
111
+ openBrowser(options.getUrl())
112
+ return
113
+ }
114
+ if (lower === 'r') {
115
+ options.restart()
116
+ return
117
+ }
118
+ if (lower === 'q') {
119
+ options.stop()
120
+ return
121
+ }
122
+ if (lower === 'h' || lower === '?') {
123
+ logShortcuts(options.getUrl())
124
+ }
125
+ }
126
+
127
+ const onData = (chunk: Buffer | string) => {
128
+ const input = chunk.toString()
129
+ for (const key of input) {
130
+ handleKey(key)
131
+ }
132
+ }
133
+
134
+ stdin.setRawMode(true)
135
+ stdin.resume()
136
+ stdin.on('data', onData)
137
+
138
+ return () => {
139
+ stdin.off('data', onData)
140
+ if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
141
+ stdin.setRawMode(false)
142
+ }
143
+ stdin.pause()
144
+ }
145
+ }
146
+
13
147
  function startServer(port: number, hostname: string) {
14
148
  const router = createAppRouter(import.meta.dirname)
15
149
  return Bun.serve({
@@ -46,15 +180,50 @@ export async function startAppServer(options: AppServerOptions = {}) {
46
180
  const host = options.host ?? env.HOST
47
181
  const desiredPort = options.port ?? env.PORT
48
182
  const port = await getServerPort(env.NODE_ENV, desiredPort)
49
- const server = startServer(port, host)
50
- const hostname = server.hostname.includes(':')
51
- ? `[${server.hostname}]`
52
- : server.hostname
53
- const url = `http://${hostname}:${server.port}`
183
+ let server = startServer(port, host)
184
+ const getUrl = () => formatServerUrl(server.hostname, server.port)
185
+ let cleanupInput = () => {}
186
+ let isRestarting = false
187
+ const stopServer = () => {
188
+ console.log('[app] stopping server...')
189
+ cleanupInput()
190
+ server.stop()
191
+ process.exit(0)
192
+ }
193
+ const restartServer = async () => {
194
+ if (isRestarting) {
195
+ return
196
+ }
197
+ isRestarting = true
198
+ try {
199
+ console.log('[app] restarting server...')
200
+ await server.stop()
201
+ server = startServer(port, host)
202
+ console.log(`[app] running at ${getUrl()}`)
203
+ } finally {
204
+ isRestarting = false
205
+ }
206
+ }
207
+ cleanupInput = setupShortcutHandling({
208
+ getUrl,
209
+ restart: restartServer,
210
+ stop: stopServer,
211
+ })
212
+ const url = getUrl()
54
213
 
55
214
  console.log(`[app] running at ${url}`)
215
+ logShortcuts(url)
56
216
 
57
- return { server, url }
217
+ return {
218
+ get server() {
219
+ return server
220
+ },
221
+ url,
222
+ stop: () => {
223
+ cleanupInput()
224
+ server.stop()
225
+ },
226
+ }
58
227
  }
59
228
 
60
229
  if (import.meta.main) {
package/cli.ts CHANGED
@@ -164,14 +164,15 @@ async function main(rawArgs = hideBin(process.argv)) {
164
164
  })
165
165
  resultText = result.text
166
166
  },
167
- { successText: 'Transcription complete', enabled: context.interactive },
167
+ {
168
+ successText: 'Transcription complete',
169
+ enabled: context.interactive,
170
+ },
168
171
  )
169
172
  console.log(
170
173
  `Transcript written to ${transcribeArgs.outputBasePath}.txt`,
171
174
  )
172
- console.log(
173
- `Segments written to ${transcribeArgs.outputBasePath}.json`,
174
- )
175
+ console.log(`Segments written to ${transcribeArgs.outputBasePath}.json`)
175
176
  console.log(resultText)
176
177
  },
177
178
  )
@@ -208,7 +209,10 @@ async function main(rawArgs = hideBin(process.argv)) {
208
209
  end,
209
210
  })
210
211
  },
211
- { successText: 'Speech detection complete', enabled: context.interactive },
212
+ {
213
+ successText: 'Speech detection complete',
214
+ enabled: context.interactive,
215
+ },
212
216
  )
213
217
  console.log(JSON.stringify(segments, null, 2))
214
218
  },
@@ -230,9 +234,7 @@ function createCliUxContext(): CliUxContext {
230
234
  return { interactive, prompter, pathPicker }
231
235
  }
232
236
 
233
- async function promptForCommand(
234
- prompter: Prompter,
235
- ): Promise<string[] | null> {
237
+ async function promptForCommand(prompter: Prompter): Promise<string[] | null> {
236
238
  const selection = await prompter.select('Choose a command', [
237
239
  {
238
240
  name: 'Process chapters into separate files',
@@ -283,7 +285,12 @@ async function resolveProcessArgs(argv: Arguments, context: CliUxContext) {
283
285
  }
284
286
 
285
287
  let outputDir = resolveOptionalString(argv['output-dir'])
286
- if (!outputDir && context.interactive && context.prompter && context.pathPicker) {
288
+ if (
289
+ !outputDir &&
290
+ context.interactive &&
291
+ context.prompter &&
292
+ context.pathPicker
293
+ ) {
287
294
  const chooseOutput = await context.prompter.confirm(
288
295
  'Choose a custom output directory?',
289
296
  { defaultValue: false },
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "eprec",
3
3
  "type": "module",
4
- "version": "1.3.0",
4
+ "version": "1.5.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/epicweb-dev/eprec"
9
9
  },
10
10
  "scripts": {
11
- "app:start": "bun ./app-server.ts",
11
+ "app:start": "bun --watch ./cli.ts app start",
12
12
  "format": "prettier --write .",
13
13
  "test": "bun test process-course utils.test.ts",
14
14
  "test:e2e": "bun test ./e2e",
@@ -40,7 +40,10 @@ type CliUxOptions = {
40
40
  pathPicker?: PathPicker
41
41
  }
42
42
 
43
- export function buildCombinedOutputPath(video1Path: string, video2Path: string) {
43
+ export function buildCombinedOutputPath(
44
+ video1Path: string,
45
+ video2Path: string,
46
+ ) {
44
47
  const dir = path.dirname(video1Path)
45
48
  const ext = path.extname(video1Path) || path.extname(video2Path) || '.mp4'
46
49
  const name1 = path.parse(video1Path).name
@@ -81,8 +84,7 @@ export async function resolveEditVideoArgs(
81
84
  })
82
85
  }
83
86
  const defaultOutputPath = buildEditedOutputPath(input)
84
- const outputPath =
85
- resolveOptionalString(argv.output) ?? defaultOutputPath
87
+ const outputPath = resolveOptionalString(argv.output) ?? defaultOutputPath
86
88
  const paddingMs = resolvePaddingMs(argv['padding-ms'])
87
89
 
88
90
  return {
@@ -194,7 +196,9 @@ export function createEditVideoHandler(options: CliUxOptions): CommandHandler {
194
196
  }
195
197
  }
196
198
 
197
- export function createCombineVideosHandler(options: CliUxOptions): CommandHandler {
199
+ export function createCombineVideosHandler(
200
+ options: CliUxOptions,
201
+ ): CommandHandler {
198
202
  return async (argv) => {
199
203
  const args = await resolveCombineVideosArgs(argv, options)
200
204
  let outputPath = ''