feat(web): fetch scene templates and the icon catalog at runtime
The 58 template documents and the core iconify catalog follow the previews out of the wasm binary behind the same asset seam: templates resolve by id on both platforms but carry bytes only natively, so the wasm boot check validates routes instead of rejecting the catalogue, and a click on an unfetched card requests the asset and instantiates on the install edge (failure raises the retryable toast). The icon picker prefetches while open, distinguishes "still loading" from "no match", and keys its search memo on catalog readiness so the empty pre-fetch result cannot be memoized for the session. The split is a feature (runtime-icon-catalog) enabled only by op-host-web — the web-sdk viewer has no daemon to fetch from and keeps the embed, which a first cut silently broke. Bundle: 5.10 → 4.93 MiB gzip; the sdk bundle (5.20 MiB measured) gets a calibrated 6 MiB tripwire.
This commit is contained in:
parent
b01551b7fe
commit
d023dbf1de
7
.github/workflows/wasm-bundle-build.yml
vendored
7
.github/workflows/wasm-bundle-build.yml
vendored
|
|
@ -143,8 +143,11 @@ jobs:
|
|||
mkdir -p web-bundle
|
||||
cp -R crates/op-host-web/pkg/. web-bundle/
|
||||
cp -R crates/op-host-web/assets/canvaskit web-bundle/canvaskit
|
||||
test -d web-bundle/assets/prompt_center_previews \
|
||||
|| { echo "::error::web-bundle is missing assets/prompt_center_previews"; exit 1; }
|
||||
for required in assets/prompt_center_previews assets/scene_template_previews \
|
||||
assets/scene_templates assets/iconify-catalog-core.json; do
|
||||
test -e "web-bundle/$required" \
|
||||
|| { echo "::error::web-bundle is missing $required"; exit 1; }
|
||||
done
|
||||
echo "web-bundle contents:"
|
||||
find web-bundle -maxdepth 2 -type f | sort
|
||||
echo "staged runtime assets: $(find web-bundle/assets -type f | wc -l) files"
|
||||
|
|
|
|||
6
.github/workflows/web-sdk-bundle.yml
vendored
6
.github/workflows/web-sdk-bundle.yml
vendored
|
|
@ -1,8 +1,8 @@
|
|||
name: op-web-sdk bundle build + size gate
|
||||
|
||||
# Builds the op-web-sdk WASM package (canvaskit feature), asserts 0 env.*
|
||||
# imports and gzip size <= 8 MiB (this viewer bundle's own tripwire — op-host-web
|
||||
# re-baselined to 6 MiB separately), then uploads pkg/ as the `op-web-sdk-bundle`
|
||||
# imports and gzip size <= 6 MiB (this viewer bundle's own measured tripwire —
|
||||
# 5 200 519 bytes + ~20% headroom), then uploads pkg/ as the `op-web-sdk-bundle`
|
||||
# artifact. This is the CI counterpart of the local
|
||||
# `crates/op-web-sdk/tools/build-wasm.sh` developer gate.
|
||||
#
|
||||
|
|
@ -20,7 +20,7 @@ name: op-web-sdk bundle build + size gate
|
|||
# 2. wasm-bindgen --target web --out-dir crates/op-web-sdk/pkg <target.wasm>
|
||||
# 3. assert 0 env.* imports (LinkError guard)
|
||||
# 4. wasm-opt -Oz with rustc-emitted WebAssembly feature flags, then
|
||||
# gzip size <= ceiling (default 8388608 bytes = 8 MiB, overridable via
|
||||
# gzip size <= ceiling (default 6291456 bytes = 6 MiB, overridable via
|
||||
# OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES)
|
||||
# The job calls the script directly — it is the single source of truth for the
|
||||
# gate logic.
|
||||
|
|
|
|||
|
|
@ -119,13 +119,17 @@ RUN mkdir -p /out/web-bundle && \
|
|||
cp -R web-bundle/. /out/web-bundle/ ; \
|
||||
fi && \
|
||||
echo "assembled web-bundle:" && find /out/web-bundle -maxdepth 2 -type f | sort && \
|
||||
# The browser fetches preview JPEGs from `/pkg/assets/…` at runtime (see
|
||||
# `op_editor_core::web_assets`), so a bundle without them degrades every
|
||||
# card to a placeholder. Catch it here rather than in production — the
|
||||
# `copy` path is the likely offender, fed a pre-split artifact.
|
||||
if [ ! -d /out/web-bundle/assets/prompt_center_previews ]; then \
|
||||
echo "FAIL: web-bundle is missing assets/prompt_center_previews (stale artifact?)" >&2; exit 1; \
|
||||
fi
|
||||
# The browser fetches these from `/pkg/assets/…` at run time (see
|
||||
# `op_editor_core::web_assets`); without them previews fall back to
|
||||
# placeholders, templates refuse to instantiate and the icon panel stays
|
||||
# empty. Catch it here rather than in production — the `copy` path is the
|
||||
# likely offender, fed a pre-split artifact.
|
||||
for required in assets/prompt_center_previews assets/scene_template_previews \
|
||||
assets/scene_templates assets/iconify-catalog-core.json; do \
|
||||
if [ ! -e "/out/web-bundle/$required" ]; then \
|
||||
echo "FAIL: web-bundle is missing $required (stale artifact?)" >&2; exit 1; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# ── Stage 2: runtime (slim) ───────────────────────────────────────────────────
|
||||
# Only the runtime shared libs the daemon dlopens at run time (GL / fontconfig /
|
||||
|
|
|
|||
|
|
@ -66,8 +66,9 @@ a local `parse_hex` / `escape_json` again.
|
|||
- **Max 800 lines per file — zero violations workspace-wide.** As of `d2d8104c` no `.rs` file in `crates/` exceeds the cap, and the sibling-module split is the universal shape: a spine keeps the public surface and `mod` declarations, cohesive clusters move into siblings, and re-exports keep every import path and test name stable. Splits are pure code motion — when you split, do not also change behaviour. Test modules follow the same rule (`foo_tests.rs`, or a `foo/tests/` directory when the tests themselves outgrow the cap). Check with `find crates -name '*.rs' -exec wc -l {} + | awk '$1>800'`.
|
||||
- **Blocking on a future from sync host code goes through `op_host_services::chat_runtime::block_on_anywhere`.** A bare `Runtime::block_on` (or a privately-built current-thread runtime) aborts with "runtime within runtime" when the caller happens to sit on a tokio worker. `block_on_anywhere` picks the safe strategy for whichever context it is called from; it is the only sanctioned entry point and is exercised by tests for the no-runtime, multi-thread-worker, and borrowing-non-`Send`-future cases.
|
||||
- **Fallible paths carry typed error enums, not `String`.** The whole workspace is converted (80+ enums; `Result<_, String>` survives at exactly two documented boundary sites). Find the domain's enum in its `*_error.rs` / `error.rs` sibling module (e.g. `CliError`, `ProgramError`, `WebCanvasError`, `McpServeError`, `ExportError`, `McpLiveError`, `DocIoError`, `ImageGenerateError`). The pattern to copy: one enum per failure domain in its own sibling module, structured fields instead of pre-formatted text, a `Display` impl that reproduces the previous message **byte-identically** (so user-visible strings and their tests don't move), and `From` impls that collapse the `map_err` adapters at the call sites. New fallible code should introduce or reuse an enum rather than add another `Result<_, String>`.
|
||||
- **Web bundle ceiling: 6 MiB gzip + 0 env.\* imports.** Enforced by `tools/check-wasm-bundle.sh`. The CanvasKit bundle carries the full app logic (codegen AI pipeline, Figma parser, AI/live-sync, collaboration) plus the still-embedded product assets (template documents, iconify core catalog, skill corpus), so the ceiling sits well above the retired skia raster path's 1 MiB (**5.35 MiB today**; the ceiling is a runaway-regression tripwire, not a budget). Re-baselined from 8 MiB once the ~2.4 MiB of preview JPEGs moved out — see below. The 0 env.\* guard still holds — CanvasKit needs no libc shim.
|
||||
- **Product assets are embedded on native and fetched on wasm.** `op-editor-core/src/web_assets.rs` is the platform-free half: a process-global `route -> bytes` registry with single-flight, a widget-records / host-drains request queue, and `Absent/Pending/Ready/Failed` states. The widget layer calls `web_assets::request(route)` from paint and draws a placeholder; `op-host-web/src/web_asset_fetch.rs` drains it once per frame over XHR and installs the bytes. Native keeps its `include_bytes!` unchanged behind `#[cfg(not(target_arch = "wasm32"))]`, so desktop behaviour is byte-identical. Assets are served from `/pkg/assets/…` (**not** `/assets/`, which belongs to the hub frontend) and staged into the bundle by `tools/stage-web-assets.sh`, which `check-wasm-bundle.sh` runs as step 4 and both the CI workflow and `Dockerfile.web-rust` inherit. Moved so far: the Prompt Center (~2.0 MiB) and Scene Template (~388 KiB) preview JPEGs. Still embedded: the scene-template `.op` documents (~1.1 MiB) and `iconify-catalog-core.json` (~460 KiB).
|
||||
- **Web bundle ceiling: 6 MiB gzip + 0 env.\* imports.** Enforced by `tools/check-wasm-bundle.sh`. The CanvasKit bundle carries the full app logic (codegen AI pipeline, Figma parser, AI/live-sync, collaboration) plus the still-embedded AI skill corpus (~1.1 MiB of markdown via `include_dir!`), so the ceiling sits well above the retired skia raster path's 1 MiB (**4.93 MiB today**; the ceiling is a runaway-regression tripwire, not a budget). Re-baselined from 8 MiB once the ~2.4 MiB of preview JPEGs moved out — see below. The 0 env.\* guard still holds — CanvasKit needs no libc shim.
|
||||
- **Product assets are embedded on native and fetched on wasm.** `op-editor-core/src/web_assets.rs` is the platform-free half: a process-global `route -> bytes` registry with single-flight, a widget-records / host-drains request queue, and `Absent/Pending/Ready/Failed` states. The widget layer calls `web_assets::request(route)` from paint and draws a placeholder; `op-host-web/src/web_asset_fetch.rs` drains it once per frame over XHR and installs the bytes. Native keeps its `include_bytes!` unchanged behind `#[cfg(not(target_arch = "wasm32"))]`, so desktop behaviour is byte-identical. Assets are served from `/pkg/assets/…` (**not** `/assets/`, which belongs to the hub frontend) and staged into the bundle by `tools/stage-web-assets.sh`, which `check-wasm-bundle.sh` runs as step 4 and both the CI workflow and `Dockerfile.web-rust` inherit. Moved: the Prompt Center (~2.0 MiB) and Scene Template (~388 KiB) preview JPEGs, the 58 scene-template `.op` documents (~4.9 MiB), and `iconify-catalog-core.json` (~452 KiB) — ~9.7 MiB of staged assets in total. Still embedded everywhere: the `op-ai-skills` corpus (~1.1 MiB, `include_dir!`).
|
||||
- **The icon-catalog split is opt-in, the rest is `cfg(target_arch = "wasm32")`.** `op-editor-ui`'s `runtime-icon-catalog` feature is enabled only by `op-host-web`, which has a daemon to fetch from and a frame pump to install on. `op-web-sdk` renders iconFont nodes but has no daemon, so it keeps the embed — for that viewer "not fetched yet" would mean "never". Bundle ceilings are per-bundle and measured: op-host-web 6 MiB (5.17 MiB measured), op-web-sdk 6 MiB (5.20 MiB measured).
|
||||
|
||||
## Document model (`shell-core/src/document/`)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,174 +20,187 @@ use crate::catalog_toml::{
|
|||
|
||||
const SCENE_TEMPLATES_TOML: &str = include_str!("../assets/scene_templates.toml");
|
||||
|
||||
/// The shipped documents, embedded so opening a template needs no filesystem
|
||||
/// and works identically on the web host.
|
||||
const SCREENSHOT_TUTORIAL_OP: &str =
|
||||
include_str!("../assets/scene_templates/screenshot-tutorial.op");
|
||||
const KNOWLEDGE_CAROUSEL_OP: &str = include_str!("../assets/scene_templates/knowledge-carousel.op");
|
||||
const BEFORE_AFTER_OP: &str = include_str!("../assets/scene_templates/before-after.op");
|
||||
const SLIDE_DECK_OP: &str = include_str!("../assets/scene_templates/slide-deck.op");
|
||||
const KNOWLEDGE_CARD_VERTICAL_OP: &str =
|
||||
include_str!("../assets/scene_templates/knowledge-card-vertical.op");
|
||||
const KNOWLEDGE_CARD_SQUARE_OP: &str =
|
||||
include_str!("../assets/scene_templates/knowledge-card-square.op");
|
||||
const PITCH_DECK_DARK_OP: &str = include_str!("../assets/scene_templates/pitch-deck-dark.op");
|
||||
const LECTURE_DECK_LIGHT_OP: &str = include_str!("../assets/scene_templates/lecture-deck-light.op");
|
||||
const MINIMAL_KEYNOTE_OP: &str = include_str!("../assets/scene_templates/minimal-keynote.op");
|
||||
const GRADIENT_TECH_OP: &str = include_str!("../assets/scene_templates/gradient-tech.op");
|
||||
const SAAS_LANDING_ORANGE_OP: &str =
|
||||
include_str!("../assets/scene_templates/saas-landing-orange.op");
|
||||
const PRODUCT_LANDING_LIGHT_OP: &str =
|
||||
include_str!("../assets/scene_templates/product-landing-light.op");
|
||||
const PUNCH_QUOTE_CARD_OP: &str = include_str!("../assets/scene_templates/punch-quote-card.op");
|
||||
const JOURNAL_CHECKLIST_CARD_OP: &str =
|
||||
include_str!("../assets/scene_templates/journal-checklist-card.op");
|
||||
const DATA_REPORT_INFOGRAPHIC_OP: &str =
|
||||
include_str!("../assets/scene_templates/data-report-infographic.op");
|
||||
const STEPS_FLOW_INFOGRAPHIC_OP: &str =
|
||||
include_str!("../assets/scene_templates/steps-flow-infographic.op");
|
||||
const EVENT_POSTER_DECK_OP: &str = include_str!("../assets/scene_templates/event-poster-deck.op");
|
||||
const PITFALL_LIST_INFOGRAPHIC_OP: &str =
|
||||
include_str!("../assets/scene_templates/pitfall-list-infographic.op");
|
||||
const SPINE_CULTURE_CARD_OP: &str = include_str!("../assets/scene_templates/spine-culture-card.op");
|
||||
const METRIC_SINGLE_CARD_OP: &str = include_str!("../assets/scene_templates/metric-single-card.op");
|
||||
const QUOTE_FRAME_CARD_OP: &str = include_str!("../assets/scene_templates/quote-frame-card.op");
|
||||
const DAILY_SIGN_CARD_OP: &str = include_str!("../assets/scene_templates/daily-sign-card.op");
|
||||
const PRICE_TIER_CARD_OP: &str = include_str!("../assets/scene_templates/price-tier-card.op");
|
||||
const NOTICE_BOARD_CARD_OP: &str = include_str!("../assets/scene_templates/notice-board-card.op");
|
||||
const MILESTONE_TIMELINE_INFOGRAPHIC_OP: &str =
|
||||
include_str!("../assets/scene_templates/milestone-timeline-infographic.op");
|
||||
const CONCEPT_CONTRAST_INFOGRAPHIC_OP: &str =
|
||||
include_str!("../assets/scene_templates/concept-contrast-infographic.op");
|
||||
const RANKING_BOARD_INFOGRAPHIC_OP: &str =
|
||||
include_str!("../assets/scene_templates/ranking-board-infographic.op");
|
||||
const FAQ_THREAD_INFOGRAPHIC_OP: &str =
|
||||
include_str!("../assets/scene_templates/faq-thread-infographic.op");
|
||||
const DATA_STORY_INFOGRAPHIC_OP: &str =
|
||||
include_str!("../assets/scene_templates/data-story-infographic.op");
|
||||
const CHALLENGE_TRACKER_INFOGRAPHIC_OP: &str =
|
||||
include_str!("../assets/scene_templates/challenge-tracker-infographic.op");
|
||||
const ECOSYSTEM_MAP_INFOGRAPHIC_OP: &str =
|
||||
include_str!("../assets/scene_templates/ecosystem-map-infographic.op");
|
||||
const DO_DONT_COMPARISON_OP: &str = include_str!("../assets/scene_templates/do-dont-comparison.op");
|
||||
const MYTH_TRUTH_COMPARISON_OP: &str =
|
||||
include_str!("../assets/scene_templates/myth-truth-comparison.op");
|
||||
const PRICING_TIERS_COMPARISON_OP: &str =
|
||||
include_str!("../assets/scene_templates/pricing-tiers-comparison.op");
|
||||
const SCENARIO_GUIDE_COMPARISON_OP: &str =
|
||||
include_str!("../assets/scene_templates/scenario-guide-comparison.op");
|
||||
const SPEC_TABLE_COMPARISON_OP: &str =
|
||||
include_str!("../assets/scene_templates/spec-table-comparison.op");
|
||||
const THREE_WAY_COMPARISON_OP: &str =
|
||||
include_str!("../assets/scene_templates/three-way-comparison.op");
|
||||
const TIME_SHIFT_COMPARISON_OP: &str =
|
||||
include_str!("../assets/scene_templates/time-shift-comparison.op");
|
||||
const TRADEOFF_SCALE_COMPARISON_OP: &str =
|
||||
include_str!("../assets/scene_templates/tradeoff-scale-comparison.op");
|
||||
const VERSION_DIFF_COMPARISON_OP: &str =
|
||||
include_str!("../assets/scene_templates/version-diff-comparison.op");
|
||||
const APP_ONBOARDING_TRIPTYCH_OP: &str =
|
||||
include_str!("../assets/scene_templates/app-onboarding-triptych.op");
|
||||
const DIY_BLUEPRINT_GUIDE_OP: &str =
|
||||
include_str!("../assets/scene_templates/diy-blueprint-guide.op");
|
||||
const PHOTO_COMPOSITION_TUTORIAL_OP: &str =
|
||||
include_str!("../assets/scene_templates/photo-composition-tutorial.op");
|
||||
const RECIPE_FOUR_STEP_OP: &str = include_str!("../assets/scene_templates/recipe-four-step.op");
|
||||
const SKINCARE_ROUTINE_CARDS_OP: &str =
|
||||
include_str!("../assets/scene_templates/skincare-routine-cards.op");
|
||||
const SOFTWARE_STEP_TUTORIAL_OP: &str =
|
||||
include_str!("../assets/scene_templates/software-step-tutorial.op");
|
||||
const STORAGE_MAKEOVER_STEPS_OP: &str =
|
||||
include_str!("../assets/scene_templates/storage-makeover-steps.op");
|
||||
const WEEKLY_REPORT_LESSON_OP: &str =
|
||||
include_str!("../assets/scene_templates/weekly-report-lesson.op");
|
||||
const WORKOUT_BREAKDOWN_GUIDE_OP: &str =
|
||||
include_str!("../assets/scene_templates/workout-breakdown-guide.op");
|
||||
const BOOKREVIEW_SILK_CAROUSEL_OP: &str =
|
||||
include_str!("../assets/scene_templates/bookreview-silk-carousel.op");
|
||||
const CITYGUIDE_FILM_CAROUSEL_OP: &str =
|
||||
include_str!("../assets/scene_templates/cityguide-film-carousel.op");
|
||||
const DATAREPORT_GRID_CAROUSEL_OP: &str =
|
||||
include_str!("../assets/scene_templates/datareport-grid-carousel.op");
|
||||
const OPINION_LONGFORM_CAROUSEL_OP: &str =
|
||||
include_str!("../assets/scene_templates/opinion-longform-carousel.op");
|
||||
const QA_CHALKBOARD_CAROUSEL_OP: &str =
|
||||
include_str!("../assets/scene_templates/qa-chalkboard-carousel.op");
|
||||
const STORY_NIGHT_CAROUSEL_OP: &str =
|
||||
include_str!("../assets/scene_templates/story-night-carousel.op");
|
||||
const TOOLKIT_NOTEBOOK_CAROUSEL_OP: &str =
|
||||
include_str!("../assets/scene_templates/toolkit-notebook-carousel.op");
|
||||
const TUTORIAL_JOURNAL_CAROUSEL_OP: &str =
|
||||
include_str!("../assets/scene_templates/tutorial-journal-carousel.op");
|
||||
const YEARREVIEW_MINERAL_CAROUSEL_OP: &str =
|
||||
include_str!("../assets/scene_templates/yearreview-mineral-carousel.op");
|
||||
/// The shipped documents.
|
||||
///
|
||||
/// Embedded on desktop so opening a template needs no filesystem. NOT embedded
|
||||
/// on `wasm32`: the ten documents are ~1.1 MB of JSON that every visitor would
|
||||
/// download before the editor painted, to open a panel most sessions never
|
||||
/// touch. There the document is fetched from the daemon when a template is
|
||||
/// actually instantiated — see [`scene_template_document_route`] and
|
||||
/// `op_editor_core::web_assets`.
|
||||
macro_rules! template_document {
|
||||
($file:literal) => {{
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
Some(include_str!(concat!(
|
||||
"../assets/scene_templates/",
|
||||
$file,
|
||||
".op"
|
||||
)))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
crate::web_assets::installed_str(concat!(
|
||||
"/pkg/assets/",
|
||||
"scene_templates",
|
||||
"/",
|
||||
$file,
|
||||
".op"
|
||||
))
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// Return the embedded document JSON for a template id.
|
||||
/// Daemon route carrying a template's document.
|
||||
///
|
||||
/// `None` for an unknown id. Always present for a shipped template on both
|
||||
/// platforms — native simply has no use for it, because its bytes are already
|
||||
/// in the binary.
|
||||
pub fn scene_template_document_route(template_id: &str) -> Option<&'static str> {
|
||||
macro_rules! route {
|
||||
($file:literal) => {
|
||||
Some(concat!(
|
||||
"/pkg/assets/",
|
||||
"scene_templates",
|
||||
"/",
|
||||
$file,
|
||||
".op"
|
||||
))
|
||||
};
|
||||
}
|
||||
match template_id {
|
||||
"screenshot-tutorial" => route!("screenshot-tutorial"),
|
||||
"knowledge-carousel" => route!("knowledge-carousel"),
|
||||
"before-after" => route!("before-after"),
|
||||
"slide-deck" => route!("slide-deck"),
|
||||
"knowledge-card-vertical" => route!("knowledge-card-vertical"),
|
||||
"knowledge-card-square" => route!("knowledge-card-square"),
|
||||
"pitch-deck-dark" => route!("pitch-deck-dark"),
|
||||
"lecture-deck-light" => route!("lecture-deck-light"),
|
||||
"minimal-keynote" => route!("minimal-keynote"),
|
||||
"gradient-tech" => route!("gradient-tech"),
|
||||
"saas-landing-orange" => route!("saas-landing-orange"),
|
||||
"product-landing-light" => route!("product-landing-light"),
|
||||
"punch-quote-card" => route!("punch-quote-card"),
|
||||
"journal-checklist-card" => route!("journal-checklist-card"),
|
||||
"data-report-infographic" => route!("data-report-infographic"),
|
||||
"steps-flow-infographic" => route!("steps-flow-infographic"),
|
||||
"event-poster-deck" => route!("event-poster-deck"),
|
||||
"pitfall-list-infographic" => route!("pitfall-list-infographic"),
|
||||
"spine-culture-card" => route!("spine-culture-card"),
|
||||
"metric-single-card" => route!("metric-single-card"),
|
||||
"quote-frame-card" => route!("quote-frame-card"),
|
||||
"daily-sign-card" => route!("daily-sign-card"),
|
||||
"price-tier-card" => route!("price-tier-card"),
|
||||
"notice-board-card" => route!("notice-board-card"),
|
||||
"milestone-timeline-infographic" => route!("milestone-timeline-infographic"),
|
||||
"concept-contrast-infographic" => route!("concept-contrast-infographic"),
|
||||
"ranking-board-infographic" => route!("ranking-board-infographic"),
|
||||
"faq-thread-infographic" => route!("faq-thread-infographic"),
|
||||
"data-story-infographic" => route!("data-story-infographic"),
|
||||
"challenge-tracker-infographic" => route!("challenge-tracker-infographic"),
|
||||
"ecosystem-map-infographic" => route!("ecosystem-map-infographic"),
|
||||
"do-dont-comparison" => route!("do-dont-comparison"),
|
||||
"myth-truth-comparison" => route!("myth-truth-comparison"),
|
||||
"pricing-tiers-comparison" => route!("pricing-tiers-comparison"),
|
||||
"scenario-guide-comparison" => route!("scenario-guide-comparison"),
|
||||
"spec-table-comparison" => route!("spec-table-comparison"),
|
||||
"three-way-comparison" => route!("three-way-comparison"),
|
||||
"time-shift-comparison" => route!("time-shift-comparison"),
|
||||
"tradeoff-scale-comparison" => route!("tradeoff-scale-comparison"),
|
||||
"version-diff-comparison" => route!("version-diff-comparison"),
|
||||
"app-onboarding-triptych" => route!("app-onboarding-triptych"),
|
||||
"diy-blueprint-guide" => route!("diy-blueprint-guide"),
|
||||
"photo-composition-tutorial" => route!("photo-composition-tutorial"),
|
||||
"recipe-four-step" => route!("recipe-four-step"),
|
||||
"skincare-routine-cards" => route!("skincare-routine-cards"),
|
||||
"software-step-tutorial" => route!("software-step-tutorial"),
|
||||
"storage-makeover-steps" => route!("storage-makeover-steps"),
|
||||
"weekly-report-lesson" => route!("weekly-report-lesson"),
|
||||
"workout-breakdown-guide" => route!("workout-breakdown-guide"),
|
||||
"bookreview-silk-carousel" => route!("bookreview-silk-carousel"),
|
||||
"cityguide-film-carousel" => route!("cityguide-film-carousel"),
|
||||
"datareport-grid-carousel" => route!("datareport-grid-carousel"),
|
||||
"opinion-longform-carousel" => route!("opinion-longform-carousel"),
|
||||
"qa-chalkboard-carousel" => route!("qa-chalkboard-carousel"),
|
||||
"story-night-carousel" => route!("story-night-carousel"),
|
||||
"toolkit-notebook-carousel" => route!("toolkit-notebook-carousel"),
|
||||
"tutorial-journal-carousel" => route!("tutorial-journal-carousel"),
|
||||
"yearreview-mineral-carousel" => route!("yearreview-mineral-carousel"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the document JSON for a template id.
|
||||
///
|
||||
/// The match is exhaustive over what ships; [`scene_template_catalogue`]
|
||||
/// verifies every catalogue entry resolves here, so a template added to the
|
||||
/// TOML without its document fails at first use rather than presenting a card
|
||||
/// that does nothing when clicked.
|
||||
/// verifies every catalogue entry resolves here on native, so a template added
|
||||
/// to the TOML without its document fails at first use rather than presenting a
|
||||
/// card that does nothing when clicked.
|
||||
///
|
||||
/// On `wasm32` a `None` for a SHIPPED id means "not fetched yet" rather than
|
||||
/// "no such template" — callers there must distinguish the two through
|
||||
/// [`scene_template_document_route`], which answers for the id alone.
|
||||
pub fn scene_template_document(template_id: &str) -> Option<&'static str> {
|
||||
match template_id {
|
||||
"screenshot-tutorial" => Some(SCREENSHOT_TUTORIAL_OP),
|
||||
"knowledge-carousel" => Some(KNOWLEDGE_CAROUSEL_OP),
|
||||
"before-after" => Some(BEFORE_AFTER_OP),
|
||||
"slide-deck" => Some(SLIDE_DECK_OP),
|
||||
"knowledge-card-vertical" => Some(KNOWLEDGE_CARD_VERTICAL_OP),
|
||||
"knowledge-card-square" => Some(KNOWLEDGE_CARD_SQUARE_OP),
|
||||
"pitch-deck-dark" => Some(PITCH_DECK_DARK_OP),
|
||||
"lecture-deck-light" => Some(LECTURE_DECK_LIGHT_OP),
|
||||
"minimal-keynote" => Some(MINIMAL_KEYNOTE_OP),
|
||||
"gradient-tech" => Some(GRADIENT_TECH_OP),
|
||||
"saas-landing-orange" => Some(SAAS_LANDING_ORANGE_OP),
|
||||
"product-landing-light" => Some(PRODUCT_LANDING_LIGHT_OP),
|
||||
"punch-quote-card" => Some(PUNCH_QUOTE_CARD_OP),
|
||||
"journal-checklist-card" => Some(JOURNAL_CHECKLIST_CARD_OP),
|
||||
"data-report-infographic" => Some(DATA_REPORT_INFOGRAPHIC_OP),
|
||||
"steps-flow-infographic" => Some(STEPS_FLOW_INFOGRAPHIC_OP),
|
||||
"event-poster-deck" => Some(EVENT_POSTER_DECK_OP),
|
||||
"pitfall-list-infographic" => Some(PITFALL_LIST_INFOGRAPHIC_OP),
|
||||
"spine-culture-card" => Some(SPINE_CULTURE_CARD_OP),
|
||||
"metric-single-card" => Some(METRIC_SINGLE_CARD_OP),
|
||||
"quote-frame-card" => Some(QUOTE_FRAME_CARD_OP),
|
||||
"daily-sign-card" => Some(DAILY_SIGN_CARD_OP),
|
||||
"price-tier-card" => Some(PRICE_TIER_CARD_OP),
|
||||
"notice-board-card" => Some(NOTICE_BOARD_CARD_OP),
|
||||
"milestone-timeline-infographic" => Some(MILESTONE_TIMELINE_INFOGRAPHIC_OP),
|
||||
"concept-contrast-infographic" => Some(CONCEPT_CONTRAST_INFOGRAPHIC_OP),
|
||||
"ranking-board-infographic" => Some(RANKING_BOARD_INFOGRAPHIC_OP),
|
||||
"faq-thread-infographic" => Some(FAQ_THREAD_INFOGRAPHIC_OP),
|
||||
"data-story-infographic" => Some(DATA_STORY_INFOGRAPHIC_OP),
|
||||
"challenge-tracker-infographic" => Some(CHALLENGE_TRACKER_INFOGRAPHIC_OP),
|
||||
"ecosystem-map-infographic" => Some(ECOSYSTEM_MAP_INFOGRAPHIC_OP),
|
||||
"do-dont-comparison" => Some(DO_DONT_COMPARISON_OP),
|
||||
"myth-truth-comparison" => Some(MYTH_TRUTH_COMPARISON_OP),
|
||||
"pricing-tiers-comparison" => Some(PRICING_TIERS_COMPARISON_OP),
|
||||
"scenario-guide-comparison" => Some(SCENARIO_GUIDE_COMPARISON_OP),
|
||||
"spec-table-comparison" => Some(SPEC_TABLE_COMPARISON_OP),
|
||||
"three-way-comparison" => Some(THREE_WAY_COMPARISON_OP),
|
||||
"time-shift-comparison" => Some(TIME_SHIFT_COMPARISON_OP),
|
||||
"tradeoff-scale-comparison" => Some(TRADEOFF_SCALE_COMPARISON_OP),
|
||||
"version-diff-comparison" => Some(VERSION_DIFF_COMPARISON_OP),
|
||||
"app-onboarding-triptych" => Some(APP_ONBOARDING_TRIPTYCH_OP),
|
||||
"diy-blueprint-guide" => Some(DIY_BLUEPRINT_GUIDE_OP),
|
||||
"photo-composition-tutorial" => Some(PHOTO_COMPOSITION_TUTORIAL_OP),
|
||||
"recipe-four-step" => Some(RECIPE_FOUR_STEP_OP),
|
||||
"skincare-routine-cards" => Some(SKINCARE_ROUTINE_CARDS_OP),
|
||||
"software-step-tutorial" => Some(SOFTWARE_STEP_TUTORIAL_OP),
|
||||
"storage-makeover-steps" => Some(STORAGE_MAKEOVER_STEPS_OP),
|
||||
"weekly-report-lesson" => Some(WEEKLY_REPORT_LESSON_OP),
|
||||
"workout-breakdown-guide" => Some(WORKOUT_BREAKDOWN_GUIDE_OP),
|
||||
"bookreview-silk-carousel" => Some(BOOKREVIEW_SILK_CAROUSEL_OP),
|
||||
"cityguide-film-carousel" => Some(CITYGUIDE_FILM_CAROUSEL_OP),
|
||||
"datareport-grid-carousel" => Some(DATAREPORT_GRID_CAROUSEL_OP),
|
||||
"opinion-longform-carousel" => Some(OPINION_LONGFORM_CAROUSEL_OP),
|
||||
"qa-chalkboard-carousel" => Some(QA_CHALKBOARD_CAROUSEL_OP),
|
||||
"story-night-carousel" => Some(STORY_NIGHT_CAROUSEL_OP),
|
||||
"toolkit-notebook-carousel" => Some(TOOLKIT_NOTEBOOK_CAROUSEL_OP),
|
||||
"tutorial-journal-carousel" => Some(TUTORIAL_JOURNAL_CAROUSEL_OP),
|
||||
"yearreview-mineral-carousel" => Some(YEARREVIEW_MINERAL_CAROUSEL_OP),
|
||||
"screenshot-tutorial" => template_document!("screenshot-tutorial"),
|
||||
"knowledge-carousel" => template_document!("knowledge-carousel"),
|
||||
"before-after" => template_document!("before-after"),
|
||||
"slide-deck" => template_document!("slide-deck"),
|
||||
"knowledge-card-vertical" => template_document!("knowledge-card-vertical"),
|
||||
"knowledge-card-square" => template_document!("knowledge-card-square"),
|
||||
"pitch-deck-dark" => template_document!("pitch-deck-dark"),
|
||||
"lecture-deck-light" => template_document!("lecture-deck-light"),
|
||||
"minimal-keynote" => template_document!("minimal-keynote"),
|
||||
"gradient-tech" => template_document!("gradient-tech"),
|
||||
"saas-landing-orange" => template_document!("saas-landing-orange"),
|
||||
"product-landing-light" => template_document!("product-landing-light"),
|
||||
"punch-quote-card" => template_document!("punch-quote-card"),
|
||||
"journal-checklist-card" => template_document!("journal-checklist-card"),
|
||||
"data-report-infographic" => template_document!("data-report-infographic"),
|
||||
"steps-flow-infographic" => template_document!("steps-flow-infographic"),
|
||||
"event-poster-deck" => template_document!("event-poster-deck"),
|
||||
"pitfall-list-infographic" => template_document!("pitfall-list-infographic"),
|
||||
"spine-culture-card" => template_document!("spine-culture-card"),
|
||||
"metric-single-card" => template_document!("metric-single-card"),
|
||||
"quote-frame-card" => template_document!("quote-frame-card"),
|
||||
"daily-sign-card" => template_document!("daily-sign-card"),
|
||||
"price-tier-card" => template_document!("price-tier-card"),
|
||||
"notice-board-card" => template_document!("notice-board-card"),
|
||||
"milestone-timeline-infographic" => template_document!("milestone-timeline-infographic"),
|
||||
"concept-contrast-infographic" => template_document!("concept-contrast-infographic"),
|
||||
"ranking-board-infographic" => template_document!("ranking-board-infographic"),
|
||||
"faq-thread-infographic" => template_document!("faq-thread-infographic"),
|
||||
"data-story-infographic" => template_document!("data-story-infographic"),
|
||||
"challenge-tracker-infographic" => template_document!("challenge-tracker-infographic"),
|
||||
"ecosystem-map-infographic" => template_document!("ecosystem-map-infographic"),
|
||||
"do-dont-comparison" => template_document!("do-dont-comparison"),
|
||||
"myth-truth-comparison" => template_document!("myth-truth-comparison"),
|
||||
"pricing-tiers-comparison" => template_document!("pricing-tiers-comparison"),
|
||||
"scenario-guide-comparison" => template_document!("scenario-guide-comparison"),
|
||||
"spec-table-comparison" => template_document!("spec-table-comparison"),
|
||||
"three-way-comparison" => template_document!("three-way-comparison"),
|
||||
"time-shift-comparison" => template_document!("time-shift-comparison"),
|
||||
"tradeoff-scale-comparison" => template_document!("tradeoff-scale-comparison"),
|
||||
"version-diff-comparison" => template_document!("version-diff-comparison"),
|
||||
"app-onboarding-triptych" => template_document!("app-onboarding-triptych"),
|
||||
"diy-blueprint-guide" => template_document!("diy-blueprint-guide"),
|
||||
"photo-composition-tutorial" => template_document!("photo-composition-tutorial"),
|
||||
"recipe-four-step" => template_document!("recipe-four-step"),
|
||||
"skincare-routine-cards" => template_document!("skincare-routine-cards"),
|
||||
"software-step-tutorial" => template_document!("software-step-tutorial"),
|
||||
"storage-makeover-steps" => template_document!("storage-makeover-steps"),
|
||||
"weekly-report-lesson" => template_document!("weekly-report-lesson"),
|
||||
"workout-breakdown-guide" => template_document!("workout-breakdown-guide"),
|
||||
"bookreview-silk-carousel" => template_document!("bookreview-silk-carousel"),
|
||||
"cityguide-film-carousel" => template_document!("cityguide-film-carousel"),
|
||||
"datareport-grid-carousel" => template_document!("datareport-grid-carousel"),
|
||||
"opinion-longform-carousel" => template_document!("opinion-longform-carousel"),
|
||||
"qa-chalkboard-carousel" => template_document!("qa-chalkboard-carousel"),
|
||||
"story-night-carousel" => template_document!("story-night-carousel"),
|
||||
"toolkit-notebook-carousel" => template_document!("toolkit-notebook-carousel"),
|
||||
"tutorial-journal-carousel" => template_document!("tutorial-journal-carousel"),
|
||||
"yearreview-mineral-carousel" => template_document!("yearreview-mineral-carousel"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -351,6 +364,13 @@ pub struct SceneTemplateDefinition {
|
|||
|
||||
impl SceneTemplateDefinition {
|
||||
/// The embedded document for this template.
|
||||
///
|
||||
/// Native only, and deliberately so: it is infallible exactly because the
|
||||
/// bytes are in the binary. On `wasm32` the document may not have been
|
||||
/// fetched yet, which is not a failure this accessor could express without
|
||||
/// becoming an `Option` at every call site for the benefit of a platform
|
||||
/// that must go through the async path anyway.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn document(&self) -> &'static str {
|
||||
scene_template_document(&self.id)
|
||||
.expect("catalogue load verified every template has a document")
|
||||
|
|
@ -514,9 +534,14 @@ impl TemplateBuilder {
|
|||
));
|
||||
}
|
||||
// The load-time guarantee `document()` relies on: a catalogue entry
|
||||
// without an embedded document would compile fine and fail only when
|
||||
// a user clicked the card.
|
||||
if scene_template_document(&id).is_none() {
|
||||
// without a document would compile fine and fail only when a user
|
||||
// clicked the card.
|
||||
//
|
||||
// Checked through the ROUTE, not the bytes: on wasm the bytes are not
|
||||
// in the binary at all, so asking for them here would reject the whole
|
||||
// catalogue at start-up. The route answers the question actually being
|
||||
// asked — "does this id ship a document?" — on both platforms.
|
||||
if scene_template_document_route(&id).is_none() {
|
||||
return Err(SceneTemplateCatalogError::new(
|
||||
line,
|
||||
format!("template `{id}` has no embedded document"),
|
||||
|
|
|
|||
|
|
@ -208,3 +208,36 @@ fn a_multi_frame_template_positions_every_frame() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shipped_template_has_a_document_route_under_the_shared_prefix() {
|
||||
// The route is how the browser identifies a template document, and it is
|
||||
// what the catalogue's load-time check now asks about — on wasm the bytes
|
||||
// are not in the binary, so checking for them there would reject the whole
|
||||
// catalogue at start-up.
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::scene_template_catalog::scene_template_document_route;
|
||||
|
||||
let mut routes = HashSet::new();
|
||||
for template in scene_template_catalogue() {
|
||||
let route = scene_template_document_route(&template.id)
|
||||
.unwrap_or_else(|| panic!("{} has no document route", template.id));
|
||||
assert_eq!(
|
||||
route,
|
||||
format!(
|
||||
"{}scene_templates/{}.op",
|
||||
crate::web_assets::WEB_ASSET_ROUTE_PREFIX,
|
||||
template.id
|
||||
),
|
||||
"{} route must match the staged bundle layout",
|
||||
template.id
|
||||
);
|
||||
assert!(routes.insert(route), "{} duplicates a route", template.id);
|
||||
}
|
||||
assert!(
|
||||
scene_template_document_route("no-such-template").is_none(),
|
||||
"an unknown id has no route, which is how a caller tells it apart from \
|
||||
a shipped template whose document has not been fetched yet"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,3 +58,15 @@ resvg = { version = "0.48", default-features = false }
|
|||
# Enable jian-scene's find-visit instrumentation so the canvas-viewport
|
||||
# paint tests can assert scene-traversal visit counts.
|
||||
jian-scene = { path = "../../vendor/jian/crates/jian-scene", features = ["test-support"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Leave the core (lucide + feather) icon catalog OUT of the binary and resolve
|
||||
# it at run time through `set_core_catalog` instead.
|
||||
#
|
||||
# Opt-in, and only `op-host-web` opts in: it has a daemon to fetch the catalog
|
||||
# from (`/pkg/assets/iconify-catalog-core.json`) and a frame pump to install it
|
||||
# on. Every other consumer — the desktop hosts, and the `op-web-sdk` viewer,
|
||||
# which renders iconFont nodes but has no daemon behind it — keeps the embed,
|
||||
# because for them "not fetched yet" would simply mean "never".
|
||||
runtime-icon-catalog = []
|
||||
|
|
|
|||
|
|
@ -63,7 +63,10 @@ pub use theme::Theme;
|
|||
// Brand-logo catalog registration, surfaced at the crate root so non-widget
|
||||
// host code (the web brand-catalog fetcher) can install the runtime-loaded
|
||||
// catalog without importing the boundary-restricted `widgets` facade.
|
||||
pub use widgets::icon_catalog::{brand_catalog_loaded, set_brand_catalog, ICONIFY_BRANDS_ROUTE};
|
||||
pub use widgets::icon_catalog::{
|
||||
brand_catalog_loaded, core_catalog_loaded, set_brand_catalog, set_core_catalog,
|
||||
ICONIFY_BRANDS_ROUTE, ICONIFY_CORE_ROUTE,
|
||||
};
|
||||
|
||||
/// Re-exports of Jian gesture / event types so widget code can use the
|
||||
/// canonical Jian types directly via the short `crate::` form.
|
||||
|
|
|
|||
|
|
@ -42,13 +42,24 @@ pub struct ParsedIconBody {
|
|||
pub d: String,
|
||||
}
|
||||
|
||||
// Only the general-purpose UI sets (lucide + feather, ~0.46 MB) are embedded.
|
||||
// The ~3700 simple-icons brand logos (~4.83 MB) are loaded at runtime via
|
||||
// `set_brand_catalog` — embedded at startup on desktop, fetched from the daemon
|
||||
// on web — so they never bloat the wasm first-load. Both stores yield `'static`
|
||||
// references (each backed by a `OnceLock`).
|
||||
// Two catalogs, and NEITHER is embedded in the browser bundle.
|
||||
//
|
||||
// The ~3700 simple-icons brand logos (~4.83 MB) have always been runtime-loaded
|
||||
// via `set_brand_catalog` — embedded at startup on desktop, fetched from the
|
||||
// daemon on web. The general-purpose UI sets (lucide + feather, ~0.46 MB) now
|
||||
// follow the same rule on `wasm32` only: they are `include_str!`-embedded on
|
||||
// native, where the binary is already local, and fetched from
|
||||
// `ICONIFY_CORE_ROUTE` when the icon panel first opens on web. Both stores
|
||||
// yield `'static` references.
|
||||
#[cfg(not(all(target_arch = "wasm32", feature = "runtime-icon-catalog")))]
|
||||
const CORE_CATALOG_JSON: &str = include_str!("../../assets/iconify-catalog-core.json");
|
||||
|
||||
/// Daemon route carrying the core (lucide + feather) catalog.
|
||||
///
|
||||
/// Only a `runtime-icon-catalog` wasm build fetches it; every other build has
|
||||
/// the bytes in the binary.
|
||||
pub const ICONIFY_CORE_ROUTE: &str = "/pkg/assets/iconify-catalog-core.json";
|
||||
|
||||
/// Brand-logo catalog (simple-icons): set once at runtime. `None` until loaded;
|
||||
/// lookups / searches simply skip it while it is absent.
|
||||
static BRAND_CATALOG: OnceLock<BrandCatalog> = OnceLock::new();
|
||||
|
|
@ -101,16 +112,21 @@ pub fn lookup_icon(collection: &str, name: &str) -> Option<&'static IconCatalogE
|
|||
|
||||
/// Identity of a cached [`search_icons`] result. The catalog itself
|
||||
/// (`core_catalog` + `BRAND_CATALOG`) is process-global and immutable
|
||||
/// once loaded, so `(query, limit, brand_catalog_loaded())` fully
|
||||
/// determines the result — comparing it by value (never a
|
||||
/// pointer/length shortcut) makes a stale-serving collision
|
||||
/// once loaded, so `(query, limit, brand_catalog_loaded(),
|
||||
/// core_catalog_loaded())` fully determines the result — comparing it by value
|
||||
/// (never a pointer/length shortcut) makes a stale-serving collision
|
||||
/// impossible regardless of which caller or document triggered the
|
||||
/// search, so this cache needs no per-document owner-scoping.
|
||||
///
|
||||
/// `core_loaded` joined `brand_loaded` when the core catalog stopped being
|
||||
/// embedded on web: a search run before that fetch lands returns nothing, and
|
||||
/// without this the empty answer would be served for the rest of the session.
|
||||
#[derive(PartialEq)]
|
||||
struct SearchCacheKey {
|
||||
query: String,
|
||||
limit: usize,
|
||||
brand_loaded: bool,
|
||||
core_loaded: bool,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
|
|
@ -131,9 +147,13 @@ thread_local! {
|
|||
pub fn search_icons(query: &str, limit: usize) -> Vec<&'static IconCatalogEntry> {
|
||||
let query = query.trim().to_lowercase();
|
||||
let brand_loaded = brand_catalog_loaded();
|
||||
let core_loaded = core_catalog_loaded();
|
||||
let hit = SEARCH_CACHE.with(|cell| {
|
||||
cell.borrow().as_ref().and_then(|(key, result)| {
|
||||
(key.query == query && key.limit == limit && key.brand_loaded == brand_loaded)
|
||||
(key.query == query
|
||||
&& key.limit == limit
|
||||
&& key.brand_loaded == brand_loaded
|
||||
&& key.core_loaded == core_loaded)
|
||||
.then(|| result.clone())
|
||||
})
|
||||
});
|
||||
|
|
@ -148,6 +168,7 @@ pub fn search_icons(query: &str, limit: usize) -> Vec<&'static IconCatalogEntry>
|
|||
query,
|
||||
limit,
|
||||
brand_loaded,
|
||||
core_loaded,
|
||||
},
|
||||
result.clone(),
|
||||
));
|
||||
|
|
@ -219,7 +240,57 @@ pub fn parse_iconify_body(body: &str, width: f32, height: f32) -> Option<ParsedI
|
|||
})
|
||||
}
|
||||
|
||||
/// Whether the core catalog is available to search and look up.
|
||||
///
|
||||
/// Always `true` on native. On web it is `false` until the fetch lands, which
|
||||
/// is what the icon panel's empty state reads — an empty result and "not
|
||||
/// loaded yet" are different answers and the panel must not show the first
|
||||
/// when it means the second.
|
||||
pub fn core_catalog_loaded() -> bool {
|
||||
#[cfg(not(all(target_arch = "wasm32", feature = "runtime-icon-catalog")))]
|
||||
{
|
||||
true
|
||||
}
|
||||
#[cfg(all(target_arch = "wasm32", feature = "runtime-icon-catalog"))]
|
||||
{
|
||||
CORE_CATALOG_WEB.get().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Web only: the catalog fetched from the daemon. Set once.
|
||||
#[cfg(all(target_arch = "wasm32", feature = "runtime-icon-catalog"))]
|
||||
static CORE_CATALOG_WEB: OnceLock<Catalog> = OnceLock::new();
|
||||
|
||||
/// Install the core catalog from JSON.
|
||||
///
|
||||
/// Returns `true` when this call installed it. Invalid JSON returns `false`
|
||||
/// and leaves the catalog absent, so the panel keeps its retryable empty state
|
||||
/// rather than silently serving nothing forever.
|
||||
///
|
||||
/// Present on every target but a no-op on native, where the catalog is already
|
||||
/// embedded — `op-host-web` compiles for the host too (its tests), and one
|
||||
/// unconditional function keeps the cfg out of the host's call site.
|
||||
pub fn set_core_catalog(json: &str) -> bool {
|
||||
#[cfg(not(all(target_arch = "wasm32", feature = "runtime-icon-catalog")))]
|
||||
{
|
||||
let _ = json;
|
||||
false
|
||||
}
|
||||
#[cfg(all(target_arch = "wasm32", feature = "runtime-icon-catalog"))]
|
||||
{
|
||||
if CORE_CATALOG_WEB.get().is_some() {
|
||||
return false;
|
||||
}
|
||||
let Ok(catalog) = serde_json::from_str::<Catalog>(json) else {
|
||||
return false;
|
||||
};
|
||||
CORE_CATALOG_WEB.set(catalog).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn core_catalog() -> &'static [IconCatalogEntry] {
|
||||
#[cfg(not(all(target_arch = "wasm32", feature = "runtime-icon-catalog")))]
|
||||
{
|
||||
static CATALOG: OnceLock<Catalog> = OnceLock::new();
|
||||
&CATALOG
|
||||
.get_or_init(|| {
|
||||
|
|
@ -227,16 +298,43 @@ fn core_catalog() -> &'static [IconCatalogEntry] {
|
|||
})
|
||||
.icons
|
||||
}
|
||||
#[cfg(all(target_arch = "wasm32", feature = "runtime-icon-catalog"))]
|
||||
{
|
||||
// Empty until the fetch lands. Every lookup and search degrades to
|
||||
// "no core icons" meanwhile, which the panel reports as a loading /
|
||||
// retryable state rather than as an empty search.
|
||||
CORE_CATALOG_WEB
|
||||
.get()
|
||||
.map(|catalog| catalog.icons.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
fn core_index() -> &'static HashMap<String, usize> {
|
||||
#[cfg(not(all(target_arch = "wasm32", feature = "runtime-icon-catalog")))]
|
||||
{
|
||||
static INDEX: OnceLock<HashMap<String, usize>> = OnceLock::new();
|
||||
INDEX.get_or_init(|| {
|
||||
INDEX.get_or_init(build_core_index)
|
||||
}
|
||||
#[cfg(all(target_arch = "wasm32", feature = "runtime-icon-catalog"))]
|
||||
{
|
||||
// Built once the catalog is installed, not at first call: an index
|
||||
// built over the empty pre-fetch catalog would be cached forever.
|
||||
static INDEX: OnceLock<HashMap<String, usize>> = OnceLock::new();
|
||||
static EMPTY: OnceLock<HashMap<String, usize>> = OnceLock::new();
|
||||
if CORE_CATALOG_WEB.get().is_none() {
|
||||
return EMPTY.get_or_init(HashMap::new);
|
||||
}
|
||||
INDEX.get_or_init(build_core_index)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_core_index() -> HashMap<String, usize> {
|
||||
core_catalog()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, icon)| (format!("{}:{}", icon.collection, icon.name), idx))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn matches_query(icon: &IconCatalogEntry, query: &str) -> bool {
|
||||
|
|
@ -470,4 +568,52 @@ mod tests {
|
|||
"a changed limit must invalidate the cache and rebuild"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn the_core_catalog_route_matches_the_staged_bundle_layout() {
|
||||
// `tools/stage-web-assets.sh` copies the file to `<bundle>/assets/` under
|
||||
// exactly this name; a mismatch is a silent 404 that leaves the icon
|
||||
// panel permanently on its loading state.
|
||||
assert_eq!(
|
||||
ICONIFY_CORE_ROUTE,
|
||||
format!(
|
||||
"{}iconify-catalog-core.json",
|
||||
op_editor_core::web_assets::WEB_ASSET_ROUTE_PREFIX
|
||||
)
|
||||
);
|
||||
assert_ne!(
|
||||
ICONIFY_CORE_ROUTE, ICONIFY_BRANDS_ROUTE,
|
||||
"the two catalogs must not collide on one route"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_always_has_its_core_catalog_and_refuses_a_runtime_install() {
|
||||
// Desktop embeds the catalog, so it is loaded before anything asks and
|
||||
// `set_core_catalog` is inert — the web install path must never be able
|
||||
// to swap what a native build already resolved against.
|
||||
assert!(core_catalog_loaded());
|
||||
assert!(!core_catalog().is_empty());
|
||||
assert!(!set_core_catalog(r#"{"icons":[]}"#));
|
||||
assert!(!core_catalog().is_empty(), "the embedded catalog stands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_cache_is_keyed_on_whether_each_catalog_has_loaded() {
|
||||
// Web fetches the core catalog after the panel opens, so a search run
|
||||
// before it lands returns nothing. Without `core_loaded` in the key
|
||||
// that empty answer would be served for the rest of the session.
|
||||
let a = SearchCacheKey {
|
||||
query: "arrow".into(),
|
||||
limit: 20,
|
||||
brand_loaded: false,
|
||||
core_loaded: false,
|
||||
};
|
||||
let b = SearchCacheKey {
|
||||
query: "arrow".into(),
|
||||
limit: 20,
|
||||
brand_loaded: false,
|
||||
core_loaded: true,
|
||||
};
|
||||
assert!(a != b, "loading the core catalog must invalidate the cache");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,8 +365,17 @@ impl<'a> IconPickerPanel<'a> {
|
|||
}
|
||||
|
||||
fn paint_empty(&self, cx: &mut PaintCx<'_>, panel: Rect) {
|
||||
// "Nothing matched" and "the catalog has not arrived yet" are different
|
||||
// answers, and on web the second is a real state: the core catalog is
|
||||
// fetched when this panel first opens. Reporting the first would tell
|
||||
// the user their query failed when in fact nothing has been searched.
|
||||
let key = if crate::widgets::icon_catalog::core_catalog_loaded() {
|
||||
"icon.noIconsFound"
|
||||
} else {
|
||||
"icon.catalogLoading"
|
||||
};
|
||||
let empty = TextLayout::single_run(
|
||||
self.t("icon.noIconsFound"),
|
||||
self.t(key),
|
||||
"system-ui",
|
||||
12.0,
|
||||
(self.theme.muted_foreground).to_jian(),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ ttf-parser = "0.25"
|
|||
# Phase 7.3 reorg: openpencil-shell-core dissolved — widget facade /
|
||||
# theme / layout scene / scene vars / render-backend / gesture types
|
||||
# all resolve through the op-editor-ui crate.
|
||||
op-editor-ui = { path = "../op-editor-ui" }
|
||||
op-editor-ui = { path = "../op-editor-ui", features = ["runtime-icon-catalog"] }
|
||||
# Reorg: the web host owns an `op_editor_core::EditorState` as its
|
||||
# single source of truth; `op-pen-loader` bridges it into the
|
||||
# read-only paint `Document` the shared widgets consume.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,18 @@ impl CkInner {
|
|||
// Assets the last paint asked for but the bundle does not carry
|
||||
// (preview JPEGs, template documents, the icon catalog). Bounded per
|
||||
// call; the installs wake a later frame through `repaint_coalescer`.
|
||||
// Ask for the icon catalog the first time the picker is up. Idempotent
|
||||
// and single-flighted, so a per-frame check is the cheapest place to
|
||||
// notice — the `open` flag is set by shared flows with no single
|
||||
// host-side call site to hang this off.
|
||||
if self.host.editor_state().editor_ui.icon_picker.open {
|
||||
crate::iconify_web::ensure_core_catalog();
|
||||
}
|
||||
crate::web_asset_fetch::drain_pending();
|
||||
// A template clicked before its document arrived is instantiated here,
|
||||
// the frame after the fetch lands. No-op on every other frame and on
|
||||
// native, where the document was already in the binary.
|
||||
self.host.retry_pending_scene_template();
|
||||
crate::web_chat::reconcile_models(self.host.editor_state_mut());
|
||||
// Detect a credential edit and enqueue the daemon sync BEFORE mirroring
|
||||
// the sync status below: a corrective edit clears the stale error in
|
||||
|
|
|
|||
|
|
@ -93,6 +93,23 @@ const BRAND_CATALOG_PATH: &str = op_editor_ui::ICONIFY_BRANDS_ROUTE;
|
|||
/// the icon picker and figma icon substitution can resolve brand logos shortly
|
||||
/// after load. Best-effort: a missing daemon / failed fetch just leaves brand
|
||||
/// logos unavailable (lookups fall back to the unknown-glyph dot).
|
||||
/// Fetch the core (lucide + feather) catalog the first time the icon panel is
|
||||
/// opened, and register it.
|
||||
///
|
||||
/// Unlike the brand catalog this is NOT pulled at mount: it is only needed by
|
||||
/// the icon picker and the iconFont resolver, so pulling it on first open
|
||||
/// keeps it off the critical path of a session that never opens either. The
|
||||
/// route is a `/pkg/assets/` asset (the bundle no longer embeds it), so it
|
||||
/// rides the shared `web_assets` single-flight rather than a private latch —
|
||||
/// a failed fetch leaves the panel on its "still loading" empty state and the
|
||||
/// next open asks again.
|
||||
pub(crate) fn ensure_core_catalog() {
|
||||
if op_editor_ui::core_catalog_loaded() {
|
||||
return;
|
||||
}
|
||||
op_editor_core::web_assets::request(op_editor_ui::ICONIFY_CORE_ROUTE);
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_brand_catalog<C: RepaintContext + 'static>(inner: &Rc<RefCell<C>>) {
|
||||
// Crate-root re-exports (not the `widgets` facade) so this stays within the
|
||||
// op-host-web widget-boundary rule (`tools/check-widget-boundary.sh` F4).
|
||||
|
|
|
|||
|
|
@ -81,6 +81,14 @@ fn fetch_asset(route: String) {
|
|||
fetch_bytes(&url, move |result| match result {
|
||||
Ok(bytes) => {
|
||||
if op_editor_core::web_assets::install(&route, bytes) {
|
||||
// The icon catalog is not consumed as raw bytes: it has to be
|
||||
// parsed into the shared catalog before any lookup can see it.
|
||||
// Done here, once, on the install edge.
|
||||
if route == op_editor_ui::ICONIFY_CORE_ROUTE {
|
||||
if let Some(json) = op_editor_core::web_assets::installed_str(&route) {
|
||||
op_editor_ui::set_core_catalog(json);
|
||||
}
|
||||
}
|
||||
// Wake the editor so the card showing a placeholder picks the
|
||||
// picture up; the response is not an input event, so nothing
|
||||
// else would.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@
|
|||
//! file path and rewrites the window title.
|
||||
|
||||
use op_editor_core::scene_template_append::template_boards;
|
||||
use op_editor_core::scene_template_catalog::{scene_template_by_id, scene_template_document};
|
||||
use op_editor_core::scene_template_catalog::{
|
||||
scene_template_by_id, scene_template_document, scene_template_document_route,
|
||||
};
|
||||
use op_editor_ui::widgets::press_flow;
|
||||
use op_editor_ui::Point2D;
|
||||
|
||||
|
|
@ -88,6 +90,32 @@ impl WidgetHost {
|
|||
// make the "choose file" button do nothing.
|
||||
}
|
||||
|
||||
/// Instantiate a template whose document arrived after the click.
|
||||
///
|
||||
/// Called once per frame by the CanvasKit mount. On native this is a
|
||||
/// no-op — the document is in the binary, so the press path already
|
||||
/// finished the job and nothing is ever left pending.
|
||||
pub(crate) fn retry_pending_scene_template(&mut self) -> bool {
|
||||
if self
|
||||
.editor_state
|
||||
.editor_ui
|
||||
.scene_template_center
|
||||
.pending_open
|
||||
.is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (w, h) = (self.last_viewport_w, self.last_viewport_h);
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let brought_in = self.drain_pending_scene_template(w, h);
|
||||
if brought_in {
|
||||
self.mark_dirty();
|
||||
}
|
||||
brought_in
|
||||
}
|
||||
|
||||
/// Bring a chosen template into the document. Returns whether it changed.
|
||||
fn drain_pending_scene_template(&mut self, viewport_w: f32, viewport_h: f32) -> bool {
|
||||
let Some(template_id) = self
|
||||
|
|
@ -99,6 +127,35 @@ impl WidgetHost {
|
|||
return false;
|
||||
};
|
||||
let Some(source) = scene_template_document(&template_id) else {
|
||||
// Web only: the ~1.1 MB of template documents are not in the wasm
|
||||
// bundle, so the first use of a template fetches it. Unknown ids
|
||||
// have no route and simply drop, exactly as before.
|
||||
let Some(route) = scene_template_document_route(&template_id) else {
|
||||
return false;
|
||||
};
|
||||
match op_editor_core::web_assets::state(route) {
|
||||
op_editor_core::web_assets::WebAssetState::Failed => {
|
||||
// Say so instead of leaving a click that did nothing. The
|
||||
// request stays retryable, so clicking the card again asks
|
||||
// the daemon again.
|
||||
self.editor_state.editor_ui.show_toast(
|
||||
"sceneTemplate.documentUnavailable",
|
||||
Vec::new(),
|
||||
op_editor_core::editor_toast::EditorToastLevel::Warn,
|
||||
self.now_ms,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Re-arm the request so the per-frame retry
|
||||
// (`retry_pending_scene_template`) instantiates it the
|
||||
// moment the bytes land.
|
||||
op_editor_core::web_assets::request(route);
|
||||
self.editor_state
|
||||
.editor_ui
|
||||
.scene_template_center
|
||||
.request_open(template_id);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
let Some(boards) = template_boards(source, &template_id) else {
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ fn placeholders(value: &str) -> BTreeSet<String> {
|
|||
fn every_locale_has_exactly_the_english_key_set() {
|
||||
let all_tables = tables();
|
||||
let expected = table_keys(all_tables[0].0, all_tables[0].1, all_tables[0].2);
|
||||
assert_eq!(expected.len(), 1641, "update the intentional catalog size");
|
||||
assert_eq!(expected.len(), 1643, "update the intentional catalog size");
|
||||
|
||||
for (name, main, git, lookup) in all_tables {
|
||||
let actual = table_keys(name, main, git);
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "Cursor von {{name}}",
|
||||
"icon.catalogLoading" => "Symbolkatalog wird geladen…",
|
||||
"sceneTemplate.documentUnavailable" => "Das Dokument dieser Vorlage konnte nicht geladen werden. Bitte erneut versuchen.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "{{name}}'s cursor",
|
||||
"icon.catalogLoading" => "Icon catalog is still loading…",
|
||||
"sceneTemplate.documentUnavailable" => "That template's document could not be loaded. Try again.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "Cursor de {{name}}",
|
||||
"icon.catalogLoading" => "Cargando el catálogo de iconos…",
|
||||
"sceneTemplate.documentUnavailable" => "No se pudo cargar el documento de esa plantilla. Inténtalo de nuevo.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "Curseur de {{name}}",
|
||||
"icon.catalogLoading" => "Chargement du catalogue d'icônes…",
|
||||
"sceneTemplate.documentUnavailable" => "Le document de ce modèle n'a pas pu être chargé. Réessayez.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "{{name}} का कर्सर",
|
||||
"icon.catalogLoading" => "आइकन कैटलॉग लोड हो रहा है…",
|
||||
"sceneTemplate.documentUnavailable" => "इस टेम्पलेट का दस्तावेज़ लोड नहीं हो सका। फिर से कोशिश करें।",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "Kursor {{name}}",
|
||||
"icon.catalogLoading" => "Katalog ikon sedang dimuat…",
|
||||
"sceneTemplate.documentUnavailable" => "Dokumen templat itu tidak dapat dimuat. Coba lagi.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}、{{role}}",
|
||||
"collab.a11y.remoteCursor" => "{{name}} のカーソル",
|
||||
"icon.catalogLoading" => "アイコンカタログを読み込んでいます…",
|
||||
"sceneTemplate.documentUnavailable" => "このテンプレートのドキュメントを読み込めませんでした。もう一度お試しください。",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,10 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "{{name}}의 커서",
|
||||
"icon.catalogLoading" => "아이콘 카탈로그를 불러오는 중…",
|
||||
"sceneTemplate.documentUnavailable" => {
|
||||
"이 템플릿의 문서를 불러오지 못했습니다. 다시 시도하세요."
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "Cursor de {{name}}",
|
||||
"icon.catalogLoading" => "Carregando o catálogo de ícones…",
|
||||
"sceneTemplate.documentUnavailable" => "Não foi possível carregar o documento desse modelo. Tente novamente.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "Курсор пользователя {{name}}",
|
||||
"icon.catalogLoading" => "Загрузка каталога значков…",
|
||||
"sceneTemplate.documentUnavailable" => "Не удалось загрузить документ этого шаблона. Попробуйте снова.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "เคอร์เซอร์ของ {{name}}",
|
||||
"icon.catalogLoading" => "กำลังโหลดแคตตาล็อกไอคอน…",
|
||||
"sceneTemplate.documentUnavailable" => "โหลดเอกสารของเทมเพลตนี้ไม่สำเร็จ ลองอีกครั้ง",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "{{name}} imleci",
|
||||
"icon.catalogLoading" => "Simge kataloğu yükleniyor…",
|
||||
"sceneTemplate.documentUnavailable" => "Bu şablonun belgesi yüklenemedi. Tekrar deneyin.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
}
|
||||
"collab.a11y.participant" => "{{name}}, {{role}}",
|
||||
"collab.a11y.remoteCursor" => "Con trỏ của {{name}}",
|
||||
"icon.catalogLoading" => "Đang tải danh mục biểu tượng…",
|
||||
"sceneTemplate.documentUnavailable" => "Không tải được tài liệu của mẫu này. Hãy thử lại.",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
"collab.status.localEditPreserved" => "已应用远端版本。使用“撤销”即可恢复你的本地更改。",
|
||||
"collab.a11y.participant" => "{{name}},{{role}}",
|
||||
"collab.a11y.remoteCursor" => "{{name}} 的光标",
|
||||
"icon.catalogLoading" => "图标库正在加载…",
|
||||
"sceneTemplate.documentUnavailable" => "该模板的文档加载失败,请重试。",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
|
|||
"collab.status.localEditPreserved" => "已套用遠端版本。使用「復原」即可還原你的本機變更。",
|
||||
"collab.a11y.participant" => "{{name}},{{role}}",
|
||||
"collab.a11y.remoteCursor" => "{{name}} 的游標",
|
||||
"icon.catalogLoading" => "圖示庫正在載入…",
|
||||
"sceneTemplate.documentUnavailable" => "該範本的文件載入失敗,請重試。",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,9 @@
|
|||
# document logic; CanvasKit itself is loaded externally by the host page.
|
||||
# 4. Assert 0 env.* imports in the raw bindgen wasm (LinkError guard).
|
||||
# 5. wasm-opt -Oz (with the rustc-emitted WebAssembly feature flags) then
|
||||
# gzip size <= OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES (default 8 388 608 = 8 MiB).
|
||||
# gzip size <= OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES (default 6 291 456 = 6 MiB).
|
||||
# The SDK is pure logic with no CanvasKit WASM included — the ceiling
|
||||
# is its own tripwire, not op-host-web's — this bundle has never been
|
||||
# measured against a tighter one, so it is left at 8 MiB until it is.
|
||||
# is its own tripwire, not op-host-web's, and is now measured: see below.
|
||||
#
|
||||
# This approach mirrors tools/check-wasm-bundle.sh (op-host-web gate) exactly:
|
||||
# cargo build → wasm-bindgen → wasm-opt, without wasm-pack. This removes the
|
||||
|
|
@ -61,14 +60,21 @@ WASM_OPT_CANDIDATE_FEATURES=(
|
|||
--enable-nontrapping-float-to-int
|
||||
)
|
||||
|
||||
# Gzip ceiling. NOT the same number as op-host-web any more: that bundle was
|
||||
# re-baselined to 6 MiB once its preview JPEGs moved out behind the runtime
|
||||
# `/pkg/assets/` fetch (`tools/check-wasm-bundle.sh`). This viewer bundle has a
|
||||
# different feature set and has not been measured against a tighter ceiling, so
|
||||
# it keeps 8 MiB until it is. (see
|
||||
# tools/check-wasm-bundle.sh for the embedded-asset composition note).
|
||||
# Override via env when intentionally re-baselining.
|
||||
LIMIT="${OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES:-8388608}"
|
||||
# Gzip ceiling. Its own number, NOT op-host-web's — that bundle re-baselined to
|
||||
# 6 MiB on its own measurement (`tools/check-wasm-bundle.sh`).
|
||||
#
|
||||
# Measured 2026-08-09: this bundle gzips to 5 200 519 bytes, having taken the
|
||||
# same benefit as op-host-web from moving the preview JPEGs and scene-template
|
||||
# `.op` documents out of the binary (7 472 939 -> 5 200 519 across both
|
||||
# rounds). The ceiling is that measurement + ~20% headroom, rounded up to a
|
||||
# whole MiB: a tripwire 3 MiB above the real number catches nothing, which is
|
||||
# why it moves down with the bundle rather than staying where it was set.
|
||||
#
|
||||
# This viewer keeps the icon catalog EMBEDDED (`op-editor-ui`'s
|
||||
# `runtime-icon-catalog` feature is opt-in and only op-host-web opts in): the
|
||||
# SDK renders iconFont nodes but has no daemon to fetch a catalog from, so for
|
||||
# it "not fetched yet" would mean "never".
|
||||
LIMIT="${OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES:-6291456}"
|
||||
|
||||
step() { printf '\n[step %d/%d] %s\n' "$1" "$2" "$3"; }
|
||||
fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; }
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ copy_file() {
|
|||
mkdir -p "${DEST}"
|
||||
copy_dir "${UI_ASSETS}/prompt_center_previews" "prompt_center_previews"
|
||||
copy_dir "${UI_ASSETS}/scene_template_previews" "scene_template_previews"
|
||||
# Scene-template `.op` documents — fetched when a template is instantiated.
|
||||
copy_dir "${CORE_ASSETS}/scene_templates" "scene_templates"
|
||||
# Core (lucide + feather) icon catalog — fetched when the icon panel opens.
|
||||
copy_file "${UI_ASSETS}/iconify-catalog-core.json" "iconify-catalog-core.json"
|
||||
|
||||
staged="$(du -sk "${DEST}" | cut -f1)"
|
||||
printf ' ✓ staged runtime assets into %s (%s KiB)\n' "${DEST}" "${staged}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue