Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ render-crypto: ## Check the render's c4_shim resolves its FFI crypto backend

render-ui-js: ## Check the render's lib/ui scripts under Node, each one the render ships
@if [ -f "$(RENDER)/src/lib/ui/page.js" ]; then \
for kit in page variables expressions; do \
for kit in page variables expressions collapse menu combobox color pickgrid; do \
if [ -f "$(RENDER)/src/lib/ui/$$kit.js" ]; then \
UI_DIR="$(RENDER)/src/lib/ui" node --test "tests/ui_$${kit}_js.test.mjs" || exit 1; \
fi; \
Expand Down
2 changes: 1 addition & 1 deletion copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ icon_style:

vendor_modules:
type: str
help: "Space-separated optional vendor modules (available: bitn crypto bthome noiseprotocol protobuf oauth expressions ui). Note: the crypto/noiseprotocol/protobuf/bthome bundles are core builds that require bitn on the path; include bitn alongside them (crypto also for noiseprotocol). oauth ships Control4's OAuth authorization-code grant (drivers-common-public module/auth_code_grant.lua) and its short-link helper (global/make_short_link.lua). ui ships the kit for a custom Composer tab in src/lib/ui: the house style.css and page.js for a page to embed, the variable browser (variables.js and .css, lib.ui.devices), with expressions the expression editor (expressions.js and .css, lib.ui.expression-tab), and the driver side (lib.ui.page, lib.ui.page-params, lib.ui.tab-properties)."
help: "Space-separated optional vendor modules (available: bitn crypto bthome noiseprotocol protobuf oauth expressions ui). Note: the crypto/noiseprotocol/protobuf/bthome bundles are core builds that require bitn on the path; include bitn alongside them (crypto also for noiseprotocol). oauth ships Control4's OAuth authorization-code grant (drivers-common-public module/auth_code_grant.lua) and its short-link helper (global/make_short_link.lua). ui ships the kit for a custom Composer tab in src/lib/ui: the house style.css and page.js for a page to embed, the variable browser (variables.js and .css, lib.ui.devices), with expressions the expression editor (expressions.js and .css, lib.ui.expression-tab), the widgets (collapse, menu, combobox, color and pickgrid, each a .js and .css pair), and the driver side (lib.ui.page, lib.ui.page-params, lib.ui.tab-properties)."
default: ""

cloud_diagnostics:
Expand Down
13 changes: 10 additions & 3 deletions template/CONTRIBUTING.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -672,8 +672,10 @@ end
The library clears the property at once, keeps its value out of the dispatcher's
Trace output, and prints its answer to the Lua output whatever Log Mode is.
While the window is open, Log Level also offers Trace and Ultra (see
[Log Level](#log-level)). A driver that shows more while the window is open
(extra properties) passes `supportCode:onChange(function(open) ... end)`.
[Log Level](#log-level)). A web tab's properties card follows the window by
itself, once the driver calls `lib.ui.tab-properties`' `serve()`. A driver that
shows more elsewhere while the window is open passes
`supportCode:onChange(function(open) ... end)`.

### Log Level

Expand Down Expand Up @@ -836,7 +838,9 @@ such as `drivers/tplink_light/www/documentation/index.md` in
others, so a page keeps only its own views and its driver only its own commands:

- `style.css`: the house look, from the tab bar and cards to form fields,
tables, chips, the segmented switch and a list beside its editor
tables, chips, the segmented switch and a list beside its editor, and a set of
`.kit-*` widgets and layout helpers to use as needed, as with a CSS framework:
states, tags, counts, a form grid, a switch, popovers and more
- `page.js`: the page's link to its driver, the live-link and demo banners, the
tabs, the properties card and demo mode
- `variables.js` and `variables.css`: the variable browser, with
Expand All @@ -846,6 +850,9 @@ others, so a page keeps only its own views and its driver only its own commands:
highlighting, help and live preview, and `lib.ui.expression-tab` on the
driver's side
{%- endif %}
- `collapse.js`, `menu.js`, `combobox.js`, `color.js` and `pickgrid.js`, each
with its stylesheet: groups that fold with Expand all, a button with a menu, a
searchable dropdown, a color picker, and a picture grid with a drop zone
- `lib.ui.page`, `lib.ui.page-params` and `lib.ui.tab-properties`: the driver's
side of the link and the properties card

Expand Down
19 changes: 17 additions & 2 deletions template/src/lib/support-code.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ OPC.suppressDebug["Support Code"] = true
--- @field _secret string The build secret the day's code comes from.
--- @field _open boolean Whether this code opened the Lua window.
--- @field _onChange? fun(open: boolean) Called as the code opens and closes the window.
--- @field _followers fun(open: boolean)[] The libraries told the same, after the driver.
local SupportCode = {}
SupportCode.__index = SupportCode

Expand All @@ -40,6 +41,7 @@ function SupportCode:new(secret)
local instance = setmetatable({}, self)
instance._secret = secret or SECRET
instance._open = false
instance._followers = {}
return instance
end

Expand Down Expand Up @@ -81,8 +83,8 @@ function SupportCode:accepts(value)
return false
end

--- Tells the driver's `onChange` callback, if any. A raise there would reach the dispatcher's error print, which shows
--- the typed value, so it is caught and reported without it.
--- Tells the driver's `onChange` callback, if any, then each follower. A raise there would reach the dispatcher's
--- error print, which shows the typed value, so it is caught and reported without it.
--- @param open boolean Whether the window is now open.
function SupportCode:_tell(open)
if self._onChange then
Expand All @@ -91,6 +93,12 @@ function SupportCode:_tell(open)
print("The Support Code's onChange failed: " .. tostring(err))
end
end
for _, follower in ipairs(self._followers) do
local ok, err = pcall(follower, open)
if not ok then
print("A Support Code follower failed: " .. tostring(err))
end
end
end

--- Lets Log Level reach Trace and Ultra, or stops it at Debug. Caught as `_tell` is, since a raise would print the
Expand All @@ -110,6 +118,13 @@ function SupportCode:onChange(callback)
self._onChange = callback
end

--- Calls `callback(open)` as the window opens and closes, alongside the driver's `onChange`, for a library whose view
--- changes with the window.
--- @param callback fun(open: boolean)
function SupportCode:follow(callback)
table.insert(self._followers, callback)
end

--- Takes what was typed into the Support Code property. The property is cleared at once, so the code is never left
--- in the project. Today's code opens the Lua window and Log Level's Trace and Ultra for an hour, or closes them while
--- open.
Expand Down
11 changes: 11 additions & 0 deletions template/src/lib/ui/collapse.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* lib/ui/collapse.js's groups: a head that turns its body on a click, its caret turned while open, and the Expand
all / Collapse all button that sits in a card's heading. */
.kit-collapse-head { cursor: pointer; border-radius: 5px; }
.kit-collapse-head:hover { background: var(--surface-hover); }
.kit-collapse-body { display: none; }
.kit-collapse.open > .kit-collapse-body { display: block; }
.kit-caret { width: 9px; font-size: 9px; line-height: 1; color: var(--text-muted); display: inline-flex; justify-content: center; transition: transform 0.14s ease; }
.kit-collapse.open > .kit-collapse-head .kit-caret { transform: rotate(90deg); }
.kit-caret.none { visibility: hidden; }
.kit-collapse-all { text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 11px; color: var(--accent); background: none; border: none; cursor: pointer; padding: 2px; white-space: nowrap; }
.kit-collapse-all:hover { text-decoration: underline; }
80 changes: 80 additions & 0 deletions template/src/lib/ui/collapse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Groups that fold under a caret, for a page that draws its markup as strings and draws it again on every push: each
// set keeps its groups' open state by key, a click on a head turns it, and Expand all / Collapse all follows what is
// on screen. A page embeds it after page.js, and collapse.css after style.css, the same way.
// Kit.collapse(name, options) a set of groups; options.open is the default state (true), options.onChange() draws
// the page again after a click
// set.isOpen(key) a group's state: its own once clicked, else the set's default
// set.anyOpen(keys) whether any of these groups is open
// set.head(key) the attributes for the page's own head element, which also takes kit-collapse-head
// set.group(key, head, body, bodyClass) a group: its head's markup, then its body, shown while it is open
// set.toggleAll(keys) the Expand all / Collapse all button for these groups, or "" when there are none
// set.toggle(key), set.setAll(open)
// Kit.collapse.caret(none) the caret a head shows; none keeps its place on a row with nothing to open

(function (Kit) {
var sets = {};

function collapse(name, options) {
options = options || {};
var own = {};
var all = options.open !== false;

function isOpen(key) {
return Object.prototype.hasOwnProperty.call(own, key) ? own[key] : all;
}

var set = {
onChange: options.onChange || function () {},
isOpen: isOpen,
anyOpen: function (keys) {
return keys.some(isOpen);
},
toggle: function (key) {
own[key] = !isOpen(key);
},
// Every group follows the new default, so a group opened by hand closes with the rest.
setAll: function (open) {
own = {};
all = !!open;
},
head: function (key) {
return 'data-kit-collapse="' + Kit.esc(name) + '" data-key="' + Kit.esc(key) + '"';
},
group: function (key, head, body, bodyClass) {
return (
'<div class="kit-collapse' + (isOpen(key) ? " open" : "") + '">' + head +
'<div class="kit-collapse-body' + (bodyClass ? " " + bodyClass : "") + '">' + body + "</div></div>"
);
},
// It offers what a click would do to what is on screen: with any group open, Collapse all.
toggleAll: function (keys) {
if (!keys.length) return "";
var open = set.anyOpen(keys);
return (
'<button type="button" class="kit-collapse-all" data-kit-collapse-all="' + Kit.esc(name) + '" data-open="' +
!open + '">' + (open ? "Collapse all" : "Expand all") + "</button>"
);
},
};
sets[name] = set;
return set;
}

collapse.caret = function (none) {
return '<span class="kit-caret' + (none ? ' none">' : '">▸') + "</span>";
};

document.addEventListener("click", function (e) {
var t = e.target;
if (!t || !t.closest) return;
var all = t.closest("[data-kit-collapse-all]");
var head = all ? null : t.closest("[data-kit-collapse]");
var set = all ? sets[all.getAttribute("data-kit-collapse-all")] : head ? sets[head.getAttribute("data-kit-collapse")] : null;
if (!set) return;
if (all) set.setAll(all.getAttribute("data-open") === "true");
else set.toggle(head.getAttribute("data-key"));
set.onChange();
});

Kit.collapse = collapse;
})(Kit);
34 changes: 34 additions & 0 deletions template/src/lib/ui/color.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* The color picker: Kit.colorPicker's swatch button in place of each color input, and its popover on .kit-pop. A row
that shows a color input and its value is .kit-color-row. A page embeds this after style.css, with color.js. */

.kit-color-row { display: flex; align-items: center; gap: 8px; }
.kit-color-row code { font-size: 12px; color: var(--text-muted); }

.kit-swatch { width: 44px; height: 28px; padding: 4px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); cursor: pointer; flex: none; }
.kit-swatch span { display: block; width: 100%; height: 100%; border-radius: 3px; border: 1px solid rgba(0, 0, 0, 0.12); }
.kit-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.kit-swatch.no-color span { background: linear-gradient(to top right, transparent calc(50% - 1px), #9ca3af calc(50% - 1px), #9ca3af calc(50% + 1px), transparent calc(50% + 1px)), #fff !important; }

.kit-color { width: 264px; }
.kit-color-label { font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 4px; }
.kit-color-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.kit-color-chip { width: 22px; height: 22px; padding: 0; border-radius: 4px; border: 1px solid rgba(0, 0, 0, 0.18); cursor: pointer; }
.kit-color-chip:focus-visible, .kit-color-chip.on { outline: 2px solid var(--accent); outline-offset: 1px; }
.kit-color-hex { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.kit-color-hex span { width: 28px; height: 28px; border-radius: 4px; border: 1px solid rgba(0, 0, 0, 0.18); flex: none; }
.kit-color-hex input { flex: 1; min-width: 0; font-family: var(--mono); }
.kit-color-slider { display: grid; grid-template-columns: 14px minmax(0, 1fr) 34px; align-items: center; gap: 8px; margin: 6px 0 0; font-size: 11px; color: var(--text-muted); }
.kit-color-slider input { -webkit-appearance: none; appearance: none; width: 100%; height: 12px; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.15); margin: 0; }
.kit-color-slider input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: #fff; border: 2px solid #374151; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); cursor: pointer; }
.kit-color-slider code { text-align: right; font-size: 11px; }
.kit-color-dropper { width: 30px; height: 30px; padding: 6px; flex: none; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); cursor: pointer; }
.kit-color-dropper[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
.kit-color-dropper svg { display: block; width: 100%; height: 100%; }
.kit-color-hint { margin: -4px 0 10px; font-size: 11px; color: var(--text-muted); }

/* While the dropper is armed: the picture's area outlined, and the loupe beside the pointer. */
.kit-color-target { outline: 2px solid var(--accent); outline-offset: 3px; }
.kit-color-target img { cursor: crosshair; }
.kit-color-loupe { position: fixed; z-index: 60; pointer-events: none; padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16); text-align: center; }
.kit-color-loupe canvas { display: block; width: 99px; height: 99px; border-radius: 4px; image-rendering: pixelated; }
.kit-color-loupe code { display: block; margin-top: 3px; font-size: 11px; }
Loading
Loading