@inkdropapp/theme-dev-helpers 0.3.10 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inkdropapp/theme-dev-helpers",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "A helper module for creating themes for Inkdrop",
5
5
  "keywords": [
6
6
  "inkdrop",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@inkdropapp/base-ui-theme": "^0.7.5",
28
- "@inkdropapp/css": "^0.6.7",
28
+ "@inkdropapp/css": "^0.6.8",
29
29
  "@vitejs/plugin-react": "^6.0.2",
30
30
  "commander": "^15.0.0",
31
31
  "puppeteer": "^25.1.0",
@@ -1,4 +1,4 @@
1
- import themeVariableNames from '@inkdropapp/css/ui.json'
1
+ import themeVariableNames from '@inkdropapp/css/variables.json'
2
2
  import { getCSSVariables } from './get-css-variables'
3
3
 
4
4
  export const VariablesPage = () => {
@@ -4,7 +4,7 @@ import path from 'path'
4
4
  import { pathToFileURL } from 'url'
5
5
  import { Command } from 'commander'
6
6
  import packageJson from '../package.json'
7
- import { buildPreviewHTML, mapThemeVariables } from './palette'
7
+ import { buildPreviewHTML, deriveAppearance, mapThemeVariables, resolveLightDark } from './palette'
8
8
 
9
9
  const program = new Command()
10
10
 
@@ -13,7 +13,10 @@ program
13
13
  .name('generate-palette')
14
14
  .description('CLI tool for extracting CSS variables from a theme package for Inkdrop')
15
15
  .version(packageJson.version)
16
- .option('-a, --appearance <light/dark>', 'Force the UI appearance ("light" or "dark")')
16
+ .option(
17
+ '-a, --appearance <light/dark>',
18
+ 'Force the UI appearance ("light" or "dark"); defaults to the theme\'s declared appearance'
19
+ )
17
20
  .option('-o, --output <path>', 'Output file path (default: ./palette.json)', './palette.json')
18
21
  .parse(process.argv)
19
22
 
@@ -28,13 +31,14 @@ const baseStyleSheetSpecifiers = [
28
31
  '@inkdropapp/css/ui.css',
29
32
  '@inkdropapp/css/tags.css',
30
33
  '@inkdropapp/css/status.css',
34
+ '@inkdropapp/css/task-progress.css',
31
35
  '@inkdropapp/base-ui-theme/styles/theme.css'
32
36
  ]
33
37
 
34
38
  // Function to extract theme CSS variables
35
39
  async function extractPalette(outputPath: string) {
36
40
  const themePackageJson = await import(path.join(process.cwd(), 'package.json'))
37
- const themeVariableNames: string[] = (await import(`@inkdropapp/css/ui.json`)).default
41
+ const themeVariableNames: string[] = (await import(`@inkdropapp/css/variables.json`)).default
38
42
 
39
43
  const browser = await puppeteer.launch()
40
44
  const page = await browser.newPage()
@@ -47,9 +51,18 @@ async function extractPalette(outputPath: string) {
47
51
  })
48
52
  .on('pageerror', (error) => console.error(error instanceof Error ? error.message : error))
49
53
 
54
+ // Fall back to the theme's declared appearance so `light-dark()` resolves to
55
+ // the branch the theme actually ships, even when no --appearance is forced.
56
+ const resolvedAppearance = appearance ?? deriveAppearance(themePackageJson)
57
+
50
58
  const baseUrl = pathToFileURL(process.cwd()).toString() + '/'
51
59
  const baseStyleSheetURLs = baseStyleSheetSpecifiers.map((spec) => import.meta.resolve(spec))
52
- const content = buildPreviewHTML(themePackageJson, baseUrl, baseStyleSheetURLs, appearance)
60
+ const content = buildPreviewHTML(
61
+ themePackageJson,
62
+ baseUrl,
63
+ baseStyleSheetURLs,
64
+ resolvedAppearance
65
+ )
53
66
 
54
67
  await page.goto(baseUrl)
55
68
  await page.setContent(content)
@@ -68,9 +81,18 @@ async function extractPalette(outputPath: string) {
68
81
 
69
82
  const themeCSSVariables = mapThemeVariables(themeVariableNames, computedCSSVariables)
70
83
 
84
+ // Resolve `light-dark()` to raw values so non-CSS consumers (e.g. the mobile
85
+ // app's React Native renderer) don't have to evaluate it themselves.
86
+ const resolvedVariables = Object.fromEntries(
87
+ Object.entries(themeCSSVariables).map(([name, value]) => [
88
+ name,
89
+ typeof value === 'string' ? resolveLightDark(value, resolvedAppearance) : value
90
+ ])
91
+ )
92
+
71
93
  // Write to the output file
72
94
  const outputFilePath = path.resolve(outputPath)
73
- await writeFile(outputFilePath, JSON.stringify(themeCSSVariables, null, 2))
95
+ await writeFile(outputFilePath, JSON.stringify(resolvedVariables, null, 2))
74
96
 
75
97
  await browser.close()
76
98
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { buildPreviewHTML, mapThemeVariables } from './palette'
2
+ import { buildPreviewHTML, deriveAppearance, mapThemeVariables, resolveLightDark } from './palette'
3
3
 
4
4
  describe('buildPreviewHTML', () => {
5
5
  const baseUrl = 'file:///themes/acme/'
@@ -51,6 +51,56 @@ describe('buildPreviewHTML', () => {
51
51
  })
52
52
  })
53
53
 
54
+ describe('deriveAppearance', () => {
55
+ test('is dark when the name contains "dark"', () => {
56
+ expect(deriveAppearance({ name: 'solarized-dark-ui' })).toBe('dark')
57
+ })
58
+
59
+ test('is dark when themeAppearance is "dark"', () => {
60
+ expect(deriveAppearance({ name: 'acme-ui', themeAppearance: 'dark' })).toBe('dark')
61
+ })
62
+
63
+ test('is light otherwise', () => {
64
+ expect(deriveAppearance({ name: 'acme-light-ui' })).toBe('light')
65
+ expect(deriveAppearance({ name: 'acme-ui', themeAppearance: 'light' })).toBe('light')
66
+ expect(deriveAppearance({})).toBe('light')
67
+ })
68
+ })
69
+
70
+ describe('resolveLightDark', () => {
71
+ test('keeps the first branch for light, the second for dark', () => {
72
+ expect(resolveLightDark('light-dark(red, blue)', 'light')).toBe('red')
73
+ expect(resolveLightDark('light-dark(red, blue)', 'dark')).toBe('blue')
74
+ })
75
+
76
+ test('leaves values without light-dark untouched', () => {
77
+ expect(resolveLightDark('hsl(192deg 100% 5%)', 'dark')).toBe('hsl(192deg 100% 5%)')
78
+ })
79
+
80
+ test('ignores commas nested inside the branches', () => {
81
+ const value = 'light-dark(hsl(215deg 14% 34%), hsl(218deg 11% 65% / 40%))'
82
+ expect(resolveLightDark(value, 'dark')).toBe('hsl(218deg 11% 65% / 40%)')
83
+ expect(resolveLightDark(value, 'light')).toBe('hsl(215deg 14% 34%)')
84
+ })
85
+
86
+ test('resolves nested light-dark within the chosen branch', () => {
87
+ const value = 'light-dark(light-dark(a, b), c)'
88
+ expect(resolveLightDark(value, 'light')).toBe('a')
89
+ expect(resolveLightDark(value, 'dark')).toBe('c')
90
+ })
91
+
92
+ test('resolves light-dark embedded in a larger value', () => {
93
+ const value = '0px 1px 2px 0 light-dark(rgba(0, 0, 0, 0.1), hsl(0deg 0% 0% / 20%)) inset'
94
+ expect(resolveLightDark(value, 'dark')).toBe('0px 1px 2px 0 hsl(0deg 0% 0% / 20%) inset')
95
+ })
96
+
97
+ test('resolves multiple light-dark occurrences in one value', () => {
98
+ const value = 'light-dark(a, b), light-dark(c, d)'
99
+ expect(resolveLightDark(value, 'light')).toBe('a, c')
100
+ expect(resolveLightDark(value, 'dark')).toBe('b, d')
101
+ })
102
+ })
103
+
54
104
  describe('mapThemeVariables', () => {
55
105
  test('maps each declared name to its computed value, ignoring extras', () => {
56
106
  const result = mapThemeVariables(['--accent', '--bg'], {
package/src/palette.ts CHANGED
@@ -4,13 +4,28 @@
4
4
  * browser lives in `generate-palette.ts`.
5
5
  */
6
6
 
7
+ export type ThemeAppearance = 'light' | 'dark'
8
+
7
9
  /** Theme metadata read from a theme package's `package.json`. */
8
10
  export interface ThemePackage {
9
11
  name?: string
12
+ /** Explicit appearance declared by the theme (`"light"` | `"dark"`). */
13
+ themeAppearance?: ThemeAppearance
10
14
  styleSheets?: string[]
11
15
  }
12
16
 
13
- export type ThemeAppearance = 'light' | 'dark'
17
+ /**
18
+ * Derives a theme's appearance the way Inkdrop's desktop app does (see
19
+ * `ThemePackage#getAppearance`): dark when the package name contains "dark" or
20
+ * `themeAppearance` is "dark", otherwise light. Used to pick which branch of a
21
+ * `light-dark()` value to keep.
22
+ *
23
+ * @param theme - The theme package's metadata.
24
+ * @returns The resolved appearance.
25
+ */
26
+ export function deriveAppearance(theme: ThemePackage): ThemeAppearance {
27
+ return theme.name?.includes('dark') || theme.themeAppearance === 'dark' ? 'dark' : 'light'
28
+ }
14
29
 
15
30
  /**
16
31
  * Builds the standalone HTML document that is loaded into a headless browser to
@@ -83,3 +98,64 @@ export function mapThemeVariables(
83
98
  {} as Record<string, string>
84
99
  )
85
100
  }
101
+
102
+ /** Index of the `)` matching the `(` at `openIndex`, or -1 when unbalanced. */
103
+ function matchingParen(value: string, openIndex: number): number {
104
+ let depth = 0
105
+ for (let i = openIndex; i < value.length; i++) {
106
+ if (value[i] === '(') depth++
107
+ else if (value[i] === ')' && --depth === 0) return i
108
+ }
109
+ return -1
110
+ }
111
+
112
+ /**
113
+ * Splits `value` at its first top-level (paren-depth-zero) comma into
114
+ * `[before, after]`. Commas inside nested function calls (e.g. `hsl(…)`) are
115
+ * ignored. Returns `[value, '']` when there is no top-level comma.
116
+ */
117
+ function splitTopLevelComma(value: string): [string, string] {
118
+ let depth = 0
119
+ for (let i = 0; i < value.length; i++) {
120
+ const ch = value[i]
121
+ if (ch === '(') depth++
122
+ else if (ch === ')') depth--
123
+ else if (ch === ',' && depth === 0) return [value.slice(0, i), value.slice(i + 1)]
124
+ }
125
+ return [value, '']
126
+ }
127
+
128
+ /**
129
+ * Replaces every `light-dark(<light>, <dark>)` in a CSS value with the branch
130
+ * matching `appearance`, recursively and in place.
131
+ *
132
+ * Inkdrop's mobile app (React Native) can't evaluate the CSS `light-dark()`
133
+ * function, so the generated palette must carry already-resolved raw values.
134
+ * The browser leaves `light-dark()` unresolved inside custom properties, so we
135
+ * pick the branch ourselves — mirroring how the desktop app selects light/dark
136
+ * via the body's `color-scheme`. Nested calls (the kept branch may itself
137
+ * contain `light-dark()`) and calls embedded in larger values (shadows,
138
+ * gradients, borders) are handled.
139
+ *
140
+ * @param value - A CSS value that may contain one or more `light-dark()` calls.
141
+ * @param appearance - Which branch to keep (`light` → first, `dark` → second).
142
+ * @returns The value with all `light-dark()` calls resolved.
143
+ */
144
+ export function resolveLightDark(value: string, appearance: ThemeAppearance): string {
145
+ const marker = 'light-dark('
146
+ const start = value.indexOf(marker)
147
+ if (start === -1) return value
148
+
149
+ const open = start + marker.length - 1
150
+ const close = matchingParen(value, open)
151
+ if (close === -1) return value // unbalanced — leave the value untouched
152
+
153
+ const [light, dark] = splitTopLevelComma(value.slice(open + 1, close))
154
+ const chosen = (appearance === 'dark' ? dark : light).trim()
155
+
156
+ return (
157
+ value.slice(0, start) +
158
+ resolveLightDark(chosen, appearance) +
159
+ resolveLightDark(value.slice(close + 1), appearance)
160
+ )
161
+ }