@colixsystems/widget-sdk 0.157.0 → 0.159.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.
package/README.md CHANGED
@@ -119,7 +119,7 @@ Behaviour: a bare date given to a `mode="datetime"` picker now shows local midni
119
119
 
120
120
  **The app header's row is arrangeable, and `./host` publishes the resolver both hosts read (sc-8023).** The row runs across the top of the content outside the side panel, and is drawn three times — as the phone header where the panel is a drawer, as the desktop strip, and as the full-width bar when one is switched on. Each surface hard-coded its own copy of it, so an author could colour the row but never move anything in it.
121
121
 
122
- - **`resolveTopStripeLayout(theme)`** reads `theme.sidebar.stripe` and returns the arrangement fully resolved: `slots` (already filtered and sequenced, so no host re-derives it), `order`, the box (`height`, `paddingX`, `contentOffsetY`), the resolved `backgroundColor` (joined in 0.155.0 by `tintColor` and `titleColor`), and `menuSide` — which edge the drawer must arrive from, since the brand grows and a burger ordered after it sits at the trailing edge. The web Player picks the drawer's side with it; the export makes it `drawerPosition`.
122
+ - **`resolveTopStripeLayout(theme)`** reads `theme.sidebar.stripe` and returns the arrangement fully resolved: `slots` (already filtered and sequenced, so no host re-derives it), `order`, the box (`height`, `paddingX`, `contentOffsetY`, and `barHeight` — the height once the nudge is held, since pushing the contents down grows the bar rather than letting them fall out of it), the resolved `backgroundColor` (joined in 0.155.0 by `tintColor` and `titleColor`), and `menuSide` — which edge the drawer must arrive from, since the brand grows and a burger ordered after it sits at the trailing edge. The web Player picks the drawer's side with it; the export makes it `drawerPosition`.
123
123
  - **`TOP_STRIPE_SLOTS`** is the vocabulary (`back`, `menu`, `brand`, `notifications`) and **`TOP_STRIPE_ALWAYS_SHOWN_SLOTS`** the slots that move but never hide. The burger is the only member: it is drawn only where the panel is a drawer, and there it is the sole route into the menu, so hiding it would strand the visitor. It keeps its place in `order` regardless.
124
124
  - **`topStripeShowKey(slot)`** derives the `theme_config` key that shows or hides one slot, in one place, so a control, the write path and the resolver cannot spell it differently.
125
125
  - **The bounds travel with the resolver** — `MIN`/`MAX_TOP_STRIPE_HEIGHT_PX`, `MIN`/`MAX_TOP_STRIPE_PADDING_X_PX`, `MAX_TOP_STRIPE_OFFSET_Y_PX` — so a control offers exactly the range the resolver clamps to. `DEFAULT_TOP_STRIPE_HEIGHT_PX` / `DEFAULT_TOP_STRIPE_PADDING_X_PX` are what the DESKTOP row measures with nothing stored, published so a control can show the number an author is departing from; the resolver still answers `null`, because each surface keeps its own box until one is named.
@@ -128,6 +128,12 @@ Additive — nothing in the existing `./host` surface changed, and the contract
128
128
 
129
129
  ### What's new in 0.151.0 (contract 1.118.0 — unchanged)
130
130
 
131
+ **`resolveSidebarTokens` answers `activeInk` — the rail's active colour made readable as TEXT (sc-8655, REQ-NAV-LOCAL / REQ-THEME-14).** `activeColor` is authored as a SURFACE: the filled style paints it and forces a white label, so nothing in the system ever pressured it to be legible as ink. Every place that paints it as text inherited that — the accent style's active label, and now the entry a folded sidebar section hands its marking to — and the brand default reads 3.5:1 on the rail, below the 4.5:1 AA asks of normal text and worse than the unmarked rows around it.
132
+
133
+ - `activeInk` is the same hue stepped toward the rail's own polarity (darker on a light rail, lighter on a dark one) only as far as AA needs, and is `activeColor` unchanged whenever that already reads. Derived, never authored: it moves with whatever colour the workspace sets, so there is no second control to keep in step.
134
+ - **Additive.** `activeColor` still names the surface the filled style paints and the colour a non-text mark takes — a dot or an accent edge answers to 3:1, not 4.5:1, so those keep the author's actual colour. `CONTRACT` is untouched (`version` stays `1.122.0`): a host resolver, not a widget-facing surface.
135
+ - Both hosts read it, so the Player, the exported drawer and the Design page's chrome preview cannot paint three different labels off one theme.
136
+
131
137
  **`resolveSidebarUnfold` joins the host navigation surface: where a page's local menu unfolds inside the app's own sidebar (sc-7696, REQ-NAV-LOCAL).** A page's local navigation could only be a bar, above or below its content. `local_nav` now also takes `SIDEBAR`, which takes no strip of the page: the saved menu unfolds in the rail and the drawer, under the global menu entry it belongs to.
132
138
 
133
139
  - `resolveSidebarUnfold({ entries, active, sections })` answers `{ under, items, standalone }` from host-neutral keys — page slugs on web, screen names in the export — so it is the ONE derivation both chromes read. Web calls it per render; the compiler asks it once per screen at build time, because the only runtime input is which screen is showing.
package/dist/host.d.ts CHANGED
@@ -208,7 +208,14 @@ export function resolveFooterTokens(theme: unknown): FooterTokens;
208
208
  export interface SidebarTokens {
209
209
  backgroundColor: string;
210
210
  textColor: string;
211
+ /** The active colour as a SURFACE — what `filled` paints, what a non-text
212
+ * mark (an accent edge, a folded section's dot) is drawn in. */
211
213
  activeColor: string;
214
+ /** sc-8655: the same colour as INK. `activeColor` is authored to sit under a
215
+ * white label, so it can fall below AA as text on the rail; this is it
216
+ * stepped toward the rail's own polarity until it clears 4.5:1, and equal to
217
+ * `activeColor` whenever that already reads. Derived, never authored. */
218
+ activeInk: string;
212
219
  activeStyle: "filled" | "accent";
213
220
  borderColor: string | null;
214
221
  borderWidth: number | null;
@@ -332,8 +339,12 @@ export interface TopStripeLayout {
332
339
  * bar's 64px, the phone header's content-sized row. */
333
340
  height: number | null;
334
341
  paddingX: number | null;
335
- /** How far the row is pushed down, 0-60px. */
342
+ /** How far the row's contents are pushed down, 0-60px. */
336
343
  contentOffsetY: number;
344
+ /** The bar's total height once the nudge is held — its box plus
345
+ * `contentOffsetY`, so contents never fall out of the bottom. Null only
346
+ * while both are unset, where each surface keeps its own box. */
347
+ barHeight: number | null;
337
348
  /** Every slot in the author's sequence, hidden ones included. */
338
349
  order: TopStripeSlot[];
339
350
  /** What the bar actually SHOWS, already filtered and sequenced. A surface
@@ -25,7 +25,7 @@
25
25
  // the SAME literal, so what a planner may persist and what a host draws cannot
26
26
  // diverge.
27
27
 
28
- const { CONTRACT, isHexColor } = require("./contract.cjs");
28
+ const { CONTRACT, isHexColor, contrastRatio, mixHex, readableTextColor } = require("./contract.cjs");
29
29
 
30
30
  // Absent or unknown resolves here, so every app authored before menu types
31
31
  // existed renders and compiles byte-identically.
@@ -267,14 +267,46 @@ function brandPrimary(config) {
267
267
  * @returns {{ backgroundColor: string, textColor: string, activeColor: string,
268
268
  * activeStyle: string, borderColor: string|null, borderWidth: number|null }}
269
269
  */
270
+ // sc-8655: the sidebar's active colour is authored to sit UNDER a white label
271
+ // (the filled style), so nothing ever pressured it to read as INK — the brand
272
+ // default lands at 3.5:1 on the rail, below the 4.5:1 AA asks of normal text,
273
+ // and worse than the inactive rows beside it. Every surface that paints the
274
+ // active colour as TEXT reads the derived ink instead: the same hue, stepped
275
+ // toward the rail's own polarity only as far as legibility needs.
276
+ const AA_TEXT_CONTRAST = 4.5;
277
+ const INK_STEP = 0.05;
278
+ const INK_MAX_SHIFT = 0.7;
279
+
280
+ function readableInk(color, background) {
281
+ if (!isHexColor(color) || !isHexColor(background)) return color;
282
+ if (contrastRatio(color, background) >= AA_TEXT_CONTRAST) return color;
283
+ // Darken on a light rail, lighten on a dark one — the direction that keeps
284
+ // the hue rather than washing it toward grey.
285
+ const toward = readableTextColor(background, "#000000", "#ffffff");
286
+ let ink = color;
287
+ for (let t = INK_STEP; t <= INK_MAX_SHIFT + 1e-9; t += INK_STEP) {
288
+ ink = mixHex(color, toward, t);
289
+ if (contrastRatio(ink, background) >= AA_TEXT_CONTRAST) break;
290
+ }
291
+ // A colour that cannot reach AA within the shift keeps the closest it got:
292
+ // the most readable answer available beats reverting to the unreadable one.
293
+ return ink;
294
+ }
295
+
270
296
  function resolveSidebarTokens(theme) {
271
297
  const config = isPlainObject(theme) ? theme : {};
272
298
  const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
273
299
  const borderColor = hexOrNull(sidebar.borderColor);
300
+ const backgroundColor = hexOr(sidebar.backgroundColor, chromeSurface(config));
301
+ const activeColor = hexOr(sidebar.activeColor, brandPrimary(config));
274
302
  return {
275
- backgroundColor: hexOr(sidebar.backgroundColor, chromeSurface(config)),
303
+ backgroundColor,
276
304
  textColor: hexOr(sidebar.textColor, DEFAULT_CHROME_TEXT),
277
- activeColor: hexOr(sidebar.activeColor, brandPrimary(config)),
305
+ activeColor,
306
+ // sc-8655: `activeColor` where it is a SURFACE, `activeInk` where it is
307
+ // text on the rail. Both hosts read the second for an accent-marked label
308
+ // and for a folded section's entry.
309
+ activeInk: readableInk(activeColor, backgroundColor),
278
310
  activeStyle: sidebar.activeStyle === "accent" ? "accent" : "filled",
279
311
  // REQ-THEME-LOOK: the colour is the switch — no colour, no line.
280
312
  borderColor,
@@ -405,6 +437,7 @@ function topStripeOrder(value) {
405
437
  * @param {unknown} theme — the whole `theme_config`.
406
438
  * @returns {{ backgroundColor: string, tintColor: string, titleColor: string,
407
439
  * height: number|null, paddingX: number|null, contentOffsetY: number,
440
+ * barHeight: number|null,
408
441
  * order: string[], slots: string[], menuSide: "start"|"end" }}
409
442
  */
410
443
  function resolveTopStripeLayout(theme) {
@@ -422,30 +455,40 @@ function resolveTopStripeLayout(theme) {
422
455
  : TOP_STRIPE_SLOT_SHOWN_BY_DEFAULT[slot];
423
456
  };
424
457
  const slots = order.filter(shown);
458
+ // Unset defers to each surface's own box — the desktop bar's fixed 64px, the
459
+ // phone header's content-sized row.
460
+ const height = topStripeClampedOr(
461
+ stripe.height,
462
+ MIN_TOP_STRIPE_HEIGHT_PX,
463
+ MAX_TOP_STRIPE_HEIGHT_PX,
464
+ null,
465
+ );
466
+ const contentOffsetY = topStripeClampedOr(
467
+ stripe.contentOffsetY,
468
+ 0,
469
+ MAX_TOP_STRIPE_OFFSET_Y_PX,
470
+ 0,
471
+ );
425
472
  return {
426
473
  // There is no alignment to resolve: the brand slot GROWS, so the row has no
427
474
  // free space for `justify-content` to distribute and every value looked
428
475
  // identical. The row spreads because the brand takes the slack.
429
- // Unset defers to each surface's own box — the desktop bar's fixed 64px,
430
- // the phone header's content-sized row.
431
- height: topStripeClampedOr(
432
- stripe.height,
433
- MIN_TOP_STRIPE_HEIGHT_PX,
434
- MAX_TOP_STRIPE_HEIGHT_PX,
435
- null,
436
- ),
476
+ height,
437
477
  paddingX: topStripeClampedOr(
438
478
  stripe.paddingX,
439
479
  MIN_TOP_STRIPE_PADDING_X_PX,
440
480
  MAX_TOP_STRIPE_PADDING_X_PX,
441
481
  null,
442
482
  ),
443
- contentOffsetY: topStripeClampedOr(
444
- stripe.contentOffsetY,
445
- 0,
446
- MAX_TOP_STRIPE_OFFSET_Y_PX,
447
- 0,
448
- ),
483
+ contentOffsetY,
484
+ // sc-8552: the bar GROWS by the nudge rather than letting its contents fall
485
+ // out of the bottom — the box the author sized, plus what they pushed into
486
+ // it. For a surface that must state a height (the desktop bar, the native
487
+ // header); the phone row is content-sized and grows on its padding alone.
488
+ barHeight:
489
+ height === null && contentOffsetY === 0
490
+ ? null
491
+ : (height ?? DEFAULT_TOP_STRIPE_HEIGHT_PX) + contentOffsetY,
449
492
  // sc-8023: the stripe's OWN surface, inheriting the app-header colour when
450
493
  // unset — so an untouched app is unmoved, and an author who dresses a
451
494
  // page's top navigation bar no longer repaints the stripe above the panel by
@@ -16,7 +16,7 @@
16
16
  // the SAME literal, so what a planner may persist and what a host draws cannot
17
17
  // diverge.
18
18
 
19
- import { CONTRACT, isHexColor } from "./contract.js";
19
+ import { CONTRACT, isHexColor, contrastRatio, mixHex, readableTextColor } from "./contract.js";
20
20
 
21
21
  // Absent or unknown resolves here, so every app authored before menu types
22
22
  // existed renders and compiles byte-identically.
@@ -258,14 +258,46 @@ function brandPrimary(config) {
258
258
  * @returns {{ backgroundColor: string, textColor: string, activeColor: string,
259
259
  * activeStyle: string, borderColor: string|null, borderWidth: number|null }}
260
260
  */
261
+ // sc-8655: the sidebar's active colour is authored to sit UNDER a white label
262
+ // (the filled style), so nothing ever pressured it to read as INK — the brand
263
+ // default lands at 3.5:1 on the rail, below the 4.5:1 AA asks of normal text,
264
+ // and worse than the inactive rows beside it. Every surface that paints the
265
+ // active colour as TEXT reads the derived ink instead: the same hue, stepped
266
+ // toward the rail's own polarity only as far as legibility needs.
267
+ const AA_TEXT_CONTRAST = 4.5;
268
+ const INK_STEP = 0.05;
269
+ const INK_MAX_SHIFT = 0.7;
270
+
271
+ function readableInk(color, background) {
272
+ if (!isHexColor(color) || !isHexColor(background)) return color;
273
+ if (contrastRatio(color, background) >= AA_TEXT_CONTRAST) return color;
274
+ // Darken on a light rail, lighten on a dark one — the direction that keeps
275
+ // the hue rather than washing it toward grey.
276
+ const toward = readableTextColor(background, "#000000", "#ffffff");
277
+ let ink = color;
278
+ for (let t = INK_STEP; t <= INK_MAX_SHIFT + 1e-9; t += INK_STEP) {
279
+ ink = mixHex(color, toward, t);
280
+ if (contrastRatio(ink, background) >= AA_TEXT_CONTRAST) break;
281
+ }
282
+ // A colour that cannot reach AA within the shift keeps the closest it got:
283
+ // the most readable answer available beats reverting to the unreadable one.
284
+ return ink;
285
+ }
286
+
261
287
  export function resolveSidebarTokens(theme) {
262
288
  const config = isPlainObject(theme) ? theme : {};
263
289
  const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
264
290
  const borderColor = hexOrNull(sidebar.borderColor);
291
+ const backgroundColor = hexOr(sidebar.backgroundColor, chromeSurface(config));
292
+ const activeColor = hexOr(sidebar.activeColor, brandPrimary(config));
265
293
  return {
266
- backgroundColor: hexOr(sidebar.backgroundColor, chromeSurface(config)),
294
+ backgroundColor,
267
295
  textColor: hexOr(sidebar.textColor, DEFAULT_CHROME_TEXT),
268
- activeColor: hexOr(sidebar.activeColor, brandPrimary(config)),
296
+ activeColor,
297
+ // sc-8655: `activeColor` where it is a SURFACE, `activeInk` where it is
298
+ // text on the rail. Both hosts read the second for an accent-marked label
299
+ // and for a folded section's entry.
300
+ activeInk: readableInk(activeColor, backgroundColor),
269
301
  activeStyle: sidebar.activeStyle === "accent" ? "accent" : "filled",
270
302
  // REQ-THEME-LOOK: the colour is the switch — no colour, no line.
271
303
  borderColor,
@@ -396,6 +428,7 @@ function topStripeOrder(value) {
396
428
  * @param {unknown} theme — the whole `theme_config`.
397
429
  * @returns {{ backgroundColor: string, tintColor: string, titleColor: string,
398
430
  * height: number|null, paddingX: number|null, contentOffsetY: number,
431
+ * barHeight: number|null,
399
432
  * order: string[], slots: string[], menuSide: "start"|"end" }}
400
433
  */
401
434
  export function resolveTopStripeLayout(theme) {
@@ -413,30 +446,40 @@ export function resolveTopStripeLayout(theme) {
413
446
  : TOP_STRIPE_SLOT_SHOWN_BY_DEFAULT[slot];
414
447
  };
415
448
  const slots = order.filter(shown);
449
+ // Unset defers to each surface's own box — the desktop bar's fixed 64px, the
450
+ // phone header's content-sized row.
451
+ const height = topStripeClampedOr(
452
+ stripe.height,
453
+ MIN_TOP_STRIPE_HEIGHT_PX,
454
+ MAX_TOP_STRIPE_HEIGHT_PX,
455
+ null,
456
+ );
457
+ const contentOffsetY = topStripeClampedOr(
458
+ stripe.contentOffsetY,
459
+ 0,
460
+ MAX_TOP_STRIPE_OFFSET_Y_PX,
461
+ 0,
462
+ );
416
463
  return {
417
464
  // There is no alignment to resolve: the brand slot GROWS, so the row has no
418
465
  // free space for `justify-content` to distribute and every value looked
419
466
  // identical. The row spreads because the brand takes the slack.
420
- // Unset defers to each surface's own box — the desktop bar's fixed 64px,
421
- // the phone header's content-sized row.
422
- height: topStripeClampedOr(
423
- stripe.height,
424
- MIN_TOP_STRIPE_HEIGHT_PX,
425
- MAX_TOP_STRIPE_HEIGHT_PX,
426
- null,
427
- ),
467
+ height,
428
468
  paddingX: topStripeClampedOr(
429
469
  stripe.paddingX,
430
470
  MIN_TOP_STRIPE_PADDING_X_PX,
431
471
  MAX_TOP_STRIPE_PADDING_X_PX,
432
472
  null,
433
473
  ),
434
- contentOffsetY: topStripeClampedOr(
435
- stripe.contentOffsetY,
436
- 0,
437
- MAX_TOP_STRIPE_OFFSET_Y_PX,
438
- 0,
439
- ),
474
+ contentOffsetY,
475
+ // sc-8552: the bar GROWS by the nudge rather than letting its contents fall
476
+ // out of the bottom — the box the author sized, plus what they pushed into
477
+ // it. For a surface that must state a height (the desktop bar, the native
478
+ // header); the phone row is content-sized and grows on its padding alone.
479
+ barHeight:
480
+ height === null && contentOffsetY === 0
481
+ ? null
482
+ : (height ?? DEFAULT_TOP_STRIPE_HEIGHT_PX) + contentOffsetY,
440
483
  // sc-8023: the stripe's OWN surface, inheriting the app-header colour when
441
484
  // unset — so an untouched app is unmoved, and an author who dresses a
442
485
  // page's top navigation bar no longer repaints the stripe above the panel by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.157.0",
3
+ "version": "0.159.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",