, which is why the UI half lives in panel.ts at the "page" stage.
A11Y_CSS is effects.css, passed in as a string argument by the integration.
========================================================================== */
try {
var W = window;
var D = document;
var H = D.documentElement;
/* Bail. /admin and /login are operator-only SSR surfaces that run their own
dark-mode system (src/layouts/Layout.astro), and data-a11y-disable is the
documented client escape hatch. On a bail we return WITHOUT defining
window.a11y — that absence is the single signal panel.ts keys on, so it
also covers this file having thrown. */
if (/^\/(admin|login)(\/|$)/.test(W.location.pathname)) return;
if (H.hasAttribute("data-a11y-disable")) return;
var KEY = "a11y:prefs";
var VERSION = 1;
/* Every value is a string enum, never a boolean: costs nothing today and
lets a future third state land without a storage migration.
`always:1` means the attribute is present whatever the value, so clients
test one thing (data-a11y-motion) rather than presence-or-value. */
var DEFS = [
{ k: "motion", attr: "data-a11y-motion", on: "reduce", off: "allow", os: "(prefers-reduced-motion: reduce)", always: 1 },
{ k: "links", attr: "data-a11y-links", on: "underline", off: null },
{ k: "spacing", attr: "data-a11y-spacing", on: "loose", off: null },
{ k: "font", attr: "data-a11y-font", on: "readable", off: null },
{ k: "focus", attr: "data-a11y-focus", on: "strong", off: null },
{ k: "contrast", attr: "data-a11y-contrast", enums: ["standard", "high"], off: null, os: "(prefers-contrast: more)", osValue: "high" },
{ k: "scheme", attr: "data-a11y-scheme", enums: ["light", "dark"], off: null, os: "(prefers-color-scheme: dark)", osValue: "dark" }
];
/* side + min are host-only: stored here, applied by panel.ts. They get no
attribute on because nothing in client CSS should key on them. */
var HOST = { side: ["start", "end"], min: ["yes"] };
/* ---- storage -----------------------------------------------------------
localStorage throws outright in some privacy modes, so every access is
wrapped and falls back to an in-memory object: the panel still works for
the session, it just does not persist. */
var mem = null;
var memOn = false;
function rawRead() {
if (memOn) return mem;
var s;
try { s = W.localStorage.getItem(KEY); } catch (e) { memOn = true; return mem; }
if (s == null) return null;
var o;
try { o = JSON.parse(s); } catch (e) { return null; }
if (!o || typeof o !== "object") return null;
if (Object.prototype.toString.call(o) === "[object Array]") return null;
if (typeof o.v !== "number") return null;
return o;
}
function rawWrite(o) {
if (!memOn) {
try { W.localStorage.setItem(KEY, JSON.stringify(o)); return; } catch (e) { memOn = true; }
}
mem = o;
}
/* Effective preferences. A future version is IGNORED but never deleted, so
rolling the build back loses nothing. A past version would migrate here. */
function prefs() {
var o = rawRead();
if (!o) return {};
if (o.v > VERSION) return {};
return o;
}
/* ---- resolution --------------------------------------------------------
explicit stored value > OS media query > built-in default
Never write a value just because the OS said so. Persisting a detected
value makes "unset" indistinguishable from "chosen" and the site stops
tracking the OS forever — the bug that silently kills OS-following. */
function mq(q) {
try { return W.matchMedia(q); } catch (e) { return null; }
}
function mqOn(q) {
var m = mq(q);
return !!(m && m.matches);
}
function defOf(k) {
for (var i = 0; i < DEFS.length; i++) { if (DEFS[i].k === k) return DEFS[i]; }
return null;
}
function allowed(d, v) {
var list = d.enums;
if (list) {
for (var i = 0; i < list.length; i++) { if (list[i] === v) return true; }
return false;
}
return v === d.on || v === d.off;
}
function hostAllowed(k, v) {
var list = HOST[k];
if (!list) return false;
for (var i = 0; i < list.length; i++) { if (list[i] === v) return true; }
return false;
}
function resolved(k) {
var p = prefs();
var v = p[k];
var d = defOf(k);
if (!d) return hostAllowed(k, v) ? v : null;
if (v != null && allowed(d, v)) return v;
if (d.os && mqOn(d.os)) return d.osValue || d.on;
return d.off;
}
function apply() {
for (var i = 0; i < DEFS.length; i++) {
var d = DEFS[i];
var v = resolved(d.k);
if (v == null) H.removeAttribute(d.attr);
else H.setAttribute(d.attr, v);
}
}
/* ---- effects sheet ----------------------------------------------------
Inserted unconditionally: predictability beats micro-optimisation, and
every selector is gated on a root attribute that fails immediately when
absent, so the universal-selector motion rules cost nothing when motion
resolves to "allow". */
function injectCss() {
if (D.getElementById("a11y-effects")) return;
var s = D.createElement("style");
s.id = "a11y-effects";
s.appendChild(D.createTextNode(A11Y_CSS));
(D.head || H).appendChild(s);
}
function fire(k) {
try {
D.dispatchEvent(new W.CustomEvent("a11y:change", {
detail: {
key: k,
value: k == null ? null : prefs()[k],
resolved: k == null ? null : resolved(k)
}
}));
} catch (e) {}
}
function set(k, v) {
if (v != null) {
var d = defOf(k);
if (d ? !allowed(d, v) : !hostAllowed(k, v)) return;
}
var stored = rawRead();
/* A stored version ahead of ours cannot be safely merged, so the first
explicit change replaces it wholesale. Acceptable: the user is actively
choosing at that moment. Otherwise read-modify-write preserves unknown
keys so a newer build's settings survive a rollback. */
var future = !!(stored && stored.v > VERSION);
var o = future || !stored ? {} : stored;
o.v = VERSION;
if (v == null) delete o[k];
else o[k] = v;
rawWrite(o);
apply();
fire(k);
}
function reset() {
if (!memOn) {
try { W.localStorage.removeItem(KEY); } catch (e) { memOn = true; }
}
mem = null;
apply();
fire(null);
}
apply();
injectCss();
var api = {
version: VERSION,
get: function (k) { return prefs()[k]; },
resolved: resolved,
set: set,
reset: reset,
motionReduced: function () { return resolved("motion") === "reduce"; }
};
/* caps is a LIVE getter and is never read pre-paint. In a production build
the client CSS is a before this script and a classic blocking
script waits on pending stylesheets, so getComputedStyle would resolve —
but under `astro dev` the CSS arrives through Vite's module graph and a
pre-paint read can return empty. That failure would be silent and
byte-identical to a client that never opted in. Nothing pre-paint needs
capabilities: the gated fieldsets live in the lazily-imported panel body. */
try {
Object.defineProperty(api, "caps", {
enumerable: true,
get: function () {
var raw = "";
try {
raw = getComputedStyle(H).getPropertyValue("--a11y-caps") || "";
} catch (e) {}
/* getPropertyValue returns the token stream verbatim, leading space and
literal quotes included, so a naive split yields ["", "\"contrast",
"dark\""] and every capability check silently fails. */
return raw.replace(/["']/g, " ").replace(/^\s+|\s+$/g, "").split(/\s+/)
.filter(function (t) { return !!t; });
}
});
} catch (e) {
api.caps = [];
}
W.a11y = api;
/* While a key is absent the OS query is authoritative and LIVE. Once the
user has chosen, an OS change must not move the site under them. */
for (var i = 0; i < DEFS.length; i++) {
(function (d) {
if (!d.os) return;
var m = mq(d.os);
if (!m) return;
var onChange = function () {
if (prefs()[d.k] != null) return;
apply();
fire(d.k);
};
if (m.addEventListener) m.addEventListener("change", onChange);
else if (m.addListener) m.addListener(onChange);
})(DEFS[i]);
}
try {
W.addEventListener("storage", function (e) {
if (e && e.key && e.key !== KEY) return;
mem = null;
memOn = false;
apply();
fire(null);
});
} catch (e) {}
} catch (e) {}
})("/* ============================================================================\n a11y effects sheet.\n\n Injected pre-paint by boot.js as the LAST node in
.\n ------------------------------------------------------------------------- */\n:root[data-a11y-spacing=\"loose\"]\n :is(main, article, footer, [role=\"main\"], [role=\"contentinfo\"])\n :is(p, li, dd, dt, blockquote, figcaption, summary, td, th):not(nav *) {\n line-height: 1.5 !important;\n letter-spacing: 0.12em !important;\n word-spacing: 0.16em !important;\n overflow-wrap: break-word !important;\n min-width: 0;\n}\n:root[data-a11y-spacing=\"loose\"]\n :is(main, article, footer, [role=\"main\"], [role=\"contentinfo\"])\n :is(p, blockquote, figcaption):not(nav *) { margin-block-end: 2em !important; }\n\n/* ---------- Readable font (prose only) -------------------------------------\n Honest scope: this switches PROSE type to the system stack. The strongest\n real win is a client's condensed display face — on 4bkstorage that is\n --f-display, a \"Google Sans Flex Cond\" family pinned to a 75% width axis,\n used for every heading inside main/footer.\n\n It deliberately does NOT touch letter-spacing (the spacing setting owns\n that) and does NOT touch text-transform: killing uppercase would render the\n brand as \"bk storage\" — misrepresentation, not accessibility. Nav micro-\n labels (.brand-l2 :397, .sheet-title :516) are out of scope by the header\n rule above; that is the price of deleting the header regression, and it is\n the right trade.\n\n `font-stretch: normal` and `font-variation-settings: normal` are NOT set,\n and must not be: a client may pin a variable font's width axis with a\n `font-stretch` DESCRIPTOR inside @font-face rather than a declaration on the\n element (4bkstorage does exactly this to get a condensed display family and a\n normal body family out of one file). A descriptor belongs to the @font-face,\n so replacing font-family drops the pinned width along with it. Resetting\n those properties here would instead fight any client that legitimately sets\n them on elements, for no gain.\n\n CONTRACT NOTE for clients: if you set `font-variation-settings` on ELEMENTS\n rather than as an @font-face descriptor, this override cannot reset it and\n the system stack may inherit meaningless axis values. Pin axes in\n @font-face.\n\n Ligatures: `none` includes `no-contextual`, which breaks Arabic and several\n Indic scripts. Narrowed to the three Latin-disambiguation families only.\n ------------------------------------------------------------------------- */\n:root[data-a11y-font=\"readable\"]\n :is(main, article, footer, [role=\"main\"], [role=\"contentinfo\"]),\n:root[data-a11y-font=\"readable\"]\n :is(main, article, footer, [role=\"main\"], [role=\"contentinfo\"])\n *:not(code, pre, kbd, samp, svg, svg *, iframe, nav, nav *, a11y-prefs, a11y-prefs *) {\n font-family: var(--a11y-readable-stack,\n system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif) !important;\n font-variant-ligatures: no-common-ligatures no-discretionary-ligatures\n no-historical-ligatures !important;\n}\n\n/* ---------- Strong focus ---------------------------------------------------\n White halo hugged by a black outline. Verified on 4bkstorage's palette:\n #fff is 13.69:1 on --navy #0c2e54 and 11.43:1 on --deep #0f3a6b; #000 is\n 21:1 on white. So one edge always clears SC 1.4.11's 3:1 whatever it lands\n on. GOV.UK yellow alone would not (#ffdd00 is 1.35:1 on white).\n\n !important is mandatory: .field input:focus{outline:none} (bkstorage.css\n :1489-1495) is (0,2,1) and beats :root[data-a11y-focus] :focus-visible at\n (0,2,0) — it would kill the ring on exactly the form fields where it matters\n most.\n\n NO border-radius declaration. At (0,3,0) it would outrank .btn's 11px\n (:265) and .btn-lg's var(--radius) (:275) and snap every focused button to a\n near-square on focus and back on blur. `outline` follows the element's own\n border shape in every current engine, which is the desired result for free.\n\n offset 2px + a 2px halo (5px total beyond the border box, was 7px) plus\n scroll-margin, because the ring is drawn inside the client's own clipping\n ancestors: .hero (:592), .type (:947), .cta (:1296), and body{overflow-x:\n hidden} (:134).\n ------------------------------------------------------------------------- */\n:root[data-a11y-focus=\"strong\"] :focus-visible {\n outline: 3px solid #000 !important;\n outline-offset: 2px !important;\n box-shadow: 0 0 0 2px #fff !important;\n scroll-margin: 12px;\n}\n@media (forced-colors: active) {\n :root[data-a11y-focus=\"strong\"] :focus-visible {\n outline-color: Highlight !important;\n box-shadow: none !important;\n }\n}\n\n/* ---------- Panel plumbing ------------------------------------------------\n No scroll lock. `:root{overflow:hidden}` was deleted: 4bkstorage's\n body{overflow-x:hidden} (:134) makes viewport-overflow propagation\n engine-dependent, and iOS Safari — the only platform where the bottom-sheet\n layout exists — ignores overflow:hidden for touch scrolling. It was\n unverifiable behaviour that changed the page under the user and contradicted\n the non-modal ruling. overscroll-behavior:contain on the sheet plus\n focusout-closes at <=40rem replaces it. Never write body.style.overflow:\n both clients' Nav.tsx reset it to \"\" unconditionally (4bkstorage :29-32,\n rockin2ind :40-43) and whichever closed last would destroy the other's lock.\n ------------------------------------------------------------------------- */\n@media print { a11y-prefs { display: none !important; } }\n");
Skip to content
Keep every product, variant, and SKU in one place. Set low-stock thresholds that trigger automatic reorder alerts, track quantities across multiple locations, and get real-time visibility into what's moving and what's sitting. Pairs seamlessly with the Storefront and Invoicing modules to keep numbers in sync without double entry.
On hand
Reorder point
Fog Machine 1500WNN-1042
24 / 12 In stock
Truss Section 10ftNN-2277
6 / 10 Low
LED Par CanNN-3310
96 / 24 In stock
Cable Ramp 4chNN-4185
0 / 8 On order
Speaker Stand PairNN-5023
11 / 12 Low
2 reorder alerts raised this morning. Nobody counted a shelf, and nobody
re-keyed a number into a second system to find out.
Launch a full online store without a separate platform. Publish products with rich media, manage variants and pricing, and give customers a smooth cart-to-checkout experience. Every sale automatically updates Inventory, triggers Delivery workflows, and generates an Invoice.
Manage any rental fleet, from equipment and vehicles to spaces and anything in between. Build combo packages that bundle multiple items at a discounted rate, attach add-on features customers can select at checkout, and define custom options like sizes, durations, or insurance tiers. Assign rich metadata to every listing, set discount rules based on quantity, duration, or season, and let the system enforce availability constraints automatically. Late returns? Flagged and invoiced without lifting a finger.
Availability
One week
Mon Tue Wed Thu Fri Sat Sun
Fog Machine 1500W
Stage Pack combo
Truss Section 10ft
Stage Pack Maintenance hold
LED Par Can ×4
Booked Late, invoiced
Combo, one booking, several itemsHeld back, not sellableLate return, auto-invoiced
Perfect for service-based businesses. Create combo deals that bundle services together at a special price, let customers pick add-on features like premium upgrades or extended sessions, and offer configurable options like therapist preference, room type and group size. Attach custom metadata to any booking for internal tracking, apply discount rules based on loyalty, time of day, or promo codes, and enforce scheduling constraints automatically. Customers pick a time slot, get instant confirmation, and receive automated reminders. Staff calendars update in real time, and no-show tracking helps you optimize over time.
What it does
Online appointment booking
Automated reminders
Staff calendar sync
No-show tracking
What you can configure
Service combo bundles at special pricing
Add-on upgrades (premium, extended, VIP)
Configurable options (therapist, room, group size)
Manage your entire team from a single dashboard. Assign roles with granular permissions, build and publish schedules, and monitor performance metrics. Employees get their own portal to view shifts, request changes, and stay in the loop. No spreadsheets, no group chats.
Every interaction in one timeline. See purchase history, rental contracts, booking records, and communication logs for each customer. Segment your audience, tag VIPs, and let the system surface insights so you can build relationships that last.
Talk to your customers from the system that already knows them. Build a segment out of anything Customers holds, whether that is lapsed accounts, VIPs, or everyone with a rental due back on Friday, and send it an email or an SMS on your own template. Campaigns are the marketing half. The other half is the quiet one, where a status update goes out on its own because a record changed. Consent, opt-outs and reply routing are handled for you, and every send lands back on the customer's timeline beside the orders and contracts it belongs to.
From warehouse to doorstep, track every package in real time. Assign drivers, optimize routes, and let customers follow their delivery with live status updates. Integrates directly with the Storefront and Inventory modules so stock levels adjust the moment an order ships.
Create professional invoices in seconds, send them via email or SMS, and track payment status in real time. Set up automatic reminders for overdue balances and get a clear financial picture with built-in reporting. Connects to Stripe for seamless online payments.
One invoice, start to paid
You
On its own
Day 0
Invoice raised
From the order, the rental or the booking. Nothing re-keyed.
Day 0
Sent
Email and SMS, on your template, with a Stripe link attached.
Day 7
First reminder
Unpaid, so it goes again. You are not chasing it.
Day 12
Second reminder
Same schedule, and the balance is flagged in reporting.
Day 13
Paid
Customer opens the link and pays. The record closes itself.
1 of the 5 steps is yours. The reminder schedule, the template and the
payment terms are all configurable. The chasing is not something you do.
Every other module already records what happened. Forecasting is what the platform does with that history. Project demand per SKU from real sales, rental and booking records, see how many weeks of cover you have left at the current run rate, and get back the date you need to place an order rather than a number you have to interpret. Seasonality windows and supplier lead times are yours to set, and the projection re-runs itself as the underlying records change, so there is nothing to rebuild and no spreadsheet pointed at last month's export.
Fog Machine 1500W · units on hand
Counted
Projected
Reorder point
0 80 160 45
Order by W38
W31TodayW40
Fog Machine 1500W, units on hand by week
Week
Units on hand
Source
W31
148
Counted
W32
129
Counted
W33
112
Counted
W34
96
Counted
W35
84
Counted
W36
70
Projected
W37
56
Projected
W38
42
Projected
W39
28
Projected
W40
14
Projected
Reorder point
45
Set by you
Two weeks of cover left at the current run rate, so the order has to be
placed by W38. That date is the output. Nobody read a chart to find it, and the
run rate it came from is the same sales, rental and booking history the rest of the
platform is already writing.
Dashboards, reports & exports across every module.
One question, asked once, answered across the whole business. Revenue, orders, utilization, no-shows and outstanding balances all come out of the same records the operational screens read, so a dashboard here cannot disagree with an invoice there. Build the views you want, break them down by segment, period or module, schedule them to arrive in an inbox on Monday morning, and export anything an accountant asks for. There is nothing to join, nothing to reconcile, and no month-end afternoon spent making three tools agree on one number.
Last 30 days
vs. previous 30
Revenue $128,400 +12.4%
Orders 1,842 +6.1%
Avg. order $69.71 +5.9%
Overdue $4,120 +18.2%
Where it came from
Storefront $61,300 · 47.7%1,104 orders
Rentals $42,900 · 33.4%318 contracts
Bookings $24,200 · 18.8%420 appointments
$128,400 across 3 modules, and 47.7% of it from
one of them. No export, no join, no reconciling three tools at month end — the
dashboard is reading the same rows the orders, contracts and appointments were written
to.
Build any form you need, such as contact requests, feedback surveys, intake questionnaires or event registrations. Drag-and-drop fields, set up conditional logic, and collect submissions straight into your dashboard. Auto-responses confirm receipt instantly, and you can route entries to the right team member automatically.
Take control of how your business appears in search results. Edit meta titles, descriptions, and Open Graph tags for every page. Auto-generate sitemaps, manage canonical URLs, and get clear recommendations to improve your rankings. No SEO expertise required.
Share your expertise and keep customers engaged with a built-in blog. Write and schedule posts with a rich text editor, organize content with categories and tags, and let the SEO module optimize every article automatically. Perfect for company news, how-to guides, and thought leadership.
Open your platform to the outside world with a fully documented REST API. Generate scoped API keys, set rate limits, and let third-party apps read and write data securely. Whether you're syncing with an external POS, feeding data into a BI tool, or building a custom mobile app, the API gives you programmatic access to every module, including inventory, orders, customers and bookings.