@meginhufr/brua-web 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Meginhufr AS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Your backend passes asset ids on, and the page reads each asset's current releas
10
10
 
11
11
  ## From an id
12
12
 
13
- Configure once where your organization's assets live, then render by id. The asset is fetched from `{base}/{id}.json` with no key, reused for a minute (the edge's own lifetime) by everyone who asks, and a merge in Brua reaches the page within that minute with no deploy of yours.
13
+ Configure once where your organization's assets live, then render by id. The asset is fetched from `{base}/{id}.json` with no key, and remembered by everyone who asks: answered from memory for the edge's minute, read again after that, and kept when the edge can't be reached. A merge in Brua reaches the page within that minute with no deploy of yours; `brua.cachedAsset(id)` gives the last release read at once, and `brua.prefetch(ids)` reads a list's ids before it renders.
14
14
 
15
15
  ```ts
16
16
  import { createBrua } from "@meginhufr/brua-web";
@@ -1,25 +1,27 @@
1
1
  <!-- Brua for Svelte 5: the asset as a picture element (SPEC.md 7.2). The browser picks the format, density
2
2
  and color scheme, and follows dark mode live. Animations show their still. Import from "@meginhufr/brua-web/svelte".
3
3
  Given an id instead of an asset, the current release is read from the edge through the Brua set with
4
- setBrua(); the children show until it has loaded, and when nothing is released under the id. -->
4
+ setBrua(); the last release it read shows at once, else the children until it has loaded, and when nothing is released under the id. -->
5
5
  <script>
6
6
  import { getContext } from "svelte";
7
- import { pictureModel } from "./picture.ts";
7
+ import { fillStyle, pictureModel } from "./picture.ts";
8
8
 
9
- /** @type {{ asset?: import("./types.ts").AssetResponse, id?: string, brua?: import("./brua.ts").Brua, sizes?: string, loading?: "lazy" | "eager", class?: string, colorScheme?: "light" | "dark", children?: import("svelte").Snippet }} */
10
- let { asset: given, id, brua = getContext("brua"), sizes, loading, class: imgClass, colorScheme, children } = $props();
11
- let fetched = $state();
9
+ /** @type {{ asset?: import("./types.ts").AssetResponse, id?: string, brua?: import("./brua.ts").Brua, sizes?: string, loading?: "lazy" | "eager", class?: string, colorScheme?: "light" | "dark", objectFit?: "contain" | "cover" | "none", objectPosition?: string, children?: import("svelte").Snippet }} */
10
+ let { asset: given, id, brua = getContext("brua"), sizes, loading, class: imgClass, colorScheme, objectFit, objectPosition, children } = $props();
11
+ // The last release the Brua read renders at once, so a remounted picture doesn't flash its children.
12
+ let fetched = $state(id === undefined ? undefined : brua?.cachedAsset(id));
12
13
 
13
14
  $effect(() => {
14
15
  if (given || id === undefined) return;
15
16
  if (!brua) throw new Error("BruaPicture with an id needs setBrua() in a parent, or a brua prop.");
16
17
  let live = true;
17
- brua.asset(id).then((loaded) => { if (live) fetched = loaded; });
18
+ fetched = brua.cachedAsset(id);
19
+ brua.asset(id).then((loaded) => { if (live) fetched = loaded; }, () => {});
18
20
  return () => { live = false; };
19
21
  });
20
22
 
21
23
  let asset = $derived(given ?? fetched);
22
- let model = $derived(asset ? pictureModel(asset, { sizes, loading, className: imgClass, colorScheme }) : undefined);
24
+ let model = $derived(asset ? pictureModel(asset, { sizes, loading, className: imgClass, colorScheme, objectFit, objectPosition }) : undefined);
23
25
  </script>
24
26
 
25
27
  {#if model}
@@ -38,6 +40,7 @@
38
40
  loading={model.img.loading}
39
41
  decoding={model.img.decoding}
40
42
  class={model.img.className}
43
+ style={fillStyle(model.img)}
41
44
  />
42
45
  </picture>
43
46
  {:else if children}
package/dist/brua.d.ts CHANGED
@@ -2,18 +2,29 @@ import type { AssetResponse } from "./types.ts";
2
2
  export interface BruaOptions {
3
3
  /** The organization's asset prefix on the edge, such as https://cdn.brua.dev/assets/{org}. */
4
4
  base: string;
5
- /** The language for alt text and language-specific files; left out, the browser's Accept-Language decides. */
5
+ /** The language for alt text and language-specific files; left out, the browser's languages decide. */
6
6
  lang?: string;
7
- /** How long a fetched asset is reused before it is asked for again, in milliseconds: the edge's own minute by default. */
7
+ /** How long a fetched asset is reused before it is asked for again, in milliseconds; by default the edge's max-age. */
8
8
  maxAge?: number;
9
9
  /** The fetch to use; the global one by default. */
10
10
  fetch?: typeof fetch;
11
11
  }
12
- /** Assets by id from the edge, with no key and no backend: each id costs one small request a minute at most. */
12
+ /**
13
+ * Assets by id from the edge, with no key and no backend. Every release read is remembered: answered from memory for
14
+ * the edge's minute, asked for again after that, and kept when the edge can't be reached, so a merge lands within
15
+ * the minute and a page that loses its connection keeps its images.
16
+ */
13
17
  export interface Brua {
14
18
  /** The JSON URL of the asset's current release. */
15
19
  url(id: string): string;
16
- /** The asset's current release, or undefined when the id is unknown or nothing is released. */
20
+ /**
21
+ * The asset's current release, or undefined when the id is unknown or nothing is released. Reads of one id at the
22
+ * same time share a request. Rejects only when the edge can't be reached and no release of the id is known.
23
+ */
17
24
  asset(id: string): Promise<AssetResponse | undefined>;
25
+ /** The last release of the id this Brua has read, however old, or undefined: for a component's first render. */
26
+ cachedAsset(id: string): AssetResponse | undefined;
27
+ /** Reads these ids at the same time, so a list's pictures render at once when it appears. */
28
+ prefetch(ids: string[]): Promise<void>;
18
29
  }
19
30
  export declare function createBrua(options: BruaOptions): Brua;
package/dist/brua.js CHANGED
@@ -1,31 +1,60 @@
1
+ /** "nb-NO, en;q=0.9": the browser's languages in order, with falling quality values, as the edge reads ?lang=. */
2
+ function acceptLanguage(languages) {
3
+ return languages.slice(0, 5).map((language, index) => (index === 0 ? language : `${language};q=${(1 - index * 0.1).toFixed(1)}`)).join(", ");
4
+ }
5
+ /** The edge's max-age in milliseconds, or undefined when the response doesn't say. */
6
+ function maxAgeOf(response) {
7
+ const match = /(?:^|,)\s*max-age\s*=\s*(\d+)/i.exec(response.headers.get("Cache-Control") ?? "");
8
+ return match ? Number(match[1]) * 1000 : undefined;
9
+ }
1
10
  export function createBrua(options) {
2
11
  const base = options.base.replace(/\/+$/, "");
3
- const maxAge = options.maxAge ?? 60_000;
4
- const query = options.lang ? `?lang=${encodeURIComponent(options.lang)}` : "";
5
- const held = new Map();
6
- const url = (id) => `${base}/${encodeURIComponent(id)}.json${query}`;
7
- return {
12
+ const entries = new Map();
13
+ const reads = new Map();
14
+ // Without a language, the browser's languages go in the query (in a browser only, not on a server), so each has
15
+ // its own entry here and in the HTTP cache, the same as the other SDKs.
16
+ const languages = () => options.lang ?? (typeof window !== "undefined" && typeof navigator !== "undefined" ? acceptLanguage(navigator.languages ?? []) : "");
17
+ const url = (id) => {
18
+ const lang = languages();
19
+ return `${base}/${encodeURIComponent(id)}.json${lang ? `?lang=${encodeURIComponent(lang)}` : ""}`;
20
+ };
21
+ const read = async (id, at) => {
22
+ const response = await (options.fetch ?? fetch)(at);
23
+ const freshFor = options.maxAge ?? maxAgeOf(response) ?? 60_000;
24
+ if (response.status === 404)
25
+ return { asset: undefined, freshUntil: Date.now() + freshFor };
26
+ if (!response.ok)
27
+ throw new Error(`Brua: ${id} answered ${response.status}`);
28
+ return { asset: (await response.json()), freshUntil: Date.now() + freshFor };
29
+ };
30
+ const brua = {
8
31
  url,
9
- asset(id) {
10
- const now = Date.now();
11
- const have = held.get(id);
12
- if (have && now - have.at < maxAge)
13
- return have.result;
14
- const request = options.fetch ?? fetch;
15
- const result = request(url(id)).then(async (response) => {
16
- if (response.status === 404)
17
- return undefined;
18
- if (!response.ok)
19
- throw new Error(`Brua: ${id} answered ${response.status}`);
20
- return (await response.json());
21
- });
22
- held.set(id, { at: now, result });
23
- // A failure is asked again next time, not remembered for a minute.
24
- result.catch(() => {
25
- if (held.get(id)?.result === result)
26
- held.delete(id);
27
- });
28
- return result;
32
+ async asset(id) {
33
+ const at = url(id);
34
+ const known = entries.get(at);
35
+ if (known && Date.now() < known.freshUntil)
36
+ return known.asset;
37
+ let running = reads.get(at);
38
+ if (!running) {
39
+ running = read(id, at).then((entry) => { entries.set(at, entry); return entry; }).finally(() => reads.delete(at));
40
+ reads.set(at, running);
41
+ }
42
+ try {
43
+ return (await running).asset;
44
+ }
45
+ catch (error) {
46
+ const kept = entries.get(at);
47
+ if (kept)
48
+ return kept.asset;
49
+ throw error;
50
+ }
51
+ },
52
+ cachedAsset(id) {
53
+ return entries.get(url(id))?.asset;
54
+ },
55
+ async prefetch(ids) {
56
+ await Promise.all([...new Set(ids)].map((id) => brua.asset(id).catch(() => undefined)));
29
57
  },
30
58
  };
59
+ return brua;
31
60
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type { AssetResponse, AssetVariant, ClientState } from "./types.ts";
2
2
  export { selectUrl, selectVariant } from "./select.ts";
3
- export { pictureHtml, pictureModel, type PictureOptions, type PictureModel, type PictureSource, type PictureImage } from "./picture.ts";
3
+ export { fillStyle, pictureHtml, pictureModel, type PictureOptions, type PictureModel, type PictureSource, type PictureImage } from "./picture.ts";
4
+ export { objectFitOf, objectPositionOf, targetWidth, type ObjectFit } from "./sizing.ts";
4
5
  export { browserClientState, defaultClientState, watchClientState, type ClientOptions } from "./client.ts";
5
6
  export { createBrua, type Brua, type BruaOptions } from "./brua.ts";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { selectUrl, selectVariant } from "./select.js";
2
- export { pictureHtml, pictureModel } from "./picture.js";
2
+ export { fillStyle, pictureHtml, pictureModel } from "./picture.js";
3
+ export { objectFitOf, objectPositionOf, targetWidth } from "./sizing.js";
3
4
  export { browserClientState, defaultClientState, watchClientState } from "./client.js";
4
5
  export { createBrua } from "./brua.js";
package/dist/picture.d.ts CHANGED
@@ -9,6 +9,13 @@ export interface PictureOptions {
9
9
  * the dark files go behind a media query and the browser follows the system setting.
10
10
  */
11
11
  colorScheme?: "light" | "dark";
12
+ /**
13
+ * How the img fills its box: "contain" or "cover" overrides the asset's own fit, and "none" leaves the fit and
14
+ * the position to the page's CSS. Left out, the asset's fit is used.
15
+ */
16
+ objectFit?: "contain" | "cover" | "none";
17
+ /** Where the img sits in its box, as "x% y%"; left out, the asset's position. */
18
+ objectPosition?: string;
12
19
  }
13
20
  /** A `<source>` of the picture: one format, optionally one color scheme. */
14
21
  export interface PictureSource {
@@ -29,6 +36,9 @@ export interface PictureImage {
29
36
  loading: "lazy" | "eager";
30
37
  decoding: "async";
31
38
  className?: string;
39
+ /** The object-fit and object-position the img is drawn with, when the asset or the call site gives a fill. */
40
+ objectFit?: "contain" | "cover";
41
+ objectPosition?: string;
32
42
  }
33
43
  /** What a picture element for the asset consists of; the HTML renderer and the framework components build from it. */
34
44
  export interface PictureModel {
@@ -41,5 +51,7 @@ export interface PictureModel {
41
51
  * without client hints and switches live with the color scheme. Animations aren't rendered; their stills are.
42
52
  */
43
53
  export declare function pictureModel(asset: AssetResponse, options?: PictureOptions): PictureModel;
54
+ /** The img's fill as a style attribute's value. */
55
+ export declare function fillStyle(img: PictureImage): string | undefined;
44
56
  /** The picture as an HTML string, for innerHTML or server-rendered pages. */
45
57
  export declare function pictureHtml(asset: AssetResponse, options?: PictureOptions): string;
package/dist/picture.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { objectFitOf, objectPositionOf } from "./sizing.js";
1
2
  const SVG = "image/svg+xml";
2
3
  /** Formats offered as <source> elements, best first; anything else becomes the <img> fallback. */
3
4
  const SOURCE_TYPES = ["image/avif", "image/webp"];
@@ -51,9 +52,25 @@ export function pictureModel(asset, options = {}) {
51
52
  loading: options.loading ?? "lazy",
52
53
  decoding: "async",
53
54
  className: options.className,
55
+ ...fill(asset, options),
54
56
  },
55
57
  };
56
58
  }
59
+ /**
60
+ * The fill to put on the img: the call site's or the asset's, cleaned to what CSS takes. A response from before the
61
+ * fill, with no override either, gets none, so the page draws it as it always has.
62
+ */
63
+ function fill(asset, options) {
64
+ if (options.objectFit === "none")
65
+ return {};
66
+ if (asset.objectFit === undefined && asset.objectPosition === undefined && options.objectFit === undefined && options.objectPosition === undefined)
67
+ return {};
68
+ return { objectFit: objectFitOf(asset, options.objectFit), objectPosition: objectPositionOf(asset, options.objectPosition) };
69
+ }
70
+ /** The img's fill as a style attribute's value. */
71
+ export function fillStyle(img) {
72
+ return img.objectFit === undefined ? undefined : `object-fit: ${img.objectFit}; object-position: ${img.objectPosition}`;
73
+ }
57
74
  /** The picture as an HTML string, for innerHTML or server-rendered pages. */
58
75
  export function pictureHtml(asset, options = {}) {
59
76
  const { sources, img } = pictureModel(asset, options);
@@ -69,6 +86,7 @@ export function pictureHtml(asset, options = {}) {
69
86
  loading: img.loading,
70
87
  decoding: img.decoding,
71
88
  class: img.className,
89
+ style: fillStyle(img),
72
90
  }));
73
91
  return `<picture>${rendered.join("")}</picture>`;
74
92
  }
package/dist/react.d.ts CHANGED
@@ -11,8 +11,8 @@ export type BruaProviderProps = {
11
11
  /** Where the app's assets are read from by id: wrap the app once, then <BruaPicture id="…"> anywhere below. */
12
12
  export declare function BruaProvider(props: BruaProviderProps): ReactElement;
13
13
  /**
14
- * The asset's current release for an id, from the provider's Brua or the one given: undefined until it has loaded,
15
- * and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
14
+ * The asset's current release for an id, from the provider's Brua or the one given: the last release it read at once, else
15
+ * undefined until it has loaded, and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
16
16
  */
17
17
  export declare function useBruaAsset(id: string | undefined, brua?: Brua): AssetResponse | undefined;
18
18
  export interface BruaPictureProps extends PictureOptions {
@@ -29,7 +29,7 @@ export interface BruaPictureProps extends PictureOptions {
29
29
  * The asset as a picture element (SPEC.md 7.2): the browser picks the format, density and color scheme,
30
30
  * and follows dark mode live. Animations show their still.
31
31
  */
32
- export declare function BruaPicture({ asset, id, brua, fallback, sizes, loading, className, colorScheme }: BruaPictureProps): ReactElement | null;
32
+ export declare function BruaPicture({ asset, id, brua, fallback, sizes, loading, className, colorScheme, objectFit, objectPosition }: BruaPictureProps): ReactElement | null;
33
33
  /**
34
34
  * One variant URL for the asset, chosen for this browser and kept current as its appearance, motion setting
35
35
  * or pixel density changes: for a CSS background, a canvas or an animation player. Renders light at 1x on
package/dist/react.js CHANGED
@@ -13,21 +13,23 @@ export function BruaProvider(props) {
13
13
  return createElement(BruaContext.Provider, { value: brua }, props.children);
14
14
  }
15
15
  /**
16
- * The asset's current release for an id, from the provider's Brua or the one given: undefined until it has loaded,
17
- * and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
16
+ * The asset's current release for an id, from the provider's Brua or the one given: the last release it read at once, else
17
+ * undefined until it has loaded, and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
18
18
  */
19
19
  export function useBruaAsset(id, brua) {
20
20
  const fromContext = useContext(BruaContext);
21
21
  const client = brua ?? fromContext;
22
- const [asset, setAsset] = useState();
22
+ // The last release the Brua read renders at once, so a remounted picture doesn't flash its fallback.
23
+ const [asset, setAsset] = useState(() => (id === undefined ? undefined : client?.cachedAsset(id)));
23
24
  if (id !== undefined && !client)
24
25
  throw new Error("useBruaAsset needs a <BruaProvider> above it, or a Brua passed in.");
25
26
  useEffect(() => {
26
27
  if (id === undefined || !client)
27
28
  return;
28
29
  let live = true;
30
+ setAsset(client.cachedAsset(id));
29
31
  client.asset(id).then((loaded) => { if (live)
30
- setAsset(loaded); });
32
+ setAsset(loaded); }, () => { });
31
33
  return () => { live = false; };
32
34
  }, [client, id]);
33
35
  return id === undefined ? undefined : asset;
@@ -36,12 +38,12 @@ export function useBruaAsset(id, brua) {
36
38
  * The asset as a picture element (SPEC.md 7.2): the browser picks the format, density and color scheme,
37
39
  * and follows dark mode live. Animations show their still.
38
40
  */
39
- export function BruaPicture({ asset, id, brua, fallback = null, sizes, loading, className, colorScheme }) {
41
+ export function BruaPicture({ asset, id, brua, fallback = null, sizes, loading, className, colorScheme, objectFit, objectPosition }) {
40
42
  const fetched = useBruaAsset(asset ? undefined : id, brua);
41
43
  const shown = asset ?? fetched;
42
44
  if (!shown)
43
45
  return fallback;
44
- const { sources, img } = pictureModel(shown, { sizes, loading, className, colorScheme });
46
+ const { sources, img } = pictureModel(shown, { sizes, loading, className, colorScheme, objectFit, objectPosition });
45
47
  return createElement("picture", null, ...sources.map((s) => createElement("source", { key: `${s.type}${s.media ?? ""}`, type: s.type, media: s.media, srcSet: s.srcset, sizes: s.sizes })), createElement("img", {
46
48
  src: img.src,
47
49
  srcSet: img.srcset,
@@ -53,6 +55,7 @@ export function BruaPicture({ asset, id, brua, fallback = null, sizes, loading,
53
55
  loading: img.loading,
54
56
  decoding: img.decoding,
55
57
  className: img.className,
58
+ style: img.objectFit === undefined ? undefined : { objectFit: img.objectFit, objectPosition: img.objectPosition },
56
59
  }));
57
60
  }
58
61
  /**
@@ -0,0 +1,12 @@
1
+ import type { AssetResponse } from "./types.ts";
2
+ export type ObjectFit = "contain" | "cover";
3
+ /** The fit to draw with: the call site's override, else the asset's, else contain. Unknown values are contain. */
4
+ export declare function objectFitOf(asset: Pick<AssetResponse, "objectFit">, override?: string): ObjectFit;
5
+ /** Where the picture sits in its box, as "x% y%": the call site's, else the asset's, else the centre. */
6
+ export declare function objectPositionOf(asset: Pick<AssetResponse, "objectPosition">, override?: string): string;
7
+ /**
8
+ * How wide the picture is drawn in a box of this size, in CSS pixels or points, for the selection rule's
9
+ * `targetWidth`. Contain, or a box without a height yet, draws it at the box width; cover at the larger of the box
10
+ * width and the box height times the asset's proportions.
11
+ */
12
+ export declare function targetWidth(asset: Pick<AssetResponse, "width" | "height" | "objectFit">, boxWidth: number, boxHeight?: number, objectFit?: string): number;
package/dist/sizing.js ADDED
@@ -0,0 +1,21 @@
1
+ const POSITION = /^(100|[1-9]?[0-9])% (100|[1-9]?[0-9])%$/;
2
+ /** The fit to draw with: the call site's override, else the asset's, else contain. Unknown values are contain. */
3
+ export function objectFitOf(asset, override) {
4
+ const fit = override ?? asset.objectFit;
5
+ return fit === "cover" ? "cover" : "contain";
6
+ }
7
+ /** Where the picture sits in its box, as "x% y%": the call site's, else the asset's, else the centre. */
8
+ export function objectPositionOf(asset, override) {
9
+ const position = override ?? asset.objectPosition;
10
+ return position !== undefined && POSITION.test(position) ? position : "50% 50%";
11
+ }
12
+ /**
13
+ * How wide the picture is drawn in a box of this size, in CSS pixels or points, for the selection rule's
14
+ * `targetWidth`. Contain, or a box without a height yet, draws it at the box width; cover at the larger of the box
15
+ * width and the box height times the asset's proportions.
16
+ */
17
+ export function targetWidth(asset, boxWidth, boxHeight, objectFit) {
18
+ if (objectFitOf(asset, objectFit) !== "cover" || boxHeight === undefined || boxHeight <= 0 || asset.height <= 0)
19
+ return boxWidth;
20
+ return Math.max(boxWidth, boxHeight * (asset.width / asset.height));
21
+ }
package/dist/svelte.d.ts CHANGED
@@ -9,8 +9,8 @@ export declare const bruaContext = "brua";
9
9
  export declare function setBrua(options: Brua | BruaOptions): Brua;
10
10
  export declare function getBrua(): Brua | undefined;
11
11
  /**
12
- * A store of the asset's current release for an id: undefined until it has loaded, and when nothing is released
13
- * under the id. Reads through the context's Brua unless one is given; call it during component setup.
12
+ * A store of the asset's current release for an id: the last release the Brua read at once, else undefined until it
13
+ * has loaded, and when nothing is released under the id. Reads through the context's Brua unless one is given; call it during component setup.
14
14
  */
15
15
  export declare function bruaAsset(id: string, brua?: Brua | undefined): Readable<AssetResponse | undefined>;
16
16
  /**
package/dist/svelte.js CHANGED
@@ -17,16 +17,16 @@ export function getBrua() {
17
17
  return getContext(bruaContext);
18
18
  }
19
19
  /**
20
- * A store of the asset's current release for an id: undefined until it has loaded, and when nothing is released
21
- * under the id. Reads through the context's Brua unless one is given; call it during component setup.
20
+ * A store of the asset's current release for an id: the last release the Brua read at once, else undefined until it
21
+ * has loaded, and when nothing is released under the id. Reads through the context's Brua unless one is given; call it during component setup.
22
22
  */
23
23
  export function bruaAsset(id, brua = getBrua()) {
24
24
  if (!brua)
25
25
  throw new Error("bruaAsset needs setBrua() in a parent, or a Brua passed in.");
26
- return readable(undefined, (set) => {
26
+ return readable(brua.cachedAsset(id), (set) => {
27
27
  let live = true;
28
28
  brua.asset(id).then((loaded) => { if (live)
29
- set(loaded); });
29
+ set(loaded); }, () => { });
30
30
  return () => { live = false; };
31
31
  });
32
32
  }
package/dist/types.d.ts CHANGED
@@ -8,6 +8,10 @@ export interface AssetResponse {
8
8
  alt: string;
9
9
  lang?: string;
10
10
  variants: AssetVariant[];
11
+ /** How a layout's box of another shape holds the asset, as CSS object-fit: "contain" or "cover". */
12
+ objectFit?: string;
13
+ /** Where it sits in the box, as CSS object-position: "50% 35%", the point a cover crop keeps in view. */
14
+ objectPosition?: string;
11
15
  }
12
16
  /** Value fields are plain strings, not unions, so new values from the API never break decoding (SPEC.md 14). */
13
17
  export interface AssetVariant {
package/dist/vue.d.ts CHANGED
@@ -6,8 +6,8 @@ export declare const bruaKey: InjectionKey<Brua>;
6
6
  /** Where the app's assets are read from by id: call once in the root component, then <BruaPicture id="…"> anywhere below. */
7
7
  export declare function provideBrua(options: Brua | BruaOptions): Brua;
8
8
  /**
9
- * The asset's current release for an id, from the provided Brua or the one given: undefined until it has loaded,
10
- * and when nothing is released under the id. A server render shows the fallback.
9
+ * The asset's current release for an id, from the provided Brua or the one given: the last release it read at once, else
10
+ * undefined until it has loaded, and when nothing is released under the id. A server render shows the fallback.
11
11
  */
12
12
  export declare function useBruaAsset(id: MaybeRefOrGetter<string | undefined>, brua?: Brua): Ref<AssetResponse | undefined>;
13
13
  /**
@@ -45,6 +45,16 @@ export declare const BruaPicture: import("vue").DefineComponent<import("vue").Ex
45
45
  type: PropType<"light" | "dark">;
46
46
  required: false;
47
47
  };
48
+ /** "contain" or "cover" to override the asset's fit, or "none" to leave the fit to the page's CSS. */
49
+ objectFit: {
50
+ type: PropType<"contain" | "cover" | "none">;
51
+ required: false;
52
+ };
53
+ /** Where the img sits in its box, as "x% y%", instead of the asset's position. */
54
+ objectPosition: {
55
+ type: StringConstructor;
56
+ required: false;
57
+ };
48
58
  }>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
49
59
  [key: string]: any;
50
60
  }> | import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
@@ -79,6 +89,16 @@ export declare const BruaPicture: import("vue").DefineComponent<import("vue").Ex
79
89
  type: PropType<"light" | "dark">;
80
90
  required: false;
81
91
  };
92
+ /** "contain" or "cover" to override the asset's fit, or "none" to leave the fit to the page's CSS. */
93
+ objectFit: {
94
+ type: PropType<"contain" | "cover" | "none">;
95
+ required: false;
96
+ };
97
+ /** Where the img sits in its box, as "x% y%", instead of the asset's position. */
98
+ objectPosition: {
99
+ type: StringConstructor;
100
+ required: false;
101
+ };
82
102
  }>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
83
103
  /**
84
104
  * One variant URL for the asset, chosen for this browser and kept current as its appearance, motion setting
package/dist/vue.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Brua for Vue 3: provideBrua, <BruaPicture>, useBruaAsset and useBruaUrl. Import from "@meginhufr/brua-web/vue".
2
2
  import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, provide, ref, toValue, unref, watchEffect, } from "vue";
3
- import { pictureModel } from "./picture.js";
3
+ import { fillStyle, pictureModel } from "./picture.js";
4
4
  import { selectUrl } from "./select.js";
5
5
  import { defaultClientState, watchClientState } from "./client.js";
6
6
  import { createBrua } from "./brua.js";
@@ -12,8 +12,8 @@ export function provideBrua(options) {
12
12
  return brua;
13
13
  }
14
14
  /**
15
- * The asset's current release for an id, from the provided Brua or the one given: undefined until it has loaded,
16
- * and when nothing is released under the id. A server render shows the fallback.
15
+ * The asset's current release for an id, from the provided Brua or the one given: the last release it read at once, else
16
+ * undefined until it has loaded, and when nothing is released under the id. A server render shows the fallback.
17
17
  */
18
18
  export function useBruaAsset(id, brua) {
19
19
  const client = brua ?? inject(bruaKey, undefined);
@@ -28,8 +28,9 @@ export function useBruaAsset(id, brua) {
28
28
  throw new Error("useBruaAsset needs provideBrua() in a parent, or a Brua passed in.");
29
29
  let live = true;
30
30
  onCleanup(() => { live = false; });
31
+ asset.value = client.cachedAsset(wanted);
31
32
  client.asset(wanted).then((loaded) => { if (live)
32
- asset.value = loaded; });
33
+ asset.value = loaded; }, () => { });
33
34
  });
34
35
  return asset;
35
36
  }
@@ -53,6 +54,10 @@ export const BruaPicture = defineComponent({
53
54
  loading: { type: String, required: false },
54
55
  /** "light" or "dark" to show that scheme's files only, for apps that switch themes themselves. */
55
56
  colorScheme: { type: String, required: false },
57
+ /** "contain" or "cover" to override the asset's fit, or "none" to leave the fit to the page's CSS. */
58
+ objectFit: { type: String, required: false },
59
+ /** Where the img sits in its box, as "x% y%", instead of the asset's position. */
60
+ objectPosition: { type: String, required: false },
56
61
  },
57
62
  setup(props, { attrs, slots }) {
58
63
  const fetched = useBruaAsset(() => (props.asset ? undefined : props.id), props.brua);
@@ -60,7 +65,9 @@ export const BruaPicture = defineComponent({
60
65
  const asset = props.asset ?? fetched.value;
61
66
  if (!asset)
62
67
  return slots.default?.() ?? null;
63
- const { sources, img } = pictureModel(asset, { sizes: props.sizes, loading: props.loading, colorScheme: props.colorScheme });
68
+ const { sources, img } = pictureModel(asset, {
69
+ sizes: props.sizes, loading: props.loading, colorScheme: props.colorScheme, objectFit: props.objectFit, objectPosition: props.objectPosition,
70
+ });
64
71
  return h("picture", [
65
72
  ...sources.map((s) => h("source", { key: `${s.type}${s.media ?? ""}`, type: s.type, media: s.media, srcset: s.srcset, sizes: s.sizes })),
66
73
  h("img", {
@@ -74,6 +81,8 @@ export const BruaPicture = defineComponent({
74
81
  lang: img.lang,
75
82
  loading: img.loading,
76
83
  decoding: img.decoding,
84
+ // The fill first, so a style the page gives the component still wins.
85
+ style: [fillStyle(img), attrs.style],
77
86
  }),
78
87
  ]);
79
88
  };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@meginhufr/brua-web",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Brua for the web: AssetResponse types, the variant selection rule, <picture> rendering, and components for React, Vue and Svelte.",
5
5
  "author": "Meginhufr AS <hello@brua.dev> (https://brua.dev)",
6
6
  "license": "MIT",
7
7
  "homepage": "https://brua.dev",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/Meginhufr/brua-web.git"
10
+ "url": "git+https://github.com/Meginhufr/brua-web.git"
11
11
  },
12
12
  "type": "module",
13
13
  "sideEffects": false,
@@ -36,7 +36,7 @@
36
36
  ],
37
37
  "scripts": {
38
38
  "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/BruaPicture.svelte dist/",
39
- "test": "node --test test/"
39
+ "test": "node --test \"test/**/*.test.ts\""
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public",