@inkdropapp/theme-dev-helpers 0.3.9 → 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.9",
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,6 +1,6 @@
1
1
  allowBuilds:
2
- esbuild: set this to true or false
3
- puppeteer: set this to true or false
2
+ esbuild: true
3
+ puppeteer: true
4
4
  minimumReleaseAgeExclude:
5
5
  - '@inkdropapp/base-ui-theme'
6
6
  - '@inkdropapp/css'
@@ -1,4 +1,4 @@
1
- import themeVariableNames from '@inkdropapp/base-ui-theme/lib/variable-names.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
 
@@ -22,10 +25,20 @@ const options = program.opts()
22
25
  const outputPath = options.output as string
23
26
  const appearance = options.appearance as 'light' | 'dark' | undefined
24
27
 
28
+ const baseStyleSheetSpecifiers = [
29
+ '@inkdropapp/css/reset.css',
30
+ '@inkdropapp/css/tokens.css',
31
+ '@inkdropapp/css/ui.css',
32
+ '@inkdropapp/css/tags.css',
33
+ '@inkdropapp/css/status.css',
34
+ '@inkdropapp/css/task-progress.css',
35
+ '@inkdropapp/base-ui-theme/styles/theme.css'
36
+ ]
37
+
25
38
  // Function to extract theme CSS variables
26
39
  async function extractPalette(outputPath: string) {
27
40
  const themePackageJson = await import(path.join(process.cwd(), 'package.json'))
28
- const themeVariableNames: string[] = (await import(`@inkdropapp/css/ui.json`)).default
41
+ const themeVariableNames: string[] = (await import(`@inkdropapp/css/variables.json`)).default
29
42
 
30
43
  const browser = await puppeteer.launch()
31
44
  const page = await browser.newPage()
@@ -38,8 +51,18 @@ async function extractPalette(outputPath: string) {
38
51
  })
39
52
  .on('pageerror', (error) => console.error(error instanceof Error ? error.message : error))
40
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
+
41
58
  const baseUrl = pathToFileURL(process.cwd()).toString() + '/'
42
- const content = buildPreviewHTML(themePackageJson, baseUrl, appearance)
59
+ const baseStyleSheetURLs = baseStyleSheetSpecifiers.map((spec) => import.meta.resolve(spec))
60
+ const content = buildPreviewHTML(
61
+ themePackageJson,
62
+ baseUrl,
63
+ baseStyleSheetURLs,
64
+ resolvedAppearance
65
+ )
43
66
 
44
67
  await page.goto(baseUrl)
45
68
  await page.setContent(content)
@@ -58,9 +81,18 @@ async function extractPalette(outputPath: string) {
58
81
 
59
82
  const themeCSSVariables = mapThemeVariables(themeVariableNames, computedCSSVariables)
60
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
+
61
93
  // Write to the output file
62
94
  const outputFilePath = path.resolve(outputPath)
63
- await writeFile(outputFilePath, JSON.stringify(themeCSSVariables, null, 2))
95
+ await writeFile(outputFilePath, JSON.stringify(resolvedVariables, null, 2))
64
96
 
65
97
  await browser.close()
66
98
  }
@@ -1,52 +1,106 @@
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/'
6
+ const baseStyleSheetURLs = [
7
+ 'file:///pkgs/@inkdropapp/css/reset.css',
8
+ 'file:///pkgs/@inkdropapp/base-ui-theme/styles/theme.css'
9
+ ]
6
10
 
7
- test('emits the base href so relative links resolve', () => {
8
- const html = buildPreviewHTML({ name: 'acme' }, baseUrl)
11
+ test('emits the base href so relative theme links resolve', () => {
12
+ const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [])
9
13
  expect(html).toContain(`<base href="${baseUrl}" />`)
10
14
  })
11
15
 
12
- test('always includes the Inkdrop base stylesheets', () => {
13
- const html = buildPreviewHTML({ name: 'acme' }, baseUrl)
14
- expect(html).toContain('node_modules/@inkdropapp/css/reset.css')
15
- expect(html).toContain('node_modules/@inkdropapp/css/tokens.css')
16
- expect(html).toContain('node_modules/@inkdropapp/css/tags.css')
17
- expect(html).toContain('node_modules/@inkdropapp/base-ui-theme/styles/theme.css')
16
+ test('links each resolved Inkdrop base stylesheet as an absolute URL', () => {
17
+ const html = buildPreviewHTML({ name: 'acme' }, baseUrl, baseStyleSheetURLs)
18
+ for (const href of baseStyleSheetURLs) {
19
+ expect(html).toContain(`<link rel="stylesheet" href="${href}" />`)
20
+ }
18
21
  })
19
22
 
20
23
  test('links each declared theme stylesheet under styles/', () => {
21
24
  const html = buildPreviewHTML(
22
25
  { name: 'acme', styleSheets: ['base.css', 'syntax.css'] },
23
- baseUrl
26
+ baseUrl,
27
+ []
24
28
  )
25
29
  expect(html).toContain('<link rel="stylesheet" href="styles/base.css" />')
26
30
  expect(html).toContain('<link rel="stylesheet" href="styles/syntax.css" />')
27
31
  })
28
32
 
29
33
  test('omits theme stylesheet links when none are declared', () => {
30
- const html = buildPreviewHTML({ name: 'acme' }, baseUrl)
34
+ const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [])
31
35
  expect(html).not.toContain('href="styles/')
32
36
  })
33
37
 
34
38
  test('sets the body class to the theme name', () => {
35
- const html = buildPreviewHTML({ name: 'acme-dark' }, baseUrl)
39
+ const html = buildPreviewHTML({ name: 'acme-dark' }, baseUrl, [])
36
40
  expect(html).toContain('<body class="acme-dark">')
37
41
  })
38
42
 
39
43
  test('appends an appearance-mode class when an appearance is forced', () => {
40
- const html = buildPreviewHTML({ name: 'acme' }, baseUrl, 'dark')
44
+ const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [], 'dark')
41
45
  expect(html).toContain('<body class="acme dark-mode">')
42
46
  })
43
47
 
44
48
  test('adds no appearance class when appearance is omitted', () => {
45
- const html = buildPreviewHTML({ name: 'acme' }, baseUrl)
49
+ const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [])
46
50
  expect(html).not.toContain('-mode')
47
51
  })
48
52
  })
49
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
+
50
104
  describe('mapThemeVariables', () => {
51
105
  test('maps each declared name to its computed value, ignoring extras', () => {
52
106
  const result = mapThemeVariables(['--accent', '--bg'], {
package/src/palette.ts CHANGED
@@ -4,25 +4,45 @@
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
17
32
  * compute a theme's CSS custom properties.
18
33
  *
19
34
  * It pulls in Inkdrop's base CSS plus the theme's own stylesheets so that the
20
- * computed style of `<body>` resolves every themed variable. A `<base href>` is
21
- * emitted so the relative `node_modules/` and `styles/` links resolve against
35
+ * computed style of `<body>` resolves every themed variable. Inkdrop's base
36
+ * stylesheets are passed in as already-resolved absolute URLs (they live in
37
+ * this package's own dependencies, not the theme's), while the theme's own
38
+ * `styles/` links stay relative and resolve against the `<base href>` — i.e.
22
39
  * the theme project root.
23
40
  *
24
41
  * @param theme - The theme package's metadata (`name`, `styleSheets`).
25
- * @param baseUrl - File URL used as the document `<base href>`.
42
+ * @param baseUrl - File URL used as the document `<base href>`; the theme's own
43
+ * `styles/` links resolve against it.
44
+ * @param baseStyleSheetURLs - Absolute URLs of Inkdrop's base stylesheets,
45
+ * resolved from this package's dependency graph by the caller.
26
46
  * @param appearance - Optional forced appearance; adds a `<appearance>-mode`
27
47
  * body class when provided.
28
48
  * @returns The full HTML document as a string.
@@ -30,8 +50,13 @@ export type ThemeAppearance = 'light' | 'dark'
30
50
  export function buildPreviewHTML(
31
51
  theme: ThemePackage,
32
52
  baseUrl: string,
53
+ baseStyleSheetURLs: string[],
33
54
  appearance?: ThemeAppearance
34
55
  ): string {
56
+ const baseCSSLinks = baseStyleSheetURLs
57
+ .map((href) => `<link rel="stylesheet" href="${href}" />`)
58
+ .join('\n')
59
+
35
60
  const themeCSSLinks = (theme.styleSheets ?? [])
36
61
  .map((filePath) => `<link rel="stylesheet" href="styles/${filePath}" />`)
37
62
  .join('\n')
@@ -42,10 +67,7 @@ export function buildPreviewHTML(
42
67
  <html>
43
68
  <head>
44
69
  <base href="${baseUrl}" />
45
- <link rel="stylesheet" href="node_modules/@inkdropapp/css/reset.css" />
46
- <link rel="stylesheet" href="node_modules/@inkdropapp/css/tokens.css" />
47
- <link rel="stylesheet" href="node_modules/@inkdropapp/css/tags.css" />
48
- <link rel="stylesheet" href="node_modules/@inkdropapp/base-ui-theme/styles/theme.css" />
70
+ ${baseCSSLinks}
49
71
  ${themeCSSLinks}
50
72
  </head>
51
73
  <body class="${bodyClass}">
@@ -76,3 +98,64 @@ export function mapThemeVariables(
76
98
  {} as Record<string, string>
77
99
  )
78
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
+ }