@birdycom/omniaui 0.12.6 → 0.13.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.
@@ -0,0 +1,5 @@
1
+ // File: packages/ui/src/contact-form.d.ts
2
+ // Types for the contact form script.
3
+
4
+ export function initContactForm(form: HTMLFormElement): void;
5
+ export function initContactForms(scope?: ParentNode): void;
@@ -0,0 +1,138 @@
1
+ // File: packages/ui/src/contact-form.js
2
+ // Map: messages -> field errors -> busy -> send (fetch, JSON) -> result / errors -> "send another" -> boot
3
+ // Contact form without a page reload. The form keeps working without this script (POST + redirect); with it, it sends
4
+ // with fetch and shows the result or the errors in place.
5
+ // Protocol: POST the FormData to form.action with "Accept: application/json"; the server answers
6
+ // { sent: true } (200) or { sent: false, errors: { field: "message", form?: "message" } } (422); a list per field
7
+ // (Laravel: { errors: { email: ["…"] } }) shows its first message.
8
+ // Markup (data attributes, all inside [data-contact-root] or the document):
9
+ // form[data-contact-form] the form (novalidate); [data-contact-submit] its button, [data-contact-label] the text
10
+ // [data-contact-body] what hides after sending (title, intro, alert, form)
11
+ // [data-contact-success] the .oc-result that shows after sending (hidden, role="status", tabindex="-1")
12
+ // [data-contact-alert] the .oc-alert above the form (hidden), for errors.form or the general message
13
+ // [data-contact-again] the "Send another message" link or button in the result
14
+ // Field errors: aria-invalid="true" + aria-describedby on the field and <p class="oc-field-error" id="err-<name>">.
15
+ // Texts: data-busy-label, data-error-fields, data-error-network on the form (English by default).
16
+
17
+ const TEXT = {
18
+ busy: "Sending…",
19
+ fields: "Please check the fields in red.",
20
+ network: "Sending failed. Check your connection, or mail or call us directly.",
21
+ };
22
+
23
+ function clearFieldError(field) {
24
+ field.removeAttribute("aria-invalid");
25
+ field.removeAttribute("aria-describedby");
26
+ field.ownerDocument.getElementById(`err-${field.name}`)?.remove();
27
+ }
28
+
29
+ function setFieldError(field, message) {
30
+ const error = field.ownerDocument.createElement("p");
31
+ error.className = "oc-field-error";
32
+ error.id = `err-${field.name}`;
33
+ error.textContent = message;
34
+ field.setAttribute("aria-invalid", "true");
35
+ field.setAttribute("aria-describedby", error.id);
36
+ field.insertAdjacentElement("afterend", error);
37
+ }
38
+
39
+ /** Send one form[data-contact-form] with fetch and show the result or the errors in place. */
40
+ export function initContactForm(form) {
41
+ if (!form || form.dataset.ocBound !== undefined || typeof fetch === "undefined") return;
42
+ form.dataset.ocBound = "";
43
+ const root = form.closest("[data-contact-root]") || form.ownerDocument;
44
+ const body = root.querySelector("[data-contact-body]");
45
+ const success = root.querySelector("[data-contact-success]");
46
+ const alertBox = root.querySelector("[data-contact-alert]");
47
+ const button = form.querySelector("[data-contact-submit]") || form.querySelector('[type="submit"]');
48
+ const label = form.querySelector("[data-contact-label]");
49
+ const idleLabel = label?.textContent ?? "";
50
+ const text = {
51
+ busy: form.dataset.busyLabel || TEXT.busy,
52
+ fields: form.dataset.errorFields || TEXT.fields,
53
+ network: form.dataset.errorNetwork || TEXT.network,
54
+ };
55
+
56
+ const showAlert = (message) => {
57
+ if (!alertBox) return;
58
+ alertBox.textContent = message;
59
+ alertBox.hidden = message === "";
60
+ };
61
+
62
+ const setBusy = (busy) => {
63
+ if (button) button.disabled = busy;
64
+ form.setAttribute("aria-busy", String(busy));
65
+ if (label) label.textContent = busy ? text.busy : idleLabel;
66
+ };
67
+
68
+ // A field you change loses its error at once.
69
+ form.addEventListener("input", (event) => {
70
+ if (event.target.name) clearFieldError(event.target);
71
+ });
72
+
73
+ form.addEventListener("submit", async (event) => {
74
+ event.preventDefault();
75
+ form.querySelectorAll("[aria-invalid]").forEach(clearFieldError);
76
+ showAlert("");
77
+ setBusy(true);
78
+ try {
79
+ const response = await fetch(form.action, {
80
+ method: (form.getAttribute("method") || "POST").toUpperCase(),
81
+ body: new FormData(form),
82
+ headers: { Accept: "application/json" },
83
+ });
84
+ const result = await response.json();
85
+ if (result.sent) {
86
+ form.reset();
87
+ if (body) body.hidden = true;
88
+ if (success) {
89
+ success.hidden = false;
90
+ success.focus({ preventScroll: true });
91
+ success.scrollIntoView?.({ behavior: "smooth", block: "center" });
92
+ }
93
+ form.dispatchEvent(new CustomEvent("oc-contact-sent", { bubbles: true }));
94
+ return;
95
+ }
96
+ // Laravel sends a list per field ({ email: ["…"] }): the first message is shown.
97
+ const first = (message) => (Array.isArray(message) ? message[0] : message);
98
+ const errors = result.errors || {};
99
+ for (const [name, message] of Object.entries(errors)) {
100
+ const field = form.elements.namedItem(name);
101
+ if (field instanceof HTMLElement && "name" in field) setFieldError(field, first(message));
102
+ }
103
+ showAlert(first(errors.form) || text.fields);
104
+ (form.querySelector('[aria-invalid="true"]') || alertBox)?.focus?.();
105
+ } catch {
106
+ showAlert(text.network);
107
+ } finally {
108
+ setBusy(false);
109
+ }
110
+ });
111
+
112
+ // "Send another message": the form comes back without a reload.
113
+ root.querySelector("[data-contact-again]")?.addEventListener("click", (event) => {
114
+ event.preventDefault();
115
+ if (success) success.hidden = true;
116
+ if (body) body.hidden = false;
117
+ form.querySelector("input:not([type=hidden]):not([tabindex='-1']), textarea, select")?.focus();
118
+ });
119
+ }
120
+
121
+ /** Bind every contact form under scope. */
122
+ export function initContactForms(scope = document) {
123
+ scope.querySelectorAll("form[data-contact-form]").forEach(initContactForm);
124
+ }
125
+
126
+ // Starts on its own as /js/oc-contact-form.js (`npx omniaui copy-js`), with ?auto in the URL or data-oc-auto on the tag.
127
+ function autoStart() {
128
+ if (typeof document === "undefined") return false;
129
+ const url = String(import.meta.url);
130
+ if (url.includes("oc-contact-form") || /[?&]auto(=|&|$)/.test(url)) return true;
131
+ const file = url.split(/[?#]/)[0];
132
+ return [...document.querySelectorAll("script[data-oc-auto][src]")].some((s) => s.src.split(/[?#]/)[0] === file);
133
+ }
134
+ if (autoStart()) {
135
+ const boot = () => initContactForms(document);
136
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
137
+ else boot();
138
+ }
package/dist/fly.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ // File: packages/ui/src/fly.d.ts
2
+ // Types for the fly-in script.
3
+
4
+ export function initFly(scope?: ParentNode): () => void;
package/dist/fly.js ADDED
@@ -0,0 +1,80 @@
1
+ // File: packages/ui/src/fly.js
2
+ // Map: row of a card (index, count) -> start offset -> delay -> observe (once or both ways) -> boot
3
+ // Cards that fly in (.oc-reveal--fly): each card starts from the middle of its row (--oc-fly-x) and flies to its place
4
+ // when it scrolls into view (.is-visible). Adds .oc-fly-ready on <html> first, so without this script, or with
5
+ // "reduce motion", nothing is hidden.
6
+ // Options (CSS custom properties on a card or a parent, and one attribute):
7
+ // --oc-fly-step: 45px start offset per column from the middle of the row (instead of the full distance)
8
+ // --oc-fly-stagger: 90ms delay per column, so a row lands from left to right
9
+ // data-oc-fly-repeat the cards fly out again when they leave the view (both scroll directions)
10
+
11
+ /** Index of the card in its row (cards with the same top in the same parent) and the number of cards in that row. */
12
+ function rowOf(card) {
13
+ const top = card.offsetTop;
14
+ const row = [...(card.parentElement?.children ?? [])].filter((el) => el.classList.contains("oc-reveal--fly") && el.offsetTop === top);
15
+ return { index: Math.max(0, row.indexOf(card)), count: row.length || 1 };
16
+ }
17
+
18
+ /** Horizontal distance from the card to the middle of its parent, so the row opens from the center. */
19
+ function flyOffset(card, step, index, count) {
20
+ if (step) return Math.round(((count - 1) / 2 - index) * step);
21
+ const box = card.getBoundingClientRect();
22
+ const parent = card.parentElement?.getBoundingClientRect();
23
+ if (!parent || !box.width) return 0;
24
+ return Math.round(parent.left + parent.width / 2 - (box.left + box.width / 2));
25
+ }
26
+
27
+ /** "90ms", "0.09s" or "45px" -> number (ms or px); 0 when empty. */
28
+ function num(value, unitS = false) {
29
+ const v = String(value).trim();
30
+ const n = parseFloat(v);
31
+ if (!v || Number.isNaN(n)) return 0;
32
+ return unitS && /\ds$/.test(v) ? n * 1000 : n;
33
+ }
34
+
35
+ /** Let every .oc-reveal--fly under scope fly in once it comes into view. Returns a function that stops watching. */
36
+ export function initFly(scope = document) {
37
+ const doc = scope.ownerDocument || scope;
38
+ const win = doc.defaultView;
39
+ const cards = [...scope.querySelectorAll(".oc-reveal--fly:not(.is-visible)")];
40
+ if (!cards.length || !win) return () => {};
41
+ if (!("IntersectionObserver" in win) || win.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
42
+ cards.forEach((c) => c.classList.add("is-visible"));
43
+ return () => {};
44
+ }
45
+ for (const card of cards) {
46
+ const style = win.getComputedStyle(card);
47
+ const { index, count } = rowOf(card);
48
+ card.style.setProperty("--oc-fly-x", `${flyOffset(card, num(style.getPropertyValue("--oc-fly-step")), index, count)}px`);
49
+ const stagger = num(style.getPropertyValue("--oc-fly-stagger"), true);
50
+ if (stagger) card.style.setProperty("--oc-fly-delay", `${index * stagger}ms`);
51
+ }
52
+ doc.documentElement.classList.add("oc-fly-ready");
53
+ const io = new win.IntersectionObserver((entries) => {
54
+ for (const entry of entries) {
55
+ const repeat = entry.target.closest("[data-oc-fly-repeat]");
56
+ if (entry.isIntersecting) {
57
+ entry.target.classList.add("is-visible");
58
+ if (!repeat) io.unobserve(entry.target);
59
+ } else if (repeat) {
60
+ entry.target.classList.remove("is-visible");
61
+ }
62
+ }
63
+ }, { rootMargin: "0px 0px -10% 0px", threshold: 0.12 });
64
+ cards.forEach((c) => io.observe(c));
65
+ return () => io.disconnect();
66
+ }
67
+
68
+ // Starts on its own as /js/oc-fly.js (`npx omniaui copy-js`), with ?auto in the URL or data-oc-auto on the tag.
69
+ function autoStart() {
70
+ if (typeof document === "undefined") return false;
71
+ const url = String(import.meta.url);
72
+ if (url.includes("oc-fly") || /[?&]auto(=|&|$)/.test(url)) return true;
73
+ const file = url.split(/[?#]/)[0];
74
+ return [...document.querySelectorAll("script[data-oc-auto][src]")].some((s) => s.src.split(/[?#]/)[0] === file);
75
+ }
76
+ if (autoStart()) {
77
+ const boot = () => initFly(document);
78
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
79
+ else boot();
80
+ }
package/dist/oc-theme.css CHANGED
@@ -1,4 +1,4 @@
1
- /* File: oc-theme.css — generated from tokens/semantic.json by @birdycom/omniacss 0.12.6. Do not edit. */
1
+ /* File: oc-theme.css — generated from tokens/semantic.json by @birdycom/omniacss 0.13.0. Do not edit. */
2
2
  /* Semantic theme tokens: every color is light-dark() of an Omnia palette token (oc-colors.css). */
3
3
  /* Same source as the desktop export: npx omniacss tokens --format json|dtcg|python */
4
4
  :root {
@@ -14,6 +14,7 @@
14
14
  --oc-foreground: light-dark(var(--color-duskblue-60), var(--color-frostblue-10));
15
15
  --oc-muted: light-dark(var(--color-frostblue-60), var(--color-shallowblue-50));
16
16
  --oc-muted-foreground: light-dark(var(--color-stormblue-40), var(--color-mistblue-70));
17
+ --oc-subtle-foreground: light-dark(var(--color-stormblue-10), var(--color-mistblue-95));
17
18
  --oc-card: light-dark(var(--color-white-50), var(--color-shallowblue-70));
18
19
  --oc-popover: light-dark(var(--color-white-50), var(--color-shallowblue-60));
19
20
  --oc-popover-foreground: var(--oc-foreground);
@@ -58,6 +59,7 @@
58
59
  --oc-transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
59
60
  --oc-font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Cantarell, "Helvetica Neue", sans-serif;
60
61
  --oc-font-mono: "Cascadia Code", "JetBrains Mono", Consolas, ui-monospace, monospace;
62
+ --oc-font-display: system-ui, -apple-system, "Segoe UI", Roboto, Cantarell, "Helvetica Neue", sans-serif;
61
63
 
62
64
  /* Shadows (x y blur spread; color + alpha per mode in --oc-shadow-<name>-color) */
63
65
  --oc-shadow-sm-color: light-dark(oklch(from var(--color-stormblue-50) l c h / 0.08), oklch(from var(--color-black-50) l c h / 0.4));
@@ -0,0 +1,39 @@
1
+ /* File: packages/ui/src/parts/app-result.css */
2
+ /* Map: result -> icon tile -> title -> text -> actions */
3
+ /* The outcome of an action in the place where the form was: "Thank you, your message was sent" with an icon tile, a
4
+ short text and a button ("Send another message"). Give it role="status" and tabindex="-1" and move the focus to it.
5
+ --center centers it; the tile takes its color from an .oc-accent--* preset. */
6
+
7
+ .oc-result {
8
+ display: grid;
9
+ justify-items: start;
10
+ gap: 0.75rem;
11
+ }
12
+ .oc-result:focus { outline: none; }
13
+ .oc-result[hidden] { display: none; }
14
+ .oc-result > .oc-icon-tile { margin-block-end: 0.35rem; }
15
+ .oc-result__title {
16
+ margin: 0;
17
+ font-size: 1.2rem;
18
+ line-height: 1.25;
19
+ }
20
+ .oc-result__text {
21
+ margin: 0;
22
+ color: var(--oc-muted-foreground);
23
+ }
24
+ .oc-result__text a {
25
+ color: var(--oc-primary);
26
+ text-decoration: underline;
27
+ text-underline-offset: 3px;
28
+ }
29
+ .oc-result__actions {
30
+ display: flex;
31
+ flex-wrap: wrap;
32
+ gap: 0.75rem;
33
+ margin-block-start: 0.5rem;
34
+ }
35
+ .oc-result--center {
36
+ justify-items: center;
37
+ text-align: center;
38
+ }
39
+ .oc-result--center .oc-result__actions { justify-content: center; }
@@ -0,0 +1,76 @@
1
+ /* File: packages/ui/src/parts/forms-form.css */
2
+ /* Map: form grid -> label hint -> actions + note -> busy -> filled variant */
3
+ /* A form as one grid of fields with one gap, an "(optional)" hint in a label, a row with the submit button and a short
4
+ note, the busy state while it sends (aria-busy on the form), and .oc-form--filled: fields with a fill and a stronger
5
+ border, for a form on a card, a photo or a dark band. All through the field tokens of .oc-input. */
6
+
7
+ .oc-form {
8
+ display: grid;
9
+ gap: var(--oc-form-gap, 1.1rem);
10
+ }
11
+ .oc-form .oc-field-group { gap: var(--oc-form-gap, 1.1rem); }
12
+ .oc-form .oc-field {
13
+ gap: 0.4rem;
14
+ margin-bottom: 0;
15
+ }
16
+
17
+ /* <label class="oc-field-label">Phone <span class="oc-field-label__hint">(optional)</span></label> */
18
+ .oc-field-label__hint {
19
+ color: var(--oc-muted-foreground);
20
+ font-weight: 400;
21
+ }
22
+
23
+ /* Submit button with a short note next to it (wraps under it on a narrow form). */
24
+ .oc-form-actions {
25
+ display: flex;
26
+ flex-wrap: wrap;
27
+ align-items: center;
28
+ gap: 1rem 1.5rem;
29
+ margin-block-start: 0.4rem;
30
+ }
31
+ .oc-form-note {
32
+ flex: 1 1 16rem;
33
+ margin: 0;
34
+ color: var(--oc-muted-foreground);
35
+ font-size: 0.85rem;
36
+ }
37
+ .oc-form-note a {
38
+ color: var(--oc-primary);
39
+ text-decoration: underline;
40
+ text-underline-offset: 3px;
41
+ }
42
+ .oc-form-note a:hover { color: var(--oc-foreground); }
43
+
44
+ /* While the form sends (form aria-busy="true"): the submit button dims and shows a progress cursor. */
45
+ .oc-form[aria-busy="true"] :is([type="submit"], button:not([type])) {
46
+ opacity: 0.7;
47
+ cursor: progress;
48
+ }
49
+
50
+ /* Filled fields: a fill (dark in dark mode), the strong border, more room and a wider focus ring. */
51
+ .oc-form--filled {
52
+ --oc-input-bg: light-dark(oklch(from var(--oc-foreground) l c h / 0.03), oklch(0 0 0 / 0.25));
53
+ --oc-input-border: var(--oc-border-strong);
54
+ --oc-input-padding: 0.75rem 0.9rem;
55
+ --oc-input-radius: var(--oc-radius-md, 0.75rem);
56
+ --oc-input-size: 1rem;
57
+ --oc-ring-width: 3px;
58
+ --oc-textarea-min: 9rem;
59
+ }
60
+ .oc-form--filled .oc-field-label {
61
+ font-size: 0.9rem;
62
+ font-weight: 600;
63
+ line-height: 1.4;
64
+ }
65
+ .oc-form--filled .oc-field-error { font-size: 0.85rem; }
66
+ .oc-form--filled .oc-select option { background: var(--oc-card); }
67
+
68
+ /* Honeypot: a field for bots, off screen for people. Not display:none (some bots skip hidden fields). Give the wrapper
69
+ aria-hidden="true" and the input tabindex="-1" autocomplete="off"; the server ignores a form where it is filled. */
70
+ .oc-honeypot {
71
+ position: absolute;
72
+ inset-inline-start: -10000px;
73
+ inline-size: 1px;
74
+ block-size: 1px;
75
+ overflow: hidden;
76
+ }
@@ -97,6 +97,10 @@
97
97
  .oc-lightbox__nav { grid-row: 1; justify-self: center; }
98
98
  .oc-lightbox__nav--prev { grid-column: 1; }
99
99
  .oc-lightbox__nav--next { grid-column: 3; }
100
+ .oc-lightbox__close :is(svg, iconify-icon, .oc-icon) { inline-size: 1.1rem; block-size: 1.1rem; font-size: 1.1rem; }
101
+ /* A <picture> (animated WebP with a still image for "reduce motion") gets the frame; the image fits in it. */
102
+ .oc-lightbox__media > picture { display: grid; overflow: hidden; }
103
+ .oc-lightbox__media > picture > img { max-inline-size: 100%; max-block-size: calc(100dvh - 9rem); object-fit: contain; }
100
104
 
101
105
  /* ---- Caption with the "2 / 5" counter ---- */
102
106
  .oc-lightbox__caption {
@@ -0,0 +1,33 @@
1
+ /* File: packages/ui/src/parts/site-base.css */
2
+ /* Map: skip link -> icon */
3
+ /* Two small site helpers: a skip link for keyboard users (hidden above the page until it gets the focus) and a basic
4
+ inline icon (svg or iconify-icon) that is as large as the text around it. */
5
+
6
+ /* <a class="oc-skip-link" href="#main">Skip to content</a> as the first element in <body>. */
7
+ .oc-skip-link {
8
+ position: absolute;
9
+ inset-inline-start: 1rem;
10
+ inset-block-start: 1rem;
11
+ /* Fully above the edge, whatever its height; drops in on focus. */
12
+ translate: 0 calc(-100% - 2rem);
13
+ z-index: 100;
14
+ padding: 0.6rem 1rem;
15
+ border-radius: var(--oc-radius-md, 0.75rem);
16
+ background: var(--oc-foreground);
17
+ color: var(--oc-background);
18
+ font-weight: 600;
19
+ text-decoration: none;
20
+ transition: translate var(--oc-transition-fast);
21
+ }
22
+ .oc-skip-link:focus { translate: none; }
23
+
24
+ /* Inline icon: 1.25em (1.1em in a button), never squeezed in a flex row. */
25
+ .oc-icon {
26
+ inline-size: 1.25em;
27
+ block-size: 1.25em;
28
+ flex-shrink: 0;
29
+ }
30
+ .oc-btn > .oc-icon {
31
+ inline-size: 1.1em;
32
+ block-size: 1.1em;
33
+ }
@@ -0,0 +1,56 @@
1
+ /* File: packages/ui/src/parts/site-brand-mark.css */
2
+ /* Map: brand mark (icon + text) -> word (accent part in the brand gradient) -> tagline -> sizes */
3
+ /* A logo built from an icon and text: the mark (svg or img) with a soft glow in the brand color, the word mark with one
4
+ part in the brand gradient, and a small tagline under it. Sizes --sm and --lg (a footer, a hero or a map). */
5
+
6
+ .oc-brand-mark {
7
+ --oc-brand-mark-icon: 2.25rem;
8
+ --oc-brand-mark-word: 1.35rem;
9
+ --oc-brand-mark-tagline: 0.6rem;
10
+ display: inline-flex;
11
+ align-items: center;
12
+ gap: 0.6rem;
13
+ color: var(--oc-foreground);
14
+ text-decoration: none;
15
+ }
16
+ .oc-brand-mark__icon {
17
+ flex: none;
18
+ inline-size: var(--oc-brand-mark-icon);
19
+ block-size: var(--oc-brand-mark-icon);
20
+ filter: drop-shadow(0 6px 14px oklch(from var(--oc-brand-1) l c h / 0.35));
21
+ }
22
+ .oc-brand-mark__text {
23
+ display: grid;
24
+ line-height: 1;
25
+ }
26
+ .oc-brand-mark__word {
27
+ font-family: var(--oc-font-display, inherit);
28
+ font-size: var(--oc-brand-mark-word);
29
+ font-weight: 800;
30
+ letter-spacing: -0.04em;
31
+ }
32
+ .oc-brand-mark__accent {
33
+ background: linear-gradient(90deg, var(--oc-brand-1), var(--oc-brand-2));
34
+ -webkit-background-clip: text;
35
+ background-clip: text;
36
+ color: transparent;
37
+ }
38
+ .oc-brand-mark__tagline {
39
+ margin-block-start: 0.3rem;
40
+ color: var(--oc-subtle-foreground, var(--oc-muted-foreground));
41
+ font-family: var(--oc-font-mono);
42
+ font-size: var(--oc-brand-mark-tagline);
43
+ font-weight: 600;
44
+ letter-spacing: 0.16em;
45
+ text-transform: uppercase;
46
+ }
47
+ .oc-brand-mark--sm {
48
+ --oc-brand-mark-icon: 1.75rem;
49
+ --oc-brand-mark-word: 1.1rem;
50
+ --oc-brand-mark-tagline: 0.55rem;
51
+ }
52
+ .oc-brand-mark--lg {
53
+ --oc-brand-mark-icon: 2.6rem;
54
+ --oc-brand-mark-word: 1.6rem;
55
+ --oc-brand-mark-tagline: 0.66rem;
56
+ }
@@ -0,0 +1,183 @@
1
+ /* File: packages/ui/src/parts/site-cards.css */
2
+ /* Map: feature card -> link card -> icon list (+ points) -> accent list + note -> badge row + accent tagline
3
+ -> screenshot grid -> browser frame glow + mark */
4
+ /* Cards and lists for the pages of a company site: services and features (icon tile + title + text), cards that are
5
+ links, a list of icon rows in frames (or as points with a title), a list with markers in the accent color, a small
6
+ note, a row of badges, a tagline in the accent color, a grid of screenshots that open in the lightbox, and a browser
7
+ frame with a glow in the accent color and a product mark on it. */
8
+
9
+ /* Feature / service card: tile, title, text; a quiet hover. In an .oc-card-grid (--oc-card-min sets the column width). */
10
+ .oc-feature-card {
11
+ padding: 1.6rem;
12
+ border: 1px solid var(--oc-border);
13
+ border-radius: var(--oc-radius-lg, 1.25rem);
14
+ background: var(--oc-surface);
15
+ transition: border-color 0.3s, background 0.3s;
16
+ }
17
+ .oc-feature-card:hover {
18
+ border-color: var(--oc-border-strong);
19
+ background: var(--oc-surface-strong);
20
+ }
21
+ .oc-feature-card > .oc-icon-tile { margin-block-end: 1.1rem; }
22
+ .oc-feature-card__title {
23
+ margin: 0 0 0.6rem;
24
+ font-family: var(--oc-font-display, inherit);
25
+ font-size: 1.2rem;
26
+ line-height: 1.25;
27
+ }
28
+ .oc-feature-card__text {
29
+ margin: 0;
30
+ color: var(--oc-muted-foreground);
31
+ font-size: 0.95rem;
32
+ }
33
+
34
+ /* Card that is a link (an agent, a tool, a document): tile, title, text and a "more" line; hover in the accent color. */
35
+ .oc-link-card {
36
+ display: grid;
37
+ align-content: start;
38
+ gap: 0.4rem;
39
+ padding: 1.25rem;
40
+ border: 1px solid var(--oc-border);
41
+ border-radius: var(--oc-radius-lg, 1.25rem);
42
+ background: var(--oc-surface);
43
+ color: var(--oc-foreground);
44
+ text-decoration: none;
45
+ transition: border-color 0.3s, background 0.3s;
46
+ }
47
+ /* --oc-link-card-hover: the hover color alone (without an accent preset, so the tile keeps its own color). */
48
+ .oc-link-card:hover {
49
+ border-color: oklch(from var(--oc-link-card-hover, var(--oc-accent-from)) l c h / 0.5);
50
+ background: var(--oc-surface-strong);
51
+ }
52
+ .oc-link-card:focus-visible { outline: 2px solid var(--oc-ring); outline-offset: 2px; }
53
+ .oc-link-card > .oc-icon-tile { margin-block-end: 0.4rem; }
54
+ .oc-link-card__title { font-family: var(--oc-font-display, inherit); font-weight: 700; }
55
+ .oc-link-card__text { color: var(--oc-muted-foreground); font-size: 0.93rem; }
56
+ .oc-link-card__more {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ gap: 0.35rem;
60
+ margin-block-start: 0.4rem;
61
+ font-size: 0.9rem;
62
+ font-weight: 600;
63
+ }
64
+ .oc-link-card__more::after { content: "→"; transition: translate 0.25s var(--oc-ease-out, ease); }
65
+ .oc-link-card:hover .oc-link-card__more::after { translate: 4px 0; }
66
+
67
+ /* Icon list: rows with an icon in a frame. --points: an icon tile next to a title and a text, without frames. */
68
+ .oc-icon-list {
69
+ display: grid;
70
+ gap: 0.75rem;
71
+ margin: 0;
72
+ padding: 0;
73
+ list-style: none;
74
+ }
75
+ .oc-icon-list > li {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 0.75rem;
79
+ padding: 0.85rem 1rem;
80
+ border: 1px solid var(--oc-border);
81
+ border-radius: var(--oc-radius-md, 0.75rem);
82
+ background: var(--oc-surface);
83
+ }
84
+ .oc-icon-list > li > :is(svg, iconify-icon, .oc-icon) {
85
+ flex: none;
86
+ inline-size: 1.25rem;
87
+ block-size: 1.25rem;
88
+ font-size: 1.25rem;
89
+ color: var(--oc-primary);
90
+ }
91
+ .oc-icon-list--points { gap: 1rem; }
92
+ .oc-icon-list--points > li {
93
+ display: grid;
94
+ grid-template-columns: auto 1fr;
95
+ align-items: start;
96
+ gap: 0.25rem 0.9rem;
97
+ padding: 0;
98
+ border: 0;
99
+ background: none;
100
+ }
101
+ .oc-icon-list--points > li > .oc-icon-tile { grid-row: span 2; }
102
+ .oc-icon-list__title { font-family: var(--oc-font-display, inherit); font-weight: 700; }
103
+ .oc-icon-list__text { color: var(--oc-muted-foreground); font-size: 0.95rem; }
104
+
105
+ /* List with markers in the accent color, and a small note under a block. */
106
+ .oc-list--accent {
107
+ display: grid;
108
+ gap: 0.6rem;
109
+ margin: 0;
110
+ padding-inline-start: 1.1rem;
111
+ list-style: disc;
112
+ color: var(--oc-muted-foreground);
113
+ font-size: 0.95rem;
114
+ }
115
+ .oc-list--accent > li::marker { color: var(--oc-accent-from, var(--oc-primary)); }
116
+ .oc-note {
117
+ margin: 1.25rem 0 0;
118
+ color: var(--oc-muted-foreground);
119
+ font-size: 0.85rem;
120
+ }
121
+ .oc-note--md {
122
+ max-inline-size: 60rem;
123
+ font-size: 1rem;
124
+ }
125
+
126
+ /* Row of badges or pills above a title, and a tagline in the accent color (lighter, so it reads on a dark page). */
127
+ .oc-badge-row {
128
+ display: flex;
129
+ flex-wrap: wrap;
130
+ gap: 0.5rem;
131
+ margin-block-end: 1.25rem;
132
+ }
133
+ .oc-tagline--accent {
134
+ margin: 0 0 1rem;
135
+ color: light-dark(var(--oc-accent-from), color-mix(in oklch, var(--oc-accent-from) 55%, white));
136
+ font-size: clamp(1.2rem, 2.5vw, 1.6rem);
137
+ font-weight: 600;
138
+ }
139
+
140
+ /* Screenshot grid: figures with the whole image visible (contain, 16:10 on the card color) and a caption;
141
+ a link or a button around the image opens the lightbox
142
+ (<button command="show-modal" commandfor="<lightbox id>">). A <picture> (animated WebP with a still image) works the same. */
143
+ .oc-shot-grid {
144
+ display: grid;
145
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--oc-shot-min, 18rem)), 1fr));
146
+ gap: 1.25rem;
147
+ }
148
+ .oc-shot {
149
+ margin: 0;
150
+ border: 1px solid var(--oc-border);
151
+ border-radius: var(--oc-radius-lg, 1.25rem);
152
+ overflow: hidden;
153
+ background: var(--oc-surface);
154
+ }
155
+ .oc-shot > :is(a, button) { display: block; inline-size: 100%; padding: 0; border: 0; background: none; cursor: zoom-in; }
156
+ .oc-shot > :is(a, button):focus-visible { outline: 2px solid var(--oc-ring); outline-offset: -2px; }
157
+ .oc-shot :is(picture) { display: block; }
158
+ .oc-shot img {
159
+ display: block;
160
+ inline-size: 100%;
161
+ aspect-ratio: var(--oc-shot-ratio, 16 / 10);
162
+ object-fit: contain;
163
+ background: var(--oc-card);
164
+ }
165
+ .oc-shot > figcaption {
166
+ padding: 0.8rem 1rem;
167
+ color: var(--oc-muted-foreground);
168
+ font-size: 0.88rem;
169
+ }
170
+
171
+ /* Browser frame with a shadow in the accent color and a product mark on the bottom end corner. */
172
+ .oc-browser-frame--glow {
173
+ position: relative;
174
+ box-shadow: 0 40px 90px -40px color-mix(in srgb, var(--oc-accent-from) 70%, transparent);
175
+ }
176
+ .oc-browser-frame--glow :is(.oc-browser-frame__view, .oc-browser-frame__body) img { transition: scale 0.6s var(--oc-ease-out, ease); }
177
+ .oc-browser-frame--glow:hover :is(.oc-browser-frame__view, .oc-browser-frame__body) img { scale: 1.02; }
178
+ .oc-browser-frame__mark {
179
+ position: absolute;
180
+ inset-block-end: 1rem;
181
+ inset-inline-end: 1rem;
182
+ filter: drop-shadow(0 10px 20px oklch(0 0 0 / 0.6));
183
+ }