Lolly
啟動應用程式

Composition and brand overlays

Compose tools and resolve brand-specific logos and overlays.

Part of Authoring Tools.

Composition (composes)

A tool can embed another tool's rendered output as an image instead of re-implementing it. Declare it in the manifest and reference it in the template like any asset - no hook code, no copy-paste.

// tool.json
"capabilities": ["compose"],
"composes": [
  { "id": "badgeQr", "tool": "qr-code", "format": "svg",
    "inputs": { "url": "{{url}}", "color": "#0c322c", "join": true } }
]
{{!-- template.html - guard it: composition can fail gracefully --}}
{{#if badgeQr}}<img src="{{asset badgeQr}}" alt="">{{/if}}

Composition depth and baking

Nesting is capped at 3 levels - a tool composing a tool composing a tool. A deeper chain fails the same way a cycle does: gracefully, with an empty slot. When a design genuinely needs to go deeper, bake the inner render: tick Freeze as a static image in the picker's render card. A baked image is a frozen copy - self-contained bytes that consume no nesting depth and never live-re-render - so it won't update when the source tool changes. Its slot shows a "❄ baked from …" row with a Re-bake button (and an Edit path into the source tool's inputs) that re-renders on demand, so a stale copy is one click from fresh.

Brand logo (auto-switching)

The catalog ships the SUSE logo as 8 variants under suse/logo/ - {hor|vert}-{neg|pos}-{green|white|black} (hor/vert = wide vs stacked; neg = for dark backgrounds, pos = for light; green is the brand mark, white/black are the high-contrast mono pair). A tool shouldn't hard-code one - it should pick the variant that fits the current background and space, and use the actual SVG image (this is distinct from brand-lockup, which renders the wordmark from the SUSE font, outlined via HarfBuzz host.text).

The pattern: a hook chooses the id, resolves it with host.assets.get() and hands the template a ready <image>/<img>:

// hooks.js - WCAG luminance decides neg/pos; orientation + ink come from inputs.
function logoId(inputs) {
  const dark   = relLuminance(inputs.background) < 0.5;   // dark bg → neg
  const orient = inputs.orientation === 'vertical' ? 'vert' : 'hor';
  const ink    = inputs.ink === 'mono' ? (dark ? 'white' : 'black') : 'green';
  return `suse/logo/${orient}-${dark ? 'neg' : 'pos'}-${ink}`;
}
async function onInit({ model }) {
  const inputs = Object.fromEntries(model.map(i => [i.id, i.value]));
  return { logo: await host.assets.get(logoId(inputs)) }; // → extras.logo (an AssetRef)
}
<!-- template.html - the actual SVG asset, not a font lockup -->
{{#if logo}}<image href="{{asset logo}}" .../>{{/if}}   <!-- inside an <svg> → true vector export -->
{{!-- or, in an HTML canvas: --}}
{{#if logo}}<img src="{{asset logo}}" alt="Logo">{{/if}}

Putting the <image> inside an <svg> lets the export inline it (data-URI) and emit true vector SVG; an <img> in an HTML canvas exports raster/PDF only. tools/tool-logo/ is the reference implementation (background colour, orientation, brand/mono, transparent-bg export). Reusing this in another org: keep the structure and swap the suse/logo/... id prefix for your own logo namespace (same variant matrix).

Brand overlays (extends)

A brand pack that only needs to tweak a community tool - a different template, a handful of re-worded translations - shouldn't carry a whole fork that silently drifts from its base. Instead, declare the brand's tool dir an overlay:

// brands/<brand>/tools/<id>/tool.json - same id as the community tool
{
  "id": "color-palette",
  "extends": "community",
  ...
}

and keep only the files that differ in the overlay dir. The content resolver (packages/node-shell/src/content-roots.ts) then reads that tool as the per-file union of the base (community/<id>/) and the overlay (brands/<brand>/tools/<id>/), the same union for every consumer - a dist/ build, the CLI, the dev server, the catalog signer:

Fail-closed: a declared overlay whose base is missing (community/<id>/tool.json doesn't exist), an extends value other than "community" (the only base pack in v1) or an extends declared on a community tool itself fails loudly the first time anything resolves that profile, and is also rejected by pnpm run validate:catalog. You never get a silent partial tool. The composed result is validated like any other tool, because the validator asks the same resolver every shell asks.

Back to Authoring Tools.