mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
feat(web-app-bigbluebutton): carry the title into Greenlight and the palette into the meeting client
Greenlight 3.8.0 hard-codes <title>BigBlueButton</title> in its layout and resets it on every client-side navigation, so the configured title never showed. The design script now replaces exactly that title, and a MutationObserver on the head reapplies it after each navigation; room titles stay untouched. The spec reads DESIGN_TITLE and asserts the title on the landing page, after sign-in and in the admin area; with a probe title it failed before the change. The meeting client under /html5client is proxied with proxy_lua_enabled false, so the injected stylesheet never reached it and it kept the upstream design. It has its own hooks for this: public.app.customStyleUrl and public.app.clientTitle in /etc/bigbluebutton/bbb-html5.yml. The role now renders that file (the upstream copy carries only appName, which stays) and binds it into apps-akka over the upstream bind of the same target. A config entry would have left the upstream bind in place, hence the volume entry with its nocheck reason. meeting.css imports the shared default.css, maps the client's CSS variables onto the tokens, gives #Navbar and #ActionsBar the on-frame scope, and covers the parts upstream colors outside its variables: the chat input, the scroll shadows of the user list and chat, the MUI menus, and the body background that upstream sets inline. The design task publishes it under the role's CDN path, or removes it with design off; its URL carries the template mtime so a change busts the cache. The new meeting test starts the showcase meeting and asserts the sheet is loaded once, the title prefix, and the tokens of stage, header, actions bar, presentation title, user list, chat input and leave button in light and dark mode, plus contrast. It fails with meeting.css aborted through page.route. The gallery gains a meeting view. The TODO line about the unstyled meeting client is replaced by the remaining upstream limit: link previews keep og:title BigBlueButton, since Greenlight has no site-name setting. Verified on the designer stack: final deploy 12 passed 3 skipped failed=0, palette with sync=role failed=0 10 passed, gallery 27 of 27 views including the meeting. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
bac3308f75
commit
76e9441117
11 files changed
+247
-8
No files matched your search
@@ -91,7 +91,7 @@ Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (
|
||||
- 🧱 Automatic `.env` templating and domain/NGINX integration
|
||||
- 🛠 Volume patching and Docker Compose customization
|
||||
- 📬 SMTP integration and Greenlight admin creation: the administrator is verified and active, and its password converges on every deploy
|
||||
- 🎨 Corporate design for Greenlight: brand color and logo through the Greenlight site settings, dark mode through the role stylesheet; the meeting client (`/html5client`) keeps the upstream design
|
||||
- 🎨 Corporate design for Greenlight: brand color and logo through the Greenlight site settings, the configured title through the design script, dark mode through the role stylesheet; the meeting client (`/html5client`) takes the palette and the configured title through its own `customStyleUrl` and `clientTitle` settings
|
||||
- 🧪 Workarounds for known Docker Compose or Etherpad issues
|
||||
|
||||
## Quick Setup
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
- Propper implement and test the LDAP integration, the configuration values just had been set during refactoring
|
||||
- The declared `cpus` and `mem_limit` of the `bigbluebutton` and `greenlight` entities never reach a container: `docker inspect` reports `NanoCpus=0` and `Memory=0` for `bigbluebutton-greenlight-1`, `bigbluebutton-bbb-web-1` and `bigbluebutton-apps-akka-1`, because `templates/compose.override.yml.j2` does not include `roles/sys-svc-container/templates/base.yml.j2`. Options: include the base template per service in the override, or render `resource.yml.j2` for the services that carry limits.
|
||||
- The meeting client under `/html5client` keeps the upstream design: the role proxies it with `proxy_lua_enabled: false`, so the injected stylesheet never reaches it. Option: point `public.app.customStyleUrl` in `conf/bbb-html5.yml` (mounted into `apps-akka` by upstream) at the role stylesheet on the CDN, then verify it on a meeting page with a working media backend.
|
||||
- Link previews keep the title `BigBlueButton`: Greenlight 3.8.0 has no site-name setting, its layout hard-codes `<title>BigBlueButton</title>` and `og:title` comes from `page_title`, which returns `BigBlueButton` outside room pages (`app/views/layouts/application.html.erb`, `app/helpers/application_helper.rb`). The design script carries the visible tab title. Upstream option: a site-name setting read by `App.jsx` and `page_title`.
|
||||
- Three tasks report `changed` on every role sync of an unchanged tree (measured in two consecutive syncs): `✏️ Write compose.override.yml for BigBlueButton`, `🏷️ Tag dockerize so the upstream script finds it locally` and `📁 create compose.yml for bigbluebutton`, so the role is not idempotent and notifies `compose-up` each run. Options: compare the rendered content before writing, and retag dockerize only when the local tag is missing.
|
||||
- Greenlight's site setting `RegistrationMethod` is `open` on a fresh deploy (measured through `rails runner`), so any visitor can create an account on `/signup`. Options: set it to `invite` or `approval` from the role through `SiteSetting`, or make it a role variable.
|
||||
@@ -35,6 +35,14 @@ const NEW_ROOM = "button.btn-brand.ms-auto";
|
||||
const ADMIN = "#admin-panel";
|
||||
const USERS_ROW = "#manage-users-table tbody tr";
|
||||
const SHOWCASE = "Design Showcase";
|
||||
const MEETING_NAVBAR = "#Navbar";
|
||||
const MEETING_ACTIONS = "#ActionsBar";
|
||||
const MEETING_TITLE = "#presentationTitle";
|
||||
const MEETING_USERS = "[data-test='userListContainer']";
|
||||
const MEETING_INPUT = "#message-input";
|
||||
const MEETING_LEAVE = "[data-test='leaveMeetingDropdown']";
|
||||
const MEETING_AUDIO_MODAL = "[data-test='audioModal']";
|
||||
const MEETING_AUDIO_CLOSE = "[data-test='closeModal']";
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
@@ -109,6 +117,56 @@ async function seedShowcase(page) {
|
||||
}, SHOWCASE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Args:
|
||||
* page: Playwright page signed in as the administrator; starts or joins the showcase room meeting and closes the audio dialog.
|
||||
* room: friendly id of the showcase room.
|
||||
*/
|
||||
async function joinMeeting(page, room) {
|
||||
await gotoOnion(page, `${base}/rooms/${room}`);
|
||||
await page.getByRole("button", { name: /(start|join) meeting/i }).first().click({ timeout: resolveTimeout(20_000) });
|
||||
await page.waitForURL(/html5client/, { timeout: resolveTimeout(60_000) });
|
||||
await shown(MEETING_USERS)(page);
|
||||
const close = page.locator(MEETING_AUDIO_CLOSE).first();
|
||||
await close.waitFor({ state: "visible", timeout: resolveTimeout(15_000) });
|
||||
await close.click();
|
||||
await expect(page.locator(MEETING_AUDIO_MODAL)).toHaveCount(0, { timeout: resolveTimeout(10_000) });
|
||||
const details = page.getByText("Session Details", { exact: true });
|
||||
const appeared = await details.waitFor({ state: "visible", timeout: resolveTimeout(5_000) }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (appeared) await page.keyboard.press("Escape");
|
||||
await expect(details).toBeHidden({ timeout: resolveTimeout(10_000) });
|
||||
}
|
||||
|
||||
test("design: the meeting client takes frame, panels, primary action, title and text from the palette", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
skipUnreachable();
|
||||
test.setTimeout(resolveTimeout(240_000));
|
||||
await signIn(page);
|
||||
await joinMeeting(page, (await seedShowcase(page)).room);
|
||||
await expect(page.locator("link[rel='stylesheet'][href*='/meeting.css']")).toHaveCount(1);
|
||||
const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE || "");
|
||||
if (title) {
|
||||
await expect.poll(async () => (await page.title()).startsWith(`${title} - `), { message: "the meeting title starts with the configured title" }).toBe(true);
|
||||
}
|
||||
for (const mode of MODES) {
|
||||
await page.emulateMedia({ colorScheme: mode });
|
||||
await assertToken(page, "body", "background-color", "--design-frame", `meeting stage ${mode}`);
|
||||
await assertToken(page, MEETING_NAVBAR, "background-color", "--design-frame", `meeting header ${mode}`);
|
||||
await assertToken(page, MEETING_ACTIONS, "background-color", "--design-frame", `meeting actions bar ${mode}`);
|
||||
await assertToken(page, MEETING_TITLE, "color", "--design-on-frame", `presentation title ${mode}`);
|
||||
await assertToken(page, MEETING_USERS, "background-color", "--design-surface-2", `user list ${mode}`);
|
||||
await assertToken(page, MEETING_USERS, "color", "--design-text", `user list text ${mode}`);
|
||||
await assertToken(page, MEETING_INPUT, "background-color", "--design-surface-2", `chat input ${mode}`);
|
||||
await assertToken(page, MEETING_LEAVE, "background-color", "--design-danger", `leave button ${mode}`);
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: null });
|
||||
await assertLightAndDark(page, MEETING_USERS, "meeting user list");
|
||||
await assertReadable(page, [MEETING_USERS, MEETING_INPUT, MEETING_TITLE, MEETING_LEAVE], "meeting client");
|
||||
});
|
||||
|
||||
test("design: the administrator signs in with the account the role provisions", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
skipUnreachable();
|
||||
@@ -246,14 +304,28 @@ test("design: admin tables use the border token as divider in both modes", async
|
||||
await assertReadable(page, [`${USERS_ROW} td`, "#admin-sidebar .nav-link"], "bigbluebutton admin users");
|
||||
});
|
||||
|
||||
test("design: the favicon and the title are the configured ones", async ({ page }) => {
|
||||
test("design: the favicon is the configured one", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
skipUnreachable();
|
||||
test.skip(!faviconUrl, "logo replacement is disabled for this role");
|
||||
await gotoOnion(page, `${base}/`);
|
||||
await shown(HERO)(page);
|
||||
await expect(page.locator("link[rel~='icon']").first()).toHaveAttribute("href", faviconUrl);
|
||||
await expect(page).toHaveTitle("BigBlueButton");
|
||||
});
|
||||
|
||||
test("design: Greenlight pages carry the configured title", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
skipUnreachable();
|
||||
const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE || "");
|
||||
test.skip(!title, "title replacement is disabled for this role");
|
||||
await gotoOnion(page, `${base}/`);
|
||||
await shown(HERO)(page);
|
||||
await expect(page).toHaveTitle(title);
|
||||
await signIn(page);
|
||||
await expect(page).toHaveTitle(title);
|
||||
await gotoOnion(page, `${base}/admin/users`);
|
||||
await shown(USERS_ROW)(page);
|
||||
await expect(page).toHaveTitle(title);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -388,6 +460,14 @@ function galleryViews(seed) {
|
||||
visitor("sign-in", "/signin", EMAIL),
|
||||
visitor("sign-up", "/signup", ".card input[type=email]"),
|
||||
visitor("room-join", `/rooms/${seed.room}/join`, ".card input"),
|
||||
{
|
||||
name: "meeting",
|
||||
url: `${base}/rooms/${seed.room}`,
|
||||
prepare: async (page) => {
|
||||
await signedInAt(page, `${base}/rooms/${seed.room}`);
|
||||
await joinMeeting(page, seed.room);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,14 @@ coturn:
|
||||
mounts:
|
||||
- service: coturn
|
||||
target: /var/lib/coturn
|
||||
# nocheck: meta-volume-file-bind-should-be-config Reason: compose_mode_force role, and the upstream compose binds this target, which only a volume entry replaces
|
||||
html5_settings:
|
||||
type: bind
|
||||
source: "{{ [lookup('container', 'web-app-bigbluebutton', 'directories.instance'), 'bbb-html5.yml'] | path_join }}"
|
||||
read_only: true
|
||||
mounts:
|
||||
- service: apps-akka
|
||||
target: /etc/bigbluebutton/bbb-html5.yml
|
||||
ep_redis_publisher_patch:
|
||||
type: bind
|
||||
source: "{{ [lookup('container', 'web-app-bigbluebutton', 'directories.instance'), 'ep_redis_publisher_index.js'] | path_join }}"
|
||||
|
||||
@@ -52,6 +52,13 @@
|
||||
mode: "0555"
|
||||
notify: compose-up
|
||||
|
||||
- name: "🎨 Render the meeting client settings of '{{ application_id }}'"
|
||||
stack_host_template:
|
||||
src: bbb-html5.yml.j2
|
||||
dest: "{{ BBB_HTML5_SETTINGS_HOST }}"
|
||||
mode: "0444"
|
||||
notify: compose-up
|
||||
|
||||
- name: 📁 Create symbolic link from .env file to target location
|
||||
file:
|
||||
src: "{{ BBB_ENV_FILE_ORIGINE }}"
|
||||
|
||||
@@ -25,3 +25,16 @@
|
||||
DESIGN_LOGO_URL: "{{ BBB_DESIGN_LOGO_URL }}"
|
||||
register: bbb_design_settings
|
||||
changed_when: "'CHANGED' in bbb_design_settings.stdout"
|
||||
|
||||
- name: "🎨 Publish the meeting client stylesheet of {{ application_id }}"
|
||||
stack_host_template:
|
||||
src: meeting.css.j2
|
||||
dest: "{{ BBB_MEETING_STYLE_HOST }}"
|
||||
mode: "0644"
|
||||
when: BBB_DESIGN_ENABLED | bool
|
||||
|
||||
- name: "🧹 Remove the meeting client stylesheet of {{ application_id }}"
|
||||
ansible.builtin.file:
|
||||
path: "{{ BBB_MEETING_STYLE_HOST }}"
|
||||
state: absent
|
||||
when: not (BBB_DESIGN_ENABLED | bool)
|
||||
@@ -0,0 +1,10 @@
|
||||
{% set design = lookup('design', application_id) %}
|
||||
public:
|
||||
app:
|
||||
appName: BigBlueButton HTML5 Client (docker)
|
||||
{% if BBB_DESIGN_ENABLED | bool %}
|
||||
{% if design.title is string %}
|
||||
clientTitle: {{ design.title | to_json }}
|
||||
{% endif %}
|
||||
customStyleUrl: {{ BBB_MEETING_STYLE_URL | to_json }}
|
||||
{% endif %}
|
||||
@@ -1,8 +1,14 @@
|
||||
{% set design = lookup('design', application_id) %}
|
||||
{% if design.logo is string %}
|
||||
{% if design.logo is string or design.title is string %}
|
||||
(function () {
|
||||
{% if design.logo is string %}
|
||||
var favicon = {{ design.urls.favicon_ico | to_json }};
|
||||
function icon() {
|
||||
{% endif %}
|
||||
{% if design.title is string %}
|
||||
var title = {{ design.title | to_json }};
|
||||
{% endif %}
|
||||
function brand() {
|
||||
{% if design.logo is string %}
|
||||
var link = document.querySelector("link[rel~='icon']");
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
@@ -13,8 +19,15 @@
|
||||
link.setAttribute("type", "image/x-icon");
|
||||
link.setAttribute("href", favicon);
|
||||
}
|
||||
{% endif %}
|
||||
{% if design.title is string %}
|
||||
if (document.title === "BigBlueButton" && title !== "BigBlueButton") document.title = title;
|
||||
{% endif %}
|
||||
}
|
||||
if (document.head) icon();
|
||||
document.addEventListener("DOMContentLoaded", icon);
|
||||
if (document.head) {
|
||||
brand();
|
||||
new MutationObserver(brand).observe(document.head, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", brand);
|
||||
})();
|
||||
{% endif %}
|
||||
@@ -0,0 +1,102 @@
|
||||
{% set __css_tpl_dir = lookup('path_absolute', 'roles', 'sys-front-inj-design', 'templates', 'css') %}
|
||||
@import url("{{ [lookup('cdn', 'web-svc-cdn').urls.shared.css, 'default.css', lookup('local_mtime_qs', [__css_tpl_dir, 'default.css.j2'] | path_join)] | url_join }}");
|
||||
|
||||
:root {
|
||||
--color-background: var(--design-frame);
|
||||
--color-content-background: var(--design-frame);
|
||||
--webcam-background-color: var(--design-frame);
|
||||
--loader-bg: var(--design-frame);
|
||||
--loader-bullet: var(--design-on-frame);
|
||||
--color-overlay: rgba(var(--design-frame-rgb), 0.75);
|
||||
--user-list-bg: var(--design-surface-2);
|
||||
--dropdown-bg: var(--design-surface-2);
|
||||
--color-off-white: var(--design-surface-1);
|
||||
--color-muted-background: var(--design-surface-1);
|
||||
--list-item-bg-hover: var(--design-surface-hover);
|
||||
--color-text: var(--design-text);
|
||||
--user-list-text: var(--design-text);
|
||||
--color-heading: var(--design-text);
|
||||
--toolbar-list-color: var(--design-text);
|
||||
--system-message-font-color: var(--design-text);
|
||||
--color-gray-label: var(--design-text-muted);
|
||||
--color-gray-light: var(--design-text-muted);
|
||||
--color-gray-lightest: var(--design-border);
|
||||
--poll-stats-border-color: var(--design-border);
|
||||
--system-message-border-color: var(--design-border);
|
||||
--color-gray-lighter: var(--design-border-strong);
|
||||
--color-primary: var(--design-primary);
|
||||
--btn-primary-bg: var(--design-primary);
|
||||
--btn-primary-border: var(--design-primary);
|
||||
--btn-primary-color: var(--design-on-primary);
|
||||
--btn-primary-hover-bg: var(--design-primary-hover);
|
||||
--btn-primary-active-bg: var(--design-primary-active);
|
||||
--poll-blue: var(--design-primary);
|
||||
--color-link: var(--design-link);
|
||||
--item-focus-border: var(--design-link);
|
||||
--color-danger: var(--design-danger);
|
||||
--btn-danger-bg: var(--design-danger);
|
||||
--btn-danger-border: var(--design-danger);
|
||||
--btn-danger-color: var(--design-on-danger);
|
||||
--btn-danger-bg-hover: var(--design-danger-hover);
|
||||
--toast-error-bg: var(--design-danger);
|
||||
--toast-error-color: var(--design-on-danger);
|
||||
--color-success: var(--design-success);
|
||||
--btn-success-bg: var(--design-success);
|
||||
--btn-success-border: var(--design-success);
|
||||
--btn-success-color: var(--design-on-success);
|
||||
--toast-success-bg: var(--design-success);
|
||||
--toast-success-color: var(--design-on-success);
|
||||
--color-warning: var(--design-warning);
|
||||
--btn-warning-bg: var(--design-warning);
|
||||
--btn-warning-border: var(--design-warning);
|
||||
--btn-warning-color: var(--design-on-warning);
|
||||
--toast-warning-bg: var(--design-warning);
|
||||
--toast-warning-color: var(--design-on-warning);
|
||||
--toast-info-bg: var(--design-info);
|
||||
--toast-info-color: var(--design-on-info);
|
||||
--color-white: var(--design-surface-2);
|
||||
--color-gray: var(--design-text-muted);
|
||||
--color-gray-dark: var(--design-text);
|
||||
}
|
||||
|
||||
#message-input {
|
||||
background-color: var(--design-surface-2);
|
||||
color: var(--design-text);
|
||||
}
|
||||
|
||||
[data-test='userListContent'] ul,
|
||||
#chat-list {
|
||||
background-image:
|
||||
linear-gradient(var(--design-surface-2) 30%, rgba(var(--design-surface-2-rgb), 0)),
|
||||
linear-gradient(rgba(var(--design-surface-2-rgb), 0), var(--design-surface-2) 70%),
|
||||
radial-gradient(farthest-side at 50% 0, rgba(var(--design-text-rgb), 0.2), rgba(var(--design-text-rgb), 0)),
|
||||
radial-gradient(farthest-side at 50% 100%, rgba(var(--design-text-rgb), 0.2), rgba(var(--design-text-rgb), 0));
|
||||
}
|
||||
|
||||
.MuiMenu-root .MuiPaper-root {
|
||||
background-color: var(--design-surface-2);
|
||||
color: var(--design-text);
|
||||
}
|
||||
|
||||
.MuiMenu-root .MuiMenuItem-root {
|
||||
color: var(--design-text);
|
||||
}
|
||||
|
||||
.MuiMenu-root .MuiMenuItem-root:hover {
|
||||
background-color: var(--design-surface-hover);
|
||||
}
|
||||
|
||||
html body {
|
||||
background-color: var(--design-frame) !important;
|
||||
}
|
||||
|
||||
#Navbar,
|
||||
#ActionsBar {
|
||||
--color-white: var(--design-on-frame);
|
||||
--color-gray-dark: var(--design-frame);
|
||||
--btn-default-color: var(--design-on-frame);
|
||||
--btn-default-bg: rgba(var(--design-on-frame-rgb), 0.1);
|
||||
--btn-default-border: rgba(var(--design-on-frame-rgb), 0.5);
|
||||
--btn-default-active-bg: rgba(var(--design-on-frame-rgb), 0.2);
|
||||
--toolbar-button-color: var(--design-on-frame);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ DESIGN_LOGO_URL={{ (lookup('design', application_id).urls.logo.png if lookup('de
|
||||
|
||||
DESIGN_SERVICE_ENABLED={{ lookup('config', application_id, 'services.design.enabled') | string | lower }}
|
||||
|
||||
DESIGN_TITLE={{ (lookup('design', application_id).title if lookup('design', application_id).title is string else '') | dotenv_quote }}
|
||||
|
||||
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
|
||||
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
|
||||
|
||||
|
||||
@@ -55,5 +55,9 @@ BBB_DESIGN_ENABLED: "{{ lookup('config', application_id, 'services.design.enable
|
||||
BBB_DESIGN_PALETTE: "{{ lookup('design_palette', lookup('config', 'web-svc-design', 'services.design.colors.base')).light }}"
|
||||
BBB_DESIGN_PRIMARY: "{{ BBB_DESIGN_PALETTE['--design-primary'] if BBB_DESIGN_ENABLED | bool else '' }}"
|
||||
BBB_DESIGN_PRIMARY_LIGHT: "{{ BBB_DESIGN_PALETTE['--design-primary-100'] if BBB_DESIGN_ENABLED | bool else '' }}"
|
||||
BBB_HTML5_SETTINGS_HOST: "{{ [lookup('container', application_id, 'directories.instance'), 'bbb-html5.yml'] | path_join }}"
|
||||
BBB_MEETING_STYLE_HOST: "{{ [lookup('cdn', application_id).role.release.css, 'meeting.css'] | path_join }}"
|
||||
BBB_MEETING_STYLE_URL: "{{ [lookup('cdn', application_id).urls.role.release.css, 'meeting.css', lookup('local_mtime_qs', [role_path, 'templates', 'meeting.css.j2']
|
||||
| path_join)] | url_join }}"
|
||||
BBB_DESIGN_LOGO_URL: "{{ lookup('design', application_id).urls.logo.png if (BBB_DESIGN_ENABLED | bool and lookup('design', application_id).logo is string) else ''
|
||||
}}"
|
||||
Reference in new issue
Block a user