From 91e9792c3c4f8adfd205ce0ab4d2fd3ee8da552e Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sun, 25 Oct 2026 15:45:57 -0400 Subject: [PATCH] Give tags a quieter look everywhere they show - the tag's colour as a faint wash, a fine edge and its icon, the name in the page's own text colour, pale colours darkened on white and dark ones lifted in dark mode - on lists, record pages, Admin > Tags, the tag window, global search, the tag picker and every Tags filter, through the one .itflow-tag style, a soft .itflow-select-badge for tag options (tagOptionLook()) and the search tag icon --- CHANGELOG.md | 4 +- admin/modals/tag/tag_merge.php | 2 +- agent/assets.php | 2 +- agent/clients.php | 2 +- agent/contacts.php | 2 +- agent/credentials.php | 2 +- agent/domains.php | 2 +- agent/locations.php | 2 +- agent/vendors.php | 2 +- css/itflow_custom.css | 72 ++++++++++++++++++++++++++-------- functions/tags.php | 11 +++++- js/itflow_select.js | 6 ++- js/tag_picker.js | 3 +- 13 files changed, 83 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f73982bea..57ab0caab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -930,13 +930,14 @@ If you are coming from 26.08 or earlier, the database update still has to be run - API: clients take `client_default_tax_id`, `client_tax_exempt`, `client_tax_exempt_number` and `client_tax_exempt_expires`, and a new invoice line takes `tax_id` `default` for the client's usual rate. A new endpoint reads the taxes with their parts. Invoices and quotes add their subtotal, tax, tax by part and registration numbers, invoice lines name their rate, and expenses include their tax. - Demo data: both taxes have a registration number, Allegheny County Sales Tax is made of the state's 6% - named PA Sales Tax, so Tax Collected adds the two up together - and the county's 1%, each client has its rate, the church and the charter school are tax exempt, one with a certificate that has run out, and what the company buys for its own use pays sales tax. - **Tags** (rebuilt) + - A quieter look, everywhere a tag shows: its color is the accent rather than the fill - a faint wash of it, a fine edge in it and its icon in it, with the name in the page's own text - so a row of tags reads as labels beside the record instead of drawing the eye from what is on the screen. Pale colors are taken a little darker to show on white, and dark ones lighter in dark mode, so every tag reads in both. The same look is on lists and record pages, Admin > Tags, the tag window as you change it, global search, the tag picker and every Tags filter. Pointing at a tag deepens it. - Admin > Tags lists each kind's tags - clients, locations, contacts, credentials, assets, vendors, domains, licenses, services, networks, documents and tickets, down the side with how many each has. Each tag shows as it looks on a record, with its description, how many records have it (and how many of those are archived), opening that list filtered by it, and the recurring invoice lines that count users or devices by it. - Search by name or description, sort by name or by how many records have each from the column headings, a page at a time, and Active and Archived tabs. - Each tag has Edit, Merge into..., a link to its records, Archive or Restore, and Delete, which asks first and says how many records lose the tag. Tick several for Merge into one, Archive or Restore, and Delete. - The tag window shows the tag as it will look while you change it: - its name, and what it is for, which can be changed while nothing has the tag yet - a description, shown when you point at the tag and in the tag picker, which searches it too - - a color from the swatches, or any other from the color picker or typed as a hex value - the text on the pill is dark or white, whichever reads + - a color from the swatches, or any other from the color picker or typed as a hex value - an icon from the grid, which you can search, or any Font Awesome icon by its name - Merge moves every record with the merged tags onto the one you keep, once each, and the recurring invoice lines that count by them count by it. The others are deleted. - Record forms: an admin types a new tag's name into the Tags field and picks Add or presses Enter, and the tag is made, with the next color along, when the record is saved - nothing reloads and nothing typed is lost. Typing the name of an archived tag brings it back. @@ -2008,6 +2009,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Contract billing and SLAs: database 3.4.5 adds `contracts.contract_recurring_invoice_id` int (0, keyed) and `contract_recurring_stopped_at` datetime, set when the contract pauses its recurring invoice so it does so once, and sets a rate of 0.00 on contracts and templates to NULL (`contractFromInput()` saves 0 as NULL too). New `functions/contract_billing.php`: `contractBillingReady()`, `contractTermsContracts()` (a client's contracts that can be in force, kept for the request), `contractInForceOn()`, `contractTermsOn()` (the one started last that sets a rate, net terms or a priority's SLA), `contractsInForce()`, `contractSlaFor()`, `contractAfterHours()` (from `getSlaSettings()` and `getBusinessHolidays()`), `contractTicketRate()` (source after_hours, contract, client or default, and a note), `contractClientRate()`, `contractNetTerms()`, `contractNetTermsToday()`, `contractRecurringRow()`, `contractForRecurring()`, `contractRecurringChoices()`, `contractRecurringShouldRun()`, `contractRecurringAmountText()`, `contractRecurringStart()` (skips missed runs with `recurringInvoiceCatchUp()`), `contractRecurringStop()`, `contractRecurringFollow()`, `contractRecurringLink()`, `contractRecurringUnlink()`, `contractRecurringFromInput()`, `contractRecurringFirstDate()`, `contractRecurringCreate()` and `contractRecurringNightly()`. `getTicketSlaId()` asks `contractSlaFor()` first, and takes `$with_contract` (false for the client's own - `contractSlaRows()` uses it). `billingUnbilledTickets()` prices each ticket with `contractTicketRate()` and adds `rate_source` and `rate_note`; `billingUnbilledByClient()` adds `rates`, `rate_note` and `rate_source`; `billingTimeInvoice()` with no price bills each ticket at its own rate, and `combine` makes one line per rate. `billingInvoiceCreate()`, `quoteConvert()`, `recurringInvoiceGenerate()`, New Invoice and Copy in `agent/post/invoice.php` take `contractNetTerms()`. `contractsNightly()` returns `paused` too, logged by `cron/nightly_tasks.php`. `agent/post/contract.php` adds `make_contract_recurring` and `link_contract_recurring` (POST) and `unlink_contract_recurring` (GET with the token), all Sales Modify, and Make active, Renew, Cancel and a countersignature that makes a draft active call `contractRecurringFollow()`; deleting a recurring invoice unlinks its contract. New `agent/modals/contract/contract_recurring.php`. The prepaid hours handlers pass the agent's name unescaped, as the history and logs escape it. - Client portal invites and sign-ups: database 3.4.6 adds `portal_invites` - `invite_kind` (invite or signup), `invite_email`, `invite_name`, `invite_billing`, `invite_technical`, `invite_token_hash` (the SHA-256 of the link's token), `invite_expires_at`, `invite_sent_at`, `invite_sent_count`, `invite_created_by` (an agent), `invite_created_by_contact_id` (a primary contact), `invite_created_at`, `invite_accepted_at`, `invite_contact_id`, `invite_cancelled_at` and `invite_client_id` (ON DELETE CASCADE), keyed on the client and address and on the contact - plus `clients.client_portal_signup` and `settings.config_client_portal_signup` tinyint(1) (0). New `functions/portal_invite.php`: `PORTAL_SIGNUP_LINK_HOURS` (24), `PORTAL_INVITE_MAX` (50), `PORTAL_SIGNUP_IP_LIMIT` (20), `portalInvitesReady()` (keeps every page and the nightly job as they were before 3.4.6 has run), `portalInviteParse()`, `portalInviteWho()` (new, contact, member or elsewhere), `portalInviteRow()`, `portalInviteBy()`, `portalInviteState()`, `portalInviteUrl()`, `portalInviteNewToken()`, `portalInviteByLink()` (hash_equals), `portalInviteMail()`, `portalInviteSend()`, `portalInviteResend()`, `portalInviteCancel()`, `portalInvitesWaiting()`, `portalInviteStateLabel()`, `portalContactJoined()`, `portalInviteAccept()` (spends the link with one guarded UPDATE first, so a second press or a race does nothing), `portalMailSignupJoined()`, `portalMailInviteAccepted()`, `portalSignupOn()`, `portalSignupDomains()`, `portalSignupClients()`, `portalSignupOpen()`, `portalSignupClientFor()`, `portalSignupRequest()` (logged as `Client Login` Sign Up Requested, Sign Up Sent and Sign Up Refused), `portalMailSignupExists()`, `portalSignupSet()` and `portalInvitesNightly()`, which `cron/nightly_tasks.php` runs and logs. `portalContactAccess()` and `portalSignInMethods()` move from `client/functions.php` to `functions/portal.php`, as cron and the agent side use them now. `agent/post/contact.php` adds `send_portal_invites` (POST) and `resend_portal_invite` and `cancel_portal_invite` (GET with the token), and `agent/post/client.php` `set_client_portal_signup` (POST), all Client Modify. `agent/modals/contact/contact_invite.php`, an unused stub (Welcome Letter, Big Wig) with no handler, is replaced by the Invite people window. `agent/contacts.php` adds `role=invites`. `client/post.php` adds `portal_invites_send`, `portal_invite_resend` and `portal_invite_cancel` (the primary contact's). New `client/invite.php`, `client/join.php` (`?invite=&token=`, Referrer-Policy no-referrer) and `client/signup.php` (a form token in the session and a hidden field robots fill in). `edit_client_portal_settings` saves `config_client_portal_signup` once 3.4.6 has run. `emailPreviewInviteCatalog()` adds `portal_join_invite`, `portal_join_invite_colleague`, `portal_join_accepted`, `portal_signup`, `portal_signup_exists` and `portal_signup_joined`. `auditLogActionStyle()` adds Invite and the Sign Up actions. New CSS: `.itflow-join-box`. - Get started: database 3.4.7 adds `settings.config_getting_started` tinyint(1) (1 in db.sql, so a new install has it; the update sets it to 0 for one already running), `config_getting_started_skipped` varchar(255) (the steps skipped, comma separated) and `config_getting_started_opened_at` datetime (when it was first opened - it opens by itself on signing in only until then). New `functions/getting_started.php`: `GETTING_STARTED_STEPS`, `GETTING_STARTED_SLAS`, `gettingStartedReady()` (nothing shows before 3.4.7 has run), `gettingStartedState()`, `gettingStartedUrl()`, `gettingStartedCronCommand()`, `gettingStartedBackupJob()`, `gettingStartedSteps()` (each step's done, skipped and status, from the settings), `gettingStartedProgress()`, `gettingStartedAfter()`, `gettingStartedSkip()`, `gettingStartedOpened()`, `gettingStartedDismiss()`, `gettingStartedFirstVisit()` (login.php takes the first admin there, after MFA enforcement and not instead of a `last_visited` page), `gettingStartedCard()` (the dashboard's card), `gettingStartedBackupSave()` (the backup job's row in `cron_jobs`, seeded from the registry when the dispatcher hasn't yet), `gettingStartedApplySlas()` and `gettingStartedDaysText()`. New `admin/getting_started.php` (a page of its own, in the setup wizard's frame; `?step=`, and `?cron_status=1` answers in JSON), its steps in `admin/getting_started/` and `admin/post/getting_started.php`: `getting_started_cron_on`, `getting_started_mail` (continue or test), `getting_started_backups`, `getting_started_billing`, `getting_started_support`, `getting_started_team`, `getting_started_content`, `getting_started_skip` and `getting_started_dismiss`. The first client posts `add_client` to `agent/post.php` and the demo data `load_demo_data`, both of which return to the step. Admin > Mail's check of a sending or receiving server moves from a closure in `admin/post/settings_mail.php` to `settingsMailServerCheck($server, $before, $post)` in `functions/settings.php`, which both use. In `admin/post/starter_content_model.php`, `starterContentLoad()` and each `starterLoad*()` take `$only`, the items to load by `starterItemKey()` (null for the whole pack, as Maintenance > Starter Content loads it), and new `starterContentRows()`, `starterItemKey()` (a category's or tag's type with its name), `starterItemName()`, `starterItemGroup()`, `starterOnly()` and `starterContentItems()` (what a pack would add, judged here as each loader judges it); `getting_started_content` and `load_starter_content` (for one pack) take `picked[pack]` and `items[pack][]`; `starterContentItems()` gives each item's library row. New `admin/includes/starter_content_pick.php` (both pages): `starterItemAbout()`, `starterItemLook()` (through `tagPill()`, `categoryChip()` and `productBilledText()`), `starterPreviewPacks()`, `starterItemPreview()`, `starterPickHtml($pack, $items, $form)` (the list, tied by `form=` to a table row's Add) and `starterPreviewModalHtml()`; new `admin/starter_content_preview.php` answers Preview in JSON (`?pack=&item=`, admins only, 404 for anything else); new `js/starter_content.js` (`data-starter-pick`, `data-starter-item`, `data-starter-pack-check`, `data-starter-count`, `data-starter-add`, `data-starter-preview`) takes over from the picking in `js/getting_started.js`; new CSS `.itflow-starter-*`. New `js/getting_started.js` (`data-gs-reveal`, `data-gs-show`, `data-gs-cron-poll`), and `js/app.js` only starts DataTables where it is loaded, so setup and Get started, which load app.js on its own, no longer log an error to the console. New CSS: `.itflow-gs-*`. +- Tags' look: `.itflow-tag` draws `--itflow-tag-color` as a `color-mix()` wash and edge with the icon in it and the name in `--bs-body-color` (`--itflow-tag-ink`, 75% or 55% under `[data-bs-theme="dark"]`, is how much of the color the icon keeps), with neutral fallbacks before each `color-mix()`; `--itflow-tag-text` is no longer used by pills. `.itflow-search-tag-icon` and the new `.itflow-select-badge.is-soft` follow it. New `tagOptionLook($color, $icon)` in `functions/tags.php` (`selectOptionColor()` plus `data-soft="1"`) for a tag's ` + diff --git a/agent/clients.php b/agent/clients.php index 61d2782dc..f201b3809 100644 --- a/agent/clients.php +++ b/agent/clients.php @@ -387,7 +387,7 @@ $total_leads = intval($row['leads']); while ($row = mysqli_fetch_assoc($sql_tags_filter)) { $filter_tag_id = intval($row['tag_id']); $filter_tag_name = escapeHtml($row['tag_name']); ?> - + diff --git a/agent/contacts.php b/agent/contacts.php index 2a9d45b75..2ae3a0c1c 100644 --- a/agent/contacts.php +++ b/agent/contacts.php @@ -386,7 +386,7 @@ $search_placeholder = $invites_view ? 'Search invites by email, name or client' "); while ($row = mysqli_fetch_assoc($sql_tags_filter)) { $tag_id = intval($row['tag_id']); ?> - + diff --git a/agent/credentials.php b/agent/credentials.php index 28c8eceff..cf97ba141 100644 --- a/agent/credentials.php +++ b/agent/credentials.php @@ -216,7 +216,7 @@ $filter_url = function ($changes) use ($client_url) { "); while ($row = mysqli_fetch_assoc($sql_tags_filter)) { $tag_id = intval($row['tag_id']); ?> - + diff --git a/agent/domains.php b/agent/domains.php index 7b861ff35..1ebb553e4 100644 --- a/agent/domains.php +++ b/agent/domains.php @@ -189,7 +189,7 @@ $bulk_vendor_options = $can_edit ? domainVendorChoices($client_url ? $client_id "); while ($row = mysqli_fetch_assoc($sql_tags_filter)) { $tag_id = intval($row['tag_id']); ?> - + diff --git a/agent/locations.php b/agent/locations.php index 378e6397f..e8a5b60db 100644 --- a/agent/locations.php +++ b/agent/locations.php @@ -182,7 +182,7 @@ $locations_counts = locationCounts(array_keys($locations)); "); while ($row = mysqli_fetch_assoc($sql_tags_filter)) { $tag_id = intval($row['tag_id']); ?> - + diff --git a/agent/vendors.php b/agent/vendors.php index 73322c017..27d05a201 100644 --- a/agent/vendors.php +++ b/agent/vendors.php @@ -209,7 +209,7 @@ $missing_templates = $view === 'client' && !$archived ? vendorTemplatesMissingFo "); while ($row = mysqli_fetch_assoc($sql_tags_filter)) { $tag_id = intval($row['tag_id']); ?> - + diff --git a/css/itflow_custom.css b/css/itflow_custom.css index 78fe9c65d..b3381512a 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -1389,6 +1389,14 @@ select.select2[multiple]:not(.itflow-select-native) { color: #fff; background-color: var(--itflow-select-color, var(--bs-secondary)); } +/* A tag's - its icon in its colour on a wash of it, as its pill looks (tagOptionLook()) */ +.itflow-select-badge.is-soft { + color: var(--itflow-select-color, var(--bs-secondary)); + color: color-mix(in oklab, var(--itflow-select-color, var(--bs-secondary)) var(--itflow-tag-ink, 75%), var(--bs-emphasis-color)); + background-color: var(--bs-tertiary-bg); + background-color: color-mix(in srgb, var(--itflow-select-color, var(--bs-secondary)) 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--itflow-select-color, var(--bs-secondary)) 40%, var(--bs-border-color)); +} .itflow-select-icon { display: inline-flex; flex: 0 0 auto; @@ -2204,8 +2212,12 @@ html { height: 1.5rem; border-radius: 50%; font-size: .7rem; - color: var(--itflow-tag-text, #fff); - background-color: var(--itflow-tag-color, var(--bs-secondary)); + /* As a tag's pill - its icon in its colour on a wash of it */ + color: var(--itflow-tag-color, var(--bs-secondary)); + color: color-mix(in oklab, var(--itflow-tag-color, var(--bs-secondary)) var(--itflow-tag-ink, 75%), var(--bs-emphasis-color)); + background-color: var(--bs-tertiary-bg); + background-color: color-mix(in srgb, var(--itflow-tag-color, var(--bs-secondary)) 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--itflow-tag-color, var(--bs-secondary)) 40%, var(--bs-border-color)); } .itflow-search-item-title.has-tags { display: flex; @@ -2927,8 +2939,8 @@ a.itflow-dash-rail-item:focus-visible { font-weight: 600; letter-spacing: .02em; } -/* Tags - functions/tags.php tagPills(), on every list and detail page that has - them. A pill opens the list filtered to that tag; the dashed "+" at the end +/* Tags - functions/tags.php tagPills() and tagPill(), on every list and detail + page that has them, and global search. A pill opens the list filtered to that tag; the dashed "+" at the end opens the tag picker (js/tag_picker.js). The group sits above stretched-link row and cell links so the pills stay clickable on top of them. */ .itflow-tags { @@ -2941,26 +2953,51 @@ a.itflow-dash-rail-item:focus-visible { max-width: 100%; vertical-align: middle; } +/* + * A tag's colour (--itflow-tag-color, tagColor()) is its accent, not its fill: + * a faint wash of it, a hairline of it and its icon in it, with the name in + * the page's own text colour - so a row of tags reads as labels beside the + * record rather than the loudest thing on the page, in light and dark alike. + * The icon is the colour taken part of the way to the text colour - further + * in dark, where the darkest tags would otherwise sink - so a pale one still + * shows on white and a dark one on dark, and the hairline starts from the + * page's own border colour so even a near-black tag has an edge. Where + * color-mix() isn't known, the plain lines before each give a neutral pill. + */ .itflow-tag { + --itflow-tag-tint: var(--itflow-tag-color, var(--bs-secondary)); + --itflow-tag-ink: 75%; display: inline-flex; align-items: center; - gap: .2rem; - padding: .05rem .45rem; - border: 1px solid transparent; + gap: .3rem; + padding: .0625rem .5rem .0625rem .4rem; + border: 1px solid var(--bs-border-color); + border-color: color-mix(in srgb, var(--itflow-tag-tint) 40%, var(--bs-border-color)); border-radius: 1rem; font-size: .75rem; - font-weight: normal; + font-weight: 500; line-height: 1.4; - /* Dark or white text, whichever reads on the tag's colour (tagTextColor()) */ - color: var(--itflow-tag-text, #fff); - background-color: var(--itflow-tag-color, var(--bs-secondary)); + color: var(--bs-body-color); + background-color: var(--bs-tertiary-bg); + background-color: color-mix(in srgb, var(--itflow-tag-tint) 10%, transparent); text-decoration: none; white-space: nowrap; + transition: background-color .15s, border-color .15s; } -.itflow-tag:hover, -.itflow-tag:focus-visible { - color: var(--itflow-tag-text, #fff); - filter: brightness(1.1); +.itflow-tag > i { + color: var(--itflow-tag-tint); + color: color-mix(in oklab, var(--itflow-tag-tint) var(--itflow-tag-ink), var(--bs-emphasis-color)); +} +[data-bs-theme="dark"] .itflow-tag, +[data-bs-theme="dark"] .itflow-search-tag-icon, +[data-bs-theme="dark"] .itflow-select-badge.is-soft { + --itflow-tag-ink: 55%; +} +a.itflow-tag:hover, +a.itflow-tag:focus-visible { + color: var(--bs-emphasis-color); + border-color: color-mix(in srgb, var(--itflow-tag-tint) 65%, var(--bs-border-color)); + background-color: color-mix(in srgb, var(--itflow-tag-tint) 18%, transparent); } .itflow-tag-add { color: var(--bs-secondary-color); @@ -2969,12 +3006,15 @@ a.itflow-dash-rail-item:focus-visible { border-style: dashed; cursor: pointer; } +.itflow-tag-add > i { + color: inherit; +} .itflow-tag-add:hover, .itflow-tag-add:focus-visible, .itflow-tag-add[aria-expanded="true"] { color: var(--itflow-accent, var(--bs-primary)); border-color: currentColor; - filter: none; + background-color: transparent; } /* The picker - the select picker's panel, plus the bulk note and the dash for a tag only some of the selected records have */ diff --git a/functions/tags.php b/functions/tags.php index 191212b5a..d3346fc9e 100644 --- a/functions/tags.php +++ b/functions/tags.php @@ -734,12 +734,21 @@ function tagOptionsHtml($entity, array $selected = []) { return $html; } +/* + * How a tag leads its '; } diff --git a/js/itflow_select.js b/js/itflow_select.js index 419ec12ff..b71f598da 100644 --- a/js/itflow_select.js +++ b/js/itflow_select.js @@ -38,7 +38,9 @@ * data-soft="1" puts it on the soft shade of * that colour (contacts - their avatar's look) * data-color="#e74c3c" colour dot (tags, categories, statuses) - * data-icon="fas fa-laptop" icon; with data-color it sits on that colour + * data-icon="fas fa-laptop" icon; with data-color it sits on that colour, + * or with data-soft="1" on a wash of it, in it + * (tags - tagOptionLook(), as their pills look) * data-description=".." second, quieter line under the label * data-keywords=".." extra words the search should match * @@ -170,7 +172,7 @@ tone = TONES.indexOf(item.tone) === -1 ? 'secondary' : item.tone; node = make('span', 'itflow-select-avatar ' + (item.soft ? 'bg-' + tone + '-subtle text-' + tone + '-emphasis' : 'text-bg-' + tone) + (item.shape === 'circle' ? '' : ' is-tile'), item.initials); } else if (item.icon && item.color) { - node = make('span', 'itflow-select-badge'); + node = make('span', 'itflow-select-badge' + (item.soft ? ' is-soft' : '')); node.style.setProperty('--itflow-select-color', item.color); node.appendChild(make('i', item.icon)); } else if (item.color) { diff --git a/js/tag_picker.js b/js/tag_picker.js index a113c36b2..a96a29d18 100644 --- a/js/tag_picker.js +++ b/js/tag_picker.js @@ -310,7 +310,8 @@ box.appendChild(make('i', some ? 'fas fa-minus' : 'fas fa-check')); node.appendChild(box); - var badge = make('span', 'itflow-select-badge'); + // The icon on a wash of the tag's colour, as its pill looks + var badge = make('span', 'itflow-select-badge is-soft'); badge.style.setProperty('--itflow-select-color', tag.color || 'var(--bs-secondary)'); badge.appendChild(make('i', 'fas fa-' + tag.icon)); badge.setAttribute('aria-hidden', 'true');