@hubpav/hwio-ui 0.8.0 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubpav/hwio-ui",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "HARDWARIO web design system for Astro: DaisyUI 5 themes generated from the brand tokens, the Tailwind entry stylesheet and the HWio* components.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,9 +32,9 @@
32
32
  "test": "node --test \"test/**/*.test.mjs\""
33
33
  },
34
34
  "dependencies": {
35
- "@hubpav/hwio-brand": "0.8.0",
36
- "@hubpav/hwio-web-runtime": "0.8.0",
37
- "@hubpav/hwio-web-worker": "0.8.0",
35
+ "@hubpav/hwio-brand": "0.9.1",
36
+ "@hubpav/hwio-web-runtime": "0.9.1",
37
+ "@hubpav/hwio-web-worker": "0.9.1",
38
38
  "daisyui": "5.7.47"
39
39
  },
40
40
  "peerDependencies": {
@@ -0,0 +1,49 @@
1
+ ---
2
+ /**
3
+ * Application tile: a picture, a title of three or four words and one line, optionally the references
4
+ * that run it. The whole tile links to the application page (its title underlines on hover and focus).
5
+ * On phones the tile becomes a row (thumbnail beside the text), so a set of eight stays one short list.
6
+ * `cutout` shows a product cut-out whole on the Panel ground (with an optional isometric `backdrop`)
7
+ * instead of a photograph. `reveal` fades the tile in once in view (needs HWioReveal on the page);
8
+ * `revealDelay` (ms) staggers a row.
9
+ */
10
+ import type { HWioBackdrop, HWioMedia, HWioTrack } from '../types';
11
+ import HWioCutout from './HWioCutout.astro';
12
+ import HWioPhoto from './HWioPhoto.astro';
13
+ interface Props {
14
+ href: string;
15
+ title: string;
16
+ line: string;
17
+ image: HWioMedia;
18
+ cutout?: boolean;
19
+ backdrop?: HWioBackdrop;
20
+ /** Names of the customers or projects that run it, shown under the line, separated by middle dots. */
21
+ references?: string[];
22
+ headingLevel?: 'h2' | 'h3' | 'h4';
23
+ track?: HWioTrack;
24
+ reveal?: boolean;
25
+ revealDelay?: number;
26
+ eager?: boolean;
27
+ class?: string;
28
+ }
29
+ const { href, title, line, image, cutout = false, backdrop, references = [], headingLevel = 'h3', track, reveal = false, revealDelay, eager = false, class: className = '' } = Astro.props;
30
+ const Heading = headingLevel;
31
+ const hover = 'transition-transform duration-300 ease-out group-hover:scale-[1.02] motion-reduce:transition-none';
32
+ ---
33
+ <a
34
+ href={href}
35
+ class:list={['hwio-card-link group grid grid-cols-[6rem_minmax(0,1fr)] content-start items-center gap-4 text-base-content sm:grid-cols-1 sm:items-start sm:gap-5', className]}
36
+ data-track={track?.id}
37
+ data-track-location={track?.location}
38
+ data-hwio-reveal={reveal ? 'true' : undefined}
39
+ data-hwio-reveal-delay={reveal && revealDelay ? String(revealDelay) : undefined}
40
+ >
41
+ {cutout
42
+ ? <HWioCutout image={image} aspect="4/3" rounded="xl" inset="lg" backdrop={backdrop} eager={eager} imgClass={hover} />
43
+ : <HWioPhoto image={image} aspect="4/3" rounded="xl" eager={eager} imgClass={hover} />}
44
+ <div class="min-w-0">
45
+ <Heading class="hwio-role-title hwio-card-title">{title}</Heading>
46
+ <p class="hwio-muted mt-1.5 text-[0.9375rem] leading-[1.5]">{line}</p>
47
+ {references.length > 0 && <p class="hwio-muted mt-3 text-[0.8125rem] leading-[1.4]">{references.join(' · ')}</p>}
48
+ </div>
49
+ </a>
@@ -4,6 +4,9 @@ import HWioIcon from './HWioIcon.astro';
4
4
  /**
5
5
  * Link or button on DaisyUI `btn`. Labels are slot content (sites own their copy; owner rule:
6
6
  * imperative verb, no article). `track` writes the measurement attributes data-track / data-track-location.
7
+ * `arrow` (the page's primary call to action only) adds an arrow that nudges 3 px on hover and focus
8
+ * (.hwio-arrow; no nudge under reduced motion). An external link shows the external glyph after the label
9
+ * unless `externalIcon={false}` (HWioHeader's icon-only CTAs place it beside the label themselves).
7
10
  */
8
11
  interface Props {
9
12
  href?: string;
@@ -12,6 +15,8 @@ interface Props {
12
15
  size?: 'sm' | 'md' | 'lg' | 'wide';
13
16
  track?: HWioTrack;
14
17
  arrow?: boolean;
18
+ /** Show the external-link glyph after the label of an external link (default true). */
19
+ externalIcon?: boolean;
15
20
  type?: 'button' | 'submit';
16
21
  class?: string;
17
22
  [key: string]: unknown;
@@ -25,19 +30,19 @@ const VARIANT = {
25
30
  link: 'btn btn-link',
26
31
  } as const;
27
32
  const SIZE = { sm: 'btn-sm', md: '', lg: 'btn-lg', wide: 'btn-wide' } as const;
28
- const { href, external = false, variant = 'primary', size = 'md', track, arrow = false, type = 'button', class: className = '', ...rest } = Astro.props;
33
+ const { href, external = false, variant = 'primary', size = 'md', track, arrow = false, externalIcon = true, type = 'button', class: className = '', ...rest } = Astro.props;
29
34
  const classes = [VARIANT[variant], SIZE[size], className];
30
35
  const trackAttrs = track ? { 'data-track': track.id, 'data-track-location': track.location } : {};
31
36
  ---
32
37
  {href ? (
33
38
  <a href={href} class:list={classes} target={external ? '_blank' : undefined} rel={external ? 'noopener noreferrer' : undefined} {...trackAttrs} {...rest}>
34
39
  <slot />
35
- {arrow && <HWioIcon name="arrow-right" size="sm" />}
36
- {external && !arrow && <HWioIcon name="external" size="sm" />}
40
+ {arrow && <HWioIcon name="arrow-right" size="sm" class="hwio-arrow" />}
41
+ {external && externalIcon && !arrow && <HWioIcon name="external" size="sm" />}
37
42
  </a>
38
43
  ) : (
39
44
  <button type={type} class:list={classes} {...trackAttrs} {...rest}>
40
45
  <slot />
41
- {arrow && <HWioIcon name="arrow-right" size="sm" />}
46
+ {arrow && <HWioIcon name="arrow-right" size="sm" class="hwio-arrow" />}
42
47
  </button>
43
48
  )}
@@ -0,0 +1,58 @@
1
+ ---
2
+ /**
3
+ * A product cut-out (transparent ground) shown whole in a fixed frame, with the one shadow recipe every
4
+ * product carries (.hwio-product-shadow: a tight contact shadow plus a soft ambient one; owner,
5
+ * 2026-10-30). `baseline` stands it on the frame's common bottom line with a blurred floor ellipse, so
6
+ * a row of products lines up as one family. `ground="panel"` puts it on the Panel ground (cards,
7
+ * tiles), `plain` on the page. `backdrop` places it in front of a quiet isometric room in light tones
8
+ * (the artwork library's backdrops, with their dark-theme drawing) when no field photograph exists.
9
+ */
10
+ import type { HWioAspect, HWioBackdrop, HWioMedia } from '../types';
11
+ interface Props {
12
+ image: HWioMedia;
13
+ aspect?: HWioAspect;
14
+ ground?: 'panel' | 'plain';
15
+ baseline?: boolean;
16
+ /** Air around the product inside the frame: sm 8 %, md 10 %, lg 12 % of the frame width. */
17
+ inset?: 'sm' | 'md' | 'lg';
18
+ backdrop?: HWioBackdrop;
19
+ rounded?: 'none' | 'xl' | '2xl';
20
+ eager?: boolean;
21
+ class?: string;
22
+ imgClass?: string;
23
+ }
24
+ const { image, aspect = '1/1', ground = 'panel', baseline = false, inset = 'md', backdrop, rounded = '2xl', eager = false, class: className = '', imgClass = '' } = Astro.props;
25
+ const ASPECT = { '1/1': 'aspect-square', '4/3': 'aspect-[4/3]', '3/2': 'aspect-[3/2]', '16/9': 'aspect-video', '4/5': 'aspect-[4/5]', '3/4': 'aspect-[3/4]' } as const;
26
+ const ROUNDED = { none: 'rounded-none', xl: 'rounded-xl', '2xl': 'rounded-2xl' } as const;
27
+ const PAD = { sm: 'p-[8%]', md: 'p-[10%]', lg: 'p-[12%]' } as const;
28
+ // On a baseline the product sits 6 % higher than its inset, above the floor shadow, aligned to the bottom.
29
+ const PAD_BASELINE = { sm: 'px-[8%] pt-[8%] pb-[14%] object-bottom', md: 'px-[10%] pt-[10%] pb-[16%] object-bottom', lg: 'px-[12%] pt-[12%] pb-[18%] object-bottom' } as const;
30
+ const FLOOR = { sm: 'bottom-[10.5%]', md: 'bottom-[12.5%]', lg: 'bottom-[14.5%]' } as const;
31
+ // In front of a room the product stands a little smaller, the same on every such tile.
32
+ const PAD_BACKDROP = 'pt-[14%] px-[18%] pb-[10%]';
33
+ const BACKDROP_PAD_BASELINE = 'pt-[14%] px-[18%] pb-[10%] object-bottom';
34
+ const padding = backdrop ? (baseline ? BACKDROP_PAD_BASELINE : PAD_BACKDROP) : baseline ? PAD_BASELINE[inset] : PAD[inset];
35
+ ---
36
+ <div class:list={['relative overflow-hidden', ASPECT[aspect], ROUNDED[rounded], ground === 'panel' && 'hwio-cutout-ground', className]}>
37
+ {backdrop && (backdrop.dark ? (
38
+ <>
39
+ <img src={backdrop.light} alt="" aria-hidden="true" loading={eager ? 'eager' : 'lazy'} decoding="async" class="hwio-backdrop hwio-backdrop-light" />
40
+ <img src={backdrop.dark} alt="" aria-hidden="true" loading="lazy" decoding="async" class="hwio-backdrop hwio-backdrop-dark" />
41
+ </>
42
+ ) : (
43
+ <img src={backdrop.light} alt="" aria-hidden="true" loading={eager ? 'eager' : 'lazy'} decoding="async" class="hwio-backdrop" />
44
+ ))}
45
+ <img
46
+ src={image.src}
47
+ srcset={image.srcset}
48
+ sizes={image.sizes}
49
+ alt={image.alt}
50
+ width={image.width}
51
+ height={image.height}
52
+ loading={eager ? 'eager' : 'lazy'}
53
+ decoding="async"
54
+ fetchpriority={eager ? 'high' : undefined}
55
+ class:list={['hwio-product-shadow absolute inset-0 z-[1] h-full w-full object-contain', padding, imgClass]}
56
+ />
57
+ {baseline && <span class:list={['hwio-product-floor', FLOOR[inset]]} aria-hidden="true"></span>}
58
+ </div>
@@ -2,6 +2,8 @@
2
2
  /**
3
3
  * Site footer on the neutral surface: social bar, logo slot, link columns, branches, legal row
4
4
  * with the cookie-settings button, the cross-site strip, copyright and tagline. All strings are props.
5
+ * The bottom line (owner, 2026-10-30) centres the copyright and the tagline together, a middle dot
6
+ * between them; on phones they stack, centred, without the dot.
5
7
  */
6
8
  import type { HWioLinkItem } from '../types';
7
9
  import HWioIcon from './HWioIcon.astro';
@@ -90,9 +92,10 @@ const linkClass = 'inline-flex items-center gap-1 text-neutral-content/80 transi
90
92
  )}
91
93
 
92
94
  <div class="border-t border-neutral-content/10">
93
- <div class="container flex flex-col items-center justify-between gap-4 py-6 text-sm opacity-80 md:flex-row">
95
+ <div class="container flex flex-col items-center justify-center gap-1 py-6 text-center text-sm opacity-80 md:flex-row md:gap-0">
94
96
  <p>{copyright}</p>
95
- {tagline && <p class="text-center">{tagline}</p>}
97
+ {tagline && <span aria-hidden="true" class="mx-3 hidden md:inline">·</span>}
98
+ {tagline && <p>{tagline}</p>}
96
99
  </div>
97
100
  </div>
98
101
  </footer>
@@ -9,6 +9,11 @@
9
9
  * trigger on close, Escape closes). Below `sm` the bar drops the language dropdown (the drawer carries the
10
10
  * pills), so logo, theme toggle and the 48 px trigger fit a 320 px viewport without horizontal overflow.
11
11
  *
12
+ * The theme toggle and the language dropdown are quiet utilities (.hwio-utility: the nav's weight, size
13
+ * and gray, thin icons). The language dropdown, the mega panels and the drawer close each other: one
14
+ * popup at a time (@hubpav/hwio-web-runtime/popup). A CTA with an icon collapses to the icon alone below
15
+ * 2xl; an external CTA's arrow shows only beside its full label.
16
+ *
12
17
  * `navDensity="tight"` with `desktopFrom="xl"`: between 1280 and 1535 px, where the container is still
13
18
  * 1280 px wide, the nav labels drop from 15 to 14 px and the item padding from 12 to 8 px, so a long nav
14
19
  * plus CTAs (SK/PL/CS labels) fits without sideways scroll. From 1536 px the nav is back to default.
@@ -73,13 +78,15 @@ const linkAttrs = (item: HWioLinkItem) => (item.external ? { target: '_blank', r
73
78
  </nav>
74
79
 
75
80
  <div class="ml-auto flex items-center gap-2">
76
- {showThemeToggle && <HWioThemeToggle label={labels.theme ?? 'Theme'} />}
77
- {languages.length > 1 && <HWioLanguageSwitcher items={languages} label={labels.languages ?? 'Language'} class="max-sm:hidden" />}
81
+ {showThemeToggle && <HWioThemeToggle label={labels.theme ?? 'Theme'} class="hwio-utility" />}
82
+ {languages.length > 1 && <HWioLanguageSwitcher items={languages} label={labels.languages ?? 'Language'} class="hwio-utility max-sm:hidden" />}
78
83
  <div class={bp.ctas}>
79
84
  {ctas.map((cta) => (
80
- <HWioButton href={cta.href} external={cta.external} variant={CTA_VARIANT[cta.variant ?? 'primary']} size="sm" track={cta.track ?? { id: 'header-cta', location: 'header' }} class={cta.icon ? '2xl:px-4 px-2.5' : ''} aria-label={cta.icon ? cta.label : undefined} title={cta.icon ? cta.label : undefined}>
85
+ <HWioButton href={cta.href} external={cta.external} externalIcon={!cta.icon} variant={CTA_VARIANT[cta.variant ?? 'primary']} size="sm" track={cta.track ?? { id: 'header-cta', location: 'header' }} class={cta.icon ? '2xl:px-4 px-2.5' : ''} aria-label={cta.icon ? cta.label : undefined} title={cta.icon ? cta.label : undefined}>
81
86
  {cta.icon && <HWioIcon name={cta.icon as 'bag'} size="sm" />}
82
87
  {cta.icon ? <span class="hidden 2xl:inline">{cta.label}</span> : cta.label}
88
+ {/* Collapsed (icon only, below 2xl) the button shows just its icon; the external glyph returns beside the label. */}
89
+ {cta.icon && cta.external && <HWioIcon name="external" size="sm" class="hidden 2xl:block" />}
83
90
  </HWioButton>
84
91
  ))}
85
92
  </div>
@@ -110,7 +117,7 @@ const linkAttrs = (item: HWioLinkItem) => (item.external ? { target: '_blank', r
110
117
  {/* One grid cell: DaisyUI lays menu items out as a column-flow grid, so the two lines nest in one span. */}
111
118
  <span class="flex flex-col items-start gap-0.5">
112
119
  <span class="inline-flex items-center gap-1">{child.label}{child.external && <HWioIcon name="external" size="sm" class="opacity-50" />}</span>
113
- {child.description && <span class="text-xs font-normal leading-snug opacity-70">{child.description}</span>}
120
+ {child.description && <span class="hwio-menu-desc">{child.description}</span>}
114
121
  </span>
115
122
  </a>
116
123
  </li>
@@ -1,17 +1,24 @@
1
1
  ---
2
- /** Two-column hero (direction A): kicker, headline with optional accent line, lead, actions, checklist; media on a soft sky glow. */
2
+ /**
3
+ * Two-column hero (direction A): kicker, headline with optional accent line, lead, actions, checklist; media on a soft sky glow.
4
+ * `titleSize="long"` sets a long opening statement (more than about eight words) at 36 to 52 px so it reads in three
5
+ * calm lines (.hwio-h1-long, change request CR-7, pending the Brand Guidelines). Up to two key words of the title may
6
+ * carry the figure gradient through the `title` slot (<span class="hwio-grad">, change request CR-8), instead of a key
7
+ * figure on the same page. `enter` eases the text column and then the media in once the font gate has shown the page.
8
+ */
3
9
  import type { HWioCta } from '../types';
4
10
  import HWioButton from './HWioButton.astro';
5
11
  import HWioIcon from './HWioIcon.astro';
6
- interface Props { id?: string; eyebrow?: string; title: string; accent?: string; lead?: string; actions?: HWioCta[]; checklist?: string[]; reverse?: boolean; glow?: boolean; class?: string }
7
- const { id, eyebrow, title, accent, lead, actions = [], checklist = [], reverse = false, glow = true, class: className = '' } = Astro.props;
12
+ interface Props { id?: string; eyebrow?: string; title: string; accent?: string; lead?: string; actions?: HWioCta[]; checklist?: string[]; reverse?: boolean; glow?: boolean; titleSize?: 'default' | 'long'; enter?: boolean; class?: string }
13
+ const { id, eyebrow, title, accent, lead, actions = [], checklist = [], reverse = false, glow = true, titleSize = 'default', enter = false, class: className = '' } = Astro.props;
14
+ const TITLE_SIZE = { default: '', long: 'hwio-h1-long' } as const;
8
15
  const VARIANT = { primary: 'primary', secondary: 'secondary', ghost: 'ghost' } as const;
9
16
  ---
10
17
  <section id={id} class:list={['bg-base-100 text-base-content', className]}>
11
18
  <div class:list={['container grid grid-cols-1 items-center gap-12 py-16 lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)] lg:gap-16 lg:py-24', reverse ? 'lg:[&>*:first-child]:order-2' : '']}>
12
- <div>
19
+ <div class:list={[enter && 'hwio-enter']}>
13
20
  {eyebrow && <p class="hwio-kicker mb-5">{eyebrow}</p>}
14
- <h1><slot name="title"><span class="block">{title}</span>{accent && <span class="block text-accent">{accent}</span>}</slot></h1>
21
+ <h1 class:list={[TITLE_SIZE[titleSize]]}><slot name="title"><span class="block">{title}</span>{accent && <span class="block text-accent">{accent}</span>}</slot></h1>
15
22
  {lead && <p class="hwio-lead mt-7 max-w-[37.5rem] text-xl">{lead}</p>}
16
23
  {actions.length > 0 && (
17
24
  <div class="mt-9 flex flex-wrap gap-3">
@@ -25,7 +32,7 @@ const VARIANT = { primary: 'primary', secondary: 'secondary', ghost: 'ghost' } a
25
32
  )}
26
33
  <slot name="extra" />
27
34
  </div>
28
- <div class:list={['relative flex items-center justify-center', glow ? 'hwio-hero-glow aspect-square rounded-3xl' : '']}><slot name="media" /></div>
35
+ <div class:list={['relative flex items-center justify-center', glow ? 'hwio-hero-glow aspect-square rounded-3xl' : '', enter && 'hwio-enter-media']}><slot name="media" /></div>
29
36
  </div>
30
37
  </section>
31
38
  <style>
@@ -0,0 +1,93 @@
1
+ ---
2
+ /**
3
+ * An isometric scene drawn with the Brand Guidelines' kit (the artwork library's illustrations/isometric,
4
+ * `make isometric`), inlined so CSS and the runtime reach its parts. Isometric drawings explain;
5
+ * photographs prove. Pass the light-theme SVG as a string (`import scene from './scene.svg?raw'`) and a
6
+ * description for screen readers; the scene carries no text of its own.
7
+ *
8
+ * Motion (@hubpav/hwio-web-runtime/iso): in view, the scene builds itself once: `.iso-object` groups
9
+ * settle in drawing order, then each `.iso-trace` draws from pad to pad and the `.iso-radio` links fade
10
+ * in. Without JavaScript or under reduced motion it shows complete. `animate={false}` keeps it still.
11
+ *
12
+ * Dark theme: the kit's -dark-theme.svg is the same drawing with the plate neutrals and the link colours
13
+ * swapped (verified byte for byte on 2026-10-30), so one inlined copy and the swaps below render both.
14
+ * A drawing outside the kit's palette keeps its colours in dark mode.
15
+ */
16
+ interface Props {
17
+ svg: string;
18
+ label: string;
19
+ animate?: boolean;
20
+ class?: string;
21
+ }
22
+ const { svg, label, animate = true, class: className = '' } = Astro.props;
23
+ const escapeAttr = (text: string) => text.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
24
+ const markup = svg
25
+ .replace(/^\s*<\?xml[^>]*>\s*/, '')
26
+ .replace(/<svg\b([^>]*?)\sdata-theme="[^"]*"/, '<svg$1')
27
+ .replace(/<svg\b/, `<svg role="img" aria-label="${escapeAttr(label)}"`);
28
+ ---
29
+ <div class:list={['hwio-iso-frame', className]} data-hwio-iso={animate ? 'true' : undefined} set:html={markup} />
30
+ <style is:global>
31
+ .hwio-iso-frame svg {
32
+ display: block;
33
+ width: 100%;
34
+ height: auto;
35
+ overflow: visible;
36
+ }
37
+
38
+ /* Dark theme: the kit's -dark-theme.svg colours (plate neutrals, link Red and Gray). */
39
+ [data-theme$='-dark'] .hwio-iso-frame [fill='#dcdcdc'] { fill: #1d1d26; }
40
+ [data-theme$='-dark'] .hwio-iso-frame [stroke='#dcdcdc'] { stroke: #1d1d26; }
41
+ [data-theme$='-dark'] .hwio-iso-frame [fill='#dddddd'] { fill: #1e1e27; }
42
+ [data-theme$='-dark'] .hwio-iso-frame [stroke='#dddddd'] { stroke: #1e1e27; }
43
+ [data-theme$='-dark'] .hwio-iso-frame [fill='#e0e0e0'] { fill: #1f1f2a; }
44
+ [data-theme$='-dark'] .hwio-iso-frame [stroke='#e0e0e0'] { stroke: #1f1f2a; }
45
+ [data-theme$='-dark'] .hwio-iso-frame [fill='#e1e1e1'] { fill: #20202b; }
46
+ [data-theme$='-dark'] .hwio-iso-frame [stroke='#e1e1e1'] { stroke: #20202b; }
47
+ [data-theme$='-dark'] .hwio-iso-frame [fill='#e3e3e3'] { fill: #22222d; }
48
+ [data-theme$='-dark'] .hwio-iso-frame [stroke='#e3e3e3'] { stroke: #22222d; }
49
+ [data-theme$='-dark'] .hwio-iso-frame [fill='#e4e4e4'] { fill: #23232e; }
50
+ [data-theme$='-dark'] .hwio-iso-frame [stroke='#e4e4e4'] { stroke: #23232e; }
51
+ [data-theme$='-dark'] .hwio-iso-frame [fill='#e7e7e7'] { fill: #242431; }
52
+ [data-theme$='-dark'] .hwio-iso-frame [stroke='#e7e7e7'] { stroke: #242431; }
53
+ [data-theme$='-dark'] .hwio-iso-frame [fill='#e8e8e8'] { fill: #252532; }
54
+ [data-theme$='-dark'] .hwio-iso-frame [stroke='#e8e8e8'] { stroke: #252532; }
55
+ [data-theme$='-dark'] .hwio-iso-frame [fill='#f7f7f7'] { fill: #2e2e3a; }
56
+ [data-theme$='-dark'] .hwio-iso-frame .iso-link [fill='#e30427'] { fill: #f43f5e; }
57
+ [data-theme$='-dark'] .hwio-iso-frame .iso-link [stroke='#e30427'] { stroke: #f43f5e; }
58
+ [data-theme$='-dark'] .hwio-iso-frame .iso-link [fill='#6b6a6a'] { fill: #8c8b8b; }
59
+ [data-theme$='-dark'] .hwio-iso-frame .iso-link [stroke='#6b6a6a'] { stroke: #8c8b8b; }
60
+
61
+ /* Build states, set by the runtime only when motion is allowed (no JavaScript: the full scene). */
62
+ .hwio-iso-anim .iso-object {
63
+ opacity: 0;
64
+ transform: translateY(4px);
65
+ transform-box: fill-box;
66
+ }
67
+ .hwio-iso-anim g.iso-radio {
68
+ opacity: 0;
69
+ }
70
+ .hwio-iso-anim .iso-trace:not(.iso-radio) {
71
+ /* pathLength="1" on every trace: 1px is the whole path. */
72
+ stroke-dasharray: 1px;
73
+ stroke-dashoffset: 1px;
74
+ }
75
+ .hwio-iso-anim .iso-pad {
76
+ opacity: 0;
77
+ }
78
+ /* Transitions only on play, so hiding the parts at bind time is instant. */
79
+ .hwio-iso-anim.hwio-iso-play .iso-object { opacity: 1; transform: none; transition: opacity 300ms ease-out, transform 300ms ease-out; }
80
+ .hwio-iso-anim.hwio-iso-play g.iso-radio { opacity: 1; transition: opacity 300ms ease-out; }
81
+ .hwio-iso-anim.hwio-iso-play .iso-trace:not(.iso-radio) { stroke-dashoffset: 0px; transition: stroke-dashoffset 700ms ease-out; }
82
+ .hwio-iso-anim.hwio-iso-play .iso-pad { opacity: 1; transition: opacity 200ms ease-out; }
83
+ @media (prefers-reduced-motion: reduce) {
84
+ .hwio-iso-anim .iso-object,
85
+ .hwio-iso-anim g.iso-radio,
86
+ .hwio-iso-anim .iso-pad { opacity: 1; transform: none; transition: none; }
87
+ .hwio-iso-anim .iso-trace:not(.iso-radio) { stroke-dashoffset: 0px; transition: none; }
88
+ }
89
+ </style>
90
+ <script>
91
+ import { hwioIsoRuntime } from '@hubpav/hwio-web-runtime/iso';
92
+ hwioIsoRuntime();
93
+ </script>
@@ -0,0 +1,45 @@
1
+ ---
2
+ /**
3
+ * A figure with its label. `gradient` fills the type with the figure gradient: the one key figure of a
4
+ * page (Brand Guidelines chapter 03, 40 px and up; D33, D44 on the dark ground). Every other figure stays
5
+ * Ink, and so does the key figure on a page whose hero H1 carries gradient words (CR-8). Pass numbers
6
+ * with a non-breaking space before units and in thousands ("7\u00a0000+").
7
+ *
8
+ * `count` counts the figure up once when it enters the viewport (800 ms, ease-out) and ends on the exact
9
+ * `value` text; without JavaScript or under reduced motion the value simply shows.
10
+ */
11
+ interface Props {
12
+ value: string;
13
+ label: string;
14
+ gradient?: boolean;
15
+ /** md 40 px; lg 40 / 48 px; xl 48 / 72 px (from lg). */
16
+ size?: 'md' | 'lg' | 'xl';
17
+ /** Count-up: to, from (default 0), template around the number ("{n}+", "Since {n}"), thousands separator. */
18
+ count?: { to: number; from?: number; template?: string; group?: string };
19
+ class?: string;
20
+ }
21
+ const { value, label, gradient = false, size = 'lg', count, class: className = '' } = Astro.props;
22
+ const SIZE = {
23
+ md: 'text-[2.5rem]',
24
+ lg: 'text-[2.5rem] lg:text-[3rem]',
25
+ xl: 'text-[3rem] lg:text-[4.5rem]',
26
+ } as const;
27
+ const countAttrs = count
28
+ ? {
29
+ 'data-hwio-count': String(count.to),
30
+ 'data-hwio-count-from': count.from === undefined ? undefined : String(count.from),
31
+ 'data-hwio-count-template': count.template,
32
+ 'data-hwio-count-group': count.group,
33
+ }
34
+ : {};
35
+ ---
36
+ <div class:list={['min-w-0', className]}>
37
+ <p class:list={['font-bold leading-[1.2] tracking-[-0.02em] tabular-nums text-balance', SIZE[size]]}>
38
+ <span class:list={[gradient && 'hwio-key-figure']} {...countAttrs}>{value}</span>
39
+ </p>
40
+ <p class="hwio-muted mt-2 max-w-[22rem] text-[0.9375rem] leading-[1.45]">{label}</p>
41
+ </div>
42
+ <script>
43
+ import { hwioCounterRuntime } from '@hubpav/hwio-web-runtime/counter';
44
+ hwioCounterRuntime();
45
+ </script>
@@ -1,4 +1,10 @@
1
1
  ---
2
+ /**
3
+ * Language switcher: `pills` (a joined row of language codes, for the drawer) or `dropdown` (a
4
+ * `<details>` menu). The dropdown closes on a click outside, on Escape (focus back on its summary),
5
+ * when focus leaves it and when another popup opens (a mega panel, the drawer, another dropdown);
6
+ * opening it closes an open mega panel (@hubpav/hwio-web-runtime/dropdown).
7
+ */
2
8
  import HWioIcon from './HWioIcon.astro';
3
9
  interface Item { code: string; label: string; href: string; current?: boolean }
4
10
  interface Props { items: Item[]; label: string; variant?: 'pills' | 'dropdown'; class?: string }
@@ -12,7 +18,7 @@ const current = items.find((i) => i.current);
12
18
  ))}
13
19
  </nav>
14
20
  ) : (
15
- <details class:list={['dropdown dropdown-end', className]}>
21
+ <details class:list={['dropdown dropdown-end', className]} data-hwio-dropdown>
16
22
  <summary class="btn btn-ghost btn-sm gap-1" title={label}>
17
23
  <HWioIcon name="globe" size="sm" />
18
24
  {/* The accessible name contains the visible code (WCAG 2.5.3), so the label is visually hidden text, not aria-label. */}
@@ -27,3 +33,7 @@ const current = items.find((i) => i.current);
27
33
  </ul>
28
34
  </details>
29
35
  )}
36
+ <script>
37
+ import { hwioDropdownRuntime } from '@hubpav/hwio-web-runtime/dropdown';
38
+ hwioDropdownRuntime();
39
+ </script>
@@ -0,0 +1,54 @@
1
+ ---
2
+ /**
3
+ * A group of links for HWioHeader's `mega:<key>` panels (owner, 2026-10-30): an optional caps title
4
+ * with an "all" link on its line, then links with a one-line description. Labels never wrap; the
5
+ * descriptions are small (13 px), Regular, Gray and balanced, so siblings read as equals. `columns` is
6
+ * fixed: the panel shows only from the header's desktop breakpoint, and a column count that grows with
7
+ * the viewport (three at 2xl) squeezed descriptions into broken lines. Products may carry a thumbnail
8
+ * (a cut-out with the shared product shadow).
9
+ */
10
+ import type { HWioLinkItem } from '../types';
11
+ import HWioIcon from './HWioIcon.astro';
12
+ interface Item extends HWioLinkItem {
13
+ description?: string;
14
+ /** A product thumbnail (cut-out on a transparent ground); decorative, the label names the product. */
15
+ image?: string;
16
+ }
17
+ interface Props {
18
+ title?: string;
19
+ all?: HWioLinkItem;
20
+ items: Item[];
21
+ columns?: 1 | 2 | 3;
22
+ /** data-track-location for the links (default "header-menu"). */
23
+ trackLocation?: string;
24
+ class?: string;
25
+ }
26
+ const { title, all, items, columns = 1, trackLocation = 'header-menu', class: className = '' } = Astro.props;
27
+ const COLUMNS = { 1: 'grid-cols-1', 2: 'grid-cols-2', 3: 'grid-cols-3' } as const;
28
+ const linkAttrs = (item: HWioLinkItem) => (item.external ? { target: '_blank', rel: 'noopener noreferrer' } : {});
29
+ ---
30
+ <div class:list={['min-w-0', className]}>
31
+ {(title || all) && (
32
+ <div class="mb-4 flex items-baseline justify-between gap-6 px-3">
33
+ {title && <p class="hwio-mega-title">{title}</p>}
34
+ {all && <a href={all.href} {...linkAttrs(all)} class="hwio-link hwio-weight-label whitespace-nowrap text-sm" data-track={all.track?.id} data-track-location={all.track?.location ?? trackLocation}>{all.label}</a>}
35
+ </div>
36
+ )}
37
+ <ul class:list={['grid gap-x-6 gap-y-1', COLUMNS[columns]]}>
38
+ {items.map((item) => (
39
+ <li class="min-w-0">
40
+ <a href={item.href} {...linkAttrs(item)} class="flex items-start gap-4 rounded-xl px-3 py-2.5 transition-colors hover:bg-base-200" aria-current={item.current ? 'page' : undefined} data-track={item.track?.id} data-track-location={item.track?.location ?? trackLocation}>
41
+ {item.image && (
42
+ <span class="flex h-20 w-16 shrink-0 items-center justify-center">
43
+ <img src={item.image} alt="" loading="lazy" decoding="async" class="hwio-product-shadow max-h-full max-w-full object-contain" />
44
+ </span>
45
+ )}
46
+ <span class="min-w-0 flex-1">
47
+ <span class="hwio-mega-label">{item.label}{item.external && <HWioIcon name="arrow-up-right" size="sm" class="opacity-50" />}</span>
48
+ {item.description && <span class="hwio-menu-desc mt-0.5 block">{item.description}</span>}
49
+ </span>
50
+ </a>
51
+ </li>
52
+ ))}
53
+ </ul>
54
+ </div>
@@ -0,0 +1,36 @@
1
+ ---
2
+ /**
3
+ * A photograph at a fixed aspect ratio, filling its frame (object-fit: cover; `focus` picks the
4
+ * part that stays in view). Photographs come from the artwork library, clearance public (Brand
5
+ * Guidelines chapter 08: photographs prove). For a product on a transparent ground use HWioCutout.
6
+ */
7
+ import type { HWioAspect, HWioMedia } from '../types';
8
+ interface Props {
9
+ image: HWioMedia;
10
+ aspect?: HWioAspect;
11
+ rounded?: 'none' | 'xl' | '2xl';
12
+ focus?: 'center' | 'top' | 'bottom' | 'left' | 'right';
13
+ /** Above the fold: load at once with high priority. */
14
+ eager?: boolean;
15
+ class?: string;
16
+ imgClass?: string;
17
+ }
18
+ const { image, aspect = '4/3', rounded = '2xl', focus = 'center', eager = false, class: className = '', imgClass = '' } = Astro.props;
19
+ const ASPECT = { '1/1': 'aspect-square', '4/3': 'aspect-[4/3]', '3/2': 'aspect-[3/2]', '16/9': 'aspect-video', '4/5': 'aspect-[4/5]', '3/4': 'aspect-[3/4]' } as const;
20
+ const ROUNDED = { none: 'rounded-none', xl: 'rounded-xl', '2xl': 'rounded-2xl' } as const;
21
+ const FOCUS = { center: 'object-center', top: 'object-top', bottom: 'object-bottom', left: 'object-left', right: 'object-right' } as const;
22
+ ---
23
+ <div class:list={['relative overflow-hidden bg-base-200', ASPECT[aspect], ROUNDED[rounded], className]}>
24
+ <img
25
+ src={image.src}
26
+ srcset={image.srcset}
27
+ sizes={image.sizes}
28
+ alt={image.alt}
29
+ width={image.width}
30
+ height={image.height}
31
+ loading={eager ? 'eager' : 'lazy'}
32
+ decoding="async"
33
+ fetchpriority={eager ? 'high' : undefined}
34
+ class:list={['absolute inset-0 h-full w-full object-cover', FOCUS[focus], imgClass]}
35
+ />
36
+ </div>
@@ -0,0 +1,15 @@
1
+ ---
2
+ /**
3
+ * Starts the scroll reveal on the page (@hubpav/hwio-web-runtime/reveal). Render it once, in the layout
4
+ * of pages that mark content with `data-hwio-reveal` (HWioSection `reveal`, HWioApplicationTile
5
+ * `reveal`, a site's own markup). Renders nothing.
6
+ *
7
+ * Fail-safe: content waiting for its reveal hides only after this runtime has started; without it the
8
+ * content simply shows. Each element fades in and settles once, the first time it enters the viewport;
9
+ * `data-hwio-reveal-delay="<ms>"` staggers siblings. Under prefers-reduced-motion everything shows at once.
10
+ */
11
+ ---
12
+ <script>
13
+ import { hwioRevealRuntime } from '@hubpav/hwio-web-runtime/reveal';
14
+ hwioRevealRuntime();
15
+ </script>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ /** Section with the system rhythm, tone and container. `reveal` fades it in once in view; the reveal runs only with HWioReveal on the page (fail-safe: without it the section simply shows). */
2
3
  import type { HWioTone } from '../types';
3
4
  interface Props { id?: string; tone?: HWioTone; spacing?: 'normal' | 'compact' | 'none'; container?: boolean; as?: 'section' | 'div' | 'article'; reveal?: boolean; class?: string; [key: string]: unknown }
4
5
  const TONE = { base: 'bg-base-100 text-base-content hwio-tone-base', alt: 'bg-base-200 text-base-content hwio-tone-alt', neutral: 'bg-neutral text-neutral-content hwio-tone-neutral' } as const;
@@ -0,0 +1,38 @@
1
+ ---
2
+ /**
3
+ * Customer-story card: a field photograph under the guidelines' scrim (chapter 08), the customer as a
4
+ * caps kicker and what they built as the title, White type over the dark, calm bottom of the frame;
5
+ * the whole card links to the case study (its title underlines on hover and focus, the arrow nudges).
6
+ * Pass the photograph as `image`, or your own picture in the `media` slot. Logos stay out: the card
7
+ * names the customer in text (the kicker) only when consent or a published case study allows it.
8
+ */
9
+ import type { HWioAspect, HWioMedia, HWioTrack } from '../types';
10
+ import HWioPhoto from './HWioPhoto.astro';
11
+ interface Props {
12
+ href: string;
13
+ kicker: string;
14
+ title: string;
15
+ /** The link line under the title ("Read the story"). */
16
+ linkLabel: string;
17
+ image?: HWioMedia;
18
+ aspect?: HWioAspect;
19
+ focus?: 'center' | 'top' | 'bottom' | 'left' | 'right';
20
+ headingLevel?: 'h2' | 'h3' | 'h4';
21
+ track?: HWioTrack;
22
+ eager?: boolean;
23
+ class?: string;
24
+ }
25
+ const { href, kicker, title, linkLabel, image, aspect = '4/5', focus = 'center', headingLevel = 'h3', track, eager = false, class: className = '' } = Astro.props;
26
+ const Heading = headingLevel;
27
+ ---
28
+ <a href={href} class:list={['hwio-card-link group relative block overflow-hidden rounded-2xl text-white', className]} data-track={track?.id} data-track-location={track?.location}>
29
+ <slot name="media">
30
+ {image && <HWioPhoto image={image} aspect={aspect} focus={focus} rounded="2xl" eager={eager} imgClass="transition-transform duration-500 ease-out group-hover:scale-[1.02] motion-reduce:transition-none" />}
31
+ </slot>
32
+ <div class="hwio-scrim pointer-events-none absolute inset-0 rounded-2xl"></div>
33
+ <div class="absolute inset-x-0 bottom-0 p-6 lg:p-7">
34
+ <p class="hwio-kicker text-[var(--hwio-kicker-on-neutral,#d1d5db)]">{kicker}</p>
35
+ <Heading class="hwio-role-title hwio-card-title mt-2 text-white">{title}</Heading>
36
+ <p class="hwio-weight-label mt-3 text-[0.9375rem] text-white">{linkLabel}<span aria-hidden="true" class="hwio-arrow ml-1.5">→</span></p>
37
+ </div>
38
+ </a>
@@ -262,17 +262,49 @@
262
262
  [data-hs-status].hs-status-success { color: var(--color-success); }
263
263
  [data-hs-status].hs-status-error { color: var(--color-error); }
264
264
 
265
- /* Scroll reveal: visible at rest for no-JS, animates in once the runtime marks it. */
266
- .js [data-hwio-reveal]:not([data-hwio-revealed]) {
265
+ /* Scroll reveal, fail-safe (owner, 2026-10-30): content waiting for its reveal hides only once the
266
+ reveal runtime has started (html.hwio-reveal-on, set by @hubpav/hwio-web-runtime/reveal). Without
267
+ JavaScript, before the runtime runs, or if it never loads, everything shows. Hiding is instant;
268
+ revealing fades in and settles once (web layer: ease-out, 400 ms at most, 24 px travel). A page
269
+ can calm it with --hwio-reveal-duration and --hwio-reveal-travel. Stagger siblings with
270
+ data-hwio-reveal-delay="<ms>" (the runtime applies it). */
271
+ html.hwio-reveal-on [data-hwio-reveal]:not([data-hwio-revealed]) {
267
272
  opacity: 0;
268
- transform: translateY(24px);
269
- }
270
- [data-hwio-reveal] {
271
- transition: opacity var(--hwio-motion-reveal, 0.6s) ease-out, transform var(--hwio-motion-reveal, 0.6s) ease-out;
273
+ transform: translateY(var(--hwio-reveal-travel, 24px));
272
274
  }
273
275
  [data-hwio-reveal][data-hwio-revealed] {
274
276
  opacity: 1;
275
277
  transform: none;
278
+ transition:
279
+ opacity var(--hwio-reveal-duration, var(--hwio-motion-reveal, 0.6s)) var(--hwio-motion-easing, ease-out),
280
+ transform var(--hwio-reveal-duration, var(--hwio-motion-reveal, 0.6s)) var(--hwio-motion-easing, ease-out);
281
+ }
282
+
283
+ /* Entrance of a hero once the font gate has shown the page: the children of .hwio-enter fade in and
284
+ settle 12 px, 60 ms apart; .hwio-enter-media follows. Pure CSS that always ends visible, and it
285
+ runs only after the gate has set .fonts-ready (no gate, no animation). */
286
+ @keyframes hwio-enter {
287
+ from { opacity: 0; transform: translateY(12px); }
288
+ to { opacity: 1; transform: none; }
289
+ }
290
+ .fonts-ready .hwio-enter > * {
291
+ animation: hwio-enter var(--hwio-motion-base, 300ms) var(--hwio-motion-easing, ease-out) both;
292
+ }
293
+ .fonts-ready .hwio-enter > :nth-child(2) { animation-delay: 60ms; }
294
+ .fonts-ready .hwio-enter > :nth-child(3) { animation-delay: 120ms; }
295
+ .fonts-ready .hwio-enter > :nth-child(n + 4) { animation-delay: 180ms; }
296
+ .fonts-ready .hwio-enter-media {
297
+ animation: hwio-enter var(--hwio-motion-base, 300ms) var(--hwio-motion-easing, ease-out) 120ms both;
298
+ }
299
+
300
+ /* The arrow of a call to action moves 3 px on hover and keyboard focus (HWioButton's arrow carries
301
+ it; a site marks its own arrow glyph with .hwio-arrow). */
302
+ .hwio-arrow {
303
+ display: inline-block;
304
+ transition: transform var(--hwio-motion-fast, 200ms) var(--hwio-motion-easing, ease-out);
305
+ }
306
+ :is(a, button):is(:hover, :focus-visible) .hwio-arrow {
307
+ transform: translateX(3px);
276
308
  }
277
309
 
278
310
  /* Count-up: reserve the width of the final value so the layout never jumps. */
@@ -311,10 +343,162 @@
311
343
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease !important;
312
344
  }
313
345
 
346
+ /* Type roles for elements whose tag is not the role's tag (a card title in an <h3>, a <p> set as H2). */
347
+ .hwio-role-heading {
348
+ font-size: var(--hwio-type-heading-size, 2rem);
349
+ font-weight: var(--hwio-type-heading-weight, 700);
350
+ line-height: var(--hwio-type-heading-leading, 1.1);
351
+ letter-spacing: var(--hwio-type-heading-tracking, -0.025em);
352
+ }
353
+ .hwio-role-subheading {
354
+ font-size: var(--hwio-type-subheading-size, 1.375rem);
355
+ font-weight: var(--hwio-type-subheading-weight, 700);
356
+ line-height: var(--hwio-type-subheading-leading, 1.2);
357
+ letter-spacing: var(--hwio-type-subheading-tracking, -0.01em);
358
+ }
359
+ .hwio-role-title {
360
+ font-size: var(--hwio-type-title-size, 1.125rem);
361
+ font-weight: var(--hwio-type-title-weight, 700);
362
+ line-height: var(--hwio-type-title-leading, 1.3);
363
+ letter-spacing: var(--hwio-type-title-tracking, -0.005em);
364
+ }
365
+
366
+ /* A long H1 (more than about eight words, an opening statement) at 36 / 44 / 48 / 52 px instead of
367
+ the web scale's 40 / 48 / 60 / 72 px, so it reads in three calm lines beside the hero media.
368
+ CHANGE REQUEST CR-7 (owner, 2026-10-30), pending the Brand Guidelines: "a long H1 may go down
369
+ to 48 to 56 px". Opt-in per heading; HWioHeroSplit sets it with titleSize="long". */
370
+ .hwio-h1-long {
371
+ font-size: 2.25rem;
372
+ line-height: var(--hwio-type-display-leading, 1.2);
373
+ letter-spacing: -0.02em;
374
+ }
375
+ @media (width >= 48rem) {
376
+ .hwio-h1-long { font-size: 2.75rem; }
377
+ }
378
+ @media (width >= 64rem) {
379
+ .hwio-h1-long { font-size: 3rem; }
380
+ }
381
+ @media (width >= 80rem) {
382
+ .hwio-h1-long { font-size: 3.25rem; }
383
+ }
384
+
385
+ /* The page's one gradient moment (Brand Guidelines chapter 03; D23, D33, D44): the figure gradient
386
+ fills the type of the key figure (.hwio-key-figure, 40 px and up) or, instead of a key figure,
387
+ of up to two key words of a hero H1 (.hwio-grad). The theme variable is the gradient's left three
388
+ quarters on white (4.5:1) and light red to Sky on the dark ground. Each word carries its own
389
+ gradient. .hwio-grad is CHANGE REQUEST CR-8 (owner, 2026-10-30), pending the guidelines. Themes
390
+ without the gradient (er3o) show plain text: the fill, not `color`, turns transparent, so the
391
+ fallback's currentColor is still the text colour. */
392
+ .hwio-key-figure,
393
+ .hwio-grad {
394
+ background-image: var(--hwio-gradient-figure, linear-gradient(currentColor, currentColor));
395
+ -webkit-background-clip: text;
396
+ background-clip: text;
397
+ -webkit-text-fill-color: transparent;
398
+ -webkit-box-decoration-break: clone;
399
+ box-decoration-break: clone;
400
+ }
401
+ @media (forced-colors: active) {
402
+ .hwio-key-figure,
403
+ .hwio-grad {
404
+ background-image: none;
405
+ -webkit-text-fill-color: CanvasText;
406
+ }
407
+ }
408
+
409
+ /* Linked cards (web layer, States): the title underlines on hover and keyboard focus; no shadow. */
410
+ .hwio-card-link:is(:hover, :focus-visible) .hwio-card-title {
411
+ text-decoration: underline;
412
+ text-underline-offset: 0.18em;
413
+ text-decoration-thickness: 1px;
414
+ }
415
+
416
+ /* White type over a photograph only on the guidelines' scrim (chapter 08): clear over the top
417
+ third, 45 % black at 60 %, 92 % at the bottom. */
418
+ .hwio-scrim {
419
+ background-image: linear-gradient(180deg, rgb(0 0 0 / 0) 33%, rgb(0 0 0 / 0.45) 60%, rgb(0 0 0 / 0.92) 100%);
420
+ }
421
+
422
+ /* Product cut-outs (owner, 2026-10-30; Brand Guidelines chapter 08): one shadow recipe for every
423
+ product, so a row reads as one family: a tight contact shadow plus a soft ambient one; lighter on
424
+ the dark ground. On a common baseline (HWioCutout baseline) a blurred floor ellipse. A cut-out
425
+ sits whole on the Panel ground (.hwio-cutout-ground) or on the page. */
426
+ .hwio-product-shadow {
427
+ filter: drop-shadow(0 2px 2px rgb(37 37 50 / 0.16)) drop-shadow(0 12px 16px rgb(37 37 50 / 0.12));
428
+ }
429
+ [data-theme$='-dark'] .hwio-product-shadow {
430
+ filter: drop-shadow(0 6px 12px rgb(0 0 0 / 0.35));
431
+ }
432
+ .hwio-product-floor {
433
+ position: absolute;
434
+ left: 20%;
435
+ right: 20%;
436
+ height: 7%;
437
+ border-radius: 50%;
438
+ background: radial-gradient(closest-side, rgb(37 37 50 / 0.18), rgb(37 37 50 / 0));
439
+ pointer-events: none;
440
+ }
441
+ [data-theme$='-dark'] .hwio-product-floor {
442
+ display: none;
443
+ }
444
+ .hwio-cutout-ground {
445
+ background-color: var(--color-base-200);
446
+ }
447
+ /* A drawn isometric room behind a cut-out (HWioCutout backdrop): it fills the frame (the kit's SVG
448
+ carries a 1 pt margin, hidden by the slight scale); the dark-theme drawing on the dark ground. */
449
+ .hwio-backdrop {
450
+ position: absolute;
451
+ inset: 0;
452
+ width: 100%;
453
+ height: 100%;
454
+ object-fit: cover;
455
+ transform: scale(1.012);
456
+ pointer-events: none;
457
+ }
458
+ .hwio-backdrop-dark { display: none; }
459
+ [data-theme$='-dark'] .hwio-backdrop-light { display: none; }
460
+ [data-theme$='-dark'] .hwio-backdrop-dark { display: block; }
461
+
462
+ /* Menu panels (HWioMegaLinks) and the drawer's second lines (owner, 2026-10-30): a caps group title
463
+ in Gray, link labels that never wrap, descriptions small (13 px), Regular, Gray and balanced. */
464
+ .hwio-mega-title {
465
+ color: var(--hwio-text-secondary, color-mix(in oklab, var(--color-base-content) 72%, transparent));
466
+ font-size: var(--hwio-type-kicker-size, 0.875rem);
467
+ font-weight: var(--hwio-type-kicker-weight, 600);
468
+ line-height: var(--hwio-type-kicker-leading, 1.4);
469
+ letter-spacing: var(--hwio-type-kicker-tracking, 0);
470
+ text-transform: var(--hwio-type-kicker-case, none);
471
+ }
472
+ .hwio-mega-label {
473
+ display: inline-flex;
474
+ align-items: center;
475
+ gap: 0.375rem;
476
+ white-space: nowrap;
477
+ color: var(--color-base-content);
478
+ font-size: 0.9375rem;
479
+ font-weight: var(--hwio-weight-label, 600);
480
+ line-height: 1.35;
481
+ transition: color var(--hwio-motion-fast, 200ms) var(--hwio-motion-easing, ease-out);
482
+ }
483
+ a:is(:hover, :focus-visible) .hwio-mega-label {
484
+ color: var(--hwio-nav-hover, var(--color-primary));
485
+ }
486
+ .hwio-menu-desc {
487
+ color: var(--hwio-text-secondary, color-mix(in oklab, var(--color-base-content) 72%, transparent));
488
+ font-size: 0.8125rem;
489
+ font-weight: 400;
490
+ line-height: 1.45;
491
+ text-wrap: balance;
492
+ }
493
+
314
494
  @media (prefers-reduced-motion: reduce) {
315
495
  [data-hwio-reveal] { opacity: 1 !important; transform: none !important; transition: none !important; }
316
496
  .hwio-marquee-track { animation: none; transform: none; will-change: auto; }
317
497
  .hwio-marquee-track > [aria-hidden='true'] { display: none; }
498
+ .fonts-ready .hwio-enter > *,
499
+ .fonts-ready .hwio-enter-media { animation: none; }
500
+ .hwio-arrow,
501
+ :is(a, button):is(:hover, :focus-visible) .hwio-arrow { transition: none; transform: none; }
318
502
  }
319
503
  }
320
504
 
@@ -371,6 +555,35 @@
371
555
  .hwio-nav-tight > .hwio-nav-current::after {
372
556
  inset-inline: 0.5rem;
373
557
  }
558
+ header:has(.hwio-nav-tight) .hwio-utility > summary.btn {
559
+ --fontsize: 14px;
560
+ font-size: 14px;
561
+ }
562
+ }
563
+ /* Header utilities (owner, 2026-10-30): HWioHeader's theme toggle and language dropdown read as quiet
564
+ utilities beside the nav, not as buttons: the nav's weight (Regular on the HARDWARIO themes), size
565
+ and 80 % ink, the nav's hover colour, the toggle's icon at 18 px (HWioIcon's 1.5 stroke), the
566
+ chevron as faint as the nav chevrons. Unlayered, because DaisyUI's .btn sits in the utilities layer. */
567
+ .hwio-utility.btn,
568
+ .hwio-utility > summary.btn {
569
+ --fontsize: 0.9375rem;
570
+ font-size: 0.9375rem;
571
+ font-weight: var(--hwio-weight-nav, 500);
572
+ letter-spacing: 0;
573
+ color: color-mix(in oklab, var(--color-base-content) 80%, transparent);
574
+ border-color: transparent;
575
+ box-shadow: none;
576
+ }
577
+ .hwio-utility.btn:hover,
578
+ .hwio-utility > summary.btn:hover {
579
+ color: var(--hwio-nav-hover, var(--color-accent));
580
+ }
581
+ .hwio-utility.btn > svg {
582
+ width: 1.125rem;
583
+ height: 1.125rem;
584
+ }
585
+ .hwio-utility > summary.btn > svg:last-child {
586
+ opacity: 0.5;
374
587
  }
375
588
  /* DaisyUI 5.7.30+ also gives pressed buttons (aria-pressed / aria-checked) a filled ground. A ghost
376
589
  toggle shows its state in its own content (HWioThemeToggle swaps moon and sun and sets
@@ -380,3 +593,16 @@
380
593
  --btn-bg: #0000;
381
594
  --btn-border: #0000;
382
595
  }
596
+ /* Forms on the brand type scale (owner, 2026-11-01; website-admin DESIGN-SYSTEM.md, web layer type table: "Form
597
+ label 15 Bold", "Form field text 16 Regular"). DaisyUI's 12 px Semibold field labels and 14 px fields sat below
598
+ the body text, and iPhones zoom into a focused field whose text is under 16 px. Unlayered, so it wins over
599
+ DaisyUI's component layer. */
600
+ .fieldset-legend {
601
+ font-size: 0.9375rem;
602
+ font-weight: var(--hwio-weight-label, 700);
603
+ }
604
+ .input,
605
+ .select,
606
+ .textarea {
607
+ font-size: 1rem;
608
+ }
package/src/types.ts CHANGED
@@ -21,6 +21,15 @@ export interface HWioMedia {
21
21
  sizes?: string;
22
22
  }
23
23
 
24
+ /** Frame proportions shared by HWioPhoto, HWioCutout and the cards built on them. */
25
+ export type HWioAspect = '1/1' | '4/3' | '3/2' | '16/9' | '4/5' | '3/4';
26
+
27
+ /** A drawn isometric room behind a product cut-out (artwork library), with its dark-theme drawing. */
28
+ export interface HWioBackdrop {
29
+ light: string;
30
+ dark?: string;
31
+ }
32
+
24
33
  export interface HWioCta extends HWioLinkItem {
25
34
  /** Visual weight; primary is the DaisyUI btn-primary fill. */
26
35
  variant?: 'primary' | 'secondary' | 'ghost';