chore: merge master into live-editor-regressions

Preserve brand-asset preparation and the worktree-aware Vite server configuration.
This commit is contained in:
Danila Poyarkov 2026-09-16 15:06:30 +03:00
commit f88a66fced
89 changed files with 1382 additions and 75 deletions

View file

@ -148,6 +148,9 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Validate generated brand assets
run: bun run check:icons
- name: Validate translations
run: bun run check:i18n
@ -213,6 +216,9 @@ jobs:
- name: Typecheck native interaction tests
run: bun run check:native-test
- name: Generate native brand assets
run: bun run generate:icons --target desktop
- name: Compile native-test Tauri feature
run: cargo check --manifest-path desktop/Cargo.toml --features native-test

11
.gitignore vendored
View file

@ -49,6 +49,17 @@ storybook-static/
/desktop/*.key
!/desktop/*.key.pub
# Generated from assets/brand by tools/brand (never hand-edit these outputs)
/.cache/brand/
/.cache/brand.lock/
/public/brand/
/public/favicon.ico
/public/apple-touch-icon.png
/packages/docs/public/brand/
/packages/docs/public/favicon.ico
/packages/docs/public/apple-touch-icon.png
/desktop/icons/
# Copied from node_modules at build time
public/canvaskit.wasm
public/canvaskit-webgpu/

View file

@ -1,11 +1,16 @@
import type { StorybookConfig } from '@storybook/vue3-vite'
import type { PluginOption } from 'vite'
import { ensureBrandAssets } from '@open-pencil/brand-tools'
function flattenPlugins(plugins: PluginOption[]): PluginOption[] {
return plugins.flatMap((plugin) => (Array.isArray(plugin) ? flattenPlugins(plugin) : [plugin]))
}
await ensureBrandAssets(['web'])
const config: StorybookConfig = {
staticDirs: ['../public'],
stories: ['../src/**/*.stories.@(js|ts)', '../packages/vue/src/**/*.stories.@(js|ts)'],
addons: ['@storybook/addon-docs', '@storybook/addon-a11y', '@storybook/addon-themes'],
framework: {

View file

@ -158,6 +158,10 @@ PR titles use Conventional Commits because GitHub uses them as merge subjects. T
- Component domains use lowercase/kebab-case folders; Vue files stay PascalCase and component composables camelCase. Do not add new PascalCase app folders or root-level base controls; migrate old ones when touched.
- Non-component folders/files use lowercase or kebab-case except standard entrypoints. Group multi-file domains in subfolders instead of repeated sibling prefixes (`selection/container.ts`, not `selection-container.ts`).
### Brand assets
Canonical brand artwork lives in `assets/brand/`: the main mark and an optical micro master. `tools/brand/` derives web/docs/native icons locally with RealFaviconGenerator and Tauri; generated assets are ignored, not committed. Vite/VitePress configs prepare their own targets, and Tauri dev/build hooks prepare native icons. Direct Cargo checks must run `bun run generate:icons --target desktop` first. Keep the app manifest in `vite/pwa.ts`, use `BrandMark` for in-app branding, and never symlink web assets to desktop icons. See `assets/brand/README.md` for commands and output ownership.
### Repo tools and scripts
Private tooling belongs under `tools/<domain>/{src,tests}`, with kebab-case domains and focused tests. `scripts/` may contain only tiny compatibility entrypoints; put real workflow, release, architecture, package, or visual tooling in `tools/`.

View file

@ -40,6 +40,7 @@
### Changed
- Refresh the OpenPencil mark across the editor, documentation, browser tabs, installed web apps, and desktop icons, with small-size and dark-background adaptations.
- Explore editable component, typography, and paint comparisons in the demo, with the original examples preserved on a reference page.
- Use compact desktop Home search actions with consistent responsive layout and control sizing.
- Keep pixel-grid rounding invisible while showing alignment guides only for real geometry, objects, and canvas/layout guides.

35
assets/brand/README.md Normal file
View file

@ -0,0 +1,35 @@
# OpenPencil brand sources
- `mark.svg` is the teal pencil-P master: a long pointed stem, contrasting teal blocks, a faint clipped pixel grid, and white editing handles with blue borders (`#005CFF`). The square sits at the top-left; the circle sits on the outer curve.
- `mark-micro.svg` renders at 16px and preserves the main P's geometry and proportions. Its square and circular handles are enlarged for small sizes, with the circle slightly larger than the square. It has no grid.
- Both use the same tight `28 28 228 228` viewBox. Keep the P geometry aligned between masters; optical adjustments belong to the handles, not the letter's proportions.
- Both use real transparent counters and no white outline. Preserve the `data-part` attributes used for derived dark and monochrome artwork.
- Dark artwork has its own brighter teal palette, lighter blue borders, soft-white handle centers, and a subtler grid. Monochrome artwork omits the grid.
Edit these SVGs, not generated files. Platform backgrounds, padding, and dark colors live in `tools/brand/src/config.ts`. The approved main colors are defined by the SVG artwork. The official app-icon variant puts the light-palette mark on an ivory (`#F5F5EF`) rounded tile that fills the image, with transparency only at the rounded corners. Native desktop assets retain their platform-specific outer margin. Desktop icons, any-purpose PWA icons, and larger in-app branding share this composition. The tile stays ivory in both themes; use `BrandMark` with `variant="app-icon"` for larger placements. The editor header uses a 24px tile. Favicons use the same tile with the optical micro master inside, in both browser themes. Generated `brand/app-icon.svg` and `brand/app-icon-1024.png` are suitable for profile images. Apple Touch keeps an opaque square for platform rounding; maskable artwork stays opaque and inside the central 80%-diameter safe circle.
## Generation
```sh
bun run generate:icons # All targets
bun run generate:icons --target web # App/browser/PWA
bun run generate:icons --target docs # Documentation
bun run generate:icons --target desktop # Native PNG/ICO/ICNS
bun run generate:icons --force # Ignore the content cache
bun run check:icons # Fresh generation, contracts, reproducibility, types
bun test tools/brand/tests
```
Generation is local using pinned RealFaviconGenerator/Sharp and Tauri versions; no hosted generator or credentials are needed. Generated assets and the cache are ignored by Git. Only the two artwork masters, settings, code, tests, and the normal Playwright visual baseline are tracked.
Vite ensures web assets before app dev/build. VitePress ensures docs assets before docs dev/build. Storybook prepares the assets for its stories. Tauri's dev/build hooks prepare native icons before compilation. Direct Cargo commands bypass those hooks: run `bun run generate:icons --target desktop` before `cargo check` or `cargo build` on a fresh checkout. CI's native-contract job does this explicitly. No icon generation runs in the shipped application.
Outputs are owned by `tools/brand/src/config.ts`: `public/brand/`, `packages/docs/public/brand/`, the root ICO/Apple Touch files in each public directory, and `desktop/icons/`. The app's manifest remains in `vite/pwa.ts`; docs intentionally do not receive an installable-app manifest. Web assets never symlink into desktop output.
Preparation fingerprints the artwork, generator code/settings, lockfile and runtime/tool versions, verifies output hashes, and skips unchanged targets. Missing or corrupted outputs regenerate. A cross-process lock serializes concurrent builds; each file is published atomically and the cache is written last. Destination symlinks are rejected. Native ICNS frame ordering is normalized without re-encoding images.
## Review
Use the **Brand/Mark** Storybook stories for main, micro, dark, and monochrome appearances. `tests/e2e/brand/icons.spec.ts` checks the browser assets, app theme selection, and the main mark's visual baseline. Review intentional changes before updating its snapshot. Run at actual 16/24/32 pixel sizes as well as large sizes.
Do not mechanically replace generic pencil/tool icons or historical screenshots. Social artwork, external profile avatars, and Apple Icon Composer layered icons are separate design/export tasks.

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="28 28 228 228">
<path id="pencil-body" data-part="body" fill="#167C76" fill-rule="evenodd" d="M76 52H144C186 52 208 80 208 118S186 184 144 184H116V222L96 248L76 222ZM116 92V144H140C157 144 168 134 168 118S157 92 140 92Z"/>
<path data-part="upper" fill="#0A6C65" d="M76 52H116V92H76Z"/>
<path data-part="middle" fill="#65C2B1" d="M76 92H116V144H76Z"/>
<path data-part="lower" fill="#299D8D" d="M76 144H116V184H76Z"/>
<path data-part="point" fill="#095F58" d="M76 184H116V222L96 248L76 222Z"/>
<path data-part="handle" fill="#005CFF" d="M54 30H98V74H54Z"/>
<path data-part="handle-surface" fill="#FFFFFF" d="M64 40H88V64H64Z"/>
<path data-part="handle" fill="#005CFF" d="M235 118A27 27 0 1 1 181 118A27 27 0 1 1 235 118Z"/>
<path data-part="handle-surface" fill="#FFFFFF" d="M225 118A17 17 0 1 1 191 118A17 17 0 1 1 225 118Z"/>
</svg>

After

Width:  |  Height:  |  Size: 923 B

20
assets/brand/mark.svg Normal file
View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="28 28 228 228">
<defs>
<pattern id="pixel-grid" x="76" y="52" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M10 0H0V10" fill="none" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="0.65"/>
</pattern>
<clipPath id="pencil-grid-clip">
<use href="#pencil-body"/>
</clipPath>
</defs>
<path id="pencil-body" clip-rule="evenodd" data-part="body" fill="#167C76" fill-rule="evenodd" d="M76 52H144C186 52 208 80 208 118S186 184 144 184H116V222L96 248L76 222ZM116 92V144H140C157 144 168 134 168 118S157 92 140 92Z"/>
<path data-part="upper" fill="#0A6C65" d="M76 52H116V92H76Z"/>
<path data-part="middle" fill="#65C2B1" d="M76 92H116V144H76Z"/>
<path data-part="lower" fill="#299D8D" d="M76 144H116V184H76Z"/>
<path data-part="point" fill="#095F58" d="M76 184H116V222L96 248L76 222Z"/>
<rect data-detail="grid" x="28" y="28" width="228" height="228" fill="url(#pixel-grid)" clip-path="url(#pencil-grid-clip)"/>
<path data-part="handle" fill="#005CFF" d="M60 36H92V68H60Z"/>
<path data-part="handle-surface" fill="#FFFFFF" d="M68 44H84V60H68Z"/>
<path data-part="handle" fill="#005CFF" d="M226 118A18 18 0 1 1 190 118A18 18 0 1 1 226 118Z"/>
<path data-part="handle-surface" fill="#FFFFFF" d="M218 118A10 10 0 1 1 198 118A10 10 0 1 1 218 118Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

128
bun.lock
View file

@ -98,6 +98,7 @@
"@hono/node-server": "^1.19.17",
"@iconify-json/lucide": "^1.2.123",
"@modelcontextprotocol/client": "^2.0.0",
"@open-pencil/brand-tools": "workspace:*",
"@open-pencil/mcp": "workspace:*",
"@oxlint/plugins": "1.57.0",
"@playwright/test": "^1.62.1",
@ -105,7 +106,7 @@
"@storybook/addon-docs": "^10.5.8",
"@storybook/addon-themes": "^10.5.8",
"@storybook/vue3-vite": "^10.5.8",
"@tauri-apps/cli": "^2.11.4",
"@tauri-apps/cli": "2.11.4",
"@types/culori": "^4.0.1",
"@types/node": "^25.9.5",
"@types/opentype.js": "^1.3.10",
@ -230,6 +231,7 @@
"@iconify/vue": "^5.0.1",
},
"devDependencies": {
"@open-pencil/brand-tools": "workspace:*",
"@shikijs/vitepress-twoslash": "^4.4.3",
"@tailwindcss/vite": "^4.3.3",
"vitepress": "^2.0.0-alpha.19",
@ -394,6 +396,24 @@
"dedent": "^1.7.2",
},
},
"tools/brand": {
"name": "@open-pencil/brand-tools",
"dependencies": {
"@open-pencil/package-artifacts": "workspace:*",
"@realfavicongenerator/generate-favicon": "0.9.0",
"@realfavicongenerator/image-adapter-node": "0.9.0",
"@tauri-apps/cli": "2.11.4",
"proper-lockfile": "4.1.2",
"sharp": "0.34.5",
"valibot": "^1.4.2",
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/node": "^25.9.5",
"@types/proper-lockfile": "4.1.4",
"typescript": "~5.8.3",
},
},
"tools/docs": {
"name": "@open-pencil/docs-tools",
},
@ -816,7 +836,7 @@
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
@ -1022,6 +1042,56 @@
"@iconify/vue": ["@iconify/vue@5.0.1", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "vue": ">=3.0.0" } }, "sha512-aumwwooJlFJ5H5qYWB6ZTAyM0C8hpfcSVLB9/a3qnH1GGvIJ+FEbpEs4s/HfErYe/M5qZeLjwmESR5fFm3lXEw=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
"@inquirer/checkbox": ["@inquirer/checkbox@4.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA=="],
@ -1232,6 +1302,8 @@
"@open-pencil/authoring-reference-tools": ["@open-pencil/authoring-reference-tools@workspace:tools/authoring-reference"],
"@open-pencil/brand-tools": ["@open-pencil/brand-tools@workspace:tools/brand"],
"@open-pencil/cli": ["@open-pencil/cli@workspace:packages/cli"],
"@open-pencil/core": ["@open-pencil/core@workspace:packages/core"],
@ -1476,6 +1548,10 @@
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
"@realfavicongenerator/generate-favicon": ["@realfavicongenerator/generate-favicon@0.9.0", "", { "dependencies": { "@svgdotjs/svg.js": "^3.2.0" } }, "sha512-Fez/4q+w02OWL3daQ71rDmTRymgbeT457sz/6kQL+lJ9yZf0EWziKIDbSBJ5145JaJuqSkDk7eaoz/migAw3HA=="],
"@realfavicongenerator/image-adapter-node": ["@realfavicongenerator/image-adapter-node@0.9.0", "", { "dependencies": { "@realfavicongenerator/generate-favicon": "^0.9.0", "@svgdotjs/svg.js": "^3.2.0", "sharp": "^0.34.5", "svgdom": "^0.1.19" } }, "sha512-MAemmKAPMRqf/7QnWmJV2GBjny6QejZhE8z0cQiaDVlZJy39pCocZ/fFqIqH2tDS0GMReaAIP+vSD1FA3j5ufw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ=="],
@ -1666,6 +1742,8 @@
"@supabase/supabase-js": ["@supabase/supabase-js@2.112.3", "", { "dependencies": { "@supabase/auth-js": "2.112.3", "@supabase/functions-js": "2.112.3", "@supabase/postgrest-js": "2.112.3", "@supabase/realtime-js": "2.112.3", "@supabase/storage-js": "2.112.3" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-Jv1bxVQmEJNkjvPEhFaKjPzsh+Ozyew6lWGD+SoYcsclDEP1z7yEvKvfUQfzy0DkxRIQnZNxmmWtAzw5XLTQoA=="],
"@svgdotjs/svg.js": ["@svgdotjs/svg.js@3.2.8", "", {}, "sha512-NUdbI/7FDdqGpzKmqr09IQoy9MUcrAC/3bC8gLgXi6OfJuyri450zMLvJekFUhuq3iN5gpdPjoSoStNl9Eijfg=="],
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
@ -1818,6 +1896,8 @@
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
"@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="],
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
"@types/rbush": ["@types/rbush@4.0.0", "", {}, "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ=="],
@ -2204,6 +2284,8 @@
"broker-factory": ["broker-factory@3.1.15", "", { "dependencies": { "@babel/runtime": "^7.29.7", "fast-unique-numbers": "^9.0.27", "tslib": "^2.8.1", "worker-factory": "^7.0.50" } }, "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og=="],
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
"browser-stdout": ["browser-stdout@1.3.1", "", {}, "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw=="],
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
@ -2284,7 +2366,7 @@
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
"cloudflare-redirect-parser": ["cloudflare-redirect-parser@1.0.0", "", {}, "sha512-puEB3wmP47RICu/CboTHd237Up/r3Nagsr622mJxWgvEVfRxNlKceNfpPieMYDe/E1nYJsf6iempsrkitgQZVw=="],
@ -2458,6 +2540,8 @@
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dns-over-http-resolver": ["dns-over-http-resolver@3.0.16", "", { "dependencies": { "quick-lru": "^7.0.0", "weald": "^1.0.2" } }, "sha512-Qnq8HhNRuMnA61pf1lVPlStCAv1BVrraCx0umPESWgYKf995tUMF5oNhW59PKdnf7E8d5yqwHlEoFywXjsNMCw=="],
@ -2682,6 +2766,8 @@
"font-family-papandreou": ["font-family-papandreou@0.2.0-patch2", "", {}, "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw=="],
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
"fontoxpath": ["fontoxpath@3.34.0", "", { "dependencies": { "prsc": "4.0.0", "xspattern": "^3.1.0" } }, "sha512-7FgBQRohn4WR/2eVdYqZtKBFZkJNaiP4kGdfqS6bAg465og05ixGA2OgfMdxsO1mnX67+64JX/8JKclVqS+bzA=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
@ -3748,6 +3834,8 @@
"resq": ["resq@1.11.0", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
@ -3792,6 +3880,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
@ -3820,6 +3910,8 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@ -3852,7 +3944,7 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
@ -3974,6 +4066,8 @@
"svg2pdf.js": ["svg2pdf.js@2.7.0", "", { "dependencies": { "cssesc": "^3.0.0", "font-family-papandreou": "^0.2.0-patch1", "specificity": "^0.4.1", "svgpath": "^2.3.0" }, "peerDependencies": { "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" } }, "sha512-nXK4Wx28H0KtOktanm5nsphl1KMEoLNMelAT/776qxPAj9DshwYcqgdpKuBnY1nrcYOriQFHVQLE4tIag+aDJA=="],
"svgdom": ["svgdom@0.1.29", "", { "dependencies": { "fontkit": "^2.0.4", "image-size": "^2.0.2", "sax": "^1.4.1" } }, "sha512-BpwpgRfvntIF3Pdn8Pe5pUvZXxaKz9GVSYfs7g3CfjHdvmVkYYqJjZeLV+2oJCXzkaPITDuVD7ZAUV+BkJedtw=="],
"svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="],
"swrv": ["swrv@1.2.0", "", { "peerDependencies": { "vue": ">=3.2.26 < 4" } }, "sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A=="],
@ -4012,6 +4106,8 @@
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
@ -4132,8 +4228,12 @@
"unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="],
"unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
"unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="],
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
@ -4456,6 +4556,8 @@
"@grpc/proto-loader/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="],
"@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
@ -4484,12 +4586,16 @@
"@multiformats/dns/uint8arrays": ["uint8arrays@6.1.1", "", { "dependencies": { "multiformats": "^14.0.0" } }, "sha512-iz7JN0XCSZYA111lhFG2Ui9EhFvTNekqSRHw3lvMHq+dzwWy1OQftxFQREEh4rffU0oSoXdQHsk2TiHKVm4fsA=="],
"@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
"@puppeteer/browsers/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="],
"@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@rollup/plugin-terser/serialize-javascript": ["serialize-javascript@7.1.0", "", {}, "sha512-RNEqWOyhhUQYN9V1GfHwu9AR/g+NTciH6Z5u3/no6X3/w+04J2lVDL+svFQVXgXrEGBMG2puMVN3gq2SNGuTGw=="],
"@shikijs/vitepress-twoslash/magic-string": ["magic-string@1.2.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vCfXkt3lIJha02CjPT1igeysyHVfCsEpIeD20O+X9aJ2hML3/kKx8E9Iv1FB+aMSAlDOEAtpRWzuooQCrYwdUg=="],
@ -4620,6 +4726,8 @@
"create-wdio/yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"detective-typescript/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.67.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.67.0", "@typescript-eslint/tsconfig-utils": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw=="],
@ -4638,6 +4746,8 @@
"execa/@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"expect-webdriverio/@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="],
"extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
@ -4650,6 +4760,8 @@
"floating-vue/@floating-ui/dom": ["@floating-ui/dom@1.1.1", "", { "dependencies": { "@floating-ui/core": "^1.1.0" } }, "sha512-TpIO93+DIujg3g7SykEAGZMDtbJRrmnYRCNYSjJlvIbGhBjRSNTLVbNeDQBrzy9qDgUbiWdc7KA0uZHZ2tJmiw=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"globby/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
@ -4750,8 +4862,6 @@
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"raw-body/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
@ -4808,6 +4918,8 @@
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"svgdom/image-size": ["image-size@2.0.4", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw=="],
"tempy/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
@ -4822,6 +4934,8 @@
"type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"unplugin-raw/unplugin": ["unplugin@3.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.4", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg=="],
"unplugin-vue/unplugin": ["unplugin@3.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.4", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg=="],
@ -5082,8 +5196,6 @@
"blamer/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"blamer/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"blamer/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"cheerio/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -4,9 +4,9 @@
"version": "0.14.0",
"identifier": "net.dannote.open-pencil",
"build": {
"beforeDevCommand": "bun run generate:tauri-menu && bun run dev",
"beforeDevCommand": "bun run generate:icons --target desktop && bun run generate:tauri-menu && bun run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run generate:tauri-menu && bun run build",
"beforeBuildCommand": "bun run generate:icons --target desktop && bun run generate:tauri-menu && bun run build",
"frontendDist": "../dist"
},
"app": {

View file

@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="128x128" href="/favicon-128.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/brand/favicon-96x96.png" />
<link rel="icon" type="image/svg+xml" href="/brand/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#1e1e1e" />
@ -39,7 +39,7 @@
color: rgb(var(--loader-fg));
background: var(--loader-bg);
}
#boot-splash svg { width: 32px; height: 32px; opacity: 0.45; }
#boot-splash img { width: 40px; height: 40px; }
#boot-splash .bar {
position: absolute;
bottom: 50%;
@ -70,9 +70,7 @@
<body>
<div id="app">
<div id="boot-splash">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m15.232 5.232 3.536 3.536m-2.036-5.036a2.5 2.5 0 0 1 3.536 3.536L6.5 21.036H3v-3.572L16.732 3.732Z"/>
</svg>
<img src="/brand/app-icon.svg" width="40" height="40" alt="OpenPencil" />
<div class="bar"></div>
</div>
</div>

View file

@ -15,6 +15,7 @@
"packages/mcp",
"packages/harness",
"packages/docs",
"tools/brand",
"tools/docs",
"tools/lint",
"tools/package-artifacts",
@ -40,7 +41,7 @@
"lint:structure": "oxlint -c oxlint.json commitlint.config.ts vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/harness/tests/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests/ scripts/ tools/",
"format": "oxfmt --write .oxfmtrc.json commitlint.config.ts vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/harness/tests/ packages/vue/src/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests scripts/ tools/",
"format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)",
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:native-test && bun run check:i18n && bun run check:docs && bun run check:changelog && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
"check": "bun run check:icons && bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:native-test && bun run check:i18n && bun run check:docs && bun run check:changelog && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
"check:commits": "commitlint",
"check:changelog": "bun tools/release-packages/src/check-changelog.ts",
"check:deps": "knip --include unlisted,unresolved,binaries",
@ -76,6 +77,8 @@
"docs:build": "bun --filter @open-pencil/docs build",
"docs:build:production": "bun --filter @open-pencil/docs build:production",
"docs:preview": "bun --filter @open-pencil/docs preview",
"generate:icons": "bun tools/brand/src/cli.ts generate",
"check:icons": "bun tools/brand/src/cli.ts check && bun --filter @open-pencil/brand-tools check",
"generate:tauri-menu": "bun tools/tauri-menu/src/generate.ts"
},
"dependencies": {
@ -172,6 +175,7 @@
"@hono/node-server": "^1.19.17",
"@iconify-json/lucide": "^1.2.123",
"@modelcontextprotocol/client": "^2.0.0",
"@open-pencil/brand-tools": "workspace:*",
"@open-pencil/mcp": "workspace:*",
"@oxlint/plugins": "1.57.0",
"@playwright/test": "^1.62.1",
@ -179,7 +183,7 @@
"@storybook/addon-docs": "^10.5.8",
"@storybook/addon-themes": "^10.5.8",
"@storybook/vue3-vite": "^10.5.8",
"@tauri-apps/cli": "^2.11.4",
"@tauri-apps/cli": "2.11.4",
"@types/culori": "^4.0.1",
"@types/node": "^25.9.5",
"@types/opentype.js": "^1.3.10",

View file

@ -1,6 +1,7 @@
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { ensureBrandAssets } from '@open-pencil/brand-tools'
import { transformerTwoslash } from '@shikijs/vitepress-twoslash'
import { createFileSystemTypesCache } from '@shikijs/vitepress-twoslash/cache-fs'
import tailwindcss from '@tailwindcss/vite'
@ -11,6 +12,8 @@ import { docsLocales } from './locales.ts'
import { rootThemeConfig } from './root-theme.ts'
import { BASE, LOCALE_PREFIXES, applyPageSeo, siteHead, withAlternateSitemapLinks } from './seo.ts'
await ensureBrandAssets(['docs'])
const configDir = dirname(fileURLToPath(import.meta.url))
const docsRoot = dirname(configDir)
const packagesRoot = dirname(docsRoot)

View file

@ -11,6 +11,7 @@ import {
import type { DefaultTheme } from 'vitepress'
export const rootThemeConfig = (): DefaultTheme.Config => ({
logo: { light: '/brand/mark.svg', dark: '/brand/mark-dark.svg', alt: 'OpenPencil' },
search: { provider: 'local' },
nav: [

View file

@ -19,7 +19,10 @@ export const LOCALES: Record<string, { hreflang: string; ogLocale: string; prefi
}
export const siteHead: [string, Record<string, string>][] = [
['link', { rel: 'icon', type: 'image/png', href: '/favicon.png' }],
['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }],
['link', { rel: 'icon', type: 'image/png', href: '/brand/favicon-96x96.png', sizes: '96x96' }],
['link', { rel: 'icon', type: 'image/svg+xml', href: '/brand/favicon.svg' }],
['link', { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }],
['link', { rel: 'alternate', type: 'text/plain', title: 'llms.txt', href: '/llms.txt' }],
['link', { rel: 'alternate', type: 'text/plain', title: 'llms-full.txt', href: '/llms-full.txt' }],
['meta', { property: 'og:type', content: 'website' }],

View file

@ -15,6 +15,7 @@
"preview": "vitepress preview"
},
"devDependencies": {
"@open-pencil/brand-tools": "workspace:*",
"@shikijs/vitepress-twoslash": "^4.4.3",
"@tailwindcss/vite": "^4.3.3",
"vitepress": "^2.0.0-alpha.19",

View file

@ -1 +0,0 @@
../../../public/favicon-32.png

View file

@ -1 +0,0 @@
../desktop/icons/icon.png

View file

@ -1 +0,0 @@
../desktop/icons/128x128.png

View file

@ -1 +0,0 @@
../desktop/icons/32x32.png

View file

@ -1 +0,0 @@
../desktop/icons/icon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -18,8 +18,8 @@ import PublishLibraryDialog from '@/components/libraries/PublishLibraryDialog.vu
import LibraryUpdateReviewDialog from '@/components/libraries/review/LibraryUpdateReviewDialog.vue'
import RecoveryDialog from '@/components/recovery/RecoveryDialog.vue'
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
import AppShell from '@/components/Shell/AppShell.vue'
import AppToast from '@/components/Shell/AppToast.vue'
import AppShell from '@/components/shell/AppShell.vue'
import AppToast from '@/components/shell/AppToast.vue'
const store = useEditorStore()
const { updates, locale } = useI18n()

View file

@ -5,7 +5,7 @@ import { computed, ref } from 'vue'
import { useI18n } from '@open-pencil/vue'
import AppMenu from '@/components/Shell/AppMenu.vue'
import AppMenu from '@/components/shell/AppMenu.vue'
import SegmentedControl from '@/components/ui/select/SegmentedControl.vue'
import splitterTheme from '@/theme/splitter'

View file

@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BrandMark from './BrandMark.vue'
import type { BrandMarkProps } from './types'
const meta = {
title: 'Brand/Mark',
component: BrandMark,
args: { variant: 'mark', appearance: 'light', class: 'size-32' }
} satisfies Meta<BrandMarkProps>
export default meta
type Story = StoryObj<BrandMarkProps>
export const Mark: Story = {}
export const Micro: Story = { args: { variant: 'micro', class: 'size-4' } }
export const Monochrome: Story = { args: { variant: 'mono' } }
export const AppIcon: Story = { args: { variant: 'app-icon' } }
export const AppIconDark: Story = {
args: { variant: 'app-icon', appearance: 'dark' },
render: (args) => ({
components: { BrandMark },
setup: () => ({ args }),
template: '<div class="bg-neutral-800 p-8"><BrandMark v-bind="args" /></div>'
})
}
export const Dark: Story = {
args: { appearance: 'dark' },
render: (args) => ({
components: { BrandMark },
setup: () => ({ args }),
template: '<div class="bg-gray-950 p-8"><BrandMark v-bind="args" /></div>'
})
}

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { brandMark } from '@/theme/brand'
import type { BrandMarkProps } from './types'
const {
variant = 'micro',
appearance = 'light',
decorative = false,
class: className
} = defineProps<BrandMarkProps>()
const sources = {
mark: 'mark',
micro: 'mark-micro',
mono: 'mark-mono',
'app-icon': 'app-icon'
} as const
const source = computed(() => {
// The ivory tile owns its background and always uses the light palette.
const suffix = variant !== 'app-icon' && appearance === 'dark' ? '-dark' : ''
return `/brand/${sources[variant]}${suffix}.svg`
})
</script>
<template>
<img
:src="source"
:class="brandMark({ class: className })"
:alt="decorative ? '' : 'OpenPencil'"
width="16"
height="16"
/>
</template>

View file

@ -0,0 +1,6 @@
export interface BrandMarkProps {
variant?: 'mark' | 'micro' | 'mono' | 'app-icon'
appearance?: 'light' | 'dark'
decorative?: boolean
class?: string
}

View file

@ -29,7 +29,7 @@ const meta = {
})
} satisfies Meta<Args>
export default meta
type Story = StoryObj<typeof meta>
type Story = StoryObj<Omit<typeof meta, 'component'>>
export const Collapsed: Story = {}
export const Expanded: Story = { args: { display: 'expanded' } }
export const WhileThinking: Story = { args: { display: 'while-thinking', streaming: true } }

View file

@ -8,7 +8,9 @@ import { useEditorStore } from '@/app/editor/active-store'
import { appRuntimeConfig } from '@/app/runtime/config'
import { loadEditorLayout, saveEditorLayout } from '@/app/shell/layout-storage'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
import { resolvedAppTheme } from '@/app/shell/theme'
import { activeTab } from '@/app/tabs'
import BrandMark from '@/components/brand/BrandMark.vue'
import CanvasSplitRoot from '@/components/canvas/CanvasSplitRoot.vue'
import CollabPanel from '@/components/CollabPanel/CollabPanel.vue'
import EditorCanvas from '@/components/EditorCanvas.vue'
@ -98,7 +100,7 @@ const horizontalSplitterStyles = tv(splitterTheme)({ direction: 'horizontal' })
v-if="!isMobile"
class="absolute top-7 left-7 z-10 flex items-center gap-2 rounded-lg border border-border bg-panel px-2 py-1 shadow-sm"
>
<img src="/favicon-32.png" class="size-4" alt="OpenPencil" />
<BrandMark variant="app-icon" :appearance="resolvedAppTheme" class="size-6" />
<span data-test-id="editor-document-name" class="text-xs text-surface">{{
store.state.documentName
}}</span>

View file

@ -15,9 +15,10 @@ const meta = {
} satisfies Meta<{ name: string; metadata: string; view: 'grid' | 'list'; disabled?: boolean }>
export default meta
export const Default: StoryObj<typeof meta> = {}
export const List: StoryObj<typeof meta> = { args: { view: 'list' } }
export const LongName: StoryObj<typeof meta> = {
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const List: Story = { args: { view: 'list' } }
export const LongName: Story = {
args: { name: 'A deliberately long document name that must stay inside the document card' }
}
export const Disabled: StoryObj<typeof meta> = { args: { disabled: true } }
export const Disabled: Story = { args: { disabled: true } }

View file

@ -3,6 +3,8 @@ import { ProgressIndicator, ProgressRoot } from 'reka-ui'
import { computed } from 'vue'
import type { EditorPreparation } from '@/app/editor/preparation/types'
import { resolvedAppTheme } from '@/app/shell/theme'
import BrandMark from '@/components/brand/BrandMark.vue'
import { preparationLabel, preparationPercent } from '@/components/preparation/presentation'
const { preparation } = defineProps<{
@ -24,7 +26,7 @@ const progressSteps = computed(() => Math.round(progressValue.value ?? 0))
class="absolute inset-0 z-50 flex items-center justify-center bg-canvas"
>
<div class="flex w-72 flex-col items-center gap-3 text-center">
<icon-lucide-pencil-line class="size-8 text-surface opacity-45" />
<BrandMark variant="app-icon" :appearance="resolvedAppTheme" decorative class="size-12" />
<div class="space-y-1">
<p class="text-sm font-medium text-surface/80">{{ label }}</p>
<p v-if="preparation.detail" class="truncate text-xs text-surface/45">

View file

@ -36,6 +36,8 @@ import {
updateMenuChecked
} from '@/app/shell/menu/entry'
import { appMenuShortcutLabel } from '@/app/shell/menu/shortcut'
import { resolvedAppTheme } from '@/app/shell/theme'
import BrandMark from '@/components/brand/BrandMark.vue'
import IconButton from '@/components/ui/button/IconButton.vue'
import AppShortcutText from '@/components/ui/menu/AppShortcutText.vue'
import { useMenuUI } from '@/components/ui/menu/menu'
@ -61,7 +63,12 @@ const subMenuCls = useMenuUI({ content: 'min-w-44' })
<template>
<div class="shrink-0 border-b border-border">
<div class="flex items-center gap-2 px-2 py-1.5">
<img data-test-id="app-logo" src="/favicon-32.png" class="size-4" alt="OpenPencil" />
<BrandMark
data-test-id="app-logo"
variant="app-icon"
:appearance="resolvedAppTheme"
class="size-6"
/>
<input
v-if="editingName"
ref="nameInput"

View file

@ -30,11 +30,12 @@ const meta = {
} satisfies Meta<{ fill: Fill; label: string }>
export default meta
export const Default: StoryObj<typeof meta> = {}
export const Transparent: StoryObj<typeof meta> = {
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Transparent: Story = {
args: { fill: { ...fill, color: { ...fill.color, a: 0.35 } } }
}
export const Gradient: StoryObj<typeof meta> = {
export const Gradient: Story = {
args: {
fill: {
...fill,
@ -46,10 +47,10 @@ export const Gradient: StoryObj<typeof meta> = {
}
}
}
export const ImagePlaceholder: StoryObj<typeof meta> = {
export const ImagePlaceholder: Story = {
args: { fill: { ...fill, type: 'IMAGE', imageHash: 'unavailable-preview' } }
}
export const PopoverComposition: StoryObj<typeof meta> = {
export const PopoverComposition: Story = {
name: 'Picker Trigger',
render: (args) => ({
components: { FillSwatchTrigger, PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent },

3
src/theme/brand.ts Normal file
View file

@ -0,0 +1,3 @@
import { tv } from 'tailwind-variants'
export const brandMark = tv({ base: 'block size-4 shrink-0 object-contain' })

View file

@ -0,0 +1,106 @@
import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
for (const deviceScaleFactor of [1, 2]) {
test.describe(`brand at ${deviceScaleFactor}x`, () => {
test.use({ deviceScaleFactor })
test('the editor keeps its 24px ivory tile in both app themes', async ({ page }) => {
await page.goto('/?test')
await new CanvasHelper(page).waitForInit()
const logo = page.getByTestId('app-logo')
await expect(logo).toHaveAttribute('alt', 'OpenPencil')
for (const appearance of ['light', 'dark'] as const) {
await page.evaluate(async (appearance) => {
const path = '/src/app/shell/theme.ts'
const module = await import(path)
module.useAppTheme().setTheme(appearance)
}, appearance)
await expect(logo).toHaveAttribute('src', '/brand/app-icon.svg')
await expect(logo).toHaveCSS('width', '24px')
await expect(logo).toHaveCSS('height', '24px')
await expect(logo).toHaveScreenshot(
`brand-header-${appearance}-${deviceScaleFactor}x.png`,
{
maxDiffPixels: 0,
threshold: 0.1,
scale: 'device'
}
)
}
})
test('the 16px favicon keeps its edge-to-edge ivory tile in both browser themes', async ({
page,
baseURL
}) => {
const src = new URL('/brand/favicon.svg', baseURL).href
await page.setContent(`<img src="${src}" alt="OpenPencil favicon" width="16" height="16">`)
const icon = page.getByRole('img', { name: 'OpenPencil favicon' })
await icon.evaluate((image: HTMLImageElement) => image.decode())
for (const colorScheme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme })
await icon.evaluate((image, colorScheme) => {
image.style.colorScheme = colorScheme
image.style.backgroundColor = colorScheme === 'dark' ? '#282828' : '#ffffff'
}, colorScheme)
await expect(icon).toHaveScreenshot(
`brand-favicon-${colorScheme}-${deviceScaleFactor}x.png`,
{
maxDiffPixels: 0,
threshold: 0.1,
scale: 'device'
}
)
}
})
})
}
test('the main SVG retains its approved appearance', async ({ page }) => {
await page.goto('/brand/mark.svg')
await expect(page.locator('svg')).toHaveScreenshot('brand-mark.png')
})
test('the larger dark SVG retains its palette and subtle grid', async ({ page }) => {
await page.goto('/brand/mark-dark.svg')
const mark = page.locator('svg')
await mark.evaluate((svg) => {
svg.style.backgroundColor = '#282828'
})
await expect(mark).toHaveScreenshot('brand-mark-dark.png')
})
test('the ivory app tile works on light and dark surfaces', async ({ page }) => {
await page.goto('/brand/app-icon.svg')
const icon = page.locator('svg').first()
for (const appearance of ['light', 'dark'] as const) {
await icon.evaluate((svg, appearance) => {
svg.setAttribute('width', '128')
svg.setAttribute('height', '128')
svg.style.backgroundColor = appearance === 'dark' ? '#282828' : '#ffffff'
}, appearance)
await expect(icon).toHaveScreenshot(`brand-app-icon-${appearance}.png`)
}
})
test('browser icon declarations resolve without a duplicate manifest', async ({
page,
request
}) => {
await page.goto('/?test')
const icons = page.locator('link[rel="icon"], link[rel="apple-touch-icon"]')
const paths = await icons.evaluateAll((links) => links.map((link) => link.getAttribute('href')))
expect(paths).toEqual(
expect.arrayContaining(['/favicon.ico', '/brand/favicon.svg', '/apple-touch-icon.png'])
)
for (const path of paths) {
expect(path).toBeTruthy()
if (!path) continue
const response = await request.get(path)
expect(response.ok()).toBe(true)
expect(response.headers()['content-type']).not.toContain('text/html')
}
// The dev server deliberately disables PWA registration; production injects one manifest.
expect(await page.locator('link[rel="manifest"]').count()).toBeLessThanOrEqual(1)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View file

@ -27,6 +27,20 @@ test('shows staged and determinate document loading progress in the existing can
await expect(loader).toContainText('Geist SemiBold')
await expect(loader).toContainText('7 of 12')
await expect(loader.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '58')
for (const appearance of ['light', 'dark'] as const) {
await editor.page.evaluate(async (appearance) => {
const path = '/src/app/shell/theme.ts'
const module = await import(path)
module.useAppTheme().setTheme(appearance)
}, appearance)
const mark = loader.locator('img')
await expect(mark).toHaveAttribute('src', '/brand/app-icon.svg')
await expect(mark).toHaveAttribute('alt', '')
await expect(mark).toHaveScreenshot(`loading-brand-${appearance}.png`, {
maxDiffPixels: 0,
threshold: 0.1
})
}
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

30
tools/brand/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "@open-pencil/brand-tools",
"private": true,
"type": "module",
"imports": {
"#brand/*": "./src/*.ts"
},
"exports": {
".": "./src/generate.ts"
},
"scripts": {
"test": "bun test tests",
"check": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@open-pencil/package-artifacts": "workspace:*",
"@realfavicongenerator/generate-favicon": "0.9.0",
"@realfavicongenerator/image-adapter-node": "0.9.0",
"@tauri-apps/cli": "2.11.4",
"proper-lockfile": "4.1.2",
"sharp": "0.34.5",
"valibot": "^1.4.2"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/node": "^25.9.5",
"@types/proper-lockfile": "4.1.4",
"typescript": "~5.8.3"
}
}

View file

@ -0,0 +1,57 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { stringToSvg as stringToSVG } from '@realfavicongenerator/generate-favicon'
import type { ImageAdapter } from '@realfavicongenerator/generate-favicon'
import { getNodeImageAdapter } from '@realfavicongenerator/image-adapter-node'
import { brand } from './config.ts'
export async function loadArtwork(root: string) {
const adapter = await getNodeImageAdapter()
const main = await readFile(join(root, 'assets/brand/mark.svg'), 'utf8')
const micro = await readFile(join(root, 'assets/brand/mark-micro.svg'), 'utf8')
return { adapter, main, micro }
}
export function recolor(
source: string,
adapter: ImageAdapter,
mode: 'dark' | 'mono' | 'mono-dark'
) {
const svg = stringToSVG(source, adapter)
if (mode === 'dark') {
for (const [part, color] of Object.entries(brand.darkPalette)) {
for (const path of svg.find(`[data-part="${part}"]`)) path.fill(color)
}
for (const grid of svg.find('[data-detail="grid"]')) grid.opacity(brand.darkGridOpacity)
} else {
for (const detail of svg.find('[data-detail]')) detail.remove()
for (const path of svg.find('[data-part]')) {
path.fill(mode === 'mono' ? brand.darkBackground : brand.background)
}
}
return svg.svg()
}
/** Shared rounded app icon; touch and maskable variants leave cropping to the platform. */
export function appArtwork(
source: string,
adapter: ImageAdapter,
kind: 'web' | 'maskable' | 'tile' | 'desktop'
) {
const svg = adapter.createSvg().size(1024, 1024).viewbox(0, 0, 1024, 1024)
const rounded = kind === 'tile' || kind === 'desktop'
const inset = kind === 'desktop' ? brand.tileInset : 0
const background = svg
.rect(1024 - inset * 2, 1024 - inset * 2)
.move(inset, inset)
.fill(brand.background)
if (rounded) background.radius(brand.tileRadius)
const regularScale = kind === 'tile' ? brand.tileScale : brand.appScale
const scale = kind === 'maskable' ? brand.maskableScale : regularScale
const mark = stringToSVG(source, adapter).size(1024 * scale, 1024 * scale)
mark.move((1024 * (1 - scale)) / 2, (1024 * (1 - scale)) / 2)
svg.add(mark)
return svg.svg()
}

118
tools/brand/src/cache.ts Normal file
View file

@ -0,0 +1,118 @@
import { createHash } from 'node:crypto'
import { lstat, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
import { dirname, join, relative, sep } from 'node:path'
import sharp from 'sharp'
import * as v from 'valibot'
import { targetDirectory, targetFiles } from './config.ts'
import type { BrandTarget } from './config.ts'
import type { BrandFiles } from './web.ts'
export const digest = (bytes: string | Buffer) => createHash('sha256').update(bytes).digest('hex')
const cacheSchema = v.object({ fingerprint: v.string(), outputs: v.record(v.string(), v.string()) })
async function inputFiles(root: string, directory: string): Promise<string[]> {
const result: string[] = []
for (const item of await readdir(join(root, directory), { withFileTypes: true })) {
const path = join(directory, item.name)
if (item.isDirectory()) result.push(...(await inputFiles(root, path)))
else if (item.isFile() && !item.name.endsWith('.md')) result.push(path)
}
return result.sort()
}
export async function fingerprint(root: string): Promise<string> {
const paths = [
...(await inputFiles(root, 'assets/brand')),
...(await inputFiles(root, 'tools/brand/src')),
'tools/brand/package.json',
'package.json',
'bun.lock'
]
const hash = createHash('sha256')
for (const path of paths)
hash
.update(path)
.update('\0')
.update(await readFile(join(root, path)))
.update('\0')
hash.update(
JSON.stringify({
platform: process.platform,
arch: process.arch,
runtime: process.versions,
sharp: sharp.versions
})
)
return hash.digest('hex')
}
export async function isCached(root: string, target: BrandTarget, key: string): Promise<boolean> {
try {
const cache = v.parse(
cacheSchema,
JSON.parse(await readFile(join(root, '.cache/brand', `${target}.json`), 'utf8'))
)
if (cache.fingerprint !== key) return false
for (const name of targetFiles(target)) {
const path = join(root, targetDirectory(target), name)
if (!(await lstat(path)).isFile() || digest(await readFile(path)) !== cache.outputs[name])
return false
}
return true
} catch {
return false
}
}
/** Refuse directory/file symlinks rather than following legacy web -> desktop links. */
export async function assertOwnedPath(root: string, path: string): Promise<void> {
const rel = relative(root, path)
if (rel.startsWith('..') || rel.startsWith(sep))
throw new Error(`Output escapes brand root: ${path}`)
let current = root
for (const part of rel.split(sep)) {
current = join(current, part)
try {
if ((await lstat(current)).isSymbolicLink())
throw new Error(`Refusing brand output symlink: ${current}`)
} catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error
}
}
}
async function atomicWrite(root: string, path: string, bytes: string | Buffer): Promise<void> {
await assertOwnedPath(root, path)
await mkdir(dirname(path), { recursive: true })
const temp = `${path}.tmp`
await assertOwnedPath(root, temp)
await writeFile(temp, bytes)
await rename(temp, path)
}
export async function publish(
root: string,
target: BrandTarget,
key: string,
files: BrandFiles
): Promise<void> {
const outputs: Record<string, string> = {}
const names = targetFiles(target)
// Preflight all destinations before writing anything. Only explicitly owned files are touched.
for (const name of names) await assertOwnedPath(root, join(root, targetDirectory(target), name))
for (const name of names) {
const bytes = files.get(name)
if (!bytes) throw new Error(`Generator did not produce ${name}`)
await atomicWrite(root, join(root, targetDirectory(target), name), bytes)
outputs[name] = digest(bytes)
}
// Publish the cache last. An interrupted generation is rebuilt on the next run.
await atomicWrite(
root,
join(root, '.cache/brand', `${target}.json`),
JSON.stringify({ fingerprint: key, outputs })
)
}

28
tools/brand/src/check.ts Normal file
View file

@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import { targetFiles } from './config.ts'
import type { BrandTarget } from './config.ts'
import { generateDesktop } from './desktop.ts'
import { validateFiles } from './validate.ts'
import { generateWeb } from './web.ts'
/** Validate fresh output and determinism without publishing into the checkout. */
export async function checkBrandAssets(
root: string,
selected: readonly BrandTarget[]
): Promise<void> {
const generators = { web: generateWeb, docs: generateWeb, desktop: generateDesktop }
for (const target of selected) {
const first = await generators[target](root)
const second = await generators[target](root)
await validateFiles(first, target)
for (const name of targetFiles(target)) {
assert.deepEqual(
first.get(name),
second.get(name),
`Non-deterministic ${target} asset: ${name}`
)
}
console.info(`Verified ${target} brand assets`)
}
}

19
tools/brand/src/cli.ts Normal file
View file

@ -0,0 +1,19 @@
import { parseArgs } from 'node:util'
import * as v from 'valibot'
import { checkBrandAssets } from './check.ts'
import { repositoryRoot, targets } from './config.ts'
import { ensureBrandAssets } from './generate.ts'
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
allowPositionals: true,
options: { target: { type: 'string', multiple: true }, force: { type: 'boolean' } }
})
const command = v.parse(v.picklist(['generate', 'check']), positionals[0] ?? 'generate')
if (positionals.length > 1)
throw new Error('Usage: brand [generate|check] [--target web|docs|desktop] [--force]')
const selected = v.parse(v.array(v.picklist(targets)), values.target ?? [...targets])
if (command === 'check') await checkBrandAssets(repositoryRoot, selected)
else await ensureBrandAssets(selected, { force: values.force })

84
tools/brand/src/config.ts Normal file
View file

@ -0,0 +1,84 @@
import { fileURLToPath } from 'node:url'
import { resolveWorkspaceRoot } from '@open-pencil/package-artifacts/workspace'
export const repositoryRoot = await resolveWorkspaceRoot(fileURLToPath(import.meta.url))
export const targets = ['web', 'docs', 'desktop'] as const
export type BrandTarget = (typeof targets)[number]
export const brand = {
background: '#F5F5EF',
darkBackground: '#202B2D',
darkPalette: {
body: '#55BAAC',
upper: '#258E83',
middle: '#86D5C6',
lower: '#45AFA0',
point: '#29978A',
handle: '#438CFF',
'handle-surface': '#F4FBFA'
},
darkGridOpacity: 0.6,
tileInset: 64,
tileRadius: 200,
appScale: 0.82,
tileScale: 0.88,
maskableScale: 0.66
} as const
export const webFiles = [
'brand/favicon.svg',
'brand/favicon-96x96.png',
'favicon.ico',
'apple-touch-icon.png',
'brand/app-icon.svg',
'brand/app-icon-1024.png',
'brand/mark.svg',
'brand/mark-dark.svg',
'brand/mark-micro.svg',
'brand/mark-micro-dark.svg',
'brand/mark-mono.svg',
'brand/mark-mono-dark.svg'
] as const
export const pwaFiles = [
'brand/pwa-192.png',
'brand/pwa-512.png',
'brand/pwa-maskable-512.png'
] as const
export const desktopFiles = [
'32x32.png',
'128x128.png',
'128x128@2x.png',
'icon.png',
'icon.icns',
'icon.ico',
'Square30x30Logo.png',
'Square44x44Logo.png',
'Square71x71Logo.png',
'Square89x89Logo.png',
'Square107x107Logo.png',
'Square142x142Logo.png',
'Square150x150Logo.png',
'Square284x284Logo.png',
'Square310x310Logo.png',
'StoreLogo.png'
] as const
export function targetDirectory(target: BrandTarget): string {
switch (target) {
case 'web':
return 'public'
case 'docs':
return 'packages/docs/public'
case 'desktop':
return 'desktop/icons'
}
}
export function targetFiles(target: BrandTarget): readonly string[] {
if (target === 'desktop') return desktopFiles
if (target === 'web') return [...webFiles, ...pwaFiles]
return webFiles
}

View file

@ -0,0 +1,28 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { appArtwork, loadArtwork } from './artwork.ts'
import { desktopFiles } from './config.ts'
import { canonicalIcns } from './validate.ts'
import type { BrandFiles } from './web.ts'
export async function generateDesktop(root: string): Promise<BrandFiles> {
const directory = await mkdtemp(join(tmpdir(), 'open-pencil-icons-'))
try {
const { adapter, main } = await loadArtwork(root)
const source = join(directory, 'app.svg')
await writeFile(source, appArtwork(main, adapter, 'desktop'))
// Direct native CLI API: no shell, package download, recursive npm script, or Rust build.
const { run } = await import('@tauri-apps/cli')
await run(['icon', source, '--output', join(directory, 'icons')], 'tauri')
const files: BrandFiles = new Map()
for (const name of desktopFiles) {
const bytes = await readFile(join(directory, 'icons', name))
files.set(name, name.endsWith('.icns') ? canonicalIcns(bytes) : bytes)
}
return files
} finally {
await rm(directory, { recursive: true, force: true })
}
}

View file

@ -0,0 +1,43 @@
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { lock } from 'proper-lockfile'
import { assertOwnedPath, fingerprint, isCached, publish } from './cache.ts'
import { repositoryRoot, targets } from './config.ts'
import type { BrandTarget } from './config.ts'
import { generateDesktop } from './desktop.ts'
import { validateFiles } from './validate.ts'
import { generateWeb } from './web.ts'
export type { BrandTarget } from './config.ts'
/** Build-time only. Safe to call from Vite/VitePress configs and from the CLI. */
export async function ensureBrandAssets(
selected: readonly BrandTarget[] = targets,
options: { root?: string; force?: boolean } = {}
): Promise<void> {
const root = options.root ?? repositoryRoot
const cache = join(root, '.cache/brand')
await assertOwnedPath(root, cache)
await mkdir(cache, { recursive: true })
// Serialize separate Vite/docs/native processes and recover locks after crashes.
const release = await lock(cache, {
realpath: false,
retries: { retries: 60, minTimeout: 100, maxTimeout: 1000, randomize: false }
})
try {
const key = await fingerprint(root)
let web: Awaited<ReturnType<typeof generateWeb>> | undefined
for (const target of selected) {
if (!options.force && (await isCached(root, target, key))) continue
const files =
target === 'desktop' ? await generateDesktop(root) : (web ??= await generateWeb(root))
await validateFiles(files, target)
await publish(root, target, key, files)
console.info(`Generated ${target} brand assets`)
}
} finally {
await release()
}
}

127
tools/brand/src/validate.ts Normal file
View file

@ -0,0 +1,127 @@
import assert from 'node:assert/strict'
import sharp from 'sharp'
import { brand, targetFiles } from './config.ts'
import type { BrandTarget } from './config.ts'
import type { BrandFiles } from './web.ts'
/** Tauri emits ICNS chunks from an unordered map; stabilize order without re-encoding images. */
export function canonicalIcns(data: Buffer): Buffer {
assert.ok(data.length >= 8 && data.toString('ascii', 0, 4) === 'icns', 'Invalid ICNS header')
assert.equal(data.readUInt32BE(4), data.length, 'Invalid ICNS length')
const chunks: Buffer[] = []
for (let offset = 8; offset < data.length;) {
assert.ok(offset + 8 <= data.length, 'Truncated ICNS chunk')
const size = data.readUInt32BE(offset + 4)
assert.ok(size >= 8 && offset + size <= data.length, 'Invalid ICNS chunk length')
chunks.push(data.subarray(offset, offset + size))
offset += size
}
assert.ok(chunks.length > 0, 'Empty ICNS')
chunks.sort((a, b) => Buffer.compare(a.subarray(0, 4), b.subarray(0, 4)))
return Buffer.concat([data.subarray(0, 8), ...chunks])
}
export function icoSizes(data: Buffer): number[] {
assert.ok(
data.length >= 6 && data.readUInt16LE(0) === 0 && data.readUInt16LE(2) === 1,
'Invalid ICO header'
)
const count = data.readUInt16LE(4)
assert.ok(count > 0 && data.length >= 6 + count * 16, 'Truncated ICO directory')
return Array.from({ length: count }, (_, index) => {
const offset = 6 + index * 16
const width = data[offset] || 256
assert.equal(width, data[offset + 1] || 256, 'Non-square ICO frame')
const length = data.readUInt32LE(offset + 8)
const start = data.readUInt32LE(offset + 12)
assert.ok(
length > 0 && start >= 6 + count * 16 && start + length <= data.length,
'Invalid ICO frame bounds'
)
return width
})
}
export async function validateMaskable(data: Buffer): Promise<void> {
const { data: pixels, info } = await sharp(data)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
const background = [1, 3, 5].map((offset) =>
Number.parseInt(brand.background.slice(offset, offset + 2), 16)
)
const radius = info.width * 0.4
let foreground = 0
for (let y = 0; y < info.height; y++) {
for (let x = 0; x < info.width; x++) {
const offset = (y * info.width + x) * 4
assert.equal(pixels[offset + 3], 255, 'Maskable icon must be opaque')
const difference = background.reduce(
(sum, channel, i) => sum + Math.abs(channel - pixels[offset + i]),
0
)
if (difference < 30) continue
foreground++
assert.ok(
Math.hypot(x + 0.5 - info.width / 2, y + 0.5 - info.height / 2) <= radius,
'Mark exceeds maskable safe circle'
)
}
}
assert.ok(foreground > 0, 'Empty maskable mark')
}
function pngSize(name: string): number | undefined {
if (name === 'brand/app-icon-1024.png') return 1024
if (name === 'apple-touch-icon.png') return 180
if (name === 'brand/favicon-96x96.png') return 96
if (name === '128x128@2x.png') return 256
if (name === 'icon.png') return 512
if (name === 'StoreLogo.png') return 50
const size = name.match(/(?:pwa-(?:maskable-)?|Square)(\d+)/)?.[1] ?? name.match(/^(\d+)x/)?.[1]
return size ? Number(size) : undefined
}
export async function validateFiles(files: BrandFiles, target: BrandTarget): Promise<void> {
for (const name of targetFiles(target)) {
const data = files.get(name)
assert.ok(data && data.length > 0, `Missing brand output: ${name}`)
if (name.endsWith('.png')) {
const meta = await sharp(data).metadata()
assert.equal(meta.format, 'png', name)
assert.equal(meta.width, meta.height, `Non-square ${name}`)
const size = pngSize(name)
if (size) assert.equal(meta.width, size, name)
if (target === 'desktop') assert.equal(meta.channels, 4, `${name} must be RGBA`)
if (name === 'apple-touch-icon.png') {
const stats = await sharp(data).ensureAlpha().stats()
assert.equal(stats.channels[3].min, 255, 'Touch icon must be opaque')
const { data: corner } = await sharp(data)
.extract({ left: 0, top: 0, width: 1, height: 1 })
.raw()
.toBuffer({ resolveWithObject: true })
assert.equal(
`#${corner.subarray(0, 3).toString('hex')}`.toUpperCase(),
brand.background,
'Touch icon must not bake in rounded corners'
)
}
}
if (name.endsWith('.ico')) {
const expected = target === 'desktop' ? [16, 24, 32, 48, 64, 256] : [16, 32, 48]
const sizes = icoSizes(data)
for (const size of expected)
assert.ok(sizes.includes(size), `Missing ${size}px ICO frame in ${name}`)
}
if (name.endsWith('.icns')) {
canonicalIcns(data)
}
}
if (target === 'web') {
const maskable = files.get('brand/pwa-maskable-512.png')
assert.ok(maskable)
await validateMaskable(maskable)
}
}

57
tools/brand/src/web.ts Normal file
View file

@ -0,0 +1,57 @@
import {
generateFaviconFiles,
initFaviconIconSettings,
stringToSvg as stringToSVG
} from '@realfavicongenerator/generate-favicon'
import sharp from 'sharp'
import { appArtwork, loadArtwork, recolor } from './artwork.ts'
export type BrandFiles = Map<string, Buffer>
export async function generateWeb(root: string): Promise<BrandFiles> {
const { adapter, main, micro } = await loadArtwork(root)
const darkMicro = recolor(micro, adapter, 'dark')
const app = appArtwork(main, adapter, 'web')
const tile = appArtwork(main, adapter, 'tile')
const favicon = appArtwork(micro, adapter, 'tile')
const maskable = appArtwork(main, adapter, 'maskable')
const icon = initFaviconIconSettings()
icon.desktop.darkIconType = 'specific'
// Use precomposed tiles with no generator background/clip transforms. This also
// avoids SVG.js process-global clip IDs leaking into reproducible output.
icon.touch.icon = stringToSVG(app, adapter)
icon.webAppManifest.icon = stringToSVG(maskable, adapter)
const generated = await generateFaviconFiles(
{ icon: stringToSVG(favicon, adapter), darkIcon: stringToSVG(favicon, adapter) },
{ icon, path: '/brand/', skipMetadataInjection: true },
adapter
)
const files: BrandFiles = new Map()
for (const [name, content] of Object.entries(generated)) {
// VitePWA owns the application's manifest; the docs are not an installable app.
if (name === 'site.webmanifest' || name === 'web-app-manifest-192x192.png') continue
let path = `brand/${name}`
if (name === 'favicon.ico' || name === 'apple-touch-icon.png') path = name
if (name === 'web-app-manifest-512x512.png') path = 'brand/pwa-maskable-512.png'
files.set(
path,
content instanceof Blob ? Buffer.from(await content.arrayBuffer()) : Buffer.from(content)
)
}
files.set('brand/app-icon.svg', Buffer.from(tile))
files.set('brand/app-icon-1024.png', await sharp(Buffer.from(tile)).png().toBuffer())
files.set('brand/mark.svg', Buffer.from(main))
files.set('brand/mark-dark.svg', Buffer.from(recolor(main, adapter, 'dark')))
files.set('brand/mark-micro.svg', Buffer.from(micro))
files.set('brand/mark-micro-dark.svg', Buffer.from(darkMicro))
files.set('brand/mark-mono.svg', Buffer.from(recolor(main, adapter, 'mono')))
files.set('brand/mark-mono-dark.svg', Buffer.from(recolor(main, adapter, 'mono-dark')))
for (const size of [192, 512]) {
files.set(
`brand/pwa-${size}.png`,
await sharp(Buffer.from(tile)).resize(size, size).png().toBuffer()
)
}
return files
}

View file

@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fingerprint, isCached } from '#brand/cache'
import { repositoryRoot, targetFiles } from '#brand/config'
import { ensureBrandAssets } from '#brand/generate'
let root: string
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'open-pencil-brand-test-'))
for (const path of [
'assets/brand',
'tools/brand/src',
'tools/brand/package.json',
'package.json',
'bun.lock'
]) {
await mkdir(dirname(join(root, path)), { recursive: true })
await cp(join(repositoryRoot, path), join(root, path), { recursive: true })
}
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
describe('brand asset preparation', () => {
test('works from a cold checkout, generates only requested targets, and leaves unrelated files alone', async () => {
await mkdir(join(root, 'public'))
await writeFile(join(root, 'public/keep.txt'), 'not owned by the generator')
await ensureBrandAssets(['web'], { root })
for (const name of targetFiles('web'))
expect((await stat(join(root, 'public', name))).isFile()).toBe(true)
expect(await readFile(join(root, 'public/keep.txt'), 'utf8')).toBe('not owned by the generator')
await expect(stat(join(root, 'desktop/icons'))).rejects.toThrow()
await expect(stat(join(root, 'packages/docs/public'))).rejects.toThrow()
})
test('warm preparation does not rewrite output; missing/corrupt assets regenerate', async () => {
await ensureBrandAssets(['docs'], { root })
const path = join(root, 'packages/docs/public/brand/mark.svg')
const before = await stat(path)
const expected = await readFile(path)
await ensureBrandAssets(['docs'], { root })
expect((await stat(path)).mtimeMs).toBe(before.mtimeMs)
await rm(path)
await ensureBrandAssets(['docs'], { root })
expect(await readFile(path)).toEqual(expected)
await writeFile(path, 'corrupt')
await ensureBrandAssets(['docs'], { root })
expect(await readFile(path)).toEqual(expected)
})
test('source and configuration changes invalidate the cache', async () => {
await ensureBrandAssets(['web'], { root })
const before = await fingerprint(root)
expect(await isCached(root, 'web', before)).toBe(true)
const source = join(root, 'assets/brand/mark.svg')
await writeFile(source, `${await readFile(source, 'utf8')}\n`)
const afterSource = await fingerprint(root)
expect(afterSource).not.toBe(before)
expect(await isCached(root, 'web', afterSource)).toBe(false)
const config = join(root, 'tools/brand/src/config.ts')
await writeFile(config, `${await readFile(config, 'utf8')}\n`)
expect(await fingerprint(root)).not.toBe(afterSource)
})
test('serializes concurrent preparation', async () => {
await Promise.all([
ensureBrandAssets(['web'], { root }),
ensureBrandAssets(['web', 'docs'], { root })
])
const key = await fingerprint(root)
expect(await isCached(root, 'web', key)).toBe(true)
expect(await isCached(root, 'docs', key)).toBe(true)
expect(await readFile(join(root, 'public/favicon.ico'))).toEqual(
await readFile(join(root, 'packages/docs/public/favicon.ico'))
)
})
test.skipIf(process.platform === 'win32')(
'refuses legacy output symlinks without modifying their targets',
async () => {
await mkdir(join(root, 'public'))
const outside = join(root, 'sentinel.ico')
await writeFile(outside, 'untouched')
await symlink(outside, join(root, 'public/favicon.ico'))
await expect(ensureBrandAssets(['web'], { root })).rejects.toThrow('symlink')
expect(await readFile(outside, 'utf8')).toBe('untouched')
await expect(stat(join(root, 'public/brand/mark.svg'))).rejects.toThrow()
}
)
})

View file

@ -0,0 +1,172 @@
import { describe, expect, test } from 'bun:test'
import { checkBrandAssets } from '#brand/check'
import { repositoryRoot, webFiles } from '#brand/config'
import { generateDesktop } from '#brand/desktop'
import { canonicalIcns, icoSizes, validateFiles, validateMaskable } from '#brand/validate'
import { generateWeb } from '#brand/web'
import sharp from 'sharp'
const files = await generateWeb(repositoryRoot)
function file(name: string): Buffer {
const bytes = files.get(name)
if (!bytes) throw new Error(`Missing ${name}`)
return bytes
}
describe('brand generation', () => {
test('produces the web and docs contract, without taking ownership of the PWA manifest', async () => {
await validateFiles(files, 'web')
await validateFiles(files, 'docs')
for (const name of webFiles) expect(files.has(name)).toBe(true)
expect([...files.keys()].some((name) => name.endsWith('.webmanifest'))).toBe(false)
expect(icoSizes(file('favicon.ico')).sort((a, b) => a - b)).toEqual([16, 32, 48])
})
test('preserves transparent counters and renders the micro master at 16px', async () => {
const main = await sharp(file('brand/mark.svg')).ensureAlpha().raw().toBuffer()
expect(main[(80 * 256 + 130) * 4 + 3]).toBe(0)
expect(main[(80 * 256 + 90) * 4 + 3]).toBe(255)
const micro = await sharp(file('brand/mark-micro.svg')).ensureAlpha().raw().toBuffer()
expect(micro[(6 * 16 + 8) * 4 + 3]).toBe(0)
expect(micro[(6 * 16 + 4) * 4 + 3]).toBe(255)
expect(micro.length).toBe(16 * 16 * 4)
})
test('preserves blue handle borders and white centers in both themes and sizes', async () => {
for (const name of ['mark', 'mark-micro']) {
for (const suffix of ['', '-dark']) {
const pixels = await sharp(file(`brand/${name}${suffix}.svg`), { density: 1152 })
.resize(256, 256)
.ensureAlpha()
.raw()
.toBuffer()
const center = suffix ? [244, 251, 250, 255] : [255, 255, 255, 255]
const border = suffix ? [67, 140, 255, 255] : [0, 92, 255, 255]
for (const [x, y, rgba] of [
[54, 27, center],
[202, 101, center],
[name === 'mark' ? 38 : 32, 27, border]
] as const) {
const offset = (y * 256 + x) * 4
expect([...pixels.subarray(offset, offset + 4)]).toEqual([...rgba])
}
}
}
})
test('keeps dark-mode favicon styling and separates light/dark UI artwork', () => {
expect(file('brand/favicon.svg').toString()).toContain('prefers-color-scheme: dark')
expect(file('brand/mark-dark.svg').equals(file('brand/mark.svg'))).toBe(false)
expect(file('brand/mark-micro-dark.svg').equals(file('brand/mark-micro.svg'))).toBe(false)
})
test('keeps monochrome artwork flat rather than retaining the decorative grid', async () => {
for (const suffix of ['', '-dark']) {
const pixels = await sharp(file(`brand/mark-mono${suffix}.svg`))
.ensureAlpha()
.raw()
.toBuffer()
const colors = new Set<string>()
for (let offset = 0; offset < pixels.length; offset += 4) {
if (pixels[offset + 3] === 255)
colors.add(pixels.subarray(offset, offset + 3).toString('hex'))
}
expect(colors.size).toBeGreaterThan(0)
const expected = Buffer.from(suffix ? 'f5f5ef' : '202b2d', 'hex')
for (const color of colors) {
// Overlapping antialiased edges can round a channel down by one.
expect(
Buffer.from(color, 'hex').every((channel, i) => Math.abs(channel - expected[i]) <= 1)
).toBe(true)
}
}
})
test('shares the ivory rounded tile across profile and any-purpose PWA assets', async () => {
for (const [name, size] of [
['brand/app-icon-1024.png', 1024],
['brand/favicon-96x96.png', 96],
['brand/pwa-192.png', 192],
['brand/pwa-512.png', 512]
] as const) {
const { data, info } = await sharp(file(name))
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
expect([info.width, info.height]).toEqual([size, size])
expect(data[3]).toBe(0)
// No desktop-style inset: the ivory reaches the midpoint of each edge.
for (const [x, y] of [
[size / 2, 0],
[0, size / 2],
[size - 1, size / 2],
[size / 2, size - 1]
]) {
const edge = (y * size + x) * 4
expect([...data.subarray(edge, edge + 4)]).toEqual([245, 245, 239, 255])
}
const offset = (Math.round((size * 80) / 1024) * size + size / 2) * 4
expect([...data.subarray(offset, offset + 4)]).toEqual([245, 245, 239, 255])
}
const svg = await sharp(file('brand/app-icon.svg')).ensureAlpha().raw().toBuffer()
const png = await sharp(file('brand/app-icon-1024.png')).ensureAlpha().raw().toBuffer()
expect(svg.equals(png)).toBe(true)
})
test('rejects a transparent maskable icon', async () => {
await expect(
validateMaskable(await sharp(file('brand/mark.svg')).resize(512).png().toBuffer())
).rejects.toThrow('opaque')
})
test('rejects missing and wrong-size output', async () => {
const missing = new Map(files)
missing.delete('favicon.ico')
await expect(validateFiles(missing, 'docs')).rejects.toThrow('Missing brand output')
const wrong = new Map(files)
wrong.set('apple-touch-icon.png', file('brand/pwa-512.png'))
await expect(validateFiles(wrong, 'docs')).rejects.toThrow('apple-touch-icon.png')
})
test('validates native icon and StoreLogo dimensions', async () => {
const desktop = await generateDesktop(repositoryRoot)
await validateFiles(desktop, 'desktop')
for (const name of ['icon.png', 'StoreLogo.png']) {
const wrong = new Map(desktop)
wrong.set(name, await sharp(file('brand/app-icon.svg')).resize(64, 64).png().toBuffer())
await expect(validateFiles(wrong, 'desktop')).rejects.toThrow(name)
}
}, 30_000)
test('web output is byte-reproducible across repeated adapter instances', async () => {
await checkBrandAssets(repositoryRoot, ['web'])
})
test('normalizes ICNS frame ordering without changing frame bytes', () => {
const chunk = (type: string) => {
const buffer = Buffer.alloc(9)
buffer.write(type)
buffer.writeUInt32BE(9, 4)
buffer[8] = 42
return buffer
}
const a = chunk('ic07'),
b = chunk('ic08')
const header = Buffer.alloc(8)
header.write('icns')
header.writeUInt32BE(26, 4)
expect(canonicalIcns(Buffer.concat([header, b, a]))).toEqual(Buffer.concat([header, a, b]))
expect(() => canonicalIcns(Buffer.from('icns'))).toThrow('header')
const bad = Buffer.concat([header, a, b])
bad.writeUInt32BE(0, 12)
expect(() => canonicalIcns(bad)).toThrow('chunk length')
})
test('rejects ICO frames that point outside their container', () => {
const bad = Buffer.from(file('favicon.ico'))
bad.writeUInt32LE(bad.length, 18)
expect(() => icoSizes(bad)).toThrow('bounds')
})
})

15
tools/brand/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["node", "bun"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View file

@ -50,6 +50,7 @@ const noMixedCaseAcronymIdentifiers = {
/(?:Acp|Ai|Api|Cli|Cors|Css|Html|Ime|Json|Jsx|Mcp|Pdf|Png|Rgb|Rpc|Rtl|Svg|Ui|Url|Uri|Xml)/g
const ignoredImports = new Set([
'@agentclientprotocol/sdk',
'@realfavicongenerator/generate-favicon',
'@tauri-apps/plugin-clipboard-manager',
'@tauri-apps/plugin-opener',
'@vueuse/core',

View file

@ -4,7 +4,8 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./tarball": "./src/tarball.ts"
"./tarball": "./src/tarball.ts",
"./workspace": "./src/workspace/root.ts"
},
"scripts": {
"test": "bun test tests"

View file

@ -2,7 +2,7 @@ import { join, resolve } from 'node:path'
import { findWorkspaceDir, readPackageJSON } from 'pkg-types'
import { parseWorkspace } from './schema'
import { parseWorkspace } from './schema.ts'
/** Resolve once at CLI boundaries; nearest lockfile keeps nested Git worktrees isolated. */
export async function resolveWorkspaceRoot(start: string, explicitRoot?: string): Promise<string> {

View file

@ -101,6 +101,7 @@ describe('discoverPublishPackages', () => {
})
describe('preparePublishDirectories', () => {
// This integration test starts npm; cold CI startup can exceed Bun's five-second default.
test('copies declared artifacts and writes transformed package metadata', async () => {
const root = await fixtureRoot()
const outRoot = join(root, '.publish')
@ -124,5 +125,5 @@ describe('preparePublishDirectories', () => {
main: './dist/index.js',
types: './dist/index.d.ts'
})
})
}, 30_000)
})

View file

@ -7,6 +7,8 @@ import Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
import { ensureBrandAssets } from '@open-pencil/brand-tools'
import packageJson from './package.json'
import { createOpenPencilAliases } from './vite/aliases'
import {
@ -22,31 +24,34 @@ import { createDevServerOptions } from './vite/server'
const host = process.env.TAURI_DEV_HOST
const automationRoute = localAutomationRoute(host)
export default defineConfig(async ({ command }) => ({
resolve: {
alias: createOpenPencilAliases(__dirname)
},
define: {
__OPENPENCIL_APP_VERSION__: JSON.stringify(packageJson.version),
__OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: JSON.stringify(localAutomationToken(command)),
__OPENPENCIL_LOCAL_AUTOMATION_URL__: JSON.stringify(automationRoute.browserURL),
__OPENPENCIL_LOCAL_AUTOMATION_HTTP_URL__: JSON.stringify(
automationRoute.browserURL.replace(/^ws/, 'http')
)
},
plugins: [
rawMarkdownPlugin(),
copyCanvasKitAssetsPlugin(),
tailwindcss(),
Icons({ compiler: 'vue3' }),
Components({ resolvers: [IconsResolver({ prefix: 'icon' })] }),
openPencilAutomationPlugin(command, host),
vue(),
openPencilPwaPlugin()
],
clearScreen: false,
build: {
chunkSizeWarningLimit: 2500
},
server: createDevServerOptions(host, __dirname)
}))
export default defineConfig(async ({ command }) => {
await ensureBrandAssets(['web'])
return {
resolve: {
alias: createOpenPencilAliases(__dirname)
},
define: {
__OPENPENCIL_APP_VERSION__: JSON.stringify(packageJson.version),
__OPENPENCIL_LOCAL_AUTOMATION_TOKEN__: JSON.stringify(localAutomationToken(command)),
__OPENPENCIL_LOCAL_AUTOMATION_URL__: JSON.stringify(automationRoute.browserURL),
__OPENPENCIL_LOCAL_AUTOMATION_HTTP_URL__: JSON.stringify(
automationRoute.browserURL.replace(/^ws/, 'http')
)
},
plugins: [
rawMarkdownPlugin(),
copyCanvasKitAssetsPlugin(),
tailwindcss(),
Icons({ compiler: 'vue3' }),
Components({ resolvers: [IconsResolver({ prefix: 'icon' })] }),
openPencilAutomationPlugin(command, host),
vue(),
openPencilPwaPlugin()
],
clearScreen: false,
build: {
chunkSizeWarningLimit: 2500
},
server: createDevServerOptions(host, __dirname)
}
})

View file

@ -6,7 +6,7 @@ export function openPencilPwaPlugin() {
devOptions: { enabled: false },
workbox: {
maximumFileSizeToCacheInBytes: 12 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,wasm,png,ico,ttf,webmanifest}'],
globPatterns: ['**/*.{js,css,html,wasm,png,svg,ico,ttf,webmanifest}'],
navigateFallback: '/index.html'
},
manifest: {
@ -21,9 +21,14 @@ export function openPencilPwaPlugin() {
background_color: '#1e1e1e',
categories: ['design', 'productivity'],
icons: [
{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/pwa-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
{ src: '/brand/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/brand/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: '/brand/pwa-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
}
})