@ti-engine/web-framework 1.20.1 → 1.23.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.
@@ -2042,6 +2042,145 @@ body {
2042
2042
  color: var(--fg-primary);
2043
2043
  }
2044
2044
 
2045
+ .ti-kv-value.mono {
2046
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
2047
+ font-size: var(--fs-xs);
2048
+ }
2049
+
2050
+ /* Dimmed value — a hint, a placeholder, or a fact that is deliberately secondary. */
2051
+ .ti-kv-value.muted {
2052
+ color: var(--fg-tertiary);
2053
+ }
2054
+
2055
+ /* ============================================================
2056
+ Identity header + info sections — the read-only "facts"
2057
+ pattern behind the Profile and About screens. An application
2058
+ supplies the content as a descriptor; this supplies the
2059
+ layout, so every consumer's screens look the same.
2060
+ ============================================================ */
2061
+ .ti-identity {
2062
+ display: flex;
2063
+ align-items: center;
2064
+ gap: var(--s-4);
2065
+ padding: var(--s-5);
2066
+ }
2067
+
2068
+ /* Square brand/app mark, for an identity block that stands for an application rather than a person
2069
+ (the circular .ti-avatar is the person case). Mirrors the sidebar brand mark at a larger size. */
2070
+ .ti-identity-mark {
2071
+ width: 60px;
2072
+ height: 60px;
2073
+ display: grid;
2074
+ place-items: center;
2075
+ border-radius: var(--r-lg);
2076
+ background: linear-gradient(135deg, var(--accent), color-mix(in oklab, var(--accent) 70%, #000));
2077
+ color: #FFF;
2078
+ font-weight: 700;
2079
+ font-size: 26px;
2080
+ letter-spacing: -0.02em;
2081
+ text-transform: uppercase;
2082
+ box-shadow: 0 4px 12px color-mix(in oklab, var(--accent) 32%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.25);
2083
+ flex: 0 0 auto;
2084
+ }
2085
+
2086
+ .ti-identity-meta {
2087
+ flex: 1;
2088
+ min-width: 0;
2089
+ }
2090
+
2091
+ .ti-identity-name {
2092
+ display: flex;
2093
+ align-items: center;
2094
+ flex-wrap: wrap;
2095
+ gap: var(--s-2);
2096
+ font-size: var(--fs-2xl);
2097
+ font-weight: 600;
2098
+ letter-spacing: -0.02em;
2099
+ line-height: 1.1;
2100
+ color: var(--fg-primary);
2101
+ }
2102
+
2103
+ .ti-identity-sub {
2104
+ display: flex;
2105
+ align-items: center;
2106
+ flex-wrap: wrap;
2107
+ gap: 6px;
2108
+ font-size: var(--fs-sm);
2109
+ color: var(--fg-secondary);
2110
+ margin-top: var(--s-1);
2111
+ }
2112
+
2113
+ .ti-identity-sub-sep {
2114
+ color: var(--fg-tertiary);
2115
+ }
2116
+
2117
+ .ti-identity-caption {
2118
+ font-size: var(--fs-sm);
2119
+ color: var(--fg-tertiary);
2120
+ margin-top: 2px;
2121
+ overflow-wrap: anywhere;
2122
+ }
2123
+
2124
+ /* Right-hand stack of status pills / tags. */
2125
+ .ti-identity-aside {
2126
+ display: flex;
2127
+ flex-direction: column;
2128
+ align-items: flex-end;
2129
+ gap: var(--s-1);
2130
+ flex: 0 0 auto;
2131
+ }
2132
+
2133
+ /* Two-up grid of section panels; a section may claim the full row with .wide. */
2134
+ .ti-info-sections {
2135
+ display: grid;
2136
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2137
+ gap: var(--s-5);
2138
+ margin-top: var(--s-5);
2139
+ align-items: start;
2140
+ }
2141
+
2142
+ .ti-info-section.wide {
2143
+ grid-column: 1 / -1;
2144
+ }
2145
+
2146
+ @media (max-width: 900px) {
2147
+ .ti-info-sections {
2148
+ grid-template-columns: 1fr;
2149
+ }
2150
+ }
2151
+
2152
+ /* Label/value pairs inside a section, stacked (label above value) rather than the side-by-side
2153
+ rhythm of .ti-kv-grid — a read-only mirror of the .ti-form-grid a matching edit screen uses. */
2154
+ .ti-info-grid {
2155
+ display: grid;
2156
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2157
+ gap: var(--s-4);
2158
+ padding: var(--s-5);
2159
+ }
2160
+
2161
+ .ti-info-item {
2162
+ display: flex;
2163
+ flex-direction: column;
2164
+ gap: 3px;
2165
+ min-width: 0;
2166
+ }
2167
+
2168
+ .ti-info-item.wide {
2169
+ grid-column: 1 / -1;
2170
+ }
2171
+
2172
+ /* An info value that is a destination rather than a fact. Only http(s)/mailto targets ever reach it — the
2173
+ normalizer in ti-framework.js drops anything else before it can become an href. */
2174
+ .ti-info-link {
2175
+ color: var(--accent);
2176
+ text-decoration: none;
2177
+ overflow-wrap: anywhere;
2178
+ }
2179
+
2180
+ .ti-info-link:hover {
2181
+ text-decoration: underline;
2182
+ }
2183
+
2045
2184
  /* ============================================================
2046
2185
  Tag / pill label
2047
2186
  ============================================================ */
@@ -2410,6 +2549,10 @@ body {
2410
2549
  border-radius: var(--r-md);
2411
2550
  }
2412
2551
 
2552
+ .ti-login-error.visible {
2553
+ display: block;
2554
+ }
2555
+
2413
2556
  .ti-login-form {
2414
2557
  display: flex;
2415
2558
  flex-direction: column;
@@ -1301,6 +1301,342 @@ document.addEventListener( "htmx:responseError", ( event ) => {
1301
1301
  }
1302
1302
  } );
1303
1303
 
1304
+ /* ============================================================================================================
1305
+ Read-only "facts" screens — Profile and About.
1306
+
1307
+ Both render the same descriptor shape: an identity header plus an ordered list of titled sections, each
1308
+ holding label/value items. The server decides the content (see TiWebAppManager#getProfileInfo /
1309
+ #getApplicationInfo); everything below only shapes it for the template, so the fragments stay free of
1310
+ formatting logic — which is also what keeps them within Alpine's CSP expression subset.
1311
+ ============================================================================================================ */
1312
+
1313
+ /**
1314
+ * Rendered in place of an item value the server left empty. Keeping it here rather than in each fragment means a
1315
+ * section never has to be conditionally assembled just to avoid a blank cell.
1316
+ *
1317
+ * @type {string}
1318
+ */
1319
+ const INFO_VALUE_PLACEHOLDER = "—";
1320
+
1321
+ /**
1322
+ * URL schemes an info item may link to. Descriptor content is server-authored, but an `href` becomes a live
1323
+ * navigation target, so the set is an allowlist rather than a `javascript:` denylist.
1324
+ *
1325
+ * @type {string[]}
1326
+ */
1327
+ const INFO_LINK_SCHEMES = [ "http:", "https:", "mailto:" ];
1328
+
1329
+ /**
1330
+ * Returns the href for an info item, or an empty string when it has none or names a scheme that is not in
1331
+ * {@link INFO_LINK_SCHEMES}. A rejected href degrades the item to plain text rather than dropping it.
1332
+ *
1333
+ * @method
1334
+ * @param {Object} item
1335
+ * @returns {string}
1336
+ * @private
1337
+ */
1338
+ const sanitizeInfoHref = ( item ) => {
1339
+ const href = String( ( item && item.href ) || "" ).trim();
1340
+ if ( !href ) {
1341
+ return "";
1342
+ }
1343
+ try {
1344
+ return INFO_LINK_SCHEMES.includes( new URL( href, window.location.origin ).protocol ) ? href : "";
1345
+ } catch {
1346
+ return "";
1347
+ }
1348
+ };
1349
+
1350
+ /**
1351
+ * Normalizes one label/value item: fills in the placeholder for an absent value, collapses the presentational
1352
+ * flags into a single class string, and vets any link target.
1353
+ *
1354
+ * @method
1355
+ * @param {Object} item
1356
+ * @returns {Object}
1357
+ * @private
1358
+ */
1359
+ const normalizeInfoItem = ( item ) => {
1360
+ const source = ( item && typeof item === "object" ) ? item : {};
1361
+ const value = String( source.value === undefined || source.value === null ? "" : source.value ).trim();
1362
+ const classes = [];
1363
+ if ( source.mono ) classes.push( "mono" );
1364
+ if ( source.muted || !value ) classes.push( "muted" );
1365
+
1366
+ return {
1367
+ label: String( source.label || "" ),
1368
+ value: value || INFO_VALUE_PLACEHOLDER,
1369
+ valueClass: classes.join( " " ),
1370
+ wide: source.wide === true,
1371
+ href: value ? sanitizeInfoHref( source ) : ""
1372
+ };
1373
+ };
1374
+
1375
+ /**
1376
+ * Normalizes a descriptor's section list so the template can iterate it without guarding for missing keys.
1377
+ * A section carrying no items at all is dropped — an empty panel is noise, not information.
1378
+ *
1379
+ * @method
1380
+ * @param {Array} sections
1381
+ * @returns {Array}
1382
+ * @private
1383
+ */
1384
+ const normalizeInfoSections = ( sections ) => {
1385
+ return ( Array.isArray( sections ) ? sections : [] )
1386
+ .filter( ( section ) => section && Array.isArray( section.items ) && section.items.length > 0 )
1387
+ .map( ( section ) => ( {
1388
+ title: String( section.title || "" ),
1389
+ description: String( section.description || "" ),
1390
+ icon: String( section.icon || "info-circle" ),
1391
+ wide: section.wide === true,
1392
+ items: section.items.map( normalizeInfoItem )
1393
+ } ) );
1394
+ };
1395
+
1396
+ /**
1397
+ * Normalizes an identity tag into a ready-to-bind pill.
1398
+ *
1399
+ * @method
1400
+ * @param {Object} tag
1401
+ * @returns {Object}
1402
+ * @private
1403
+ */
1404
+ const normalizeInfoTag = ( tag ) => {
1405
+ const source = ( tag && typeof tag === "object" ) ? tag : {};
1406
+ const classes = [ source.mono ? "ti-tag mono" : "ti-status-pill" ];
1407
+ if ( source.tone ) classes.push( String( source.tone ) );
1408
+ return {
1409
+ text: String( source.text || "" ),
1410
+ dot: source.dot === true && !source.mono,
1411
+ pillClass: classes.join( " " )
1412
+ };
1413
+ };
1414
+
1415
+ /**
1416
+ * Runs `load` once the application store has finished initializing — the descriptor endpoints are session-scoped,
1417
+ * so requesting them before `/app/config` has resolved would race the session bootstrap.
1418
+ *
1419
+ * @method
1420
+ * @param {Object} component The Alpine component (for `$watch`).
1421
+ * @param {Function} load
1422
+ * @private
1423
+ */
1424
+ const loadWhenInitialized = ( component, load ) => {
1425
+ const tiApplication = Alpine.store( "tiApplication" );
1426
+ if ( tiApplication.isInitialized ) {
1427
+ load();
1428
+ } else {
1429
+ component.$watch( () => tiApplication.isInitialized, ( isInitialized ) => {
1430
+ if ( isInitialized ) {
1431
+ load();
1432
+ }
1433
+ } );
1434
+ }
1435
+ };
1436
+
1437
+ /**
1438
+ * Returns a configuration object for the Profile screen "frame-profile.html".
1439
+ * <br/>
1440
+ * The descriptor comes from `GET /app/profile` (JSON), which the application's web app manager builds — this
1441
+ * component only shapes it for the template and never decides what a profile contains.
1442
+ *
1443
+ * @method
1444
+ * @returns {Object}
1445
+ * @public
1446
+ */
1447
+ const configureScreenProfile = () => {
1448
+ const tiToolbox = Alpine.store( "tiToolbox" );
1449
+ const tiApplication = Alpine.store( "tiApplication" );
1450
+
1451
+ return {
1452
+
1453
+ profile: null,
1454
+ busy: true,
1455
+
1456
+ init() {
1457
+ loadWhenInitialized( this, () => this.load() );
1458
+ },
1459
+
1460
+ load() {
1461
+ this.busy = true;
1462
+ tiApplication.sendRequest( "/app/profile" ).then( ( result ) => {
1463
+ const data = ( result && result.data && typeof result.data === "object" ) ? result.data : {};
1464
+ const identity = ( data.identity && typeof data.identity === "object" ) ? data.identity : {};
1465
+ this.profile = {
1466
+ identity: {
1467
+ name: String( identity.name || "" ),
1468
+ subtitle: String( identity.subtitle || "" ),
1469
+ caption: String( identity.caption || "" ),
1470
+ avatarSeed: String( identity.avatarSeed || identity.name || "" ),
1471
+ badge: identity.badge ? { text: String( identity.badge.text || "" ), tone: String( identity.badge.tone || "" ) } : null,
1472
+ tags: ( Array.isArray( identity.tags ) ? identity.tags : [] ).map( normalizeInfoTag )
1473
+ },
1474
+ sections: normalizeInfoSections( data.sections )
1475
+ };
1476
+ this.busy = false;
1477
+ } ).catch( ( error ) => {
1478
+ this.busy = false;
1479
+ if ( error && ( error.name === "AbortError" || error.isAborted ) ) return;
1480
+ tiApplication.notify( tiApplication.formatException( error ) );
1481
+ } );
1482
+ },
1483
+
1484
+ avatarStyle() {
1485
+ const identity = this.profile ? this.profile.identity : {};
1486
+ return tiToolbox.generateAvatarStyle( identity.avatarSeed, identity.name );
1487
+ },
1488
+
1489
+ avatarInitial() {
1490
+ const identity = this.profile ? this.profile.identity : {};
1491
+ return String( identity.name || "?" ).charAt( 0 ).toUpperCase();
1492
+ }
1493
+
1494
+ };
1495
+ };
1496
+
1497
+ /**
1498
+ * Returns a configuration object for the About screen "frame-about.html".
1499
+ * <br/>
1500
+ * The server descriptor carries the application's identity as flat fields; the release, component and runtime
1501
+ * sections are assembled here rather than server-side, because their labels are fixed framework chrome and the
1502
+ * client's `getLabel` takes a fallback — which is what keeps the screen readable inside a consuming application
1503
+ * that loads only its own label catalogue.
1504
+ *
1505
+ * @method
1506
+ * @returns {Object}
1507
+ * @public
1508
+ */
1509
+ const configureScreenAbout = () => {
1510
+ const tiToolbox = Alpine.store( "tiToolbox" );
1511
+ const tiApplication = Alpine.store( "tiApplication" );
1512
+
1513
+ // Runtime keys the framework itself supplies; anything else an application adds falls back to its own key.
1514
+ const RUNTIME_LABELS = {
1515
+ node: [ "interface.about.runtime-node", "Node.js" ],
1516
+ platform: [ "interface.about.runtime-platform", "Platform" ],
1517
+ application: [ "interface.about.runtime-application", "Application ID" ]
1518
+ };
1519
+
1520
+ const label = ( key, fallback ) => tiApplication.getLabel( key, fallback );
1521
+
1522
+ return {
1523
+
1524
+ application: null,
1525
+ sections: [],
1526
+ busy: true,
1527
+
1528
+ init() {
1529
+ loadWhenInitialized( this, () => this.load() );
1530
+ },
1531
+
1532
+ load() {
1533
+ this.busy = true;
1534
+ tiApplication.sendRequest( "/app/about" ).then( ( result ) => {
1535
+ const data = ( result && result.data && typeof result.data === "object" ) ? result.data : {};
1536
+ this.application = {
1537
+ name: String( data.name || "" ),
1538
+ version: String( data.version || "" ),
1539
+ releaseDate: String( data.releaseDate || "" ),
1540
+ description: String( data.description || "" )
1541
+ };
1542
+ this.sections = normalizeInfoSections( [
1543
+ this._releaseSection( data ),
1544
+ this._componentsSection( data ),
1545
+ this._runtimeSection( data )
1546
+ ].concat( Array.isArray( data.sections ) ? data.sections : [] ) );
1547
+ this.busy = false;
1548
+ } ).catch( ( error ) => {
1549
+ this.busy = false;
1550
+ if ( error && ( error.name === "AbortError" || error.isAborted ) ) return;
1551
+ tiApplication.notify( tiApplication.formatException( error ) );
1552
+ } );
1553
+ },
1554
+
1555
+ applicationInitial() {
1556
+ return String( ( this.application && this.application.name ) || "?" ).charAt( 0 ).toUpperCase();
1557
+ },
1558
+
1559
+ versionTag() {
1560
+ return "v" + ( ( this.application && this.application.version ) || "" );
1561
+ },
1562
+
1563
+ releaseDateText() {
1564
+ const released = label( "interface.about.released", "Released" );
1565
+ return released + " " + tiToolbox.formatDate( ( this.application && this.application.releaseDate ) || "" );
1566
+ },
1567
+
1568
+ _releaseSection( data ) {
1569
+ return {
1570
+ title: label( "interface.about.section-release", "Release" ),
1571
+ icon: "info-circle",
1572
+ items: [
1573
+ { label: label( "interface.about.field-version", "Version" ), value: data.version, mono: true },
1574
+ { label: label( "interface.about.field-release-date", "Release date" ), value: tiToolbox.formatDate( data.releaseDate ) },
1575
+ { label: label( "interface.about.field-license", "License" ), value: data.license },
1576
+ { label: label( "interface.about.field-author", "Author" ), value: data.author },
1577
+ { label: label( "interface.about.field-package", "Package" ), value: data.packageName, mono: true, wide: true },
1578
+ { label: label( "interface.about.field-homepage", "Homepage" ), value: data.homepage, href: data.homepage, wide: true }
1579
+ ]
1580
+ };
1581
+ },
1582
+
1583
+ _componentsSection( data ) {
1584
+ const components = Array.isArray( data.components ) ? data.components : [];
1585
+ return {
1586
+ title: label( "interface.about.section-components", "Framework components" ),
1587
+ description: label( "interface.about.section-components-desc", "The ti-engine packages this application is built on." ),
1588
+ icon: "folder",
1589
+ items: components.map( ( component ) => ( {
1590
+ label: String( component.name || "" ),
1591
+ value: String( component.version || "" ),
1592
+ mono: true
1593
+ } ) )
1594
+ };
1595
+ },
1596
+
1597
+ _runtimeSection( data ) {
1598
+ // Present only for an admin session — the server withholds `runtime` from everyone else.
1599
+ const runtime = ( data.runtime && typeof data.runtime === "object" ) ? data.runtime : {};
1600
+ return {
1601
+ title: label( "interface.about.section-runtime", "Runtime" ),
1602
+ description: label( "interface.about.section-runtime-desc", "Visible to administrators only." ),
1603
+ icon: "settings",
1604
+ items: Object.keys( runtime ).map( ( key ) => {
1605
+ const known = RUNTIME_LABELS[ key ];
1606
+ return {
1607
+ label: known ? label( known[ 0 ], known[ 1 ] ) : key,
1608
+ value: String( runtime[ key ] || "" ),
1609
+ mono: true
1610
+ };
1611
+ } )
1612
+ };
1613
+ }
1614
+
1615
+ };
1616
+ };
1617
+
1618
+ /**
1619
+ * Returns a configuration object for the login screen error message.
1620
+ * <br/>
1621
+ * The login handlers answer a failed sign-in with a `303` to `/?error=<code>` (see `defaultErrorHandler`). This reads
1622
+ * that parameter and reveals the message element; the copy itself stays declarative via `x-text-label` so it localizes
1623
+ * through the normal path. Deliberately independent of the throwaway test-user panel so it survives that panel's removal.
1624
+ *
1625
+ * @method
1626
+ * @returns {Object}
1627
+ * @public
1628
+ */
1629
+ const configureLoginError = () => {
1630
+ return {
1631
+ hasError: false,
1632
+
1633
+ init() {
1634
+ const tiToolbox = Alpine.store( "tiToolbox" );
1635
+ this.hasError = Boolean( tiToolbox.getUrlParam( "error" ) );
1636
+ }
1637
+ };
1638
+ };
1639
+
1304
1640
  /**
1305
1641
  * Returns a configuration object for the login screen test user pill panel.
1306
1642
  * <br/>
@@ -1424,4 +1760,7 @@ document.addEventListener( "alpine:init", () => {
1424
1760
  Alpine.data( "tiComponentNotificationBar", configureComponentNotificationBar );
1425
1761
  Alpine.data( "tiComponentTooltip", configureComponentTooltip );
1426
1762
  Alpine.data( "tiLoginTestUserPanel", configureLoginTestUserPanel );
1763
+ Alpine.data( "tiLoginError", configureLoginError );
1764
+ Alpine.data( "tiScreenProfile", configureScreenProfile );
1765
+ Alpine.data( "tiScreenAbout", configureScreenAbout );
1427
1766
  } );