package Developer::Dashboard::Web::App;

use strict;
use warnings;

our $VERSION = '4.26';

use Capture::Tiny qw(capture);
use Digest::SHA qw(sha256_hex);
use File::Basename qw(dirname);
use File::ShareDir qw(dist_dir);
use POSIX qw(strftime);
use File::Spec;
use Scalar::Util qw(blessed);
use URI;
use URI::Escape qw(uri_escape uri_unescape);
use Cwd qw(abs_path cwd);

use Developer::Dashboard::JSON qw(json_encode);
use Developer::Dashboard::Platform qw(command_in_path is_windows);
use Developer::Dashboard::PageDocument;
use Developer::Dashboard::PageRuntime;
use Developer::Dashboard::Codec qw(decode_payload);
use Developer::Dashboard::Zipper ();
use Developer::Dashboard::SkillDispatcher ();
our $MODULE_SOURCE_PATH = File::Spec->rel2abs(__FILE__);
our $ORIG_CWD = cwd();    # compile-time CWD so rel2abs resolves correctly even after chdir

# new(%args)
# Constructs the browser-facing dashboard web application.
# Input: auth, pages, sessions, config, and optional actions/resolver objects.
# Output: Developer::Dashboard::Web::App object.
sub new {
    my ( $class, %args ) = @_;
    my $auth     = $args{auth}     || die 'Missing auth store';
    my $pages    = $args{pages}    || die 'Missing page store';
    my $sessions = $args{sessions} || die 'Missing session store';
    my $runtime = $args{runtime};
    if ( !$runtime ) {
        $runtime = Developer::Dashboard::PageRuntime->new(
            paths => ref($pages) eq 'Developer::Dashboard::PageStore' ? $pages->{paths} : undef,
        );
    }
    return bless {
        actions  => $args{actions},
        auth     => $auth,
        config   => $args{config},
        pages    => $pages,
        prompt   => $args{prompt},
        runtime  => $runtime,
        resolver => $args{resolver},
        sessions => $sessions,
    }, $class;
}

# HTTP methods that can change server state. The Origin/Referer half of the
# cross-site request forgery defense applies to exactly these, because a
# browser only guarantees an Origin header on a cross-site state-changing
# request. The fetch-metadata half below carries no such limit and covers GET
# too, which is what the code-executing saved-Ajax route needs.
my %STATE_CHANGING_METHODS = map { $_ => 1 } qw(POST PUT DELETE PATCH);

# Fetch-metadata values that mean the request was not issued by this
# dashboard's own pages. The browser sets Sec-Fetch-Site itself and it is a
# forbidden header name, so page script can neither forge nor suppress it.
# `same-origin` is the dashboard's own page and `none` is a user-initiated load
# (typed URL, bookmark, browser start-up), so both stay outside the check.
my %FOREIGN_FETCH_SITES = map { $_ => 1 } qw(cross-site same-site);

# _csrf_rejection_response(%args)
# Cross-site request forgery choke point for every request. Browsers
# unconditionally attach an Origin header to cross-site state-changing
# requests (and legacy flows carry a Referer), so a foreign Origin/Referer
# identifies an attack regardless of trust tier: the loopback-admin shortcut,
# an ambient helper session cookie, and even valid machine credentials must all
# refuse to act on it. Requests carrying neither header are allowed because
# non-browser machine clients (curl, registered x-dd-api-key consumers) send
# neither, while a hostile page cannot suppress the Origin header on a
# cross-site state-changing request. A GET needs the separate fetch-metadata
# check, which runs first and on every method.
# Input: normalized request method and headers (host, origin, referer,
# sec-fetch-site).
# Output: 403 empty-body response array reference when the browser context is
# foreign, otherwise undef and the request proceeds to tier dispatch.
sub _csrf_rejection_response {
    my ( $self, %args ) = @_;

    # Fetch metadata is checked on every method, not just the state-changing
    # ones: a saved-Ajax handler is an operator-written script that the
    # `/ajax/<file>` route runs from a plain GET, so GET is not a safe method
    # here. Origin cannot defend that route — browsers omit Origin on
    # same-origin GETs, and a hostile page can drop its Referer with a referrer
    # policy — but it cannot touch Sec-Fetch-Site.
    return $self->_csrf_forbidden_response if $self->_fetch_site_is_foreign(%args);

    my $method = uc( $args{method} || 'GET' );
    return undef if !$STATE_CHANGING_METHODS{$method};
    my $headers = $args{headers} || {};
    my $source = _browser_source_value($headers);
    return undef if $source eq '';
    return $self->_csrf_forbidden_response
      if !$self->_request_source_is_same_site( source => $source, headers => $headers );
    return undef;
}

# _fetch_site_is_foreign(%args)
# Reports whether the browser itself labelled this request as coming from
# another site. A request carrying no Sec-Fetch-Site is never foreign, which
# keeps non-browser clients (curl, registered x-dd-api-key consumers) and
# browsers too old to send fetch metadata working exactly as before; a hostile
# page cannot reach that shape, because every browser that can be scripted into
# making the request also sends the header. A cross-site or same-site label is
# foreign unless the accompanying Origin/Referer names this dashboard or a
# trusted local alias, which keeps the localhost/127.0.0.1 pair — one host to
# the loopback trust model, two sites to the browser — serving normally.
# Input: normalized request headers (sec-fetch-site, origin, referer, host).
# Output: boolean true when the request comes from a foreign browser context.
sub _fetch_site_is_foreign {
    my ( $self, %args ) = @_;
    my $headers = $args{headers} || {};
    my $site =
      defined $headers->{'sec-fetch-site'} && !ref( $headers->{'sec-fetch-site'} )
      ? lc $headers->{'sec-fetch-site'}
      : '';
    $site =~ s/^\s+//;
    $site =~ s/\s+$//;
    return 0 if !$FOREIGN_FETCH_SITES{$site};
    my $source = _browser_source_value($headers);
    return 1 if $source eq '';
    return $self->_request_source_is_same_site( source => $source, headers => $headers ) ? 0 : 1;
}

# _browser_source_value($headers)
# Picks the header that names the browser context a request came from. Origin
# wins when present and Referer is the legacy fallback; reference-valued header
# values (a shape no HTTP stack produces) count as absent so the caller never
# dereferences one.
# Input: normalized request headers hash reference.
# Output: raw source header string, empty when the request carries neither.
sub _browser_source_value {
    my ($headers) = @_;
    my $origin = defined $headers->{origin} && !ref( $headers->{origin} ) ? $headers->{origin} : '';
    return $origin if $origin ne '';
    return defined $headers->{referer} && !ref( $headers->{referer} ) ? $headers->{referer} : '';
}

# _request_source_is_same_site(%args)
# Decides whether one Origin or Referer value names this dashboard itself.
# The serialized authority (host[:port]) must equal the request's own Host
# header, or the source host must be a trusted local alias — a numeric
# loopback literal, the localhost family, or a configured
# web.ssl_subject_alt_names entry — mirroring the exact alias semantics the
# loopback-admin trust check applies to Host. The Host comparison also stays
# correct behind the SSL front-proxy, because that proxy forwards TLS bytes
# unmodified and the backend therefore sees the browser's own Host header.
# An opaque "null" origin (sandboxed frame, data: URL) never matches.
# Input: source header value (Origin value or full Referer URL) plus the
# normalized request headers.
# Output: boolean true when the source is this dashboard's own origin.
sub _request_source_is_same_site {
    my ( $self, %args ) = @_;
    my $authority = _source_authority( $args{source} );
    return 0 if !defined $authority;
    my $headers = $args{headers} || {};
    my $request_host = defined $headers->{host} && !ref( $headers->{host} ) ? lc $headers->{host} : '';
    $request_host =~ s/^\s+//;
    $request_host =~ s/\s+$//;
    return 1 if $request_host ne '' && lc($authority) eq $request_host;
    my $config_has_web_settings = blessed( $self->{config} ) && $self->{config}->can('web_settings');
    return 1 if $self->{auth}->host_is_local_alias(
        host                 => $authority,
        extra_loopback_hosts => (
            $config_has_web_settings
            ? ( $self->{config}->web_settings->{ssl_subject_alt_names} || [] )
            : []
        ),
    );
    return 0;
}

# _source_authority($value)
# Extracts the host[:port] authority from one Origin or Referer header value.
# Origin is a serialized origin (scheme://host[:port]); Referer is a full URL,
# so everything from the first path/query/fragment delimiter is dropped. The
# opaque "null" origin and values that do not parse as a single clean
# authority (embedded userinfo or whitespace only appear in crafted headers —
# browsers serialize neither) are reported as unparsable, which callers treat
# as foreign.
# Input: raw header string.
# Output: authority string, or undef when the value is opaque or unparsable.
sub _source_authority {
    my ($value) = @_;
    return undef if !defined $value;
    $value =~ s/^\s+//;
    $value =~ s/\s+$//;
    return undef if $value eq '' || lc($value) eq 'null';
    return undef if $value !~ m{\A[A-Za-z][A-Za-z0-9+.-]*://([^/?#]+)}s;
    my $authority = $1;
    return undef if $authority =~ /[\@\s]/;
    return $authority;
}

# _csrf_forbidden_response()
# Builds the denial for a state-changing request arriving from a foreign
# browser context. The body stays empty so a cross-site attacker page learns
# nothing about the dashboard from the refusal.
# Input: none.
# Output: response array reference with a 403 empty plain-text body.
sub _csrf_forbidden_response {
    return [
        403,
        'text/plain; charset=utf-8',
        '',
    ];
}

# _transient_url_tokens_allowed()
# Reports whether tokenized transient web execution is enabled by environment.
# Input: none.
# Output: boolean true when DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS enables transient web tokens.
sub _transient_url_tokens_allowed {
    return defined $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS}
      && $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} =~ /\A(?:1|true|yes|on)\z/i;
}

# _transient_url_forbidden_response()
# Builds the default-deny response for tokenized transient web execution.
# Input: none.
# Output: response array reference with a 403 plain-text error.
sub _transient_url_forbidden_response {
    return [
        403,
        'text/plain; charset=utf-8',
        "Transient token URLs are disabled. Save the page as a bookmark file or set DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS=1.\n",
    ];
}

# _editor_disabled()
# Reports whether the web service is running in read-only no-editor mode.
# Input: none.
# Output: boolean true when bookmark editing and source views are disabled.
sub _editor_disabled {
    my ($self) = @_;
    return 0 if !blessed( $self->{config} ) || !$self->{config}->can('web_settings');
    my $settings = $self->{config}->web_settings;
    return $settings->{no_editor} ? 1 : 0;
}

# _indicators_disabled()
# Reports whether the web service is running with the top-right browser chrome hidden.
# Input: none.
# Output: boolean true when the right-top browser indicators/context area is disabled.
sub _indicators_disabled {
    my ($self) = @_;
    return 0 if !blessed( $self->{config} ) || !$self->{config}->can('web_settings');
    my $settings = $self->{config}->web_settings;
    return $settings->{no_indicators} ? 1 : 0;
}

# _no_editor_response()
# Builds the read-only denial response used when bookmark editing is disabled.
# Input: none.
# Output: response array reference with a 403 plain-text error.
sub _no_editor_response {
    return [
        403,
        'text/plain; charset=utf-8',
        "Dashboard serve is running in read-only no-editor mode.\n",
    ];
}

# handle(%args)
# Dispatches one normalized request through the service-side route map.
# Input: path, query, method, headers, body, and remote address.
# Output: array reference of status code, content type, body, and optional headers hash.
sub handle {
    my ( $self, %args ) = @_;
    my $path   = $args{path} || '/';
    my $method = uc( $args{method} || 'GET' );

    # Cross-site defense first: even the pre-authorization special-case routes
    # below (the login form POST in particular) must never act on a foreign
    # browser context.
    my $csrf_response = $self->_csrf_rejection_response(%args);
    return $csrf_response if $csrf_response;

    if ( $path eq '/login' && $method eq 'POST' ) {
        return $self->login_response(%args);
    }
    if ( $path eq '/logout' ) {
        return $self->logout_response(%args);
    }
    if ( $path eq '/favicon.ico' ) {
        return $self->favicon_response(%args);
    }

    my $auth_response = $self->authorize_request(%args);
    return $auth_response if $auth_response;

    return $self->dispatch_request(%args);
}

# _request_trust_tier(%args)
# Classifies one normalized request into its browser trust tier.
# Every caller must go through here so the loopback-admin shortcut, the
# DEVELOPER_DASHBOARD_SSL_PROXIED lockout, and the configured local-only alias
# hosts are applied identically to authorization and to route decisions that
# depend on the tier.
# Input: normalized request headers and remote address.
# Output: trust tier string, currently 'admin' or 'helper'.
sub _request_trust_tier {
    my ( $self, %args ) = @_;
    my $headers = $args{headers} || {};
    my $config_has_web_settings = blessed( $self->{config} ) && $self->{config}->can('web_settings');
    return $self->{auth}->trust_tier(
        remote_addr          => $args{remote_addr},
        host                 => $headers->{host},
        ssl_proxied          => ( $ENV{DEVELOPER_DASHBOARD_SSL_PROXIED} ? 1 : 0 ),
        extra_loopback_hosts => (
            $config_has_web_settings
            ? ( $self->{config}->web_settings->{ssl_subject_alt_names} || [] )
            : []
        ),
    );
}

# authorize_request(%args)
# Authenticates one browser request and seeds the per-request context.
# Input: normalized request path, headers, and remote address.
# Output: undef when authorized, otherwise an HTTP response array reference.
sub authorize_request {
    my ( $self, %args ) = @_;
    my $headers = $args{headers} || {};

    # The cross-site rejection sits before tier classification so one
    # mechanism covers the loopback-admin shortcut, helper sessions, and the
    # machine api tier alike; route adapters that call this method directly
    # (bypassing handle) get the same defense.
    my $csrf_response = $self->_csrf_rejection_response(%args);
    return $csrf_response if $csrf_response;

    my $tier = $self->_request_trust_tier(%args);
    my $session;
    my $api_context;

    if ( $tier ne 'admin' ) {
        if ( $self->_api_route_registered( $args{path} ) ) {
            $session = $self->{sessions}->from_cookie( $headers->{cookie}, remote_addr => $args{remote_addr} );
            if ( !$session ) {
                $api_context = $self->_authorize_api_request(%args);
                return $self->_api_forbidden_response if !$api_context;
            }
        }
        else {
            return $self->_helper_access_disabled_response
              if !$self->{auth}->helper_users_enabled;
            $session = $self->{sessions}->from_cookie( $headers->{cookie}, remote_addr => $args{remote_addr} );
            if ( !$session ) {
                return [
                    401,
                    'text/html; charset=utf-8',
                    $self->{auth}->login_page(
                        redirect_to => $self->_login_redirect_target(%args),
                    ),
                ];
            }
        }
    }

    $self->{_current_request_context} = {
        tier        => $api_context ? 'api' : $tier,
        remote_addr => $args{remote_addr} || '',
        host        => $headers->{host} || '',
        username    => ref($session) eq 'HASH' ? ( $session->{username} || '' ) : '',
        role        => $api_context ? 'api' : ( ref($session) eq 'HASH' ? ( $session->{role} || '' ) : '' ),
        api_key     => $api_context ? ( $api_context->{api_key} || '' ) : '',
    };
    return;
}

# dispatch_request(%args)
# Routes one authorized normalized request to the matching service method.
# Input: path, query, method, headers, body, and remote address.
# Output: array reference of status code, content type, body, and optional headers hash.
sub dispatch_request {
    my ( $self, %args ) = @_;
    my $path   = $args{path} || '/';
    my $method = uc( $args{method} || 'GET' );

    if ( $path =~ m{^/app/(.+)$} ) {
        my $invalid_id = $self->_invalid_saved_page_id_response( uri_unescape($1) );
        return $invalid_id if $invalid_id;
    }

    return $self->root_response(%args) if $path eq '/';
    return $self->authorized_login_redirect_response(%args) if $path eq '/login';
    return $self->apps_redirect_response(%args) if $path eq '/apps';
    return $self->legacy_ajax_response(%args) if $path eq '/ajax';
    return $self->ajax_singleton_stop_response(%args) if $path eq '/ajax/singleton/stop';
    if ( $path =~ m{^/ajax/(.+)$} ) {
        return $self->prefixed_ajax_file_response( ajax_path => uri_unescape($1), %args );
    }
    return $self->status_response(%args) if $path eq '/system/status';
    return $self->jquery_js_response(%args) if $path eq '/js/jquery.js' || $path eq '/js/jquery-4.0.0.min.js';
    return $self->marked_js_response(%args) if $path eq '/marked.min.js';
    return $self->tiff_js_response(%args) if $path eq '/tiff.min.js';
    return $self->loading_image_response(%args) if $path eq '/loading.webp';
    if ( $path =~ m{^/(js|css|others)/(.+)$} ) {
        return $self->prefixed_static_file_response( type => $1, file => uri_unescape($2), %args );
    }
    if ( $path =~ m{^/app/(.+)/source$} ) {
        return $self->page_source_response( id => $1, %args );
    }
    if ( $path =~ m{^/app/(.+)/edit$} && $method eq 'POST' ) {
        return $self->page_edit_post_response( id => $1, %args );
    }
    if ( $path =~ m{^/app/(.+)/edit$} ) {
        return $self->page_edit_response( id => $1, %args );
    }
    if ( $path =~ m{^/app/(.+)/action/([^/]+)$} && $method eq 'POST' ) {
        return $self->page_action_response( id => $1, action_id => $2, %args );
    }
    if ( $path =~ m{^/app/(.+)$} ) {
        return $self->legacy_app_response( id => $1, %args );
    }
    if ( $path =~ m{^/skill/([^/]+)/(.+)$} ) {
        return $self->skill_route_response( skill_name => $1, route => $2, %args );
    }
    return $self->transient_action_response(%args) if $path eq '/action' && $method eq 'POST';
    if ( my $custom_route = $self->_custom_skill_route_response( %args, route_path => $path ) ) {
        return $custom_route;
    }

    return [ 404, 'text/plain; charset=utf-8', "Not found\n" ];
}

# _invalid_saved_page_id_response($id)
# Converts PageStore's saved-id containment rejection into an authorized route response.
# Input: saved page id captured from an /app route.
# Output: undef for a valid id, otherwise a 400 plain-text response.
sub _invalid_saved_page_id_response {
    my ( $self, $id ) = @_;
    return if !$self->{pages}->can('page_file');
    my $ok = eval { $self->{pages}->page_file($id); 1 };
    return if $ok;
    die $@ if $@ !~ /Invalid page (?:id|path)/;
    return [ 400, 'text/plain; charset=utf-8', "Invalid page id\n" ];
}

# _saved_page_error_response($error)
# Converts PageStore containment failures at body-derived write boundaries into
# a fixed client response without exposing filesystem details or source lines.
# Input: caught exception string.
# Output: 400 response for invalid page ids/paths, otherwise undef.
sub _saved_page_error_response {
    my ( $self, $error ) = @_;
    return if !defined $error || $error !~ /Invalid page (?:id|path)/;
    return [ 400, 'text/plain; charset=utf-8', "Invalid page id\n" ];
}

# _helper_access_disabled_response()
# Builds the outsider-access denial used before any helper user exists.
# Input: none.
# Output: response array reference with a bare 401 plain-text error body.
sub _helper_access_disabled_response {
    return [
        401,
        'text/plain; charset=utf-8',
        '',
    ];
}

# _api_forbidden_response()
# Builds the machine-auth denial used for registered API ajax routes.
# Input: none.
# Output: response array reference with a 403 JSON forbidden payload.
sub _api_forbidden_response {
    return [
        403,
        'application/json; charset=utf-8',
        json_encode( { status => 'forbidden' } ),
    ];
}

# _api_route_registered($path)
# Reports whether one request path is covered by any layered config/api.json allowlist.
# Input: request path string.
# Output: boolean true when one API client entry explicitly allows the path.
sub _api_route_registered {
    my ( $self, $path ) = @_;
    return 0 if !defined $path || $path !~ m{\A/ajax(?:/|\z)};
    my $api_keys = $self->_api_keys;
    for my $entry ( values %{$api_keys} ) {
        next if ref($entry) ne 'HASH';
        next if ref( $entry->{ajax} ) ne 'ARRAY';
        return 1 if scalar grep { $_ eq $path } @{ $entry->{ajax} };
    }
    return 0;
}

# _authorize_api_request(%args)
# Validates x-dd-api-key and x-dd-api-secret headers for one registered ajax route.
# Input: normalized request path and headers.
# Output: hash reference with api_key when authorized, otherwise undef.
sub _authorize_api_request {
    my ( $self, %args ) = @_;
    my $headers = $args{headers} || {};
    my $api_key = defined $headers->{'x-dd-api-key'} && !ref( $headers->{'x-dd-api-key'} )
      ? $headers->{'x-dd-api-key'}
      : '';
    my $api_secret = defined $headers->{'x-dd-api-secret'} && !ref( $headers->{'x-dd-api-secret'} )
      ? $headers->{'x-dd-api-secret'}
      : '';
    return undef if $api_key eq '' || $api_secret eq '';
    my $api_keys = $self->_api_keys;
    my $entry = $api_keys->{$api_key};
    return undef if ref($entry) ne 'HASH';
    return undef if ref( $entry->{ajax} ) ne 'ARRAY';
    return undef if !( scalar grep { $_ eq ( $args{path} || '' ) } @{ $entry->{ajax} } );
    return undef if sha256_hex($api_secret) ne ( $entry->{secret} || '' );
    return { api_key => $api_key };
}

# _api_keys()
# Returns the layered API auth config when a config service is available.
# Input: none.
# Output: hash reference keyed by API client name.
sub _api_keys {
    my ($self) = @_;
    return {} if !blessed( $self->{config} ) || !$self->{config}->can('api_keys');
    my $api_keys = $self->{config}->api_keys;
    return ref($api_keys) eq 'HASH' ? $api_keys : {};
}

# _handle_login(%args)
# Processes helper login form submissions and issues a session cookie.
# Input: request body and remote address.
# Output: response array reference.
sub _handle_login {
    my ( $self, %args ) = @_;
    my %form = _parse_query( $args{body} );
    my $redirect_to = $self->_sanitize_redirect_target( $form{redirect_to} );
    return $self->_helper_access_disabled_response
      if !$self->{auth}->helper_users_enabled;
    my $user = $self->{auth}->verify_user(
        username => $form{username},
        password => $form{password},
    );

    if ( !$user ) {
        return [
            401,
            'text/html; charset=utf-8',
            $self->{auth}->login_page(
                message     => 'Invalid username or password.',
                redirect_to => $redirect_to,
            ),
        ];
    }

    my $session = $self->{sessions}->create(
        username    => $user->{username},
        role        => $user->{role},
        remote_addr => $args{remote_addr},
    );

    return [
        302,
        'text/plain; charset=utf-8',
        "Redirecting\n",
        {
            'Location'   => $redirect_to,    # _sanitize_redirect_target always yields a non-empty target
            'Set-Cookie' => _session_cookie( $session->{session_id}, _request_is_secure() ),
        },
    ];
}

# login_response(%args)
# Executes the helper login submission route. The route adapter calls this
# without authorization (a login cannot require a session), so the cross-site
# rejection runs here directly: a foreign page must not be able to log a
# victim into an attacker-chosen helper account (login CSRF).
# Input: normalized request method, headers, body, and remote address.
# Output: response array reference.
sub login_response {
    my ( $self, %args ) = @_;
    my $csrf_response = $self->_csrf_rejection_response(%args);
    return $csrf_response if $csrf_response;
    return $self->_handle_login(
        body        => defined $args{body} ? $args{body} : '',
        remote_addr => $args{remote_addr},
    );
}

# _post_logout_location(%args)
# Chooses the route one logged-out client is sent to next.
# The loopback-admin tier is authorized without a session and therefore never
# receives the helper login challenge, so /login is not a page it can use; send
# it to the dashboard home route instead. Every challenged tier still goes to
# /login, where the auth layer renders the login form. The target is derived
# only from the trust tier, never from request-supplied input.
# Input: normalized request headers and remote address.
# Output: local redirect target path string.
sub _post_logout_location {
    my ( $self, %args ) = @_;
    return $self->_request_trust_tier(%args) eq 'admin' ? '/' : '/login';
}

# logout_response(%args)
# Executes the logout route and expires any active helper session.
# Input: normalized request headers and remote address.
# Output: response array reference.
sub logout_response {
    my ( $self, %args ) = @_;
    my $headers = $args{headers} || {};
    my $session = $self->{sessions}->from_cookie( $headers->{cookie}, remote_addr => $args{remote_addr} );
    if ($session) {
        if ( ( $session->{role} || '' ) eq 'helper' && ( $session->{username} || '' ) ne '' ) {
            $self->{auth}->remove_user( $session->{username} );
        }
        $self->{sessions}->delete( $session->{session_id} );
    }
    return [
        302,
        'text/plain; charset=utf-8',
        "Redirecting\n",
        {
            'Location'   => $self->_post_logout_location(%args),
            'Set-Cookie' => _expired_session_cookie(),
        },
    ];
}

# authorized_login_redirect_response(%args)
# Executes the login route for a request that is already authorized.
# handle() intercepts POST /login before authorization, and an unauthorized
# client is answered by authorize_request with the 401 login challenge, so a
# request that reaches this route has nothing left to log in to. Send it to the
# dashboard home route rather than leaving /login without a GET handler, which
# dead-ended the loopback-admin tier on a bare 404.
# Input: normalized request arguments (unused; the target is a fixed local path).
# Output: response array reference with a 302 to the dashboard home route.
sub authorized_login_redirect_response {
    my ( $self, %args ) = @_;
    return [
        302,
        'text/plain; charset=utf-8',
        "Redirecting\n",
        { Location => '/' },
    ];
}

# root_response(%args)
# Executes the root route for saved index redirects, blank editor, transient, and saved-from-root pages.
# Input: normalized request path, query, body, headers, and remote address.
# Output: response array reference.
sub root_response {
    my ( $self, %args ) = @_;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $path = $args{path} || '/';
    my $headers = $args{headers} || {};

    if ( exists $body_params->{instruction} || exists $params->{instruction} ) {
        return _no_editor_response() if $self->_editor_disabled;
        my $instruction = exists $body_params->{instruction} ? $body_params->{instruction} : $params->{instruction};
        my $page = Developer::Dashboard::PageDocument->from_instruction($instruction);
        $page->{meta}{raw_instruction} = $instruction;
        my $page_id = $page->as_hash->{id} || '';
        if ( $page_id eq '' && !_transient_url_tokens_allowed() ) {
            return _transient_url_forbidden_response();
        }
        my $source_kind = 'transient';
        if ( exists $body_params->{instruction} && $page_id ne '' ) {
            my $saved = eval { $self->{pages}->save_page($page); 1 };
            if ( !$saved ) {
                my $invalid = $self->_saved_page_error_response($@);
                return $invalid if $invalid;
                die $@;
            }
            $source_kind = 'saved';
        }
        $page->{meta}{source_kind} = $source_kind;
        my $mode = $params->{mode} || $body_params->{mode} || 'edit';
        $page = $self->_page_with_runtime_state(
            $page,
            query_params => $params,
            body_params  => $body_params,
            path         => $path,
            remote_addr  => $args{remote_addr},
            headers      => $headers,
        );
        $page = $self->{runtime}->prepare_page(
            page            => $page,
            source          => $source_kind,
            runtime_context => { params => { %{$params}, %{$body_params} } },
        );
        return $self->_page_response( $page, $mode );
    }

    if ( my $token = $params->{token} ) {
        return _transient_url_forbidden_response() if !_transient_url_tokens_allowed();
        my $page = $self->{pages}->load_transient_page($token);
        $page->{meta}{raw_instruction} = $page->canonical_instruction;
        my $mode = $params->{mode} || 'edit';
        return _no_editor_response() if $self->_editor_disabled && $mode eq 'source';
        $mode = 'render' if $self->_editor_disabled && $mode eq 'edit';
        $page = $self->_page_with_runtime_state(
            $page,
            query_params => $params,
            body_params  => $body_params,
            path         => $path,
            remote_addr  => $args{remote_addr},
            headers      => $headers,
        );
        $page = $self->{runtime}->prepare_page(
            page            => $page,
            source          => 'transient',
            runtime_context => { params => { %{$params}, %{$body_params} } },
        );
        return $self->_page_response( $page, $mode );
    }

    if ( $self->_saved_page_exists('index') ) {
        return [
            302,
            'text/plain; charset=utf-8',
            "Redirecting\n",
            { Location => '/app/index' },
        ];
    }

    return _no_editor_response() if $self->_editor_disabled;
    return $self->_blank_editor_response;
}

# apps_redirect_response(%args)
# Executes the `/apps` compatibility redirect route.
# Input: normalized request arguments.
# Output: response array reference.
sub apps_redirect_response {
    my ( $self, %args ) = @_;
    return [
        302,
        'text/plain; charset=utf-8',
        "Redirecting\n",
        { Location => '/app/index' },
    ];
}

# legacy_ajax_response(%args)
# Executes the `/ajax` route using query and body parameters from one request.
# Input: normalized request query, body, headers, and remote address.
# Output: response array reference.
sub legacy_ajax_response {
    my ( $self, %args ) = @_;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my %request_params = ( %{$params}, %{$body_params} );
    return _transient_url_forbidden_response() if !$self->_legacy_ajax_allowed( \%request_params );
    return $self->_legacy_ajax_response(
        params       => \%request_params,
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );
}

# legacy_ajax_file_response(%args)
# Executes one `/ajax/<file>` compatibility route against a saved ajax file.
# Input: ajax file name plus normalized request query, body, headers, and remote address.
# Output: response array reference.
sub legacy_ajax_file_response {
    my ( $self, %args ) = @_;
    my $ajax_file = $args{ajax_file} || '';
    my ( $params, $body_params ) = $self->_request_params(%args);
    my %request_params = ( %{$params}, %{$body_params}, file => $ajax_file );
    return _transient_url_forbidden_response() if !$self->_legacy_ajax_allowed( \%request_params );
    return $self->_legacy_ajax_response(
        params       => \%request_params,
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );
}

# skill_ajax_file_response(%args)
# Executes one `/ajax/<skill>/<file>` route against a layered skill-local ajax file.
# Input: skill name, ajax file name, and normalized request metadata.
# Output: response array reference.
sub skill_ajax_file_response {
    my ( $self, %args ) = @_;
    my $skill_name = $args{skill_name} || '';
    my $ajax_file  = $args{ajax_file}  || '';
    return [ 400, 'text/plain; charset=utf-8', "Invalid skill name\n" ] if $skill_name eq '';
    my %params = _parse_query( $args{query} || '' );
    my %body_params = _parse_query( $args{body} || '' );
    my $saved_ajax_path = $self->_skill_ajax_file_path( $skill_name, $ajax_file );
    my %request_params = (
        %params,
        %body_params,
        file => $saved_ajax_path ne '' ? $ajax_file : join( '/', $skill_name, $ajax_file ),
    );
    if ( !exists $request_params{type} && ( $args{default_type} || '' ) ne '' ) {
        $request_params{type} = $args{default_type};
    }
    return _transient_url_forbidden_response() if !$self->_legacy_ajax_allowed( \%request_params );
    return $self->_legacy_ajax_response(
        params          => \%request_params,
        saved_ajax_path => $saved_ajax_path,
    );
}

# prefixed_ajax_file_response(%args)
# Resolves one `/ajax/...` request by preferring the longest matching skill prefix
# and falling back to the normal nested saved-ajax file path when no skill-local
# handler exists.
# Input: ajax_path plus normalized request metadata.
# Output: response array reference.
sub prefixed_ajax_file_response {
    my ( $self, %args ) = @_;
    my $ajax_path = $args{ajax_path} || '';
    my @segments = grep { $_ ne '' } split m{/+}, $ajax_path;
    if ( my $spec = $self->_resolve_skill_route_spec(@segments) ) {
        my $ajax_file = join '/', @{ $spec->{route_segments} || [] };
        if ( $ajax_file ne '' ) {
            my $saved_ajax_path = $self->_skill_ajax_file_path( $spec->{skill_name}, $ajax_file );
            my $route_spec = $self->_skill_ajax_route_spec( $spec->{skill_name}, $ajax_file );
            return $self->skill_ajax_file_response(
                %args,
                default_type => $route_spec ? ( $route_spec->{type} || '' ) : '',
                skill_name   => $spec->{skill_name},
                ajax_file    => $ajax_file,
            ) if $saved_ajax_path ne '';
        }
    }
    return $self->legacy_ajax_file_response( %args, ajax_file => $ajax_path );
}

# status_response(%args)
# Executes the `/system/status` route.
# Input: normalized request arguments.
# Output: response array reference.
sub status_response {
    my ( $self, %args ) = @_;
    my $payload = $self->_page_status_payload;
    return [ 200, 'application/json; charset=utf-8', json_encode($payload) ];
}

# marked_js_response(%args)
# Serves the bundled jQuery asset used by saved bookmark pages.
# Input: normalized request arguments.
# Output: response array reference.
sub jquery_js_response {
    my ( $self, %args ) = @_;
    my $path = _bundled_public_asset_path( 'js', 'jquery-4.0.0.min.js' );
    open my $fh, '<:raw', $path or die "Unable to read $path: $!";
    local $/;
    my $content = <$fh>;
    close $fh or die "Unable to close $path: $!";    # uncoverable branch true
    return [ 200, 'application/javascript; charset=utf-8', $content ];
}

# _bundled_public_asset_path($type, $file)
# Resolves one bundled public asset from the repo share tree during
# development or from the installed distribution share dir after install.
# Input: asset type directory plus file name.
# Output: absolute asset path string.
sub _bundled_public_asset_path {
    my ( $type, $file ) = @_;
    die 'asset type is required' if !defined $type || $type eq '';
    die 'asset file is required' if !defined $file || $file eq '';

    my $module_source = $MODULE_SOURCE_PATH || File::Spec->rel2abs( __FILE__, $ORIG_CWD );    # uncoverable condition false
    my $module_dir    = dirname($module_source);
    my @candidates;

    for my $levels_up ( 4 .. 7 ) {
        push @candidates, File::Spec->catfile(
            File::Spec->rel2abs(
                File::Spec->catdir(
                    $module_dir,
                    ( File::Spec->updir ) x $levels_up,
                    'share',
                    'public',
                    $type,
                )
            ),
            $file,
        );
    }

    my $dist_root = eval { dist_dir('Developer-Dashboard') };
    if ( defined $dist_root && $dist_root ne '' ) {
        push @candidates, File::Spec->catfile( $dist_root, 'public', $type, $file );
    }

    my $module_path = $INC{'Developer/Dashboard/Web/App.pm'} || __FILE__;
    my $module_root = File::Spec->rel2abs(
        File::Spec->catdir(
            dirname($module_path),
            File::Spec->updir,
            File::Spec->updir,
            File::Spec->updir,
            File::Spec->updir,
        ),
        $ORIG_CWD,
    );
    push @candidates, File::Spec->catfile( $module_root, 'auto', 'share', 'dist', 'Developer-Dashboard', 'public', $type, $file );
    push @candidates, File::Spec->catfile( $module_root, 'auto', 'Developer', 'Dashboard', 'public', $type, $file );

    my %seen;
    for my $candidate (@candidates) {
        next if $seen{$candidate}++;
        return $candidate if -f $candidate;
    }

    die "Unable to find bundled public asset $type/$file";
}

# marked_js_response(%args)
# Serves the built-in marked shim asset.
# Input: normalized request arguments.
# Output: response array reference.
sub marked_js_response {
    my ( $self, %args ) = @_;
    return [ 200, 'application/javascript; charset=utf-8', "window.marked=window.marked||{parse:function(s){return s||'';}};\n" ];
}

# tiff_js_response(%args)
# Serves the built-in TIFF shim asset.
# Input: normalized request arguments.
# Output: response array reference.
sub tiff_js_response {
    my ( $self, %args ) = @_;
    return [ 200, 'application/javascript; charset=utf-8', "window.Tiff=window.Tiff||function(){};\n" ];
}

# favicon_response(%args)
# Serves the browser tab icon that every browser requests on its own for each
# page load, preferring a layered `others/favicon.ico` override over the icon
# bundled with the distribution.
# Input: normalized request arguments.
# Output: response array reference.
sub favicon_response {
    my ( $self, %args ) = @_;
    my $override = $self->_serve_static_file( 'others', 'favicon.ico' );
    return $override if $override->[0] == 200;
    my $bundled = _bundled_public_asset_path( 'others', 'favicon.ico' );
    return $self->_serve_static_file_at_path(
        'others',
        'favicon.ico',
        $bundled,
        '',
        [ dirname($bundled) ],
    );
}

# loading_image_response(%args)
# Serves the built-in loading image response.
# Input: normalized request arguments.
# Output: response array reference.
sub loading_image_response {
    my ( $self, %args ) = @_;
    return [ 200, 'image/webp', '' ];
}

# static_file_response(%args)
# Serves one static asset from the dashboard public tree.
# Input: asset type and file name.
# Output: response array reference.
sub static_file_response {
    my ( $self, %args ) = @_;
    if ( ( $args{type} || '' ) eq 'js' ) {
        my $file = $args{file} || '';
        return $self->jquery_js_response(%args) if $file eq 'jquery.js' || $file eq 'jquery-4.0.0.min.js';
    }
    return $self->_serve_static_file( $args{type}, $args{file} );
}

# prefixed_static_file_response(%args)
# Resolves one `/js/...`, `/css/...`, or `/others/...` request by preferring the
# longest matching skill prefix and falling back to the normal nested public
# asset path when no skill-local asset exists.
# Input: asset type, requested file path, and normalized request metadata.
# Output: response array reference.
sub prefixed_static_file_response {
    my ( $self, %args ) = @_;
    my $type = $args{type} || '';
    my $file = $args{file} || '';
    my @segments = grep { $_ ne '' } split m{/+}, $file;
    if ( my $spec = $self->_resolve_skill_route_spec(@segments) ) {
        my $skill_file = join '/', @{ $spec->{route_segments} || [] };
        if ( $skill_file ne '' ) {
            my $resolved = $self->_skill_static_file_path( $spec->{skill_name}, $type, $skill_file );
            my $route_spec = $self->_skill_route_spec( $type, $spec->{skill_name}, $skill_file );
            return $self->skill_static_file_response(
                %args,
                default_type => $route_spec ? ( $route_spec->{type} || '' ) : '',
                skill_name => $spec->{skill_name},
                file       => $skill_file,
            ) if $resolved ne '';
        }
    }
    return $self->static_file_response( %args, type => $type, file => $file );
}

# legacy_app_response(%args)
# Executes the saved `/app/<id>` render route and follows saved URL forwards.
# Input: saved app id plus normalized request query, body, headers, and remote address.
# Output: response array reference.
sub legacy_app_response {
    my ( $self, %args ) = @_;
    my ( $params, $body_params ) = $self->_request_params(%args);
    return $self->_legacy_app_response(
        id           => $args{id},
        query_params => $params,
        body_params  => $body_params,
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );
}

# skill_route_response(%args)
# Executes routes provided by installed skills.
# Routes are namespaced under /skill/<repo-name>/<route>
# Input: skill_name, route, query params, headers, and remote address.
# Output: response array reference.
sub skill_route_response {
    my ( $self, %args ) = @_;
    my $skill_name = $args{skill_name} || '';
    my $route = $args{route} || '';
    
    return [ 400, 'text/plain; charset=utf-8', "Invalid skill name\n" ] if !$skill_name;
    return [ 400, 'text/plain; charset=utf-8', "Invalid skill route\n" ] if !$route;

    my $dispatcher = Developer::Dashboard::SkillDispatcher->new( paths => $self->{pages} ? $self->{pages}{paths} : undef );
    my ( $query_params, $body_params ) = $self->_request_params(%args);
    return $dispatcher->route_response(
        app          => $self,
        body_params  => $body_params,
        headers      => $args{headers} || {},
        path         => $args{path} || '',
        query_params => $query_params,
        remote_addr  => $args{remote_addr},
        route        => $route,
        skill_name   => $skill_name,
    );
}

# skill_static_file_response(%args)
# Serves one `/js/<skill>/...`, `/css/<skill>/...`, or `/others/<skill>/...` request from layered skill dashboards/public assets.
# Input: skill name, asset type, file path, and normalized request metadata.
# Output: response array reference.
sub skill_static_file_response {
    my ( $self, %args ) = @_;
    my $skill_name = $args{skill_name} || '';
    my $type       = $args{type}       || '';
    my $file       = $args{file}       || '';
    return [ 400, 'text/plain; charset=utf-8', "Invalid skill name\n" ] if $skill_name eq '';
    my $skill_file = $self->_skill_static_file_path( $skill_name, $type, $file );
    return $self->_serve_static_file_at_path(
        $type, $file, $skill_file,
        $args{default_type} || '',
        [ $self->_skill_dispatcher->skill_static_roots( $skill_name, $type ) ],
    ) if $skill_file ne '';
    return $self->_serve_static_file( $type, join( '/', $skill_name, $file ) );
}

# transient_action_response(%args)
# Executes the transient `/action` route for encoded or token-backed actions.
# Input: normalized request path, query, body, headers, and remote address.
# Output: response array reference.
sub transient_action_response {
    my ( $self, %args ) = @_;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $headers = $args{headers} || {};
    my $path = $args{path} || '/action';
    if ( my $atoken = $params->{atoken} ) {
        return _transient_url_forbidden_response() if !_transient_url_tokens_allowed();
        return $self->_encoded_action_response(
            token  => $atoken,
            params => { %{$params}, %{$body_params} },
        );
    }

    my $token = exists $params->{token} ? $params->{token} : ( $body_params->{token} || '' );
    return _transient_url_forbidden_response() if $token ne '' && !_transient_url_tokens_allowed();
    my $id   = exists $params->{id} ? $params->{id} : ( $body_params->{id} || '' );
    my $page = $self->{pages}->load_transient_page($token);
    $page = $self->_page_with_runtime_state(
        $page,
        query_params => $params,
        body_params  => $body_params,
        path         => $path,
        remote_addr  => $args{remote_addr},
        headers      => $headers,
    );
    return $self->_action_response(
        id     => $id,
        page   => $page,
        source => 'transient',
        params => { %{$params}, %{$body_params} },
    );
}

# page_source_response(%args)
# Executes the saved-page source route.
# Input: saved page id plus normalized request query, body, headers, and remote address.
# Output: response array reference.
sub page_source_response {
    my ( $self, %args ) = @_;
    return _no_editor_response() if $self->_editor_disabled;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $page = $self->_load_editable_named_page( $args{id} );
    return [ 404, 'text/plain; charset=utf-8', "Not found\n" ] if !$page;
    $page->{meta}{raw_instruction} = $page->{meta}{raw_instruction} || $page->canonical_instruction;    # uncoverable condition false
    $page = $self->_page_with_runtime_state(
        $page,
        query_params => $params,
        body_params  => $body_params,
        path         => $args{path} || '/app/' . $args{id} . '/source',
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );    # uncoverable condition false
    $page = $self->{runtime}->prepare_page(
        page            => $page,
        source          => $page->{meta}{source_kind} || 'saved',
        runtime_context => { params => { %{$params}, %{$body_params} } },
    );
    # raw_instruction was forced non-empty above and prepare_page preserves it.
    return [ 200, 'text/plain; charset=utf-8', $page->{meta}{raw_instruction} ];
}

# page_edit_post_response(%args)
# Executes the saved-page editor POST route.
# Input: saved page id plus normalized request path, query, body, headers, and remote address.
# Output: response array reference.
sub page_edit_post_response {
    my ( $self, %args ) = @_;
    return _no_editor_response() if $self->_editor_disabled;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $instruction = exists $body_params->{instruction} ? $body_params->{instruction} : $params->{instruction};
    if ( defined $instruction ) {
        my $existing_page = $self->_load_editable_named_page( $args{id} );
        my $page = Developer::Dashboard::PageDocument->from_instruction($instruction);
        $page->{meta}{raw_instruction} = $instruction;
        $page->{id} ||= $args{id};
        my $source_kind = 'saved';
        if ( $existing_page && ( $existing_page->{meta}{source_kind} || '' ) eq 'skill' ) {
            $source_kind = 'skill';
            $page->{id} = $args{id};
            $page->{meta}{source_kind} = 'skill';
            $self->_decorate_skill_page_routes($page);
        }
        else {
            $page->{meta}{source_kind} = 'saved';
            my $saved = eval { $self->{pages}->save_page($page); 1 };
            if ( !$saved ) {
                my $invalid = $self->_saved_page_error_response($@);
                return $invalid if $invalid;
                die $@;
            }
        }
        my $mode = $params->{mode} || $body_params->{mode} || 'edit';
        my $request_path = $args{path} || '/app/' . $args{id} . '/edit';    # uncoverable condition false
        $request_path = '/app/' . $args{id} if $mode eq 'render' && $source_kind eq 'skill';
        $page = $self->_page_with_runtime_state(
            $page,
            query_params => $params,
            body_params  => $body_params,
            path         => $request_path,
            remote_addr  => $args{remote_addr},
            headers      => $args{headers} || {},
        );
        $page = $self->{runtime}->prepare_page(
            page            => $page,
            source          => $source_kind,
            runtime_context => { params => { %{$params}, %{$body_params} } },
        );
        return $self->_page_response( $page, $mode );
    }
    return $self->page_edit_response(%args);
}

# page_edit_response(%args)
# Executes the saved-page editor GET route.
# Input: saved page id plus normalized request query, body, headers, and remote address.
# Output: response array reference.
sub page_edit_response {
    my ( $self, %args ) = @_;
    return _no_editor_response() if $self->_editor_disabled;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $page = $self->_load_editable_named_page( $args{id} );
    return $self->_missing_named_page_response( $args{id} ) if !$page;
    $page->{meta}{raw_instruction} = $page->{meta}{raw_instruction} || $page->canonical_instruction;    # uncoverable condition false
    $page = $self->_page_with_runtime_state(
        $page,
        query_params => $params,
        body_params  => $body_params,
        path         => $args{path} || '/app/' . $args{id} . '/edit',
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );    # uncoverable condition false
    $page = $self->{runtime}->prepare_page(
        page            => $page,
        source          => $page->{meta}{source_kind} || 'saved',
        runtime_context => { params => { %{$params}, %{$body_params} } },
    );
    return [ 200, 'text/html; charset=utf-8', $self->_edit_html($page) ];
}

# page_action_response(%args)
# Executes the saved-page named action route.
# Input: saved page id, action id, and normalized request query, body, headers, and remote address.
# Output: response array reference.
sub page_action_response {
    my ( $self, %args ) = @_;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $page = $self->_load_named_page( $args{id} );
    $page = $self->_page_with_runtime_state(
        $page,
        query_params => $params,
        body_params  => $body_params,
        path         => $args{path} || '/app/' . $args{id} . '/action/' . $args{action_id},
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );    # uncoverable condition false
    $page = $self->{runtime}->prepare_page(
        page            => $page,
        source          => $page->{meta}{source_kind} || 'saved',
        runtime_context => { params => { %{$params}, %{$body_params} } },
    );
    return $self->_action_response(
        id     => $args{action_id},
        page   => $page,
        source => $page->{meta}{source_kind} || 'saved',
        params => { %{$params}, %{$body_params} },
    );
}

# _blank_editor_response()
# Builds the default free-form bookmark editor response.
# Input: none.
# Output: response array reference.
sub _blank_editor_response {
    my ($self) = @_;
    return _no_editor_response() if $self->_editor_disabled;
    my $page = Developer::Dashboard::PageDocument->new(
        title       => 'Developer Dashboard',
        description => '',
        meta        => { source_kind => 'transient', source_format => 'legacy' },
    );
    my $request_ctx = $self->{_current_request_context} || {};
    $page->{meta}{request_context} = {
        host        => $request_ctx->{host} || '',
        path        => '/',
        remote_addr => $request_ctx->{remote_addr} || '',
        tier        => $request_ctx->{tier} || 'helper',
        username    => $request_ctx->{username} || '',
        role        => $request_ctx->{role} || '',
    };
    return [ 200, 'text/html; charset=utf-8', $self->_edit_html($page) ];
}

# _saved_page_exists($id)
# Checks whether one saved page id resolves in the effective bookmark lookup roots.
# Input: page id string.
# Output: boolean true when the saved page exists, otherwise false.
sub _saved_page_exists {
    my ( $self, $id ) = @_;
    return 0 if !defined $id || $id eq '';
    return eval { $self->{pages}->load_saved_page($id); 1 } ? 1 : 0;
}

# _load_editable_named_page($id)
# Resolves one named editor/source route through saved bookmarks first, then smart-routed skill pages.
# Input: page id string without the /app/ prefix.
# Output: page document object or undef when neither a saved page nor a skill route exists.
sub _load_editable_named_page {
    my ( $self, $id ) = @_;
    return if !defined $id || $id eq '';
    return $self->_load_named_page($id) if $self->_saved_page_exists($id);
    return $self->_load_skill_named_page($id);
}

# _load_skill_named_page($id)
# Resolves one /app/<id> alias as a smart-routed skill page, including index aliases such as /app/<skill>.
# Input: page id string without the /app/ prefix.
# Output: decorated skill page document object or undef when the id is not an installed skill route.
sub _load_skill_named_page {
    my ( $self, $id ) = @_;
    return if !defined $id || $id eq '';
    my @segments = grep { $_ ne '' } split m{/+}, $id;
    return if !@segments;

    my $dispatcher = $self->_skill_dispatcher;
    my $spec = $self->_resolve_skill_route_spec(@segments);
    return if !$spec || !@{ $spec->{skill_layers} || [] };

    my @route_segments = @{ $spec->{route_segments} || [] };
    my $route_id = @route_segments ? join( '/', @route_segments ) : 'index';
    my $page = eval {
        $dispatcher->_load_skill_page(
            skill_name => $spec->{skill_name},
            route_id   => $route_id,
        );
    };
    return if !$page || $@;    # uncoverable condition right
    return $self->_decorate_skill_page_routes($page);
}

# _decorate_skill_page_routes($page)
# Stamps the canonical smart-routed browser URLs onto one loaded skill page so render and edit stay on /app/<skill>.
# Input: skill page document object.
# Output: the same page document object with browser route metadata attached.
sub _decorate_skill_page_routes {
    my ( $self, $page ) = @_;
    return $page if ref($page) ne 'Developer::Dashboard::PageDocument';
    return $page if ( $page->{meta}{source_kind} || '' ) ne 'skill';
    my $page_id = $page->as_hash->{id} || '';
    return $page if $page_id eq '';

    my $page_url = $self->_saved_page_href($page_id);
    $page->{meta}{render_route} = $page_url;
    $page->{meta}{edit_route}   = $page_url . '/edit';
    $page->{meta}{source_route} = $page_url . '/edit';
    $page->{meta}{form_action}  = $page_url . '/edit';
    return $page;
}

# _page_route_urls($page)
# Resolves the browser edit/render/source URLs for one page, including explicit smart-routed skill aliases.
# Input: page document object.
# Output: hash reference with page_url, form_action, edit, render, and source URL strings.
sub _page_route_urls {
    my ( $self, $page ) = @_;
    my $meta = $page->{meta} || {};
    if ( $meta->{edit_route} || $meta->{render_route} || $meta->{source_route} || $meta->{form_action} ) {
        return {
            page_url    => $meta->{render_route} || '',
            form_action => $meta->{form_action} || '/',
            edit        => $meta->{edit_route} || '',
            render      => $meta->{render_route} || '',
            source      => $meta->{source_route} || '',
        };
    }

    my $page_id = $page->as_hash->{id} || '';
    my $source_kind = $meta->{source_kind} || '';
    my $is_saved = $source_kind eq 'saved' && $page_id ne '';
    my $is_transient = $source_kind eq 'transient';
    my $page_url = $is_saved ? $self->_saved_page_href($page_id) : '';
    return {
        page_url    => $is_transient ? $self->{pages}->editable_url($page) : $page_url,
        form_action => $is_saved ? $page_url . '/edit' : '/',
        edit        => $is_saved ? $page_url . '/edit' : ( $is_transient ? $self->{pages}->editable_url($page) : '' ),
        render      => $is_saved ? $page_url : ( $is_transient ? $self->{pages}->render_url($page) : '' ),
        source      => $is_saved ? $page_url . '/edit' : ( $is_transient ? $self->{pages}->editable_url($page) : '' ),
    };
}

# _page_response($page, $mode)
# Builds a page response for edit, render, or source mode.
# Input: page document object and mode string.
# Output: response array reference.
sub _page_response {
    my ( $self, $page, $mode ) = @_;
    my $source = $page->{meta}{raw_instruction} || $page->canonical_instruction;    # uncoverable condition false

    if ( $mode eq 'source' ) {
        return _no_editor_response() if $self->_editor_disabled;
        return [ 200, 'text/plain; charset=utf-8', $source ];
    }
    if ( $mode eq 'render' ) {
        return [ 200, 'text/html; charset=utf-8', $self->_render_page_html( $page, 'render' ) ];
    }

    return _no_editor_response() if $self->_editor_disabled;
    return [ 200, 'text/html; charset=utf-8', $self->_edit_html($page) ];
}

# _edit_html($page)
# Renders the browser edit/source view for a page.
# Input: page document object.
# Output: HTML string.
sub _edit_html {
    my ( $self, $page ) = @_;
    my $raw_source = $page->{meta}{raw_instruction} || $page->canonical_instruction;    # uncoverable condition false
    my $source = $raw_source;
    $source =~ s/&/&amp;/g;
    $source =~ s/</&lt;/g;
    $source =~ s/>/&gt;/g;

    my $urls = $self->_page_route_urls($page);
    my $form_action = $urls->{form_action} || '/';    # uncoverable condition right

    my $title = $page->as_hash->{title};
    $title =~ s/&/&amp;/g;
    $title =~ s/</&lt;/g;
    $title =~ s/>/&gt;/g;

    my $html = <<'HTML';
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>__TITLE__</title>
  <style>
    body { margin: 0; font-family: Georgia, serif; background: #f5efe2; color: #1f2a2e; }
    main { max-width: 980px; margin: 32px auto; background: #fffef9; border: 1px solid #ddd3c2; padding: 24px; }
    .editor-stack {
      position: relative;
      min-height: 520px;
      border: 1px solid #2a2f36;
      background: #1f2328;
      overflow: hidden;
    }
    .editor-overlay,
    .instruction-block-editor {
      display: block;
      width: 100%;
      min-height: 520px;
      box-sizing: border-box;
      margin: 0;
      padding: 12px;
      font-family: Menlo, Consolas, "Courier New", monospace;
      font-size: 14px;
      line-height: 21px;
      white-space: pre;
      word-break: normal;
      overflow-wrap: normal;
      overflow: auto;
      tab-size: 4;
      letter-spacing: 0;
    }
    .editor-overlay-viewport {
      position: absolute;
      inset: 0;
      overflow: hidden;
      pointer-events: none;
      background: transparent;
    }
    .editor-overlay {
      position: absolute;
      top: 0;
      left: 0;
      min-width: 100%;
      color: #e6edf3;
      background: transparent;
      unicode-bidi: plaintext;
      direction: ltr;
      will-change: transform;
    }
    .instruction-block-editor {
      position: relative;
      z-index: 1;
      min-height: 220px;
      color: transparent;
      border: 0;
      resize: vertical;
      background: transparent;
      caret-color: #f8f8f2;
      outline: none;
      -webkit-text-fill-color: transparent;
      unicode-bidi: plaintext;
      direction: ltr;
      overflow: auto;
      scrollbar-gutter: stable both-edges;
    }
    .instruction-block-editor::selection {
      background: rgba(121, 192, 255, 0.35);
      -webkit-text-fill-color: transparent;
    }
    .instruction-source {
      display: none;
    }
    .editor-blocks {
      display: grid;
      gap: 16px;
    }
    .editor-block {
      display: grid;
      gap: 8px;
    }
    .editor-block-label {
      color: #9da7b3;
      font: 600 12px/1.4 Menlo, Consolas, "Courier New", monospace;
      letter-spacing: 0.04em;
      text-transform: uppercase;
    }
    .editor-hint {
      color: #727b84;
      font-size: 12px;
      margin-bottom: 12px;
    }
    .tok-directive { color: #ffd866; font-weight: normal; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px; }
    .tok-separator { color: #5c6370; }
    .tok-html { color: #78dce8; }
    .tok-tag { color: #ff7ab2; }
    .tok-attr { color: #ffcf6a; }
    .tok-value { color: #a9dc76; }
    .tok-css { color: #78dce8; }
    .tok-js { color: #ab9df2; }
    .tok-code { color: #a9dc76; }
    .tok-perl-keyword { color: #ff7ab2; }
    .tok-perl-var { color: #78dce8; }
    .tok-string { color: #ffd866; }
    .tok-comment { color: #727b84; }
    .tok-note { color: #ff6188; }
    a, .chrome-button {
      color: #0b7a75;
      margin-right: 18px;
      text-decoration: none;
    }
    .chrome-button {
      border: 0;
      background: none;
      padding: 0;
      font: inherit;
      cursor: pointer;
    }
  </style>
</head>
<body>
<main>
  __TOP_CHROME__
  <form method="post" action="__FORM_ACTION__" id="instruction-form">
    <input type="hidden" name="mode" id="instruction-mode" value="edit">
    <div class="editor-hint">Each bookmark section is edited as its own block. Press Tab inside a block to start the next section.</div>
    <textarea class="instruction-source" id="instruction-source" name="instruction" aria-label="Bookmark source" wrap="off" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off">__SOURCE__</textarea>
    <div class="editor-blocks" id="instruction-blocks"></div>
  </form>
</main>
<script>
const ddForm = document.getElementById('instruction-form');
const ddSource = document.getElementById('instruction-source');
const ddMode = document.getElementById('instruction-mode');
const ddBlocks = document.getElementById('instruction-blocks');
const ddPlayButton = document.getElementById('play-button');
const ddLegacySep = ':--------------------------------------------------------------------------------:';
let ddBlockSeq = 0;
function ddEscapeHtml(text) {
  return String(text)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}
function ddStoreToken(tokens, html) {
  tokens.push(html);
  return '\u001eHL' + (tokens.length - 1) + '\u001e';
}
function ddRestoreTokens(text, tokens) {
  if (!Array.isArray(tokens) || !tokens.length) return text;
  return String(text).replace(/\u001eHL(\d+)\u001e/g, function(_, index) {
    return Object.prototype.hasOwnProperty.call(tokens, index) ? tokens[index] : '';
  });
}
function ddHighlightInstruction(text) {
  const state = { section: '', htmlMode: '' };
  return String(text).split('\n').map((line) => ddHighlightLine(line, state)).join('\n');
}
function ddHighlightLine(line, state) {
  if (line === ':--------------------------------------------------------------------------------:') {
    return '<span class="tok-separator">' + ddEscapeHtml(line) + '</span>';
  }
  const match = line.match(/^([A-Za-z][A-Za-z0-9.]*:)(\s*)(.*)$/);
  if (match) {
    state.section = match[1].slice(0, -1).toUpperCase();
    state.htmlMode = '';
    return '<span class="tok-directive">' + ddEscapeHtml(match[1]) + '</span>' +
      ddEscapeHtml(match[2]) +
      ddHighlightSectionText(match[3], state);
  }
  return ddHighlightSectionText(line, state);
}
function ddHighlightSectionText(text, state) {
  const section = state.section || '';
  if (/^CODE\d+$/.test(section)) return ddHighlightPerlLine(text);
  if (section === 'HTML') return ddHighlightHtmlLine(text, state);
  if (section === 'STASH' || section === 'NOTE') return ddHighlightNoteLine(text);
  return ddEscapeHtml(text);
}
function ddHighlightNoteLine(text) {
  return ddEscapeHtml(text).replace(/(\[%[\s\S]*?%\])/g, '<span class="tok-note">$1</span>');
}
function ddHighlightHtmlLine(text, state) {
  let line = text;
  let output = '';
  while (line.length) {
    if (state.htmlMode === 'script') {
      const closeAt = line.search(/<\/script\s*>/i);
      if (closeAt >= 0) {
        output += ddHighlightJsText(line.slice(0, closeAt));
        const closeText = line.slice(closeAt).match(/^<\/script\s*>/i)[0];
        output += ddHighlightMarkupText(closeText);
        line = line.slice(closeAt + closeText.length);
        state.htmlMode = '';
        continue;
      }
      output += ddHighlightJsText(line);
      line = '';
      continue;
    }
    if (state.htmlMode === 'style') {
      const closeAt = line.search(/<\/style\s*>/i);
      if (closeAt >= 0) {
        output += ddHighlightCssText(line.slice(0, closeAt));
        const closeText = line.slice(closeAt).match(/^<\/style\s*>/i)[0];
        output += ddHighlightMarkupText(closeText);
        line = line.slice(closeAt + closeText.length);
        state.htmlMode = '';
        continue;
      }
      output += ddHighlightCssText(line);
      line = '';
      continue;
    }
    const openAt = line.search(/<(script|style)\b/i);
    if (openAt >= 0) {
      output += ddHighlightMarkupText(line.slice(0, openAt));
      const rest = line.slice(openAt);
      const tagMatch = rest.match(/^<(script|style)\b[\s\S]*?>/i);
      if (!tagMatch) {
        output += ddHighlightMarkupText(rest);
        line = '';
        continue;
      }
      const tagText = tagMatch[0];
      output += ddHighlightMarkupText(tagText);
      line = rest.slice(tagText.length);
      state.htmlMode = tagMatch[1].toLowerCase() === 'script' ? 'script' : 'style';
      continue;
    }
    output += ddHighlightMarkupText(line);
    line = '';
  }
  return output;
}
function ddHighlightMarkupText(text) {
  let html = ddEscapeHtml(text);
  html = html.replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span class="tok-comment">$1</span>');
  html = html.replace(/(\[%[\s\S]*?%\])/g, '<span class="tok-note">$1</span>');
  html = html.replace(/(&lt;\/?)([A-Za-z0-9:-]+)([^&]*?)(\/?&gt;)/g, function(_, open, tag, attrs, close) {
    let rendered = '<span class="tok-tag">' + open + tag + '</span>';
    rendered += attrs.replace(/([A-Za-z_:][-A-Za-z0-9_:.]*)(\s*=\s*)(&quot;.*?&quot;|'.*?'|[^\s>]+)/g, function(__, name, eq, value) {
      let valueClass = 'tok-value';
      if (name === 'style') valueClass += ' tok-css';
      if (name.startsWith('on')) valueClass += ' tok-js';
      return '<span class="tok-attr">' + name + '</span>' + eq + '<span class="' + valueClass + '">' + value + '</span>';
    });
    rendered += '<span class="tok-tag">' + close + '</span>';
    return rendered;
  });
  return html;
}
function ddHighlightCssText(text) {
  let css = ddEscapeHtml(text);
  const tokens = [];
  css = css.replace(/\/\*[\s\S]*?\*\//g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-comment">' + match + '</span>');
  });
  css = css.replace(/([.#]?[A-Za-z_-][A-Za-z0-9_-]*)(\s*\{)/g, function(_, name, suffix) {
    return ddStoreToken(tokens, '<span class="tok-css">' + name + '</span>') + suffix;
  });
  css = css.replace(/([A-Za-z-]+)(\s*:)/g, function(_, name, suffix) {
    return ddStoreToken(tokens, '<span class="tok-attr">' + name + '</span>') + suffix;
  });
  css = css.replace(/(:\s*)([^;}{]+)/g, function(_, prefix, value) {
    return prefix + ddStoreToken(tokens, '<span class="tok-value tok-css">' + value + '</span>');
  });
  return ddRestoreTokens(css, tokens);
}
function ddHighlightJsText(text) {
  let js = ddEscapeHtml(text);
  const tokens = [];
  js = js.replace(/\/\/[^\n]*/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-comment">' + match + '</span>');
  });
  js = js.replace(/('(?:\\.|[^'])*'|&quot;(?:\\.|[^&])*?&quot;)/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-string">' + match + '</span>');
  });
  js = js.replace(/\b(const|let|var|function|return|if|else|for|while|class|new|await|async|try|catch|throw)\b/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-js">' + match + '</span>');
  });
  return ddRestoreTokens(js, tokens);
}
function ddHighlightPerlLine(text) {
  let perl = ddEscapeHtml(text);
  const tokens = [];
  perl = perl.replace(/(\[%[\s\S]*?%\])/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-note">' + match + '</span>');
  });
  perl = perl.replace(/(^|\s)(#.*)$/g, function(_, prefix, comment) {
    return prefix + ddStoreToken(tokens, '<span class="tok-comment">' + comment + '</span>');
  });
  perl = perl.replace(/('(?:\\.|[^'])*'|&quot;(?:\\.|[^&])*?&quot;)/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-string">' + match + '</span>');
  });
  perl = perl.replace(/\b(my|sub|return|if|elsif|else|for|foreach|while|last|next|die|print|use|local|our|state|undef)\b/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-perl-keyword">' + match + '</span>');
  });
  perl = perl.replace(/([$@%][A-Za-z_][A-Za-z0-9_]*)/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-perl-var">' + match + '</span>');
  });
  return ddRestoreTokens(perl, tokens);
}
function ddOverlayHtml(text) {
  const source = String(text);
  let html = ddHighlightInstruction(source);
  if (source.endsWith('\n')) html += ' ';
  return html;
}
function ddTrimSectionText(text) {
  return String(text).replace(/^\s+/, '').replace(/\s+$/, '');
}
function ddSplitInstruction(text) {
  const source = String(text || '').replace(/\r\n/g, '\n');
  const lines = source.split('\n');
  const sections = [];
  let current = [];
  lines.forEach(function(line) {
    if (line === ddLegacySep || /^\s*---\s*$/.test(line)) {
      const chunk = ddTrimSectionText(current.join('\n'));
      if (chunk) sections.push(chunk);
      current = [];
      return;
    }
    current.push(line);
  });
  const tail = ddTrimSectionText(current.join('\n'));
  if (tail) sections.push(tail);
  if (!sections.length) {
    const fallback = ddTrimSectionText(source);
    if (fallback) sections.push(fallback);
  }
  return sections;
}
function ddComposeInstruction() {
  const blocks = Array.prototype.slice.call(ddBlocks.querySelectorAll('.instruction-block-editor'))
    .map(function(node) { return ddTrimSectionText(node.value); })
    .filter(function(text) { return text !== ''; });
  const source = blocks.length ? blocks.join('\n' + ddLegacySep + '\n') + '\n' : '';
  ddSource.value = source;
  return source;
}
function ddDirectiveName(line) {
  const match = String(line).match(/^([A-Za-z][A-Za-z0-9.]*)\s*:/);
  return match ? match[1].toUpperCase() : '';
}
function ddHighestCodeDirective(text) {
  let highest = 0;
  String(text).split('\n').forEach(function(line) {
    const match = String(line).match(/^(CODE)(\d+)\s*:/i);
    if (!match) return;
    const number = parseInt(match[2], 10);
    if (number > highest) highest = number;
  });
  return highest;
}
function ddNextDirectiveForContext(textBefore, fullText) {
  const directives = {};
  String(fullText).split('\n').forEach(function(line) {
    const name = ddDirectiveName(line);
    if (name) directives[name] = 1;
  });
  const priorLines = String(textBefore).split('\n');
  let priorDirective = '';
  for (let index = priorLines.length - 1; index >= 0; index -= 1) {
    const name = ddDirectiveName(priorLines[index]);
    if (!name) continue;
    priorDirective = name;
    break;
  }
  if (priorDirective === 'TITLE') {
    if (!directives.BOOKMARK) return 'BOOKMARK: ';
    return directives.HTML ? '' : 'HTML: ';
  }
  if (priorDirective === 'BOOKMARK') {
    if (!directives.NOTE) return 'NOTE: ';
    if (!directives.STASH) return 'STASH: ';
    return directives.HTML ? '' : 'HTML: ';
  }
  if (priorDirective === 'NOTE' || priorDirective === 'STASH') {
    return directives.HTML ? '' : 'HTML: ';
  }
  if (priorDirective === 'HTML' || /^CODE\d+$/.test(priorDirective)) {
    return 'CODE' + (ddHighestCodeDirective(fullText) + 1) + ': ';
  }
  return '';
}
function ddApplyDirectiveAssist(editor) {
  if (editor.selectionStart !== editor.selectionEnd) return false;
  const caret = editor.selectionStart;
  const value = editor.value;
  const lineStart = value.lastIndexOf('\n', Math.max(caret - 1, 0) - 1) + 1;
  const lineEndAt = value.indexOf('\n', caret);
  const lineEnd = lineEndAt >= 0 ? lineEndAt : value.length;
  const line = value.slice(lineStart, lineEnd);
  if (line !== ':---' || caret !== lineEnd) return false;

  const textBefore = value.slice(0, lineStart);
  const textAfter = value.slice(lineEnd);
  const nextDirective = ddNextDirectiveForContext(textBefore, value);
  let replacement = ':--------------------------------------------------------------------------------:';
  let nextCaret = lineStart + replacement.length;
  if (nextDirective) {
    replacement += '\n' + nextDirective;
    nextCaret += 1 + nextDirective.length;
  }
  editor.value = textBefore + replacement + textAfter;
  editor.setSelectionRange(nextCaret, nextCaret);
  return true;
}
function ddSyncEditorOverlay(editor, highlight) {
  highlight.style.minWidth = Math.max(editor.scrollWidth, editor.clientWidth) + 'px';
  highlight.style.minHeight = Math.max(editor.scrollHeight, editor.clientHeight) + 'px';
  highlight.style.transform = 'translate(' + (-editor.scrollLeft) + 'px, ' + (-editor.scrollTop) + 'px)';
}
function ddAutoResizeEditor(editor) {
  editor.style.height = 'auto';
  editor.style.height = Math.max(editor.scrollHeight, 48) + 'px';
}
function ddRenderEditor(editor, highlight) {
  highlight.innerHTML = ddOverlayHtml(editor.value);
  ddAutoResizeEditor(editor);
  ddSyncEditorOverlay(editor, highlight);
}
function ddBlockLabel(text, index) {
  const firstLine = String(text || '').split('\n')[0] || '';
  const directive = ddDirectiveName(firstLine);
  return directive || ('SECTION ' + (index + 1));
}
function ddFocusBlock(editor) {
  editor.focus();
  const caret = editor.value.length;
  editor.setSelectionRange(caret, caret);
}
function ddCreateEditorBlock(text, index) {
  const wrapper = document.createElement('div');
  wrapper.className = 'editor-block';

  const labelId = 'editor-block-label-' + (++ddBlockSeq);
  const label = document.createElement('div');
  label.className = 'editor-block-label';
  label.id = labelId;
  label.textContent = ddBlockLabel(text, index);
  wrapper.appendChild(label);

  const stack = document.createElement('div');
  stack.className = 'editor-stack';
  wrapper.appendChild(stack);

  const viewport = document.createElement('div');
  viewport.className = 'editor-overlay-viewport';
  viewport.setAttribute('aria-hidden', 'true');
  stack.appendChild(viewport);

  const highlight = document.createElement('pre');
  highlight.className = 'editor-overlay';
  viewport.appendChild(highlight);

  const editor = document.createElement('textarea');
  editor.className = 'instruction-block-editor';
  editor.setAttribute('aria-labelledby', labelId);
  editor.wrap = 'off';
  editor.spellcheck = false;
  editor.autocapitalize = 'off';
  editor.autocomplete = 'off';
  editor.autocorrect = 'off';
  editor.value = text;
  stack.appendChild(editor);

  function sync() {
    label.textContent = ddBlockLabel(editor.value, index);
    ddComposeInstruction();
    ddRenderEditor(editor, highlight);
  }

  editor.addEventListener('input', function() {
    ddApplyDirectiveAssist(editor);
    sync();
  });
  editor.addEventListener('scroll', function() {
    ddSyncEditorOverlay(editor, highlight);
  });
  editor.addEventListener('keydown', function(event) {
    if (event.key !== 'Tab' || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return;
    event.preventDefault();
    ddInsertBlockAfter(wrapper);
  });

  sync();
  return wrapper;
}
function ddNextBlockSeed(afterNode) {
  const editors = Array.prototype.slice.call(ddBlocks.querySelectorAll('.instruction-block-editor'));
  const currentEditor = afterNode ? afterNode.querySelector('.instruction-block-editor') : null;
  const fullText = editors.map(function(node) { return node.value; }).join('\n' + ddLegacySep + '\n');
  let textBefore = '';
  if (currentEditor) {
    const index = editors.indexOf(currentEditor);
    textBefore = editors.slice(0, index + 1).map(function(node) { return node.value; }).join('\n' + ddLegacySep + '\n');
  }
  return ddNextDirectiveForContext(textBefore, fullText);
}
function ddRefreshBlockLabels() {
  Array.prototype.slice.call(ddBlocks.querySelectorAll('.editor-block')).forEach(function(block, index) {
    const editor = block.querySelector('.instruction-block-editor');
    const label = block.querySelector('.editor-block-label');
    label.textContent = ddBlockLabel(editor.value, index);
  });
}
function ddInsertBlockAfter(currentBlock) {
  const seed = ddNextBlockSeed(currentBlock);
  const newBlock = ddCreateEditorBlock(seed, ddBlocks.querySelectorAll('.editor-block').length);
  if (currentBlock && currentBlock.nextSibling) {
    ddBlocks.insertBefore(newBlock, currentBlock.nextSibling);
  } else {
    ddBlocks.appendChild(newBlock);
  }
  ddRefreshBlockLabels();
  ddComposeInstruction();
  ddFocusBlock(newBlock.querySelector('.instruction-block-editor'));
}
function ddLoadBlocks(text) {
  ddBlocks.innerHTML = '';
  const sections = ddSplitInstruction(text);
  if (!sections.length) sections.push('TITLE: Untitled');
  sections.forEach(function(section, index) {
    ddBlocks.appendChild(ddCreateEditorBlock(section, index));
  });
  ddRefreshBlockLabels();
  ddComposeInstruction();
}
ddForm.addEventListener('focusout', function() {
  window.setTimeout(function() {
    if (ddForm.contains(document.activeElement)) return;
    ddMode.value = 'edit';
    ddComposeInstruction();
    ddForm.submit();
  }, 0);
});
ddForm.addEventListener('submit', function() {
  ddComposeInstruction();
});
if (ddPlayButton) {
  ddPlayButton.addEventListener('click', function() {
    ddMode.value = 'render';
    ddComposeInstruction();
    ddForm.submit();
  });
}
window.addEventListener('resize', function() {
  Array.prototype.slice.call(ddBlocks.querySelectorAll('.editor-block')).forEach(function(block) {
    const editor = block.querySelector('.instruction-block-editor');
    const highlight = block.querySelector('.editor-overlay');
    ddAutoResizeEditor(editor);
    ddSyncEditorOverlay(editor, highlight);
  });
});
ddSource.value = __SOURCE_JSON__;
ddLoadBlocks(ddSource.value);
</script>
</body>
</html>
HTML

    $html =~ s/__TITLE__/$title/g;
    $html =~ s/__TOP_CHROME__/$self->_top_chrome_html( $page, \%$urls )/ge;
    $html =~ s/__SOURCE__/$source/g;
    $html =~ s/__SOURCE_JSON__/_json_for_inline_script($raw_source)/ge;
    $html =~ s/__FORM_ACTION__/_escape_html_attr($form_action)/ge;
    return $html;
}

# _json_for_inline_script($text)
# Encodes text as JSON for inline script assignment without allowing HTML parser breakouts.
# Input: raw text string.
# Output: JSON string literal safe for inclusion inside a <script> block.
sub _json_for_inline_script {
    my ($text) = @_;
    my $json = json_encode( defined $text ? $text : '' );
    $json =~ s/</\\u003c/g;
    $json =~ s/>/\\u003e/g;
    $json =~ s/&/\\u0026/g;
    return $json;
}

# _highlight_instruction_html($source)
# Generates the initial syntax-coloured editor HTML from bookmark source text.
# Input: canonical bookmark instruction text.
# Output: highlighted HTML string for the editable source area.
sub _highlight_instruction_html {
    my ( $self, $source ) = @_;
    my %state = ( section => '', html_mode => '' );
    return join "\n", map { $self->_highlight_editor_line( $_, \%state ) } split /\n/, ( $source // '' ), -1;
}

# _editor_overlay_html($source)
# Generates the browser overlay HTML while preserving the textarea's final blank line geometry.
# Input: canonical bookmark instruction text.
# Output: highlighted HTML string with a trailing sentinel when the source ends in a newline.
sub _editor_overlay_html {
    my ( $self, $source ) = @_;
    my $html = $self->_highlight_instruction_html($source);
    $html .= ' ' if defined $source && $source =~ /\n\z/;
    return $html;
}

# _highlight_editor_line($line, $state)
# Highlights a single bookmark editor line while preserving exact layout.
# Input: raw line text and mutable parser state hash reference.
# Output: highlighted HTML fragment for that line.
sub _highlight_editor_line {
    my ( $self, $line, $state ) = @_;
    if ( $line eq ':--------------------------------------------------------------------------------:' ) {
        return qq{<span class="tok-separator">} . _escape_html($line) . qq{</span>};
    }
    if ( $line =~ /^([A-Za-z][A-Za-z0-9.]*:)(\s*)(.*)\z/ ) {
        my ( $directive, $space, $rest ) = ( $1, $2, $3 );
        $state->{section}   = uc substr( $directive, 0, -1 );
        $state->{html_mode} = '';
        return qq{<span class="tok-directive">} . _escape_html($directive) . qq{</span>}
          . _escape_html($space)
          . $self->_highlight_section_text( $rest, $state );
    }
    return $self->_highlight_section_text( $line, $state );
}

# _highlight_section_text($text, $state)
# Dispatches line highlighting based on the active bookmark section.
# Input: raw line text and mutable parser state hash reference.
# Output: highlighted HTML fragment.
sub _highlight_section_text {
    my ( $self, $text, $state ) = @_;
    my $section = $state->{section} || '';
    return $self->_highlight_perl_text($text) if $section =~ /^CODE\d+$/;
    return $self->_highlight_html_text( $text, $state ) if $section eq 'HTML';
    return $self->_highlight_note_text($text) if $section eq 'STASH' || $section eq 'NOTE';
    return _escape_html($text);
}

# _highlight_note_text($text)
# Highlights TT-style placeholders inside simple text sections.
# Input: raw section line text.
# Output: highlighted HTML fragment.
sub _highlight_note_text {
    my ( $self, $text ) = @_;
    my $html = _escape_html($text);
    $html =~ s{(\[%[\s\S]*?%\])}{<span class="tok-note">$1</span>}g;
    return $html;
}

# _highlight_html_text($text, $state)
# Highlights HTML section text with HTML, CSS, and JavaScript awareness.
# Input: raw section line text and mutable parser state hash reference.
# Output: highlighted HTML fragment.
sub _highlight_html_text {
    my ( $self, $text, $state ) = @_;
    my $line = $text;
    my $out  = '';
    while ( length $line ) {
        if ( ( $state->{html_mode} || '' ) eq 'script' ) {
            if ( $line =~ m{</script\s*>}i ) {
                my ( $before, $close, $after ) = $line =~ m{\A(.*?)(</script\s*>)(.*)\z}is;
                $out .= $self->_highlight_js_text($before);
                $out .= $self->_highlight_markup_text($close);
                $line = $after;
                $state->{html_mode} = '';
                next;
            }
            $out .= $self->_highlight_js_text($line);
            last;
        }
        if ( ( $state->{html_mode} || '' ) eq 'style' ) {
            if ( $line =~ m{</style\s*>}i ) {
                my ( $before, $close, $after ) = $line =~ m{\A(.*?)(</style\s*>)(.*)\z}is;
                $out .= $self->_highlight_css_text($before);
                $out .= $self->_highlight_markup_text($close);
                $line = $after;
                $state->{html_mode} = '';
                next;
            }
            $out .= $self->_highlight_css_text($line);
            last;
        }
        if ( $line =~ m{<(script|style)\b}i ) {
            my ( $before, $tag, $mode_name, $after ) = $line =~ m{\A(.*?)(<(script|style)\b[\s\S]*?>)(.*)\z}is;
            if ( defined $tag ) {
                my $mode = lc($mode_name) eq 'script' ? 'script' : 'style';
                $out .= $self->_highlight_markup_text($before);
                $out .= $self->_highlight_markup_text($tag);
                $line = $after;
                $state->{html_mode} = $mode;
                next;
            }
        }
        $out .= $self->_highlight_markup_text($line);
        last;
    }
    return $out;
}

# _highlight_markup_text($text)
# Highlights markup fragments, tag names, attributes, and inline TT tokens.
# Input: raw HTML-ish text fragment.
# Output: highlighted HTML fragment.
sub _highlight_markup_text {
    my ( $self, $text ) = @_;
    my $html = _escape_html($text);
    $html =~ s{(&lt;!--[\s\S]*?--&gt;)}{<span class="tok-comment">$1</span>}g;
    $html =~ s{(\[%[\s\S]*?%\])}{<span class="tok-note">$1</span>}g;
    $html =~ s{(&lt;/?)([A-Za-z0-9:-]+)([^&]*?)(/?&gt;)}
              {$self->_highlight_tag_markup( $1, $2, $3, $4 )}ge;
    return $html;
}

# _highlight_tag_markup($open, $tag, $attrs, $close)
# Highlights a single escaped markup tag without changing surrounding layout.
# Input: escaped opening token, tag name, attribute text, and closing token.
# Output: highlighted HTML fragment.
sub _highlight_tag_markup {
    my ( $self, $open, $tag, $attrs, $close ) = @_;
    $attrs =~ s{([A-Za-z_:][-A-Za-z0-9_:.]*)(\s*=\s*)(&quot;.*?&quot;|'.*?'|[^\s>]+)}
             {my ( $name, $eq, $value ) = ( $1, $2, $3 );
              my $value_class = $name eq 'style' ? 'tok-value tok-css' : $name =~ /^on/ ? 'tok-value tok-js' : 'tok-value';
              qq{<span class="tok-attr">$name</span>$eq<span class="$value_class">$value</span>}}ge;
    return qq{<span class="tok-tag">$open$tag</span>$attrs<span class="tok-tag">$close</span>};
}

# _highlight_css_text($text)
# Highlights CSS-like text inside HTML style blocks or style attributes.
# Input: raw CSS text fragment.
# Output: highlighted HTML fragment.
sub _highlight_css_text {
    my ( $self, $text ) = @_;
    my $helper = ref($self) || __PACKAGE__;
    my $css = _escape_html($text);
    my @tokens;
    $css =~ s{(/\*[\s\S]*?\*/)}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-comment">$1</span>} )}ge;
    $css =~ s!([.\#]?[A-Za-z_-][A-Za-z0-9_-]*)(\s*\{)!$helper->_highlight_store_token( \@tokens, qq{<span class="tok-css">$1</span>} ) . $2!ge;
    $css =~ s!([A-Za-z-]+)(\s*:)!$helper->_highlight_store_token( \@tokens, qq{<span class="tok-attr">$1</span>} ) . $2!ge;
    $css =~ s!(:\s*)([^;\}\{]+)!$1 . $helper->_highlight_store_token( \@tokens, qq{<span class="tok-value tok-css">$2</span>} )!ge;
    return $helper->_highlight_restore_tokens( $css, \@tokens );
}

# _highlight_js_text($text)
# Highlights JavaScript-like text inside HTML script blocks.
# Input: raw JavaScript text fragment.
# Output: highlighted HTML fragment.
sub _highlight_js_text {
    my ( $self, $text ) = @_;
    my $helper = ref($self) || __PACKAGE__;
    my $js = _escape_html($text);
    my @tokens;
    $js =~ s{(//[^\n]*)}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-comment">$1</span>} )}ge;
    $js =~ s{('(?:\\.|[^'])*'|&quot;(?:\\.|[^&])*?&quot;)}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-string">$1</span>} )}ge;
    $js =~ s{\b(const|let|var|function|return|if|else|for|while|class|new|await|async|try|catch|throw)\b}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-js">$1</span>} )}ge;
    return $helper->_highlight_restore_tokens( $js, \@tokens );
}

# _highlight_perl_text($text)
# Highlights Perl-like text inside CODE sections.
# Input: raw Perl text fragment.
# Output: highlighted HTML fragment.
sub _highlight_perl_text {
    my ( $self, $text ) = @_;
    my $helper = ref($self) || __PACKAGE__;
    my $perl = _escape_html($text);
    my @tokens;
    $perl =~ s{(\[%[\s\S]*?%\])}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-note">$1</span>} )}ge;
    my $comment = '';
    if ( $perl =~ /\A(.*?)(\s\#.*|\#.*)\z/ ) {
        $perl = $1;
        $comment = $helper->_highlight_store_token( \@tokens, qq{<span class="tok-comment">$2</span>} );
    }
    $perl =~ s{('(?:\\.|[^'])*'|&quot;(?:\\.|[^&])*?&quot;)}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-string">$1</span>} )}ge;
    $perl =~ s{\b(my|sub|return|if|elsif|else|for|foreach|while|last|next|die|print|use|local|our|state|undef)\b}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-perl-keyword">$1</span>} )}ge;
    $perl =~ s{([$@%][A-Za-z_][A-Za-z0-9_]*)}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-perl-var">$1</span>} )}ge;
    return $helper->_highlight_restore_tokens( $perl . $comment, \@tokens );
}

# _highlight_store_token($tokens, $html)
# Replaces one rendered highlight fragment with a placeholder so later regex passes do not mutate inserted markup.
# Input: token array reference and rendered HTML fragment string.
# Output: placeholder marker string.
sub _highlight_store_token {
    my ( $self, $tokens, $html ) = @_;
    push @{$tokens}, $html;
    return sprintf "\x1EHL%d\x1E", scalar( @{$tokens} ) - 1;
}

# _highlight_restore_tokens($text, $tokens)
# Restores placeholder-protected highlight fragments after all regex passes finish.
# Input: placeholder-bearing text string and token array reference.
# Output: highlighted HTML string.
sub _highlight_restore_tokens {
    my ( $self, $text, $tokens ) = @_;
    return $text if ref($tokens) ne 'ARRAY' || !@{$tokens};
    $text =~ s/\x1EHL(\d+)\x1E/( defined $tokens->[$1] ? $tokens->[$1] : '' )/ge;
    return $text;
}

# _escape_html($text)
# Escapes plain text for safe HTML output in the bookmark editor.
# Input: raw text.
# Output: escaped HTML-safe text.
sub _escape_html {
    my ($text) = @_;
    $text = '' if !defined $text;
    $text =~ s/&/&amp;/g;
    $text =~ s/</&lt;/g;
    $text =~ s/>/&gt;/g;
    return $text;
}

# _escape_html_attr($value)
# Escapes one value for safe output inside a quoted HTML attribute.
# Quotes are what _escape_html deliberately leaves alone, and a quote is
# exactly what closes an attribute early, so attribute context needs its own
# escaper: saved bookmark ids reach the route builders with only traversal
# components rejected, so a quote in an id would otherwise end the attribute
# and let the rest of the id open a tag.
# Input: raw value, possibly undefined.
# Output: escaped value safe between either kind of attribute quote.
sub _escape_html_attr {
    my ($value) = @_;
    $value = _escape_html($value);
    $value =~ s/"/&quot;/g;
    $value =~ s/'/&#39;/g;
    return $value;
}

# _render_page_html($page, $mode)
# Renders the browser page view and action URLs for a page.
# Input: page document object and mode string.
# Output: HTML string.
sub _render_page_html {
    my ( $self, $page, $mode ) = @_;
    $page->with_mode( $mode || 'render' );
    my $request_context = $page->{meta}{request_context} || {};
    my $current_page = $self->_effective_current_page($page);
    my $transient_allowed = _transient_url_tokens_allowed();
    my %action_urls;
    for my $action ( @{ $page->as_hash->{actions} || [] } ) {
        next if ref($action) ne 'HASH' || !$action->{id};
        my $saved_action_url = $self->_saved_page_href( $page->as_hash->{id} || '' );
        $saved_action_url .= '/action/' . uri_escape( $action->{id} ) if $saved_action_url ne '';
        my $atoken = $self->{actions}
          ? $self->{actions}->encode_action_payload(
              action => $action,
              page   => $page,
              source => $page->{meta}{source_kind} || 'saved',
            )
          : undef;
        $action_urls{ $action->{id} } = $atoken
          ? ( $transient_allowed ? '/action?atoken=' . URI::Escape::uri_escape($atoken) : $saved_action_url )
          : $saved_action_url;
    }
    my $source_kind = $page->{meta}{source_kind} || '';
    my $is_saved = $source_kind eq 'saved';
    my $is_transient = $source_kind eq 'transient';
    my $urls = $self->_page_route_urls($page);
    my $page_url = $urls->{page_url} || '';
    my $runtime_context = {
        params       => { %{ $page->{state} || {} } },
        current_page => $current_page,
    };
    return $page->render_html(
        action_urls => \%action_urls,
        page_url    => $page_url,
        chrome_html => $self->_top_chrome_html(
            $page,
            {
                edit   => ( $is_transient && $transient_allowed ) ? '/?token=' . $self->{pages}->encode_page($page) : ( $urls->{edit} || '' ),
                render => ( $is_transient && $transient_allowed ) ? '/?mode=render&token=' . $self->{pages}->encode_page($page) : ( $urls->{render} || '' ),
                source => ( $is_transient && $transient_allowed ) ? '/?token=' . $self->{pages}->encode_page($page) : ( $urls->{source} || '' ),
            },
        ),
        nav_html => $self->_nav_items_html(
            page            => $page,
            runtime_context => $runtime_context,
        ),
    );
}

# _effective_current_page($page)
# Resolves the logical page path used by nav fragments and runtime template context.
# Input: page document object.
# Output: request path string, preferring the saved bookmark route for transient play of named bookmarks.
sub _effective_current_page {
    my ( $self, $page ) = @_;
    my $request_context = $page->{meta}{request_context} || {};
    my $request_path = $request_context->{path} || '';
    my $page_id = $page->as_hash->{id} || '';

    return $self->_saved_page_url($page_id)
      if $request_path eq '/'
      && $page_id ne ''
      && ( $page->{meta}{source_kind} || '' ) eq 'transient';

    return $request_path;
}

# _nav_items_html(%args)
# Renders saved nav/*.tt bookmark fragments between page chrome and body.
# Input: page document object and runtime context hash reference.
# Output: HTML fragment string.
sub _nav_items_html {
    my ( $self, %args ) = @_;
    my $page = $args{page} || return '';
    my $page_id = $page->as_hash->{id} || '';
    return '' if $page_id =~ m{\Anav/};
    return '' if ( $page->{meta}{skill_route_id} || '' ) =~ m{\Anav/};

    my $paths = $self->{pages}{paths} || return '';
    my @roots = $paths->can('dashboards_layers') ? $paths->dashboards_layers : $paths->dashboards_roots;
    my %nav_ids;
    my @nav_ids;
    for my $dashboards_root (@roots) {
        my $nav_root = File::Spec->catdir( $dashboards_root, 'nav' );
        next if !-d $nav_root;
        opendir my $dh, $nav_root or next;
        for my $entry ( sort grep {
            $_ ne '.' && $_ ne '..'
              && $_ =~ /\.tt\z/
              && -f File::Spec->catfile( $nav_root, $_ )
        } readdir $dh )
        {
            my $nav_id = 'nav/' . $entry;
            push @nav_ids, $nav_id if !$nav_ids{$nav_id}++;
        }
        closedir $dh;
    }
    my $runtime_context = $args{runtime_context} || {};
    my @items;
    my $current_page = $runtime_context->{current_page} || '';
    for my $nav_id (@nav_ids) {
        my $nav_page = eval { $self->_load_named_page($nav_id) };
        next if !$nav_page || $@;    # uncoverable condition right
        $nav_page->{meta}{raw_instruction} = $nav_page->canonical_instruction;
        $nav_page = $self->{runtime}->prepare_page(
            page            => $self->_page_with_runtime_state(
                $nav_page,
                query_params => $runtime_context->{params} || {},
                body_params  => {},
                path         => $self->_saved_page_url($page_id),
                remote_addr  => $self->{_current_request_context}{remote_addr},
                headers      => { host => $self->{_current_request_context}{host} || '' },
            ),
            source          => $nav_page->{meta}{source_kind} || 'saved',
            runtime_context => {
                %{ $runtime_context },
                current_page => $current_page,
            },
        );
        my $fragment = $self->_page_fragment_html($nav_page);
        next if $fragment eq '';
        push @items, qq{<li data-nav-id="} . _escape_html_attr($nav_id) . qq{">$fragment</li>};
    }

    my $dispatcher = Developer::Dashboard::SkillDispatcher->new( paths => $paths );
    for my $nav_page ( @{ $dispatcher->all_skill_nav_pages || [] } ) {
        $nav_page = $self->{runtime}->prepare_page(
            page            => $self->_page_with_runtime_state(
                $nav_page,
                query_params => $runtime_context->{params} || {},
                body_params  => {},
                path         => $self->_saved_page_url($page_id),
                remote_addr  => $self->{_current_request_context}{remote_addr},
                headers      => { host => $self->{_current_request_context}{host} || '' },
            ),
            source          => 'skill',
            runtime_context => {
                %{ $runtime_context },
                current_page => $current_page,
            },
        );
        my $nav_id = $nav_page->as_hash->{id} || '';
        my $fragment = $self->_page_fragment_html($nav_page);
        next if $fragment eq '';
        push @items, qq{<li data-nav-id="} . _escape_html_attr($nav_id) . qq{">$fragment</li>};
    }

    return '' if !@items;
    return qq{<section class="dashboard-nav-items"><ul>} . join( '', @items ) . qq{</ul></section>};
}

# _page_fragment_html($page)
# Builds a body-level HTML fragment from a prepared page document.
# Input: prepared page document object.
# Output: HTML fragment string.
sub _page_fragment_html {
    my ( $self, $page ) = @_;
    return '' if !$page;

    my $fragment = defined $page->{layout}{body} ? $page->{layout}{body} : '';

    for my $chunk ( @{ $page->{meta}{runtime_outputs} || [] } ) {
        next if !defined $chunk || ref($chunk);
        $fragment .= $chunk;
    }
    for my $chunk ( @{ $page->{meta}{runtime_errors} || [] } ) {
        next if !defined $chunk || ref($chunk);
        $fragment .= qq{<pre class="runtime-error">} . _escape_html($chunk) . qq{</pre>\n};
    }

    return $fragment;
}

# _action_response(%args)
# Executes a named page action and converts the result into an HTTP response.
# Input: page object, action id, source classification, and params.
# Output: response array reference.
sub _action_response {
    my ( $self, %args ) = @_;
    return [ 501, 'text/plain; charset=utf-8', "Action runner is not configured\n" ] if !$self->{actions};
    my $page = $args{page};
    my $id   = $args{id} || '';
    my ($action) = grep { ref($_) eq 'HASH' && ( $_->{id} || '' ) eq $id } @{ $page->as_hash->{actions} || [] };
    return [ 404, 'text/plain; charset=utf-8', "Action not found\n" ] if !$action;

    my $result = eval {
        $self->{actions}->run_page_action(
            action => $action,
            page   => $page,
            params => $args{params} || {},
            source => $args{source} || 'saved',
        );
    };
    if ($@) {
        return [ 403, 'text/plain; charset=utf-8', "$@" ];
    }
    if ( ref($result) eq 'HASH' && exists $result->{body} ) {
        return [ 200, $result->{content_type} || 'text/plain; charset=utf-8', $result->{body} ];
    }
    return [ 200, 'application/json; charset=utf-8', json_encode($result) ];
}

# _encoded_action_response(%args)
# Executes an encoded action payload and converts it into an HTTP response.
# Input: encoded action token and params hash reference.
# Output: response array reference.
sub _encoded_action_response {
    my ( $self, %args ) = @_;
    return [ 501, 'text/plain; charset=utf-8', "Action runner is not configured\n" ] if !$self->{actions};
    my $result = eval {
        $self->{actions}->run_encoded_action(
            token  => $args{token},
            params => $args{params} || {},
        );
    };
    if ($@) {
        return [ 403, 'text/plain; charset=utf-8', "$@" ];
    }
    if ( ref($result) eq 'HASH' && exists $result->{body} ) {
        return [ 200, $result->{content_type} || 'text/plain; charset=utf-8', $result->{body} ];
    }
    return [ 200, 'application/json; charset=utf-8', json_encode($result) ];
}

# _load_named_page($id)
# Loads a page using the resolver when present or the page store otherwise.
# Input: page id string.
# Output: page document object.
sub _load_named_page {
    my ( $self, $id ) = @_;
    return $self->{resolver}->load_named_page($id) if $self->{resolver};
    return $self->{pages}->load_saved_page($id);
}

# _legacy_app_response(%args)
# Loads an older /app/<name> resource as either a bookmark page or saved URL forward.
# Input: bookmark id and request metadata.
# Output: response array reference.
sub _legacy_app_response {
    my ( $self, %args ) = @_;
    my $id = $args{id} || die 'Missing app id';
    my $parsed = eval { $self->_load_named_page($id) };
    if ($parsed) {
        $parsed = $self->_page_with_runtime_state(
            $parsed,
            query_params => $args{query_params} || {},
            body_params  => $args{body_params}  || {},
            path         => '/app/' . $id,
            remote_addr  => $args{remote_addr},
            headers      => $args{headers},
        );
        $parsed = $self->{runtime}->prepare_page(
            page            => $parsed,
            source          => 'saved',
            runtime_context => { params => {} },
        );
        return [ 200, 'text/html; charset=utf-8', $self->_render_page_html( $parsed, 'render' ) ];
    }

    my $raw = eval { $self->{pages}->read_saved_entry($id) };
    if ( !defined $raw || $@ ) {    # uncoverable condition right
        my $skill_response = $self->_skill_app_fallback_response( id => $id, %args );
        return $skill_response if $skill_response;
        return $self->_missing_named_page_response($id);
    }
    my $target = _trim($raw);
    my $uri = URI->new($target);
    my $path = $uri->path;
    my %bookmark_params = _parse_query( scalar( $uri->query // '' ) );
    my %forward_params = (
        %bookmark_params,
        %{ $args{query_params} || {} },
        %{ $args{body_params}  || {} },
    );
    my $query = _build_query( \%forward_params );
    return $self->dispatch_request(
        path        => $path,
        query       => $query,
        method      => 'GET',
        body        => '',
        remote_addr => $args{remote_addr},
        headers     => $args{headers} || {},
    );
}

# _skill_app_fallback_response(%args)
# Attempts the /app/<skill> or /app/<skill>/<page> fallback before the blank saved-page editor opens.
# Input: unresolved app id plus normalized request metadata.
# Output: response array reference for a matched skill route, a 404 for missing namespaced skill routes, or undef when the id is not a skill route.
sub _skill_app_fallback_response {
    my ( $self, %args ) = @_;
    my $id = $args{id} || return;
    my @segments = grep { $_ ne '' } split m{/+}, $id;
    return if !@segments;

    require Developer::Dashboard::SkillDispatcher;
    require Developer::Dashboard::SkillManager;
    my $dispatcher = Developer::Dashboard::SkillDispatcher->new( paths => $self->{pages} ? $self->{pages}{paths} : undef );
    my $manager = Developer::Dashboard::SkillManager->new( paths => $self->{pages} ? $self->{pages}{paths} : undef );
    my $installed_skill = $manager->get_skill_path( $segments[0], include_disabled => 1 );
    return $segments[1] ? [ 404, 'text/plain; charset=utf-8', "Not found\n" ] : undef if !$installed_skill;
    my $spec = $dispatcher->resolve_route_segments( \@segments, include_disabled => 1 );
    return $segments[1] ? [ 404, 'text/plain; charset=utf-8', "Not found\n" ] : undef if !$spec;
    if ( !@{ $spec->{skill_layers} || [] } ) {
        return [ 404, 'text/plain; charset=utf-8', "Not found\n" ];
    }

    return $dispatcher->route_response(
        app          => $self,
        skill_name   => $spec->{skill_name},
        route        => join( '/', @{ $spec->{route_segments} || [] } ),
        query_params => $args{query_params} || {},
        body_params  => $args{body_params}  || {},
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
        path         => '/app/' . $id,
    );
}

# _resolve_skill_route_spec(@segments)
# Resolves the longest installed skill-prefix for a slash-delimited route tail.
# Input: one list of path segments.
# Output: hash reference with skill_name, route_segments, and skill_layers, or undef.
sub _resolve_skill_route_spec {
    my ( $self, @segments ) = @_;
    return $self->_skill_dispatcher->resolve_route_segments( \@segments );
}

# _custom_skill_route_response(%args)
# Resolves one non-smart custom skill route path after all primary routes have
# already declined to handle the request.
# Input: normalized request metadata plus route_path.
# Output: response array reference or undef when no custom skill route matches.
sub _custom_skill_route_response {
    my ( $self, %args ) = @_;
    my $route_path = $args{route_path} || '';
    return if $route_path eq '' || $route_path eq '/';
    my $spec = $self->_skill_dispatcher->resolve_custom_route_path($route_path);
    return if !$spec;
    if ( !( $spec->{skill_name} || '' ) ) {
        my $kind = $spec->{kind} || '';
        my $dispatch_path = '';
        if ( $kind eq 'app' ) {
            $dispatch_path = '/app/' . ( $spec->{route_id} || '' );
        }
        elsif ( $kind eq 'ajax' ) {
            $dispatch_path = '/ajax/' . ( $spec->{ajax_file} || '' );
        }
        elsif ( grep { $_ eq $kind } qw(js css others) ) {
            $dispatch_path = '/' . $kind . '/' . ( $spec->{file} || '' );
        }
        return if $dispatch_path eq '';
        return $self->dispatch_request(
            %args,
            path => $dispatch_path,
        );
    }
    if ( ( $spec->{kind} || '' ) eq 'app' ) {
        return $self->skill_route_response(
            %args,
            path       => $route_path,
            route      => $spec->{route_id},
            skill_name => $spec->{skill_name},
        );
    }
    if ( ( $spec->{kind} || '' ) eq 'ajax' ) {
        return $self->skill_ajax_file_response(
            %args,
            ajax_file    => $spec->{ajax_file},
            default_type => $spec->{type} || '',
            skill_name   => $spec->{skill_name},
        );
    }
    if ( grep { $_ eq ( $spec->{kind} || '' ) } qw(js css others) ) {
        return $self->skill_static_file_response(
            %args,
            default_type => $spec->{type} || '',
            file         => $spec->{file},
            skill_name   => $spec->{skill_name},
            type         => $spec->{kind},
        );
    }
    return;
}

# _skill_dispatcher()
# Builds the skill dispatcher used by web route helpers that resolve layered
# skill routes, ajax handlers, and static assets.
# Input: none.
# Output: Developer::Dashboard::SkillDispatcher object.
sub _skill_dispatcher {
    my ($self) = @_;
    require Developer::Dashboard::SkillDispatcher;
    return Developer::Dashboard::SkillDispatcher->new( paths => $self->{pages} ? $self->{pages}{paths} : undef );
}

# _skill_route_spec($kind, $skill_name, $target)
# Resolves one custom skill route specification for default mime handling.
# Input: route kind string, skill name string, and relative route target.
# Output: route spec hash reference or undef.
sub _skill_route_spec {
    my ( $self, $kind, $skill_name, $target ) = @_;
    return if !$kind || !$skill_name || !$target;
    return $self->_skill_dispatcher->skill_route_spec( $kind, $skill_name, $target );
}

# _skill_ajax_route_spec($skill_name, $ajax_file)
# Resolves one custom skill ajax route specification for default mime handling.
# Input: skill name string and relative ajax file name.
# Output: route spec hash reference or undef.
sub _skill_ajax_route_spec {
    my ( $self, $skill_name, $ajax_file ) = @_;
    return $self->_skill_route_spec( 'ajax', $skill_name, $ajax_file );
}

# _missing_named_page_response($id)
# Builds the editor response for an unknown /app/<id> route using a blank bookmark template.
# Input: requested app id string without the /app/ prefix.
# Output: response array reference containing the edit view.
sub _missing_named_page_response {
    my ( $self, $id ) = @_;
    return [ 404, 'text/plain; charset=utf-8', "Not found\n" ] if $self->_editor_disabled;
    my $bookmark_path = $self->_saved_page_url($id);
    my $instruction = join "\n",
        'TITLE: Developer Dashboard',
        ':--------------------------------------------------------------------------------:',
        "BOOKMARK: $bookmark_path",
        ':--------------------------------------------------------------------------------:',
        'HTML:',
        '',
        'Blank page',
        '';
    my $page = Developer::Dashboard::PageDocument->from_instruction($instruction);
    $page->{meta}{raw_instruction} = $instruction;
    $page->{meta}{source_kind} = 'saved';
    return [ 200, 'text/html; charset=utf-8', $self->_edit_html($page) ];
}

# _build_query($params)
# Builds a URL query string from a flat hash reference.
# Input: flat hash reference of request parameters.
# Output: URL-encoded query string.
sub _build_query {
    my ($params) = @_;
    return '' if ref($params) ne 'HASH' || !%{$params};
    return join '&',
      map {
          URI::Escape::uri_escape($_) . '=' . URI::Escape::uri_escape( defined $params->{$_} ? $params->{$_} : '' )
      } sort keys %{$params};
}

# _legacy_ajax_response(%args)
# Decodes and executes an older /ajax token payload.
# Input: request params and metadata.
# Output: response array reference.
sub _legacy_ajax_response {
    my ( $self, %args ) = @_;
    my $params = $args{params} || {};
    my $type  = $params->{type} || 'text';
    my $code;
    my $saved_path = '';
    if ( my $token = $params->{token} ) {
        $code = eval { decode_payload($token) };
        return [ 400, 'text/plain; charset=utf-8', "$@" ] if $@;
    }
    elsif ( ( $args{saved_ajax_path} || '' ) ne '' ) {
        $saved_path = $args{saved_ajax_path};
        return [ 404, 'text/plain; charset=utf-8', "Ajax handler not found\n" ] if !-f $saved_path;
    }
    elsif ( ( $params->{file} || '' ) ne '' ) {
        my $runtime_root = $self->{pages}{paths} ? $self->{pages}{paths}->runtime_root : '';
        $saved_path = eval {
            Developer::Dashboard::Zipper::saved_ajax_file_path(
                file         => $params->{file},
                runtime_root => $runtime_root,
            );
        };
        return [ 400, 'text/plain; charset=utf-8', "$@" ] if $@;
        return [ 404, 'text/plain; charset=utf-8', "Ajax handler not found\n" ] if $saved_path eq '' || !-f $saved_path;
    }
    else {
        return [ 400, 'text/plain; charset=utf-8', "missing token\n" ];
    }
    my $page = Developer::Dashboard::PageDocument->new(
        id    => ( $params->{page} || $params->{file} || '' ),
        title => 'Bookmark Ajax',
    );
    return [
        200,
        _ajax_content_type($type),
        {
            stream => sub {
                my ($writer) = @_;
                if ($saved_path ne '') {
                    $self->{runtime}->stream_saved_ajax_file(
                        path          => $saved_path,
                        page          => $params->{page} || '',
                        type          => $type,
                        params        => $params,
                        stdout_writer => $writer,
                        stderr_writer => $writer,
                    );
                    return;
                }
                my $result = $self->{runtime}->stream_code_block(
                    code            => $code,
                    page            => $page,
                    source          => 'transient',
                    state           => {},
                    runtime_context => { params => $params },
                    stdout_writer   => $writer,
                    stderr_writer   => $writer,
                    return_writer   => $writer,
                );
                $writer->( $result->{error} ) if defined $result->{error} && $result->{error} ne '';    # uncoverable condition left
            },
        },
    ];
}

# _ajax_content_type($type)
# Maps symbolic Ajax response types to concrete content types while accepting
# raw mime strings from skill route metadata.
# Input: symbolic type string or raw mime type string.
# Output: HTTP content type string.
sub _ajax_content_type {
    my ($type) = @_;
    my %types = (
        html => 'text/html; charset=utf-8',
        text => 'text/plain; charset=utf-8',
        json => 'application/json; charset=utf-8',
        js   => 'application/javascript; charset=utf-8',
        xml  => 'application/xml; charset=utf-8',
        yml  => 'text/plain; charset=utf-8',
        yaml => 'text/plain; charset=utf-8',
        xslt => 'application/xml; charset=utf-8',
    );
    my $value = defined $type && $type ne '' ? $type : 'text';
    return $types{$value} if exists $types{$value};
    return $value if $value =~ m{/};
    return 'text/plain; charset=utf-8';
}

# _legacy_ajax_allowed($params)
# Checks whether an older /ajax request is allowed under the transient token policy.
# A file value must not soften this: _legacy_ajax_response gives the token strict
# precedence over file and saved_ajax_path, so a request carrying a token reaches
# tokenized code execution no matter what file value travels beside it.
# Input: flat request parameter hash reference.
# Output: boolean true when no token is present or transient token URLs are enabled.
sub _legacy_ajax_allowed {
    my ( $self, $params ) = @_;
    return 1 if ref($params) ne 'HASH';
    return 1 if ( $params->{token} || '' ) eq '';
    return _transient_url_tokens_allowed();
}

# ajax_singleton_stop_response(%args)
# Terminates one saved-Ajax singleton worker on explicit browser page-lifecycle cleanup.
# Input: normalized request params containing one singleton name.
# Output: response array reference with a no-content status.
sub ajax_singleton_stop_response {
    my ( $self, %args ) = @_;
    my ($params) = $self->_request_params(%args);
    my $singleton = $self->{runtime}->_normalize_saved_ajax_singleton( $params->{singleton} );
    $self->{runtime}->_kill_saved_ajax_singleton($singleton) if $singleton ne '';
    return [ 204, 'text/plain; charset=utf-8', '' ];
}

# _parse_query($query)
# Parses a URL-encoded query/body string into a flat parameter hash.
# Input: query string.
# Output: key/value hash.
sub _parse_query {
    my ($query) = @_;
    return () if !defined $query || $query eq '';
    my %params;
    for my $pair ( split /&/, $query ) {
        my ( $k, $v ) = split /=/, $pair, 2;
        next if !defined $k || $k eq '';
        $k =~ tr/+/ /;
        my $name = uri_unescape($k);
        if ( defined $v && $name ne 'token' && $name ne 'atoken' ) {
            $v =~ tr/+/ /;
        }
        $params{$name} = defined $v ? uri_unescape($v) : '';
    }
    return %params;
}

# _request_params(%args)
# Parses the normalized raw query/body strings for one request.
# Input: request method, raw query string, and raw body string.
# Output: query-parameter and body-parameter hash references.
sub _request_params {
    my ( $self, %args ) = @_;
    my $query = defined $args{query} ? $args{query} : '';
    my $body  = defined $args{body}  ? $args{body}  : '';
    my $method = uc( $args{method} || 'GET' );
    my %params = _parse_query($query);
    my %body_params = $method eq 'POST' ? _parse_query($body) : ();
    return ( \%params, \%body_params );
}

# _page_with_runtime_state($page, %args)
# Merges request-time state into a page document before rendering or actions.
# Input: page document object plus query/body/context metadata.
# Output: updated page document object.
sub _page_with_runtime_state {
    my ( $self, $page, %args ) = @_;
    my %params = (
        %{ $args{query_params} || {} },
        %{ $args{body_params}  || {} },
    );
    my $request_ctx = $self->{_current_request_context} || {};
    delete @params{ grep { exists $params{$_} } qw(token atoken id mode instruction) };
    $page->merge_state(\%params) if %params;
    $page->{meta}{request_context} = {
        host        => $args{headers}{host} || $request_ctx->{host} || '',
        path        => $args{path} || '',
        remote_addr => $args{remote_addr} || $request_ctx->{remote_addr} || '',
        tier        => $request_ctx->{tier} || 'helper',
        username    => $request_ctx->{username} || '',
        role        => $request_ctx->{role} || '',
    };
    return $page;
}

# _trim($text)
# Trims leading and trailing whitespace from text.
# Input: text string.
# Output: trimmed string.
sub _trim {
    my ($text) = @_;
    $text = '' if !defined $text;
    $text =~ s/\A\s+//;
    $text =~ s/\s+\z//;
    return $text;
}

# _login_redirect_target(%args)
# Builds the original request path/query that helper login should return to.
# Input: normalized request path and query values.
# Output: sanitized relative redirect target string, defaulting to '/'.
sub _login_redirect_target {
    my ( $self, %args ) = @_;
    my $path = defined $args{path} && $args{path} ne '' ? $args{path} : '/';
    my $query = defined $args{query} && $args{query} ne '' ? '?' . $args{query} : '';
    return $self->_sanitize_redirect_target( $path . $query );
}

# _sanitize_redirect_target($target)
# Validates a post-login redirect target so helpers only return to local app routes.
# A target is only safe when the browser's own URL parser cannot re-read it as an
# authority. Two byte classes do exactly that after the value leaves this server:
# a backslash, which URL parsers fold to a forward slash, and a raw tab, newline
# or carriage return, which URL parsers strip before parsing - so "/\evil.com"
# and "/<tab>/evil.com" both arrive as the protocol-relative "//evil.com". Any
# legitimate local target percent-encodes those bytes, so rejecting the whole
# ASCII control range costs nothing and closes the class rather than one byte.
# Input: requested redirect target string.
# Output: safe relative redirect target string, or '/' when invalid.
sub _sanitize_redirect_target {
    my ( $self, $target ) = @_;
    $target = _trim($target);
    return '/' if $target eq '';
    return '/' if $target !~ m{\A/};
    return '/' if $target =~ m{\A//};
    return '/' if $target =~ m{\\};
    return '/' if $target =~ m{[\x00-\x1f\x7f]};
    return '/' if $target =~ m{\A/login(?:\z|[/?#])};
    return $target;
}

# _normalized_saved_page_id($id)
# Normalizes one saved bookmark id for route generation.
# Input: saved bookmark id string, optionally already prefixed with /app/.
# Output: bookmark id string without leading /app/ or duplicate slashes.
sub _normalized_saved_page_id {
    my ( $self, $id ) = @_;
    $id = _trim($id);
    $id =~ s{\A/+app/+}{};
    $id =~ s{\A/+}{};
    return $id;
}

# _saved_page_url($id)
# Builds the canonical /app/<id> route for one saved bookmark id.
# Input: saved bookmark id string.
# Output: canonical /app/<id> path string, or empty string when id is empty.
sub _saved_page_url {
    my ( $self, $id ) = @_;
    my $normalized = $self->_normalized_saved_page_id($id);
    return '' if $normalized eq '';
    return '/app/' . $normalized;
}

# _saved_page_href($id)
# Builds the browser-facing /app/<id> hyperlink for one saved bookmark id.
# Unlike _saved_page_url, which stays in decoded PATH_INFO space for
# request-context paths and the BOOKMARK: document field, this form is for
# HTML emission: each path segment is percent-encoded so ids carrying #, ?,
# %, or spaces survive as valid URLs, while the / separators stay raw so the
# route table still sees the nested segments.
# Input: saved bookmark id string.
# Output: percent-encoded /app/<id> href string, or empty string when id is empty.
sub _saved_page_href {
    my ( $self, $id ) = @_;
    my $normalized = $self->_normalized_saved_page_id($id);
    return '' if $normalized eq '';
    return '/app/' . join '/', map { uri_escape($_) } split m{/+}, $normalized;
}

# _top_chrome_html($page, $urls)
# Builds the shared top-of-page chrome for edit and render views.
# Input: page document object and hash reference of edit/render/source URLs.
# Output: HTML snippet string.
sub _top_chrome_html {
    my ( $self, $page, $urls ) = @_;
    $urls ||= {};
    my $readonly = $self->_editor_disabled;
    my $share = $readonly ? '' : ( $urls->{edit}   || '' );
    my $play  = $readonly ? '' : ( $urls->{render} || '' );
    my $src   = $readonly ? '' : ( $urls->{source} || '' );
    my $mode  = $page->as_hash->{mode} || 'edit';
    my $ctx   = $page->{meta}{request_context} || {};
    my @links;
    push @links, qq{<button type="button" class="chrome-button" id="play-button" data-play-url="} . _escape_html_attr($play) . qq{">Play</button>} if $mode ne 'render' && $play ne '';
    push @links, qq{<a href="} . _escape_html_attr($src) . qq{" id="view-source-url">View Source</a>} if $mode ne 'edit' && $src ne '';
    push @links, q{<a href="/logout" id="logout-url">Logout</a>}
      if ( $ctx->{tier} || '' ) eq 'helper';
    my $nav = join ' ', @links;
    my $hide_indicators = $self->_indicators_disabled;
    my $status = $hide_indicators ? q{} : $self->_prompt_summary;
    my $context = $hide_indicators ? q{} : $self->_top_context_html($page);
    my $share_html = $share ne ''
      ? qq{<div><a href="} . _escape_html_attr($share) . qq{" id="share-url">Right Click Copy &amp; Share or Bookmark This Page</a></div>}
      : q{};
    my $nav_html = $nav ne '' ? qq{<div style="margin-top:6px">$nav</div>} : q{};
    # The colour-emoji families must trail the text families: they carry the
    # keycap bases U+0030-U+0039 and U+002E, so leading with them paints plain
    # ASCII host addresses and timestamps at emoji advance widths, producing an
    # unbreakable token that overflowed 320px viewports. Trailing them still
    # supplies the indicator icons, which no serif text font covers.
    # min-width:0 plus overflow-wrap:anywhere keep this flex column shrinkable
    # below its min-content width whatever the host name or status text says.
    my $right_html = $hide_indicators
      ? q{}
      : sprintf(
        q{<div style="text-align:right;white-space:pre-wrap;overflow-wrap:anywhere;min-width:0;font-family:Georgia,'Times New Roman',serif,'Segoe UI Emoji','Noto Color Emoji','Segoe UI Symbol'">%s<span id="status-on-top">%s</span></div>},
        $context,
        $status,
      );
    my $script_html = $hide_indicators ? q{} : <<'HTML';
<script>
(function() {
  function renderTopStatus(items) {
    return (items || []).map(function(row) {
      return '<span id="status-' + row.prog + '">' + row.status + row.alias + '</span> ';
    }).join('');
  }
  function updateTopStatus(payload) {
    if (!payload || !payload.array) return;
    var top = document.getElementById('status-on-top');
    if (top) top.innerHTML = renderTopStatus(payload.array);
  }
  setInterval(function() {
    fetch('/system/status', { headers: { 'Accept': 'application/json' } })
      .then(function(res) { return res.json(); })
      .then(updateTopStatus)
      .catch(function() {});
  }, 5000);
  function pad(value) {
    return String(value).padStart(2, '0');
  }
  function updateDateTime() {
    var node = document.getElementById('status-datetime');
    if (!node) return;
    var now = new Date();
    node.textContent = now.getFullYear() + '-' +
      pad(now.getMonth() + 1) + '-' +
      pad(now.getDate()) + ' ' +
      pad(now.getHours()) + ':' +
      pad(now.getMinutes()) + ':' +
      pad(now.getSeconds());
  }
  updateDateTime();
  setInterval(updateDateTime, 1000);
})();
</script>
HTML
    # flex-wrap:wrap lets the two columns stack instead of being squeezed onto
    # one line, and min-width:0 lets each of them shrink below its min-content
    # width, so a narrow phone viewport never gains a horizontal scrollbar.
    return sprintf <<'HTML', $share_html, $nav_html, $right_html, $script_html;
<div class="dd-top-chrome" style="display:flex;flex-wrap:wrap;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid #ddd3c2">
  <div style="min-width:0">
    %s
    %s
  </div>
  %s
</div>
%s
HTML
}

# _top_context_html($page)
# Builds the old-style top-right user, host, and date context line for browser chrome.
# Input: page document object.
# Output: HTML snippet string.
sub _top_context_html {
    my ( $self, $page ) = @_;
    my $ctx = $page->{meta}{request_context} || {};
    my $user = (
        ( $ctx->{tier} || '' ) eq 'helper' && ( $ctx->{username} || '' ) ne ''
    ) ? $ctx->{username} : ( $ENV{USER} || eval { getpwuid($<) } || 'user' );    # uncoverable condition right count:2
    my $host = $ctx->{host} || '';
    $host =~ s/^https?:\/\///;
    $host =~ s/\/.*$//;
    my ( $host_only, $port ) = split /:/, $host, 2;
    my $machine_ip = $self->_machine_ip || $host_only || $ctx->{remote_addr} || '127.0.0.1';
    my $host_href = 'http://' . $machine_ip . ( defined $port && $port ne '' ? ':' . $port : '' );
    my $now = strftime '%Y-%m-%d %H:%M:%S', localtime;
    return sprintf q{<span class="user-name-and-icon">&#128129;&#127996; %s</span> <span id="status-server">&#128187; <a href="%s">%s</a></span> &#128467; <span id="status-datetime">%s</span><br>},
      _escape_html($user),
      _escape_html_attr($host_href),
      _escape_html($machine_ip),
      _escape_html($now);
}

# _machine_ip()
# Discovers the preferred machine IPv4 address, preferring VPN-style interfaces before other global addresses.
# Input: none.
# Output: IPv4 address string or undef when unavailable.
sub _machine_ip {
    my ($self) = @_;
    my @candidates = $self->_ip_candidates;
    return $candidates[0] if @candidates;
    return;
}

# _ip_candidates()
# Collects candidate machine IPv4 addresses from system network interfaces.
# Input: none.
# Output: ordered list of IPv4 address strings.
sub _ip_candidates {
    my ($self) = @_;
    my @pairs = $self->_ip_interface_pairs;
    my @vpn;
    my @preferred;
    my @other;
    for my $pair (@pairs) {
        push @other, $pair->{ip};
        push @vpn, $pair->{ip} if $pair->{iface} =~ /^(?:tun|tap|ppp|utun|wg|vpn)/i;
        push @preferred, $pair->{ip} if $pair->{iface} =~ /^(?:eth|en|wlan|wl)/i;
    }
    my %seen;
    return grep { !$seen{$_}++ } ( @vpn, @preferred, @other );
}

# _ip_interface_pairs()
# Reads active global IPv4 interface/address pairs from the host system.
# Input: none.
# Output: list of hashes with iface and ip keys.
sub _ip_interface_pairs {
    my ($self) = @_;
    my @pairs = $self->_ip_pairs_from_ip;
    return @pairs if @pairs;
    @pairs = $self->_ip_pairs_from_ipconfig;
    return @pairs if @pairs;
    return $self->_ip_pairs_from_ifconfig;
}

# _ip_pairs_from_ip()
# Reads IPv4 interface/address pairs using the `ip` command when available.
# Input: none.
# Output: list of hashes with iface and ip keys.
sub _ip_pairs_from_ip {
    my ($self) = @_;
    return () if !command_in_path('ip');
    my ( $stdout, undef, $exit_code ) = capture {
        system 'ip', '-o', '-4', 'addr', 'show', 'up', 'scope', 'global';
        return $? >> 8;
    };
    return () if $exit_code != 0 || !defined $stdout || $stdout eq '';
    my @pairs;
    for my $line ( split /\n/, $stdout ) {
        next if $line !~ /^\d+:\s+([^ ]+)\s+inet\s+(\d+\.\d+\.\d+\.\d+)\//;
        push @pairs, { iface => $1, ip => $2 };
    }
    return @pairs;
}

# _ip_pairs_from_ipconfig()
# Reads IPv4 interface/address pairs using Windows ipconfig output when available.
# Input: none.
# Output: list of hashes with iface and ip keys.
sub _ip_pairs_from_ipconfig {
    my ($self) = @_;
    return () if !command_in_path('ipconfig');
    my ( $stdout, undef, $exit_code ) = capture {
        system 'ipconfig';
        return $? >> 8;
    };
    return () if $exit_code != 0 || !defined $stdout || $stdout eq '';
    my @pairs;
    my $iface = '';
    for my $line ( split /\n/, $stdout ) {
        if ( $line =~ /^\S.*adapter\s+(.+?):\s*$/i ) {
            $iface = $1;
            next;
        }
        next if $iface eq '';
        next if $line !~ /IPv4[^:]*:\s*(\d+\.\d+\.\d+\.\d+)/i;
        my $ip = $1;
        next if $ip =~ /^127\./;
        push @pairs, { iface => $iface, ip => $ip };
    }
    return @pairs;
}

# _ip_pairs_from_ifconfig()
# Reads IPv4 interface/address pairs using `ifconfig` as a fallback on systems without `ip`.
# Input: none.
# Output: list of hashes with iface and ip keys.
sub _ip_pairs_from_ifconfig {
    my ($self) = @_;
    return () if !command_in_path('ifconfig');
    my ( $stdout, undef, $exit_code ) = capture {
        system 'ifconfig';
        return $? >> 8;
    };
    return () if $exit_code != 0 || !defined $stdout || $stdout eq '';
    my @pairs;
    my $iface = '';
    for my $line ( split /\n/, $stdout ) {
        if ( $line =~ /^([A-Za-z0-9._:-]+):\s/ ) {
            $iface = $1;
            next;
        }
        next if $iface eq '';
        next if $line !~ /\binet\s+(?:addr:)?(\d+\.\d+\.\d+\.\d+)/;
        my $ip = $1;
        next if $ip =~ /^127\./;
        push @pairs, { iface => $iface, ip => $ip };
    }
    return @pairs;
}

# _prompt_summary()
# Renders the older page-header indicator summary for page chrome.
# Input: none.
# Output: short status strip string.
sub _prompt_summary {
    my ($self) = @_;
    my $payload = $self->_page_status_payload;
    my @items = @{ $payload->{array} || [] };
    my @parts;
    for my $item (@items) {
        push @parts, $item->{status} . $item->{alias};
    }
    return join ' ', @parts;
}

# _page_status_payload()
# Builds the `/system/status` payload from the indicator store.
# Input: none.
# Output: hash reference with array, hash, and status maps.
sub _page_status_payload {
    my ($self) = @_;
    my $indicators = $self->{prompt} ? $self->{prompt}{indicators} : undef;
    return { array => [], hash => {}, status => {} } if !$indicators || !$indicators->can('page_header_payload');
    if ( $self->{config} && $self->{config}->can('collectors') ) {
        $indicators->sync_collectors( $self->{config}->collectors );
    }
    return $indicators->page_header_payload;
}

# _session_cookie($session_id, $secure)
# Builds the Set-Cookie header value for a dashboard session.
# Input: session id string, and an optional boolean secure flag that appends the
#        Secure attribute when the request arrived over HTTPS.
# Output: cookie header string.
sub _session_cookie {
    my ( $session_id, $secure ) = @_;
    my $cookie = "dashboard_session=$session_id; Path=/; HttpOnly; SameSite=Strict";
    $cookie .= '; Secure' if $secure;
    return $cookie;
}

# _request_is_secure()
# Reports whether the current request is being served over HTTPS/TLS.
# The dashboard only ever serves real HTTPS traffic through its SSL front-proxy,
# which marks backend worker processes with DEVELOPER_DASHBOARD_SSL_PROXIED; plain
# HTTP (loopback) serving never sets that flag.
# Input: none.
# Output: boolean true when the request arrived over HTTPS, false for plain HTTP.
sub _request_is_secure {
    return $ENV{DEVELOPER_DASHBOARD_SSL_PROXIED} ? 1 : 0;
}

# _expired_session_cookie()
# Builds a Set-Cookie header that expires the dashboard session cookie.
# Input: none.
# Output: cookie header string.
sub _expired_session_cookie {
    return 'dashboard_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0';
}

# _serve_static_file($type, $filename)
# Serves static files from the public directory (js, css, others).
# Input: $type (js, css, or others), $filename (requested filename).
# Output: array reference of status code, content type, body.
sub _serve_static_file {
    my ( $self, $type, $filename ) = @_;
    return $self->_serve_static_file_from_roots( $type, $filename, $self->_static_file_roots($type) );
}

# _serve_static_file_from_roots($type, $filename, @roots)
# Serves one static file from an explicit ordered list of public roots.
# Input: asset type string, relative filename, and candidate directory roots.
# Output: array reference of status code, content type, and body.
sub _serve_static_file_from_roots {
    my ( $self, $type, $filename, @public_roots ) = @_;

    # Prevent directory traversal attacks
    return [ 400, 'text/plain; charset=utf-8', "Bad Request\n" ]
        if $filename =~ /\.\./;

    my $file_path = '';
    for my $public_dir (@public_roots) {
        my $candidate = File::Spec->catfile( $public_dir, $filename );
        my $real_path = eval { File::Spec->rel2abs($candidate) } || '';    # uncoverable condition right
        my $quoted_public = quotemeta($public_dir);
        next if $real_path !~ /^$quoted_public(?:\/|\z)/;
        next if !-f $candidate || !-r $candidate;
        $file_path = $candidate;
        last;
    }
    return [ 404, 'text/plain; charset=utf-8', "Not Found\n" ] if $file_path eq '';

    return $self->_serve_static_file_at_path( $type, $filename, $file_path, '', \@public_roots );
}

# _skill_ajax_file_path($skill_name, $ajax_file)
# Resolves one layered skill-local dashboards/ajax file path through the skill dispatcher.
# Input: skill name string and relative ajax file name.
# Output: absolute file path string or empty string.
sub _skill_ajax_file_path {
    my ( $self, $skill_name, $ajax_file ) = @_;
    return '' if !$skill_name || !$ajax_file;
    return $self->_skill_dispatcher->skill_ajax_file_path( $skill_name, $ajax_file ) || '';
}

# _skill_static_file_path($skill_name, $type, $file)
# Resolves one layered skill-local dashboards/public asset path through the skill dispatcher.
# Input: skill name string, asset type string, and relative file path.
# Output: absolute file path string or empty string.
sub _skill_static_file_path {
    my ( $self, $skill_name, $type, $file ) = @_;
    return '' if !$skill_name || !$type || !$file;
    return $self->_skill_dispatcher->skill_static_file_path( $skill_name, $type, $file ) || '';
}

# _static_file_roots($type)
# Returns candidate public directories for static file serving in lookup order.
# Input: asset type string such as js, css, or others.
# Output: ordered list of directory path strings.
sub _static_file_roots {
    my ( $self, $type ) = @_;
    my @roots;
    my %seen;

    my $paths = $self->{pages} && ref( $self->{pages} ) eq 'Developer::Dashboard::PageStore'
      ? $self->{pages}{paths}
      : undef;
    if ($paths) {
        for my $runtime_root ( $paths->runtime_roots ) {
            my $root = File::Spec->catdir( $runtime_root, 'dashboard', 'public', $type );
            push @roots, $root if !$seen{$root}++;
        }
        for my $dashboards_root ( $paths->dashboards_roots ) {
            my $root = File::Spec->catdir( $dashboards_root, 'public', $type );
            push @roots, $root if !$seen{$root}++;
        }
    }

    my $home_root = File::Spec->catdir(
        $ENV{HOME} || $ENV{USERPROFILE} || '/root',
        '.developer-dashboard',
        'dashboard',
        'public',
        $type
    );
    push @roots, $home_root if !$seen{$home_root}++;
    return @roots;
}

# _static_path_contained($file_path, $allowed_roots)
# Asserts that one resolved static asset path still lives beneath an allowed
# public root after symlinks and parent-directory components are resolved,
# denying by default when no allowed roots are supplied. Comparison folds case
# on Windows runtimes and normalizes separators, mirroring the saved-page
# containment assertion.
# Input: resolved candidate file path string and array reference of allowed
# root directory path strings.
# Output: boolean true when the resolved path is inside one existing allowed
# root, otherwise false.
sub _static_path_contained {
    my ( $file_path, $allowed_roots ) = @_;
    return 0 if ref($allowed_roots) ne 'ARRAY';
    my $path_real = -e $file_path ? abs_path($file_path) : undef;
    return 0 if !defined $path_real;
    for my $root ( @{$allowed_roots} ) {
        next if !defined $root || $root eq '';
        my $root_real = -d $root ? abs_path($root) : undef;
        next if !defined $root_real;
        my ( $path_cmp, $root_cmp ) = ( $path_real, $root_real );
        if ( is_windows() ) {
            $path_cmp = lc $path_cmp;
            $root_cmp = lc $root_cmp;
        }
        $path_cmp =~ s{\\}{/}g;
        $root_cmp =~ s{\\}{/}g;
        return 1 if index( $path_cmp, $root_cmp . '/' ) == 0;
    }
    return 0;
}

# _serve_static_file_at_path($type, $filename, $file_path, $default_type, $allowed_roots)
# Serves one already-resolved static file path after the caller has chosen the
# lookup source, refusing any resolved path that escapes the caller's allowed
# public roots.
# Input: asset type string, request filename string, resolved file path string,
# optional explicit mime type override, and array reference of allowed root
# directories the resolved path must stay inside.
# Output: array reference of status code, content type, and body.
sub _serve_static_file_at_path {
    my ( $self, $type, $filename, $file_path, $default_type, $allowed_roots ) = @_;
    return [ 404, 'text/plain; charset=utf-8', "Not Found\n" ]
      if !defined $file_path || $file_path eq '' || !-f $file_path || !-r $file_path;
    return [ 404, 'text/plain; charset=utf-8', "Not Found\n" ]
      if !_static_path_contained( $file_path, $allowed_roots );
    my $content_type = defined $default_type && $default_type ne ''
      ? _ajax_content_type($default_type)
      : $self->_get_content_type( $type, $filename );
    open my $fh, '<', $file_path or return [ 500, 'text/plain; charset=utf-8', "Internal Server Error\n" ];    # uncoverable branch true
    my $content = do { local $/; <$fh> };
    close $fh;
    return [ 200, $content_type, $content ];
}

# _get_content_type($type, $filename)
# Determines the MIME type based on file type and extension.
# Input: $type (js, css, or others), $filename (requested filename).
# Output: MIME type string.
sub _get_content_type {
    my ( $self, $type, $filename ) = @_;

    if ( $type eq 'js' ) {
        return 'application/javascript; charset=utf-8';
    }
    elsif ( $type eq 'css' ) {
        return 'text/css; charset=utf-8';
    }
    elsif ( $filename =~ /\.json$/i ) {
        return 'application/json; charset=utf-8';
    }
    elsif ( $filename =~ /\.xml$/i ) {
        return 'application/xml; charset=utf-8';
    }
    elsif ( $filename =~ /\.txt$/i ) {
        return 'text/plain; charset=utf-8';
    }
    elsif ( $filename =~ /\.html?$/i ) {
        return 'text/html; charset=utf-8';
    }
    elsif ( $filename =~ /\.svg$/i ) {
        return 'image/svg+xml';
    }
    elsif ( $filename =~ /\.png$/i ) {
        return 'image/png';
    }
    elsif ( $filename =~ /\.jpe?g$/i ) {
        return 'image/jpeg';
    }
    elsif ( $filename =~ /\.gif$/i ) {
        return 'image/gif';
    }
    elsif ( $filename =~ /\.webp$/i ) {
        return 'image/webp';
    }
    elsif ( $filename =~ /\.ico$/i ) {
        return 'image/x-icon';
    }
    else {
        return 'application/octet-stream';
    }
}

1;

__END__

=encoding UTF-8

=head1 NAME

Developer::Dashboard::Web::App - local web application for Developer Dashboard

=head1 SYNOPSIS

  my $app = Developer::Dashboard::Web::App->new(
      auth     => $auth,
      pages    => $pages,
      sessions => $sessions,
  );

=head1 DESCRIPTION

This module handles the browser-facing dashboard routes, helper login flow,
page rendering modes, and page/action execution endpoints. It also provides
static file serving for JavaScript, CSS, and other assets from the public
directory structure (~/.developer-dashboard/dashboard/public/{js,css,others}).
The browser tab icon at C</favicon.ico> is served from the same layered
C<others> roots with a bundled fallback, and resolves before the authorization
gate because browsers request it implicitly on every page load.

Cross-site request forgery defense: every state-changing request (POST, PUT,
DELETE, PATCH) passes one origin check before any trust-tier dispatch. When
the request carries an C<Origin> header (or, absent that, a C<Referer>), its
authority must equal the request's own C<Host> header or name a trusted local
alias — a numeric loopback literal, the localhost hostname family, or a
configured C<web.ssl_subject_alt_names> entry, the same alias semantics the
loopback-admin trust check applies. Anything else, including the opaque
C<Origin: null>, is refused with an empty 403 on every tier: the
loopback-admin shortcut, helper sessions, and the machine API tier alike,
because browsers attach ambient credentials and loopback reachability to
cross-site requests automatically. Requests with neither header keep working,
since non-browser machine clients send neither while browsers always attach
C<Origin> to cross-site state-changing requests. The check also holds behind
the SSL front-proxy, which forwards TLS bytes unmodified, so the backend
compares against the browser's own C<Host> header.

That origin check cannot defend a C<GET>, because browsers omit C<Origin> on
same-origin GETs and a hostile page can drop its C<Referer> with a referrer
policy — yet C</ajax/E<lt>fileE<gt>> runs an operator-written saved handler as
a child process from a plain GET, on a tier that needs no cookie. So the same
choke point additionally refuses, on B<every> method, any request the browser
labelled C<Sec-Fetch-Site: cross-site> or C<same-site>, unless the
accompanying C<Origin>/C<Referer> names this dashboard or a trusted local
alias (the C<localhost>/C<127.0.0.1> pair is one host to the trust model and
two sites to the browser). C<Sec-Fetch-Site> is set by the browser itself and
is a forbidden header name, so page script can neither forge nor suppress it.
Requests carrying no fetch metadata are unaffected, which keeps machine
clients and browsers too old to send it working unchanged.

=head1 METHODS

=head2 new, handle

Construct and dispatch the local web application.

=head2 _serve_static_file($type, $filename)

Serves static files from the public directory.

Input: $type (js, css, or others subdirectory), $filename (requested filename).
Output: array reference of [status_code, content_type, body].

Security: Prevents directory traversal attacks and verifies files are within
the public directory before serving.

=head2 _static_path_contained($file_path, $allowed_roots)

Package function asserting that one resolved static asset path still lives
beneath an allowed public root after symlinks and parent-directory components
are resolved with C<Cwd::abs_path>, denying by default when no allowed roots
are supplied. Every static-serving entry point passes its lookup roots through
this check before opening a resolved path, including skill-namespaced assets,
whose allowed roots come from the skill dispatcher's layered
C<dashboards/public> trees.

Input: resolved candidate file path string and array reference of allowed root
directory path strings.
Output: boolean true when the resolved path is inside one existing allowed
root, otherwise false.

=head2 _get_content_type($type, $filename)

Determines the MIME type for a file based on its type and extension.

Input: $type (js, css, or others), $filename (requested filename).
Output: MIME type string suitable for Content-Type header.

Supports: JS, CSS, JSON, XML, HTML, SVG, PNG, JPEG, GIF, WebP, ICO, and others.

=for comment FULL-POD-DOC START

=head1 PURPOSE

This module is the main route backend for the browser application. It handles login and logout, saved and transient page render/source/edit routes, status endpoints, saved Ajax endpoints, and the auth checks that decide whether a request is local admin, helper user, API-authorized machine caller, or unauthorized outsider.

=head1 WHY IT EXISTS

It exists because the dashboard browser surface is large and security-sensitive. Centralizing route behavior, auth gating, saved-page handling, Ajax endpoints, layered C<config/api.json> machine auth, and response shaping keeps the product behavior coherent and testable.

=head1 WHEN TO USE

Use this file when changing browser routes, helper login behavior, page render/source/edit flows, saved Ajax endpoints, or the runtime JSON and HTML responses for dashboard workspaces.

=head1 HOW TO USE

Construct it with the action runner, auth service, config service, page store, prompt, page resolver, page runtime, and session store, then hand it to the Dancer adapter or PSGI bootstrap. Route-specific behavior belongs here rather than in the transport wrapper.

=head1 WHAT USES IT

It is used by C<Developer::Dashboard::Web::DancerApp>, by C<app.psgi>, by the CLI web server wrapper, and by the broad web/browser regression suite that covers routes, auth, Ajax, and workspace behavior.

=head1 EXAMPLES

Example 1:

  perl -Ilib -MDeveloper::Dashboard::Web::App -e 1

Do a direct compile-and-load check against the module from a source checkout.

Example 2:

  prove -lv t/03-web-app.t t/08-web-update-coverage.t t/web_app_static_files.t

Run the focused regression tests that most directly exercise this module's behavior.

Example 3:

  HARNESS_PERL_SWITCHES=-MDevel::Cover prove -lr t

Recheck the module under the repository coverage gate rather than relying on a load-only probe.

Example 4:

  prove -lr t

Put any module-level change back through the entire repository suite before release.


=for comment FULL-POD-DOC END

=cut
