`;
+}
+
+/**
+ * Every size × state combination from the Figma spec sheet, laid out as a matrix.
+ *
+ * `:hover` and `:focus` are re-declared against `[data-demo-state]` rather than
+ * triggered for real — a static grid can't hold six hovers and three focuses at
+ * once, and Chromatic snapshots can't hover at all. The demo rules deliberately
+ * read the SAME `--osui-input-*` CSS API vars the real `:hover` / `:focus` rules
+ * read, so they cannot drift from the component's actual values.
+ */
+export const SpecMatrix: Story = {
+ name: 'Spec matrix (size × state)',
+ parameters: { controls: { disable: true } },
+ render: () =>
+ renderStatic(`
+
+
`),
+};
diff --git a/stories/LightboxImage.stories.ts b/stories/LightboxImage.stories.ts
index 779dab6a86..6c583f5b2d 100644
--- a/stories/LightboxImage.stories.ts
+++ b/stories/LightboxImage.stories.ts
@@ -1,40 +1,222 @@
import type { Meta, StoryObj } from '@storybook/html-vite';
-import { renderStatic } from './_helpers/osui';
+import { renderPattern, type Register } from './_helpers/osui';
import { cls, extendedClassArgType } from './_helpers/lowcode';
/**
- * Lightbox Image — thumbnail markup only. At runtime the block opens the image
- * with PhotoSwipe (`.pswp` overrides in
- * src/scss/04-patterns/03-interaction/_lightbox-image.scss); the vendor lib is
- * not bundled in this library nor loaded in Storybook, so the story shows the
- * shipped thumbnail structure: `.lightbox-item > a > img`.
+ * Lightbox Image.
+ *
+ * There is no `LightboxImage` TypeScript pattern — the block is low-code only, and
+ * the overlay is rendered by **PhotoSwipe 4.1.0**, the version the OutSystems
+ * platform ships. It is a devDependency here purely so these stories can drive the
+ * real thing (served at /vendor/photoswipe, loaded in preview-head.html BEFORE the
+ * OUI theme so the OUI overrides win — that mirrors the platform's load order).
+ *
+ * What OUI owns is two slices of CSS:
+ * 1. thumbnail + Service Studio preview classes
+ * (src/scss/04-patterns/03-interaction/_lightbox-image.scss) — safe areas on
+ * `.pswp__top-bar`, RTL counter flip, focus ring on the thumbnail link
+ * 2. the overlay CHROME ICONS — `.pswp__button` sprites are stripped and replaced
+ * with icon-font glyphs (src/scss/01-foundations/_icon-library-odc.scss:521-552)
+ *
+ * (2) is what `Overlay chrome` exercises.
*/
-interface LightboxImageArgs {
+/**
+ * Fixture images are generated SVG, not fetched.
+ *
+ * Two reasons, both learned the hard way:
+ * • **Size matters to the chrome.** PhotoSwipe only adds `pswp--zoom-allowed` (the
+ * class that reveals the zoom button, per default-skin.css) when the image is
+ * LARGER than its fit size. And the arrows' translucent square is only visible
+ * where it overlaps image content — over the black backdrop it is invisible by
+ * definition. So the fixture must be big and wide enough to reach the viewport's
+ * left/right edges, or the design's chrome cannot be evaluated at all.
+ * • **No network.** Remote images made Chromatic and headless runs non-deterministic.
+ */
+// Wider than 16:9 on purpose. PhotoSwipeUI_Default reserves `barsSize` (44px top and
+// an auto bottom) out of the fit area, so a 16:9 image in a 16:9 viewport still ends up
+// letterboxed left/right — which parks the arrows over the backdrop instead of the image
+// and hides their square. ~2.1:1 fills the width at common viewport sizes, matching the
+// design frame (black bars above/below, image edge to edge).
+const W = 2560;
+const H = 1200;
+const COUNT = 4;
+
+/** A wide, deterministic photo stand-in. Mid-tone so the 25%-black arrow square reads. */
+function photo(i: number): string {
+ const hue = 190 + i * 34;
+ const svg =
+ ``;
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
+}
+
+const IMAGES = Array.from({ length: COUNT }, (_, i) => photo(i));
+
+/** PhotoSwipe v4 requires this exact skeleton to be present in the DOM before init. */
+const PSWP_TEMPLATE = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+interface PswpChrome {
+ counterEl: boolean;
+ zoomEl: boolean;
+ shareEl: boolean;
+ fullscreenEl: boolean;
+}
+
+/**
+ * Construct + open a gallery on the `.pswp` element inside `root`.
+ *
+ * `history: false` is not optional — with the default PhotoSwipe writes `#&gid=…` to
+ * the URL, which fights Storybook's own routing and leaves the manager stuck on a
+ * stale story after the overlay closes.
+ */
+function openGallery(root: HTMLElement, index: number, chrome: PswpChrome, register: Register, captions = false): void {
+ const { PhotoSwipe, PhotoSwipeUI_Default } = window;
+ if (!PhotoSwipe || !PhotoSwipeUI_Default) {
+ throw new Error('PhotoSwipe globals missing — is /vendor/photoswipe loaded in preview-head.html?');
+ }
+
+ const pswpEl = root.querySelector('.pswp');
+ if (!pswpEl) throw new Error('.pswp template not found in the story root');
+
+ // `title` is what populates `.pswp__caption__center`. The design has no caption, so
+ // the chrome story opts out; `Default` keeps it to exercise the caption rule OUI
+ // carries for the phone safe area (_lightbox-image.scss).
+ const items = IMAGES.map((src, i) => ({ src, w: W, h: H, ...(captions ? { title: `Image ${i + 1}` } : {}) }));
+ const gallery = new PhotoSwipe(pswpEl, PhotoSwipeUI_Default, items, {
+ index,
+ history: false, // see note above
+ bgOpacity: 1,
+ showHideOpacity: false,
+ closeOnScroll: false,
+ ...chrome,
+ });
+ gallery.init();
+ register(() => gallery.close());
+}
+
+const chromeArgTypes = {
+ counterEl: {
+ name: 'counterEl',
+ control: 'boolean',
+ description: 'PhotoSwipe UI option — show the "n / total" counter.',
+ },
+ zoomEl: { name: 'zoomEl', control: 'boolean', description: 'PhotoSwipe UI option — show the zoom button.' },
+ shareEl: {
+ name: 'shareEl',
+ control: 'boolean',
+ description: 'PhotoSwipe UI option — show the share button (not in the design).',
+ },
+ fullscreenEl: {
+ name: 'fullscreenEl',
+ control: 'boolean',
+ description: 'PhotoSwipe UI option — show the fullscreen button (not in the design).',
+ },
+} as const;
+
+/* ── Thumbnail ──────────────────────────────────────────────────────────────── */
+
+interface ThumbnailArgs extends PswpChrome {
thumbnailWidth: number;
extendedClass: string;
}
-const IMG = 'https://picsum.photos/seed/osui-lightbox/960/640';
+const meta: Meta = { title: 'Patterns/Interaction/LightboxImage' };
+export default meta;
-const meta: Meta = {
- title: 'Patterns/Interaction/LightboxImage',
+/**
+ * The shipped thumbnail markup (`.lightbox-item > a > img`), wired to a real gallery
+ * so the whole thumbnail → overlay journey is exercised. Click any image to open.
+ */
+export const Default: StoryObj = {
argTypes: {
thumbnailWidth: { name: 'ThumbnailWidth (px)', control: { type: 'number', min: 80, max: 640 } },
extendedClass: extendedClassArgType,
+ ...chromeArgTypes,
+ },
+ args: {
+ thumbnailWidth: 220,
+ extendedClass: '',
+ counterEl: true,
+ zoomEl: true,
+ shareEl: false,
+ fullscreenEl: false,
},
- args: { thumbnailWidth: 240, extendedClass: '' },
+ render: ({ thumbnailWidth, extendedClass, ...chrome }) =>
+ renderPattern(
+ `
+