@tideworthy/tidysites-platform 0.10.15 → 0.11.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +13 -0
  3. package/bin/tidysites-seed.mjs +51 -0
  4. package/package.json +14 -2
  5. package/src/sections/Photo.astro +18 -0
  6. package/src/sections/Sections.astro +42 -0
  7. package/src/sections/defaults/_Head.astro +13 -0
  8. package/src/sections/defaults/areas.astro +20 -0
  9. package/src/sections/defaults/badges.astro +17 -0
  10. package/src/sections/defaults/brands.astro +14 -0
  11. package/src/sections/defaults/case_studies.astro +16 -0
  12. package/src/sections/defaults/cases.astro +22 -0
  13. package/src/sections/defaults/clients.astro +14 -0
  14. package/src/sections/defaults/conditions.astro +13 -0
  15. package/src/sections/defaults/contact.astro +46 -0
  16. package/src/sections/defaults/cta.astro +16 -0
  17. package/src/sections/defaults/custom.astro +16 -0
  18. package/src/sections/defaults/downloads.astro +14 -0
  19. package/src/sections/defaults/faq.astro +20 -0
  20. package/src/sections/defaults/feature.astro +36 -0
  21. package/src/sections/defaults/financing.astro +17 -0
  22. package/src/sections/defaults/gallery.astro +14 -0
  23. package/src/sections/defaults/hero.astro +66 -0
  24. package/src/sections/defaults/insurance.astro +22 -0
  25. package/src/sections/defaults/intro.astro +11 -0
  26. package/src/sections/defaults/location.astro +32 -0
  27. package/src/sections/defaults/locations.astro +14 -0
  28. package/src/sections/defaults/offer.astro +18 -0
  29. package/src/sections/defaults/options.astro +12 -0
  30. package/src/sections/defaults/plans.astro +16 -0
  31. package/src/sections/defaults/policies.astro +13 -0
  32. package/src/sections/defaults/posts.astro +14 -0
  33. package/src/sections/defaults/products.astro +17 -0
  34. package/src/sections/defaults/related.astro +17 -0
  35. package/src/sections/defaults/reviews.astro +44 -0
  36. package/src/sections/defaults/services.astro +49 -0
  37. package/src/sections/defaults/signs.astro +12 -0
  38. package/src/sections/defaults/stats.astro +13 -0
  39. package/src/sections/defaults/steps.astro +32 -0
  40. package/src/sections/defaults/team.astro +42 -0
  41. package/src/sections/defaults/technologies.astro +13 -0
  42. package/src/sections/defaults/video.astro +15 -0
  43. package/src/sections/defaults.css +112 -0
  44. package/src/sections/lib.ts +157 -0
  45. package/src/template/index.ts +75 -0
  46. package/src/version.ts +1 -1
  47. package/template/README.md +33 -0
  48. package/template/structure.json +4224 -0
@@ -0,0 +1,22 @@
1
+ ---
2
+ /** Insurers accepted, as logos, names or one line, with network status and government programs. */
3
+ import { bool, entries, getBusiness, nodeOf, pic, str } from "../lib";
4
+ import Head from "./_Head.astro";
5
+ const n = nodeOf(Astro.props);
6
+ const b = await getBusiness(Astro.request);
7
+ const variant = str(n.variant, "logos");
8
+ const items = variant === "one line" ? [] : await entries("insurers", { limit: 60 });
9
+ const inNet = items.filter((i) => !/out/i.test(str(i.data.network))), outNet = items.filter((i) => /out/i.test(str(i.data.network)));
10
+ const groups = bool(n.show_network) && outNet.length ? [{ title: "In network", list: inNet }, { title: "Out of network", list: outNet }] : [{ title: "", list: items }];
11
+ ---
12
+ {(items.length > 0 || b.insuranceLine) && (
13
+ <section class="ts" id="insurance"><div class="ts-wrap">
14
+ <Head n={n} action={str(n.button_label) ? { href: str(n.button_url, "/contact"), label: str(n.button_label) } : null} />
15
+ {variant === "one line" || !items.length ? <p class="ts-lede" style="margin:0">{b.insuranceLine}</p> : groups.map((g) => (
16
+ <div>{g.title && <p class="ts-group">{g.title}</p>}
17
+ {variant === "logos" ? <div class="ts-logos">{g.list.map((i) => { const p = pic(i.data.logo); return <div class="ts-logo" data-tidy-record="insurers" data-tidy-record-id={i.id} data-tidy-record-label="Insurer">{(p.image?.src || p.url) && <img src={p.image?.src ?? p.url ?? ""} alt="" />}<span>{str(i.data.title)}</span></div>; })}</div> : <div class="ts-chips">{g.list.map((i) => <span class="ts-chip" data-tidy-record="insurers" data-tidy-record-id={i.id} data-tidy-record-label="Insurer">{str(i.data.title)}</span>)}</div>}
18
+ </div>
19
+ ))}
20
+ {bool(n.show_programs, true) && b.governmentPrograms && <p class="ts-muted" style="margin-top:14px;font-size:14px">{b.governmentPrograms}</p>}
21
+ </div></section>
22
+ )}
@@ -0,0 +1,11 @@
1
+ ---
2
+ import { nodeOf, pic, str } from "../lib";
3
+ import Photo from "../Photo.astro";
4
+ const n = nodeOf(Astro.props);
5
+ const photo = pic(n.image);
6
+ const has = Boolean(photo.image || photo.url);
7
+ ---
8
+ <section class="ts"><div class={`ts-wrap ${has ? "ts-split" : ""}`}>
9
+ <div>{str(n.eyebrow) && <p class="ts-eyebrow">{str(n.eyebrow)}</p>}{str(n.headline) && <h2>{str(n.headline)}</h2>}{str(n.body) && <div class="ts-prose" style="margin-top:14px;white-space:pre-line">{str(n.body)}</div>}{str(n.button_label) && <div class="ts-actions"><a class="ts-btn" href={str(n.button_url, "#")}>{str(n.button_label)}</a></div>}</div>
10
+ {has && <Photo image={photo.image} url={photo.url} alt={str(n.headline)} />}
11
+ </div></section>
@@ -0,0 +1,32 @@
1
+ ---
2
+ /** Address, hours (per day or grouped), parking and a map, for the main location or a named one. */
3
+ import { addressLine, bool, entries, getBusiness, hoursGrouped, hoursRows, nodeOf, str, todayKey, DAYS } from "../lib";
4
+ import Head from "./_Head.astro";
5
+ const n = nodeOf(Astro.props);
6
+ const b = await getBusiness(Astro.request);
7
+ const locs = await entries("locations", { limit: 20 });
8
+ const want = str(n.location);
9
+ const loc = (want ? locs.find((l) => (l.slug ?? l.id) === want) : locs.find((l) => bool(l.data.is_primary))) ?? locs[0] ?? null;
10
+ const d = loc?.data ?? {};
11
+ const addr = loc ? addressLine({ address: str(d.address), city: str(d.city), region: str(d.region), postal: str(d.postal), hideAddress: b.hideAddress }) : addressLine(b);
12
+ const phone = str(d.phone) || b.phone;
13
+ const grouped = str(n.hours_style, "per day") === "grouped";
14
+ const rows = loc ? hoursRows(d) : [];
15
+ const today = DAYS.find(([k]) => k === todayKey())?.[1];
16
+ const mapUrl = str(d.map_url);
17
+ const q = encodeURIComponent([b.title, addr].filter(Boolean).join(" "));
18
+ ---
19
+ <section class="ts ts-band" id="find"><div class="ts-wrap">
20
+ <Head n={n} />
21
+ <div class={bool(n.show_map, true) ? "ts-split" : ""} style="align-items:start">
22
+ {bool(n.show_map, true) && (b.hideAddress ? <div class="ts-map">{b.serviceAreaLine || "We come to you."}</div> : <div class="ts-map"><iframe title="Map" loading="lazy" referrerpolicy="no-referrer-when-downgrade" src={`https://www.google.com/maps?q=${q}&output=embed`}></iframe></div>)}
23
+ <div class="ts-card">
24
+ {addr && <><p class="ts-group" style="margin-top:0">{b.isServiceArea ? "Based in" : "Address"}</p><p>{addr}</p>{(mapUrl || !b.hideAddress) && <a href={mapUrl || `https://www.google.com/maps/search/?api=1&query=${q}`} style="font-weight:700;color:var(--ts-primary);font-size:14px">Get directions</a>}</>}
25
+ {(phone || b.email) && <><p class="ts-group">Reach us</p>{phone && <p><a href={b.phoneHref}>{phone}</a></p>}{b.email && <p><a href={`mailto:${b.email}`}>{b.email}</a></p>}</>}
26
+ <p class="ts-group">Hours</p>
27
+ {rows.length ? (grouped ? <table class="ts-table">{hoursGrouped(d).map((g) => <tr><td>{g.days}</td><td>{g.value}</td></tr>)}</table> : <table class="ts-table">{rows.map((r) => <tr class={r.day === today ? "ts-today" : ""}><td>{r.day}</td><td>{r.value}</td></tr>)}</table>) : <p>{b.hours || "By appointment"}</p>}
28
+ {(str(d.hours_note) || b.hoursNote) && <p class="ts-muted" style="font-size:13px;margin-top:6px">{str(d.hours_note) || b.hoursNote}</p>}
29
+ {bool(n.show_parking, true) && (str(d.parking_line) || b.parkingLine || str(d.directions_line) || b.directionsLine) && <><p class="ts-group">Getting here</p><p style="font-size:14px">{[str(d.parking_line) || b.parkingLine, str(d.directions_line) || b.directionsLine, str(d.transit_line)].filter(Boolean).join(" ")}</p></>}
30
+ </div>
31
+ </div>
32
+ </div></section>
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { addressLine, bool, entries, getBusiness, hoursGrouped, nodeOf, pic, str } from "../lib";
3
+ import Photo from "../Photo.astro";
4
+ import Head from "./_Head.astro";
5
+ const n = nodeOf(Astro.props);
6
+ const b = await getBusiness(Astro.request);
7
+ const items = await entries("locations", { limit: 20 });
8
+ ---
9
+ {items.length > 0 && (
10
+ <section class="ts" id="locations"><div class="ts-wrap">
11
+ <Head n={n} />
12
+ <div class="ts-grid" style={`--cols:${Math.min(3, items.length)}`}>{items.map((l) => { const p = pic(l.data.photo); const d = l.data; return <div class="ts-card" data-tidy-record="locations" data-tidy-record-id={l.id} data-tidy-record-label="Location">{(p.image || p.url) && <Photo image={p.image} url={p.url} alt={str(d.title)} ratio="wide" />}<h3>{str(d.title)}</h3><p>{addressLine({ address: str(d.address), city: str(d.city), region: str(d.region), postal: str(d.postal) })}</p>{str(d.phone) && <p><a href={`tel:${str(d.phone).replace(/[^\d+]/g, "")}`}>{str(d.phone)}</a></p>}{bool(n.show_hours, true) && hoursGrouped(d).length > 0 && <table class="ts-table" style="font-size:13px">{hoursGrouped(d).map((g) => <tr><td>{g.days}</td><td>{g.value}</td></tr>)}</table>}{bool(n.show_booking, true) && (str(d.booking_url) || b.bookingUrl) && <div class="ts-actions" style="margin-top:8px"><a class="ts-btn ts-btn-accent" href={str(d.booking_url) || b.bookingUrl}>{b.bookingLabel}</a></div>}</div>; })}</div>
13
+ </div></section>
14
+ )}
@@ -0,0 +1,18 @@
1
+ ---
2
+ import { bool, entries, nodeOf, str } from "../lib";
3
+ const n = nodeOf(Astro.props);
4
+ const offers = await entries("offers", { limit: 20 });
5
+ const want = str(n.offer);
6
+ const o = (want ? offers.find((x) => (x.slug ?? x.id) === want) : offers.find((x) => bool(x.data.active, true))) ?? null;
7
+ const ends = o ? str(o.data.ends) : "";
8
+ const live = !o || !ends || new Date(ends).getTime() > Date.now();
9
+ const title = str(n.headline) || (o ? str(o.data.title) : "");
10
+ ---
11
+ {title && live && (
12
+ <section class="ts ts-tight"><div class="ts-wrap">
13
+ <div class="ts-offer" {...(o ? { "data-tidy-record": "offers", "data-tidy-record-id": o.id, "data-tidy-record-label": "Offer" } : {})}>
14
+ <div>{ends && <p class="ts-eyebrow">Until {new Date(ends).toLocaleDateString("en-CA", { month: "long", day: "numeric" })}</p>}<h3 style="font-size:24px">{title}{o && str(o.data.value) ? ` · ${str(o.data.value)}` : ""}</h3>{(str(n.body) || (o && str(o.data.summary))) && <p style="margin-top:6px;color:var(--ts-ink-2)">{str(n.body) || str(o!.data.summary)}</p>}{o && str(o.data.how_to_claim) && <p class="ts-muted" style="font-size:13px;margin-top:6px">{str(o.data.how_to_claim)}</p>}</div>
15
+ <a class="ts-btn ts-btn-accent ts-btn-lg" href={str(n.button_url) || (o && str(o.data.cta_url)) || "/contact"}>{str(n.button_label) || (o && str(o.data.cta_label)) || "Claim this offer"}</a>
16
+ </div>
17
+ </div></section>
18
+ )}
@@ -0,0 +1,12 @@
1
+ ---
2
+ import { bool, facts, list, nodeOf, str } from "../lib";
3
+ import Head from "./_Head.astro";
4
+ const n = nodeOf(Astro.props);
5
+ const items = list<Record<string, unknown>>(n.items).filter((i) => str(i.title));
6
+ ---
7
+ {items.length > 0 && (
8
+ <section class="ts"><div class="ts-wrap">
9
+ <Head n={n} />
10
+ <div class="ts-grid" style={`--cols:${Math.min(3, items.length)}`}>{items.map((o) => <div class="ts-card" style={bool(o.featured) ? "border-color:var(--ts-primary)" : ""}>{str(o.tag) && <p class="ts-eyebrow" style="margin:0">{str(o.tag)}</p>}<h3>{str(o.title)}</h3>{str(o.price) && <p class="ts-price" style="font-size:30px">{str(o.price)}{str(o.price_note) && <small>{str(o.price_note)}</small>}</p>}{str(o.summary) && <p>{str(o.summary)}</p>}{facts(o.facts).length > 0 && <dl class="ts-kv" style="font-size:14px">{facts(o.facts).map(([k, v]) => <><dt>{k}</dt><dd>{v}</dd></>)}</dl>}{str(o.button_label) && <div class="ts-actions" style="margin-top:8px"><a class="ts-btn" href={str(o.button_url, "/contact")}>{str(o.button_label)}</a></div>}</div>)}</div>
11
+ </div></section>
12
+ )}
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { bool, entries, int, lines, nodeOf, str } from "../lib";
3
+ import Head from "./_Head.astro";
4
+ const n = nodeOf(Astro.props);
5
+ let items = await entries("plans", { limit: int(n.limit, 6) });
6
+ if (str(n.kind)) items = items.filter((p) => str(p.data.kind) === str(n.kind));
7
+ if (bool(n.featured_first, true)) items = [...items].sort((a, b) => Number(bool(b.data.featured)) - Number(bool(a.data.featured)));
8
+ ---
9
+ {items.length > 0 && (
10
+ <section class="ts" id="plans"><div class="ts-wrap">
11
+ <Head n={n} />
12
+ <div class="ts-grid" style={`--cols:${Math.min(3, items.length)}`}>
13
+ {items.map((p) => <div class="ts-card" data-tidy-record="plans" data-tidy-record-id={p.id} data-tidy-record-label="Plan" style={bool(p.data.featured) ? "border-color:var(--ts-primary)" : ""}>{str(p.data.tag) && <p class="ts-eyebrow" style="margin:0">{str(p.data.tag)}</p>}<h3>{str(p.data.title)}</h3>{str(p.data.price) && <p class="ts-price">{str(p.data.price)}{str(p.data.cadence) && <small>/ {str(p.data.cadence)}</small>}</p>}{str(p.data.summary) && <p>{str(p.data.summary)}</p>}{lines(p.data.includes).length > 0 && <ul class="ts-list">{lines(p.data.includes).map((l) => <li>{l}</li>)}</ul>}{str(p.data.eligibility) && <p class="ts-muted" style="font-size:13px">{str(p.data.eligibility)}</p>}{str(p.data.cta_label) && <div class="ts-actions" style="margin-top:12px"><a class="ts-btn ts-btn-accent" href={str(p.data.cta_url, "/contact")}>{str(p.data.cta_label)}</a></div>}{str(p.data.note) && <p class="ts-muted" style="font-size:12px">{str(p.data.note)}</p>}</div>)}
14
+ </div>
15
+ </div></section>
16
+ )}
@@ -0,0 +1,13 @@
1
+ ---
2
+ import { entries, nodeOf, str } from "../lib";
3
+ import Head from "./_Head.astro";
4
+ const n = nodeOf(Astro.props);
5
+ let items = await entries("policies", { limit: 20 });
6
+ if (str(n.group)) items = items.filter((p) => str(p.data.group) === str(n.group));
7
+ ---
8
+ {items.length > 0 && (
9
+ <section class="ts ts-band" id="policies"><div class="ts-wrap">
10
+ <Head n={n} />
11
+ <div class="ts-grid" style="--cols:3">{items.map((p) => <div class="ts-card" data-tidy-record="policies" data-tidy-record-id={p.id} data-tidy-record-label="Policy"><h3>{str(p.data.title)}</h3><p style="white-space:pre-line">{str(p.data.body)}</p></div>)}</div>
12
+ </div></section>
13
+ )}
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { bool, entries, int, nodeOf, pic, str } from "../lib";
3
+ import Photo from "../Photo.astro";
4
+ import Head from "./_Head.astro";
5
+ const n = nodeOf(Astro.props);
6
+ let items = await entries("posts", { limit: int(n.limit, 3), orderBy: { createdAt: "desc" } as never });
7
+ if (str(n.category)) items = items.filter((p) => str(p.data.category) === str(n.category));
8
+ ---
9
+ {items.length > 0 && (
10
+ <section class="ts"><div class="ts-wrap">
11
+ <Head n={n} action={bool(n.show_all, true) ? { href: "/blog", label: "All posts" } : null} />
12
+ <div class="ts-grid" style="--cols:3">{items.map((p) => { const ph = pic(p.data.featured_image); return <a class="ts-card" href={`/blog/${p.slug ?? p.id}`}><Photo image={ph.image} url={ph.url} alt={str(p.data.title)} ratio="wide" />{str(p.data.category) && <p class="ts-eyebrow" style="margin:0">{str(p.data.category)}</p>}<h3>{str(p.data.title)}</h3>{str(p.data.summary) && <p>{str(p.data.summary)}</p>}</a>; })}</div>
13
+ </div></section>
14
+ )}
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { bool, entries, getBusiness, int, nodeOf, pic, str } from "../lib";
3
+ import Photo from "../Photo.astro";
4
+ import Head from "./_Head.astro";
5
+ const n = nodeOf(Astro.props);
6
+ const b = await getBusiness(Astro.request);
7
+ let items = await entries("products", { limit: int(n.limit, 8) });
8
+ if (str(n.category)) items = items.filter((p) => str(p.data.category) === str(n.category));
9
+ if (str(n.variant) === "featured") items = items.filter((p) => bool(p.data.featured));
10
+ const prices = bool(n.show_prices, true);
11
+ ---
12
+ {items.length > 0 && (
13
+ <section class="ts" id="products"><div class="ts-wrap">
14
+ <Head n={n} action={bool(n.show_store_button, true) && b.storeUrl ? { href: b.storeUrl, label: b.storeLabel } : null} />
15
+ <div class="ts-grid" style={`--cols:${str(n.variant) === "list" ? 1 : 4}`}>{items.map((p) => { const ph = pic(p.data.image); return <a class="ts-card" href={str(p.data.buy_url) || `/products/${p.slug ?? p.id}`} data-tidy-record="products" data-tidy-record-id={p.id} data-tidy-record-label="Product"><Photo image={ph.image} url={ph.url} alt={str(p.data.title)} ratio="square" /><h3 style="font-size:17px">{str(p.data.title)}</h3>{str(p.data.summary) && <p>{str(p.data.summary)}</p>}{prices && str(p.data.price) && <p style="font-weight:700">{str(p.data.price)}{str(p.data.unit) && <span class="ts-muted" style="font-weight:400"> / {str(p.data.unit)}</span>}{str(p.data.compare_price) && <s class="ts-muted" style="margin-left:8px;font-weight:400">{str(p.data.compare_price)}</s>}</p>}{str(p.data.availability_line) && <p class="ts-muted" style="font-size:12.5px">{str(p.data.availability_line)}</p>}</a>; })}</div>
16
+ </div></section>
17
+ )}
@@ -0,0 +1,17 @@
1
+ ---
2
+ /** Other services in the same category as the service on the page. */
3
+ import { entries, int, nodeOf, str } from "../lib";
4
+ const props = Astro.props as Record<string, unknown>;
5
+ const n = nodeOf(props);
6
+ const ctx = (props.context as Record<string, unknown> | null) ?? null;
7
+ const cat = ctx ? String(ctx.category ?? "") : "";
8
+ const selfId = ctx ? String(ctx.id ?? "") : "";
9
+ let items = await entries("services", { limit: 40 });
10
+ items = items.filter((s) => s.id !== selfId && (!cat || String(s.data.category ?? "") === cat)).slice(0, int(n.limit, 4));
11
+ ---
12
+ {items.length > 0 && (
13
+ <section class="ts ts-tight ts-band"><div class="ts-wrap">
14
+ <h3 style="margin-bottom:14px">{str(n.headline) || "Related services"}</h3>
15
+ <div class="ts-grid" style="--cols:4">{items.map((s) => <a class="ts-card" href={`/services/${s.slug ?? s.id}`}><h4>{str(s.data.title)}</h4></a>)}</div>
16
+ </div></section>
17
+ )}
@@ -0,0 +1,44 @@
1
+ ---
2
+ /** Reviews from the records, with the business's rating and count beside them. */
3
+ import { bool, entries, getBusiness, int, nodeOf, pic, str } from "../lib";
4
+ import Head from "./_Head.astro";
5
+ const n = nodeOf(Astro.props);
6
+ const b = await getBusiness(Astro.request);
7
+ const variant = str(n.variant, "aggregate with quotes");
8
+ const where: Record<string, string> = {};
9
+ if (bool(n.featured_only)) where.featured = "1";
10
+ let items = await entries("reviews", { limit: int(n.limit, 3), where });
11
+ const svc = str(n.service), area = str(n.service_area);
12
+ if (svc) items = items.filter((r) => String(r.data.service ?? "") === svc || true);
13
+ if (variant === "video testimonials") items = items.filter((r) => str(r.data.video_url));
14
+ const rating = b.rating ? b.rating.toFixed(1) : null;
15
+ const stars = (v: unknown) => { const r = typeof v === "number" ? Math.max(1, Math.min(5, Math.round(v))) : 5; return "★".repeat(r); };
16
+ const embed = (u: string) => u.replace("watch?v=", "embed/").replace("youtu.be/", "www.youtube.com/embed/");
17
+ const who = (r: { data: Record<string, unknown> }) => str(r.data.display_name) || str(r.data.title);
18
+ const when = (r: { data: Record<string, unknown> }) => { const d = str(r.data.date); return d ? new Date(d).toLocaleDateString("en-CA", { month: "long", year: "numeric" }) : ""; };
19
+ ---
20
+ {(items.length > 0 || (bool(n.show_aggregate, true) && rating)) && (
21
+ <section class="ts" id="reviews"><div class="ts-wrap">
22
+ <Head n={n} action={bool(n.show_review_links) && b.reviewsUrl ? { href: b.reviewsUrl, label: "Read them all" } : null} />
23
+ {variant === "single quote band" && items[0] ? (
24
+ <div style="max-width:64ch"><p class="ts-stars">{stars(items[0].data.rating)}</p><p style="font-family:var(--ts-display);font-size:clamp(22px,2.4vw,30px);line-height:1.3;margin-top:12px">“{str(items[0].data.quote)}”</p><p class="ts-muted" style="margin-top:12px">{who(items[0])}{bool(n.show_source, true) && str(items[0].data.source) ? ` · ${str(items[0].data.source)}` : ""}</p></div>
25
+ ) : variant === "video testimonials" ? (
26
+ <div class="ts-grid" style="--cols:3">{items.map((r) => <div><div class="ts-video"><iframe src={embed(str(r.data.video_url))} title={who(r)} loading="lazy" allowfullscreen></iframe></div><p style="margin-top:8px;font-weight:600">{who(r)}</p></div>)}</div>
27
+ ) : (
28
+ <div class={variant === "aggregate with quotes" && rating ? "ts-split ts-rev" : ""} style={variant === "aggregate with quotes" && rating ? "align-items:start" : ""}>
29
+ {variant === "aggregate with quotes" && rating && (
30
+ <aside class="ts-card"><span style="font-family:var(--ts-display);font-size:64px;font-weight:800;letter-spacing:-.04em;line-height:1">{rating}</span><span class="ts-stars">★★★★★</span>{b.reviewCount && <p class="ts-muted">{b.reviewCount} {b.ratingSource} reviews</p>}{b.reviewsUrl && <div class="ts-chips" style="margin-top:12px"><a class="ts-chip" href={b.reviewsUrl}>Read them all</a><a class="ts-chip" href={b.reviewsUrl}>Leave a review</a></div>}</aside>
31
+ )}
32
+ <div class="ts-grid" style={`--cols:${variant === "aggregate with quotes" ? 1 : 3}`}>
33
+ {items.map((r) => { const ph = pic(r.data.photo); return (
34
+ <article class="ts-card ts-review" data-tidy-record="reviews" data-tidy-record-id={r.id} data-tidy-record-label="Review">
35
+ <span class="ts-stars" aria-label={`${stars(r.data.rating).length} stars`}>{stars(r.data.rating)}</span>
36
+ <blockquote>{str(r.data.quote)}</blockquote>
37
+ <div class="ts-who">{(ph.image || ph.url) && <span class="ts-avatar"><img src={ph.image?.src ?? ph.url ?? ""} alt="" /></span>}<div><b>{who(r)}</b><span>{[bool(n.show_source, true) ? str(r.data.source) : "", when(r)].filter(Boolean).join(" · ")}</span></div></div>
38
+ </article>
39
+ ); })}
40
+ </div>
41
+ </div>
42
+ )}
43
+ </div></section>
44
+ )}
@@ -0,0 +1,49 @@
1
+ ---
2
+ /** Services or categories from the records, in one of five layouts. */
3
+ import { bool, entries, int, nodeOf, pic, str } from "../lib";
4
+ import Photo from "../Photo.astro";
5
+ import Head from "./_Head.astro";
6
+ const n = nodeOf(Astro.props);
7
+ const variant = str(n.variant, "cards with summary");
8
+ const source = str(n.source, "services");
9
+ const limit = int(n.limit, 12);
10
+ let items: Array<{ id: string; slug?: string | null; data: Record<string, unknown>; href: string }> = [];
11
+ let groups: Array<{ title: string; items: typeof items }> = [];
12
+ const svc = (e: { id: string; slug?: string | null; data: Record<string, unknown> }) => ({ ...e, href: `/services/${e.slug ?? e.id}` });
13
+ if (source === "categories") items = (await entries("service_categories", { limit })).map((e) => ({ ...e, href: `/services/category/${e.slug ?? e.id}` }));
14
+ else if (source === "featured services") items = (await entries("services", { limit, where: { featured: "1" } })).map(svc);
15
+ else if (source === "services in one category" && str(n.category)) {
16
+ const cats = await entries("service_categories");
17
+ const cat = cats.find((c) => (c.slug ?? c.id) === str(n.category));
18
+ items = (cat ? await entries("services", { limit, where: { category: String(cat.id) } }) : []).map(svc);
19
+ } else items = (await entries("services", { limit })).map(svc);
20
+ if (variant === "grouped by category") {
21
+ const cats = await entries("service_categories");
22
+ const all = (await entries("services", { limit: 100 })).map(svc);
23
+ groups = cats.map((c) => ({ title: str(c.data.title), items: all.filter((s) => String(s.data.category ?? "") === String(c.id)) })).filter((g) => g.items.length);
24
+ if (!groups.length) groups = [{ title: "", items: all }];
25
+ }
26
+ const showAll = bool(n.show_all, true);
27
+ ---
28
+ {(items.length > 0 || groups.length > 0) && (
29
+ <section class="ts ts-band" id="services"><div class="ts-wrap">
30
+ <Head n={n} action={showAll ? { href: "/services", label: "All services" } : null} />
31
+ {variant === "checklist" ? (
32
+ <ul class="ts-list" style="columns:2;gap:28px">{items.map((s) => <li><a href={s.href}>{str(s.data.title)}</a></li>)}</ul>
33
+ ) : variant === "grouped by category" ? (
34
+ groups.map((g) => <div>{g.title && <p class="ts-group">{g.title}</p>}<div class="ts-chips">{g.items.map((s) => <a class="ts-chip" href={s.href} data-tidy-record="services" data-tidy-record-id={s.id} data-tidy-record-label="Service details">{str(s.data.title)}</a>)}</div></div>)
35
+ ) : (
36
+ <div class="ts-grid" style={`--cols:${variant === "image tiles" ? 3 : 3}`}>
37
+ {items.map((s) => (
38
+ <a class="ts-card" href={s.href} data-tidy-record={source === "categories" ? "service_categories" : "services"} data-tidy-record-id={s.id} data-tidy-record-label={source === "categories" ? "Category details" : "Service details"}>
39
+ {variant === "image tiles" && (() => { const p = pic(s.data.featured_image); return <Photo image={p.image} url={p.url} alt={str(s.data.title)} ratio="wide" />; })()}
40
+ {variant === "icon cards" && str(s.data.icon) && <span class="ts-eyebrow" style="margin:0">{str(s.data.icon)}</span>}
41
+ <h3>{str(s.data.title)}</h3>
42
+ {variant !== "image tiles" && str(s.data.summary) && <p>{str(s.data.summary)}</p>}
43
+ {str(s.data.price_from) && <p class="ts-muted" style="font-size:13px">From {str(s.data.price_from)}</p>}
44
+ </a>
45
+ ))}
46
+ </div>
47
+ )}
48
+ </div></section>
49
+ )}
@@ -0,0 +1,12 @@
1
+ ---
2
+ import { list, nodeOf, str } from "../lib";
3
+ import Head from "./_Head.astro";
4
+ const n = nodeOf(Astro.props);
5
+ const items = list<Record<string, unknown>>(n.items).filter((i) => str(i.title));
6
+ ---
7
+ {items.length > 0 && (
8
+ <section class="ts ts-band"><div class="ts-wrap">
9
+ <Head n={n} />
10
+ <div class="ts-grid" style="--cols:3">{items.map((i) => <div class="ts-card">{str(i.icon) && <span class="ts-eyebrow" style="margin:0">{str(i.icon)}</span>}<h3>{str(i.title)}</h3>{str(i.text) && <p>{str(i.text)}</p>}</div>)}</div>
11
+ </div></section>
12
+ )}
@@ -0,0 +1,13 @@
1
+ ---
2
+ /** Value and label tiles. A tile with no value is left out, never shown as a zero. */
3
+ import { list, nodeOf, str } from "../lib";
4
+ import Head from "./_Head.astro";
5
+ const n = nodeOf(Astro.props);
6
+ const tiles = list<Record<string, unknown>>(n.tiles).filter((t) => str(t.value) && str(t.value) !== "0");
7
+ ---
8
+ {tiles.length > 0 && (
9
+ <section class="ts ts-tight"><div class="ts-wrap">
10
+ <Head n={n} />
11
+ <div class="ts-grid" style={`--cols:${Math.min(4, tiles.length)}`}>{tiles.map((t) => <div class="ts-card ts-stat"><b>{str(t.value)}</b><span>{str(t.label)}{str(t.source) ? ` · ${str(t.source)}` : ""}</span></div>)}</div>
12
+ </div></section>
13
+ )}
@@ -0,0 +1,32 @@
1
+ ---
2
+ /** A real sequence: a first visit, a treatment, aftercare. */
3
+ import { bool, entries, lines, list, nodeOf, pic, str } from "../lib";
4
+ import Photo from "../Photo.astro";
5
+ import Head from "./_Head.astro";
6
+ const n = nodeOf(Astro.props);
7
+ const items = list<Record<string, unknown>>(n.steps).filter((s) => str(s.title));
8
+ const prep = lines(n.prep);
9
+ const forms = bool(n.show_forms) ? await entries("downloads", { limit: 6 }) : [];
10
+ const aside = bool(n.show_prep) && (prep.length > 0 || forms.length > 0);
11
+ ---
12
+ {items.length > 0 && (
13
+ <section class="ts"><div class="ts-wrap">
14
+ <Head n={n} />
15
+ <div class={aside ? "ts-split" : ""}>
16
+ <div class="ts-steps">
17
+ {items.map((s, i) => (
18
+ <div class="ts-step">
19
+ <span class="ts-n">{str(s.duration) || String(i + 1).padStart(2, "0")}</span>
20
+ <div><h3>{str(s.title)}</h3>{str(s.body) && <p>{str(s.body)}</p>}{(() => { const p = pic(s.image); return p.image || p.url ? <Photo image={p.image} url={p.url} alt={str(s.title)} ratio="wide" class="ts-step-photo" /> : null; })()}</div>
21
+ </div>
22
+ ))}
23
+ </div>
24
+ {aside && (
25
+ <aside class="ts-card">
26
+ {prep.length > 0 && <><h3>Bring with you</h3><ul class="ts-list" style="margin-top:10px">{prep.map((p) => <li>{p}</li>)}</ul></>}
27
+ {forms.length > 0 && <div class="ts-chips" style="margin-top:14px">{forms.map((f) => <a class="ts-chip" href={str(f.data.file_url, "#")}>{str(f.data.title)}</a>)}</div>}
28
+ </aside>
29
+ )}
30
+ </div>
31
+ </div></section>
32
+ )}
@@ -0,0 +1,42 @@
1
+ ---
2
+ /** People from the records: cards, grouped by department, a photo grid, or one person featured. */
3
+ import { bool, entries, int, nodeOf, pic, str } from "../lib";
4
+ import Photo from "../Photo.astro";
5
+ import Head from "./_Head.astro";
6
+ const n = nodeOf(Astro.props);
7
+ const variant = str(n.variant, "cards");
8
+ const limit = int(n.limit, 24);
9
+ const group = str(n.group);
10
+ let people = await entries("people", { limit: 100 });
11
+ if (group) people = people.filter((p) => str(p.data.group) === group);
12
+ if (bool(n.featured_only)) people = people.filter((p) => bool(p.data.is_owner) || str(p.data.credentials));
13
+ people = people.slice(0, limit);
14
+ const showCred = bool(n.show_credentials, true), showBio = bool(n.show_bio, true);
15
+ const name = (p: { data: Record<string, unknown> }) => str(p.data.display_name) || str(p.data.title);
16
+ const groups = variant === "grouped by department" ? [...new Set(people.map((p) => str(p.data.group)))].map((g) => ({ title: g, items: people.filter((p) => str(p.data.group) === g) })) : [];
17
+ const href = (p: { slug?: string | null; id: string; data: Record<string, unknown> }) => (bool(p.data.has_profile) ? `/team/${p.slug ?? p.id}` : null);
18
+ ---
19
+ {people.length > 0 && (
20
+ <section class="ts ts-band" id="team"><div class="ts-wrap">
21
+ <Head n={n} action={bool(n.show_all, true) ? { href: "/team", label: "Meet the team" } : null} />
22
+ {variant === "one person featured" ? (() => { const p = people[0]!; const ph = pic(p.data.photo); return (
23
+ <div class="ts-split"><Photo image={ph.image} url={ph.url} alt={name(p)} ratio="tall" /><div data-tidy-record="people" data-tidy-record-id={p.id} data-tidy-record-label="Team member"><p class="ts-role">{str(p.data.role)}{showCred && str(p.data.credentials) ? ` · ${str(p.data.credentials)}` : ""}</p><h3 style="font-size:28px;margin-top:6px">{name(p)}</h3>{str(p.data.bio) && <p class="ts-prose" style="margin-top:12px">{str(p.data.bio)}</p>}{href(p) && <div class="ts-actions"><a class="ts-btn ts-btn-outline" href={href(p)!}>Full bio</a></div>}</div></div>
24
+ ); })() : variant === "photo grid" ? (
25
+ <div class="ts-gallery">{people.map((p) => { const ph = pic(p.data.photo); return <div data-tidy-record="people" data-tidy-record-id={p.id} data-tidy-record-label="Team member"><Photo image={ph.image} url={ph.url} alt={name(p)} ratio="square" /><p style="margin-top:6px;font-weight:600;font-size:14px">{name(p)}</p></div>; })}</div>
26
+ ) : (groups.length ? groups : [{ title: "", items: people }]).map((g) => (
27
+ <div>{g.title && <p class="ts-group">{g.title}</p>}
28
+ <div class="ts-grid" style="--cols:3">
29
+ {g.items.map((p) => { const ph = pic(p.data.photo); const h = href(p); return (
30
+ <div class="ts-card" data-tidy-record="people" data-tidy-record-id={p.id} data-tidy-record-label="Team member">
31
+ {(ph.image || ph.url) && <Photo image={ph.image} url={ph.url} alt={name(p)} ratio="tall" />}
32
+ <div><span class="ts-person-name">{name(p)}</span>{showCred && str(p.data.credentials) && <span class="ts-muted" style="font-size:13px"> · {str(p.data.credentials)}</span>}</div>
33
+ {str(p.data.role) && <p class="ts-role">{str(p.data.role)}</p>}
34
+ {showBio && str(p.data.bio) && <p>{str(p.data.bio)}</p>}
35
+ {str(p.data.languages) && <p class="ts-muted" style="font-size:13px">{str(p.data.languages)}</p>}
36
+ {h && <a href={h} style="font-weight:700;font-size:14px;color:var(--ts-primary)">Full bio</a>}
37
+ </div>
38
+ ); })}
39
+ </div></div>
40
+ ))}
41
+ </div></section>
42
+ )}
@@ -0,0 +1,13 @@
1
+ ---
2
+ import { bool, entries, int, nodeOf, pic, str } from "../lib";
3
+ import Photo from "../Photo.astro";
4
+ import Head from "./_Head.astro";
5
+ const n = nodeOf(Astro.props);
6
+ const items = await entries("technologies", { limit: int(n.limit, 6) });
7
+ ---
8
+ {items.length > 0 && (
9
+ <section class="ts ts-band"><div class="ts-wrap">
10
+ <Head n={n} />
11
+ <div class="ts-grid" style="--cols:3">{items.map((t) => { const p = pic(t.data.image); return <div class="ts-card" data-tidy-record="technologies" data-tidy-record-id={t.id} data-tidy-record-label="Technology">{(p.image || p.url) && <Photo image={p.image} url={p.url} alt={str(t.data.title)} ratio="wide" />}<h3>{str(t.data.title)}{str(t.data.brand) && <span class="ts-muted" style="font-weight:400;font-size:14px"> · {str(t.data.brand)}</span>}</h3>{str(t.data.summary) && <p>{str(t.data.summary)}</p>}{bool(t.data.has_page) && <a href={`/technology/${t.slug ?? t.id}`} style="font-weight:700;color:var(--ts-primary);font-size:14px">Learn more</a>}</div>; })}</div>
12
+ </div></section>
13
+ )}
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { nodeOf, str } from "../lib";
3
+ import Head from "./_Head.astro";
4
+ const n = nodeOf(Astro.props);
5
+ const url = str(n.video_url);
6
+ const embed = url.replace("watch?v=", "embed/").replace("youtu.be/", "www.youtube.com/embed/").replace("vimeo.com/", "player.vimeo.com/video/");
7
+ const isFile = /\.(mp4|webm|mov)(\?|$)/i.test(url);
8
+ ---
9
+ {url && (
10
+ <section class="ts"><div class="ts-wrap">
11
+ <Head n={n} />
12
+ <div class="ts-video">{isFile ? <video src={url} controls playsinline></video> : <iframe src={embed} title={str(n.headline) || "Video"} loading="lazy" allowfullscreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>}</div>
13
+ {str(n.caption) && <p class="ts-muted" style="margin-top:10px;font-size:14px">{str(n.caption)}</p>}
14
+ </div></section>
15
+ )}
@@ -0,0 +1,112 @@
1
+ /* Default section styling. Plain, readable, and driven by the brand tokens a
2
+ theme or the layout sets on <html>: --brand-primary, --brand-accent,
3
+ --brand-font-display, --brand-font-body. Everything is namespaced ts- so a
4
+ theme's own styles never collide. A theme that renders a type itself never
5
+ loads these rules for it. */
6
+ .ts{--ts-ink:var(--ink,#17211c);--ts-ink-2:var(--ink-2,#4a5851);--ts-ink-3:var(--ink-3,#7b877f);--ts-line:var(--line,#d6ddd9);--ts-ground:var(--ground,#f6f7f5);--ts-surface:var(--surface,#fff);--ts-primary:var(--primary,var(--brand-primary,#1e5a46));--ts-primary-ink:var(--primary-ink,var(--brand-primary-ink,#fff));--ts-accent:var(--accent,var(--brand-accent,#e8b23a));--ts-accent-ink:var(--accent-ink,var(--brand-accent-ink,#111));--ts-display:var(--brand-font-display,system-ui,sans-serif);--ts-body:var(--brand-font-body,system-ui,sans-serif);--ts-radius:var(--brand-radius,10px);--ts-max:var(--max,1180px);--ts-gutter:var(--gutter,clamp(16px,4vw,40px));--ts-space:var(--space,clamp(48px,7vw,96px));
7
+ font-family:var(--ts-body);color:var(--ts-ink);padding:var(--ts-space) 0;line-height:1.55}
8
+ .ts.ts-band{background:var(--ts-surface-2,color-mix(in srgb,var(--ts-primary) 8%,var(--ts-ground)))}
9
+ .ts.ts-ink{background:var(--ts-ink);color:#fff}
10
+ .ts.ts-tight{padding:calc(var(--ts-space) * .55) 0}
11
+ .ts *{box-sizing:border-box}
12
+ .ts .ts-wrap{max-width:var(--ts-max);margin:0 auto;padding:0 var(--ts-gutter)}
13
+ .ts h1,.ts h2,.ts h3,.ts h4{font-family:var(--ts-display);line-height:1.1;margin:0;letter-spacing:-.01em}
14
+ .ts h1{font-size:clamp(34px,5vw,60px);letter-spacing:-.02em}
15
+ .ts h2{font-size:clamp(26px,3.2vw,40px)}
16
+ .ts h3{font-size:20px}
17
+ .ts h4{font-size:16px}
18
+ .ts p{margin:0}
19
+ .ts a{color:inherit}
20
+ .ts img{display:block;max-width:100%;height:auto}
21
+ .ts .ts-eyebrow{font-size:12px;letter-spacing:.12em;text-transform:uppercase;font-weight:700;color:var(--ts-primary);margin-bottom:10px}
22
+ .ts.ts-ink .ts-eyebrow{color:color-mix(in srgb,var(--ts-accent) 70%,#fff)}
23
+ .ts .ts-lede{font-size:18px;color:var(--ts-ink-2);max-width:60ch;margin-top:12px}
24
+ .ts.ts-ink .ts-lede,.ts.ts-ink .ts-muted{color:rgba(255,255,255,.78)}
25
+ .ts .ts-muted{color:var(--ts-ink-3)}
26
+ .ts .ts-head{display:flex;justify-content:space-between;align-items:flex-end;gap:20px;margin-bottom:28px;flex-wrap:wrap}
27
+ .ts .ts-btn{display:inline-flex;align-items:center;gap:8px;font-family:var(--ts-display);font-weight:700;font-size:15px;padding:12px 20px;border-radius:999px;border:1.5px solid transparent;text-decoration:none;cursor:pointer;background:var(--ts-primary);color:var(--ts-primary-ink)}
28
+ .ts .ts-btn-accent{background:var(--ts-accent);color:var(--ts-accent-ink)}
29
+ .ts .ts-btn-outline{background:transparent;color:inherit;border-color:currentColor;opacity:.9}
30
+ .ts .ts-btn-lg{padding:15px 24px;font-size:16px}
31
+ .ts .ts-actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:24px}
32
+ .ts .ts-grid{display:grid;gap:18px;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr))}
33
+ @media (max-width:960px){.ts .ts-grid{grid-template-columns:repeat(min(var(--cols,3),2),minmax(0,1fr))}}
34
+ @media (max-width:600px){.ts .ts-grid{grid-template-columns:1fr}}
35
+ .ts .ts-split{display:grid;gap:clamp(24px,5vw,64px);grid-template-columns:1.1fr .9fr;align-items:center}
36
+ .ts .ts-split.ts-rev{grid-template-columns:.9fr 1.1fr}
37
+ @media (max-width:860px){.ts .ts-split,.ts .ts-split.ts-rev{grid-template-columns:1fr}}
38
+ .ts .ts-card{background:var(--ts-surface);border:1px solid var(--ts-line);border-radius:var(--ts-radius);padding:22px;color:var(--ts-ink);text-decoration:none;display:flex;flex-direction:column;gap:8px}
39
+ .ts.ts-ink .ts-card{background:rgba(255,255,255,.06);border-color:rgba(255,255,255,.14);color:#fff}
40
+ .ts a.ts-card:hover{border-color:var(--ts-primary)}
41
+ .ts .ts-card p{color:var(--ts-ink-2);font-size:15px}
42
+ .ts.ts-ink .ts-card p{color:rgba(255,255,255,.75)}
43
+ .ts .ts-chips{display:flex;flex-wrap:wrap;gap:8px}
44
+ .ts .ts-chip{font-size:13px;font-weight:600;padding:6px 11px;border-radius:999px;border:1px solid var(--ts-line);background:var(--ts-surface);color:var(--ts-ink-2);text-decoration:none}
45
+ .ts .ts-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}
46
+ .ts .ts-list li{display:flex;gap:10px;color:var(--ts-ink-2)}
47
+ .ts .ts-list li::before{content:"✓";color:var(--ts-primary);font-weight:700;flex:none}
48
+ .ts .ts-photo{position:relative;overflow:hidden;border-radius:var(--ts-radius);background:color-mix(in srgb,var(--ts-primary) 12%,var(--ts-surface));display:grid;place-items:center;color:var(--ts-ink-3);font-size:13px;text-align:center;padding:12px}
49
+ .ts .ts-photo img{width:100%;height:100%;object-fit:cover;position:absolute;inset:0}
50
+ .ts .ts-photo-wide{aspect-ratio:16/10}.ts .ts-photo-tall{aspect-ratio:4/5}.ts .ts-photo-square{aspect-ratio:1}.ts .ts-photo-video{aspect-ratio:16/9}
51
+ .ts .ts-photo-has{padding:0}
52
+ .ts .ts-stars{color:var(--ts-accent);letter-spacing:1px;font-size:15px}
53
+ .ts .ts-avatar{width:40px;height:40px;border-radius:50%;overflow:hidden;background:color-mix(in srgb,var(--ts-primary) 15%,var(--ts-surface));flex:none;display:block}
54
+ .ts .ts-avatar img{width:100%;height:100%;object-fit:cover}
55
+ .ts .ts-kv{display:grid;grid-template-columns:auto 1fr;gap:6px 18px;font-size:15px}
56
+ .ts .ts-kv dt{color:var(--ts-ink-3)}.ts .ts-kv dd{margin:0}
57
+ .ts .ts-table{width:100%;border-collapse:collapse;font-size:15px}
58
+ .ts .ts-table td{padding:8px 0;border-top:1px solid var(--ts-line)}
59
+ .ts .ts-table tr:first-child td{border-top:0}
60
+ .ts .ts-table td:last-child{text-align:right;font-variant-numeric:tabular-nums}
61
+ .ts .ts-table tr.ts-today td{color:var(--ts-primary);font-weight:700}
62
+ .ts .ts-steps{display:grid;gap:0}
63
+ .ts .ts-step{display:grid;grid-template-columns:64px 1fr;gap:18px;padding:20px 0;border-top:1px solid var(--ts-line)}
64
+ .ts .ts-step:last-child{border-bottom:1px solid var(--ts-line)}
65
+ .ts .ts-step .ts-n{font-family:var(--ts-display);font-weight:800;font-size:22px;color:var(--ts-primary)}
66
+ .ts .ts-step p{color:var(--ts-ink-2);margin-top:4px}
67
+ .ts details{border-top:1px solid var(--ts-line);padding:4px 0}
68
+ .ts details:last-of-type{border-bottom:1px solid var(--ts-line)}
69
+ .ts summary{cursor:pointer;list-style:none;font-family:var(--ts-display);font-weight:700;font-size:18px;padding:14px 0;display:flex;justify-content:space-between;gap:16px}
70
+ .ts summary::-webkit-details-marker{display:none}
71
+ .ts summary::after{content:"+";color:var(--ts-primary);font-weight:400}
72
+ .ts details[open] summary::after{content:"–"}
73
+ .ts details .ts-a{padding:0 0 16px;color:var(--ts-ink-2);max-width:64ch;white-space:pre-line}
74
+ .ts .ts-form{display:grid;grid-template-columns:1fr 1fr;gap:12px}
75
+ .ts .ts-form label{display:grid;gap:6px;font-size:13px;font-weight:600;color:var(--ts-ink-2)}
76
+ .ts .ts-form input,.ts .ts-form select,.ts .ts-form textarea{font:inherit;font-size:15px;padding:12px 13px;border:1.5px solid var(--ts-line);border-radius:calc(var(--ts-radius) * .7);background:var(--ts-surface);color:var(--ts-ink);width:100%}
77
+ .ts .ts-form textarea{min-height:110px}
78
+ .ts .ts-form .ts-full{grid-column:1/-1}
79
+ .ts .ts-form .ts-choices{display:flex;flex-wrap:wrap;gap:8px}
80
+ .ts .ts-form .ts-choices label{flex-direction:row;align-items:center;gap:8px;font-weight:500;padding:9px 12px;border:1.5px solid var(--ts-line);border-radius:999px;background:var(--ts-surface)}
81
+ .ts .ts-form .ts-hp{position:absolute;left:-10000px;width:1px;height:1px}
82
+ @media (max-width:640px){.ts .ts-form{grid-template-columns:1fr}}
83
+ .ts .ts-stat{text-align:left}
84
+ .ts .ts-stat b{display:block;font-family:var(--ts-display);font-size:40px;letter-spacing:-.03em;line-height:1}
85
+ .ts .ts-stat span{display:block;font-size:13px;color:var(--ts-ink-3);margin-top:6px}
86
+ .ts .ts-logos{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
87
+ .ts .ts-logo{display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:var(--ts-radius);background:var(--ts-surface);border:1px solid var(--ts-line);font-weight:600;font-size:14px}
88
+ .ts .ts-logo img{max-height:32px;width:auto}
89
+ .ts .ts-price{font-family:var(--ts-display);font-size:40px;font-weight:800;letter-spacing:-.03em;line-height:1}
90
+ .ts .ts-price small{font-family:var(--ts-body);font-size:13px;font-weight:500;color:var(--ts-ink-3);margin-left:6px;letter-spacing:0}
91
+ .ts .ts-ba{display:grid;grid-template-columns:1fr 1fr;gap:2px;border-radius:var(--ts-radius);overflow:hidden}
92
+ .ts .ts-ba .ts-photo{border-radius:0}
93
+ .ts .ts-ba .ts-tag{position:absolute;left:10px;top:10px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;background:rgba(255,255,255,.9);color:#111;padding:4px 7px;border-radius:6px;z-index:1}
94
+ .ts .ts-gallery{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
95
+ @media (max-width:700px){.ts .ts-gallery{grid-template-columns:repeat(2,1fr)}}
96
+ .ts .ts-map{min-height:320px;border-radius:var(--ts-radius);border:1px solid var(--ts-line);background:color-mix(in srgb,var(--ts-primary) 8%,var(--ts-surface));display:grid;place-items:center;color:var(--ts-ink-3);font-size:14px}
97
+ .ts .ts-map iframe{width:100%;height:100%;min-height:320px;border:0;border-radius:var(--ts-radius)}
98
+ .ts .ts-video{position:relative;aspect-ratio:16/9;border-radius:var(--ts-radius);overflow:hidden;background:#111}
99
+ .ts .ts-video iframe,.ts .ts-video video{position:absolute;inset:0;width:100%;height:100%;border:0}
100
+ .ts .ts-frame{width:100%;border:0;display:block}
101
+ .ts .ts-offer{display:grid;grid-template-columns:1fr auto;gap:24px;align-items:center;padding:26px 28px;border-radius:var(--ts-radius);background:color-mix(in srgb,var(--ts-accent) 18%,var(--ts-surface));border:1px solid color-mix(in srgb,var(--ts-accent) 50%,var(--ts-line))}
102
+ @media (max-width:700px){.ts .ts-offer{grid-template-columns:1fr}}
103
+ .ts .ts-person-name{font-family:var(--ts-display);font-weight:700;font-size:18px}
104
+ .ts .ts-role{font-size:14px;color:var(--ts-primary);font-weight:600}
105
+ .ts .ts-group{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ts-ink-3);font-weight:700;margin:26px 0 10px}
106
+ .ts .ts-review blockquote{margin:0;font-size:16px;color:var(--ts-ink-2)}
107
+ .ts .ts-review .ts-who{display:flex;align-items:center;gap:10px;margin-top:12px;font-size:14px}
108
+ .ts .ts-review .ts-who b{display:block}
109
+ .ts .ts-review .ts-who span{color:var(--ts-ink-3);font-size:12.5px}
110
+ .ts .ts-missing{padding:12px;border:1px dashed #c33;color:#c33;border-radius:8px;margin:12px}
111
+ .ts .ts-prose{color:var(--ts-ink-2);max-width:66ch}
112
+ .ts .ts-prose p+p{margin-top:12px}